From 1c5f3a750d81e453987d34cc4559d50cd8ecbce6 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:05:16 +0200 Subject: [PATCH 001/201] M1: add internal/engine/engine.go --- internal/engine/engine.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 internal/engine/engine.go diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..1f302f3 --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,23 @@ +package engine + +import "context" + +type Job struct { + ID string + Scene string + Output string + Frame int + Width int + Height int + Options map[string]string +} + +type Result struct { + Output string +} + +type Engine interface { + Name() string + Probe(context.Context) error + Render(context.Context, Job) (Result, error) +} From 9491040c084a834bd2739b702819114614ff7052 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:05:18 +0200 Subject: [PATCH 002/201] M1: add internal/engine/povray/povray.go --- internal/engine/povray/povray.go | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 internal/engine/povray/povray.go diff --git a/internal/engine/povray/povray.go b/internal/engine/povray/povray.go new file mode 100644 index 0000000..caa4769 --- /dev/null +++ b/internal/engine/povray/povray.go @@ -0,0 +1,51 @@ +package povray + +import ( + "context" + "fmt" + "os/exec" + "strconv" + + "github.com/Ploos-AS/AmiRender/internal/engine" +) + +type Engine struct { + Binary string +} + +func New(binary string) *Engine { + if binary == "" { + binary = "povray" + } + return &Engine{Binary: binary} +} + +func (e *Engine) Name() string { return "povray" } + +func (e *Engine) Probe(ctx context.Context) error { + _, err := exec.LookPath(e.Binary) + if err != nil { + return fmt.Errorf("povray executable %q not found: %w", e.Binary, err) + } + return nil +} + +func (e *Engine) Render(ctx context.Context, job engine.Job) (engine.Result, error) { + if job.Scene == "" || job.Output == "" { + return engine.Result{}, fmt.Errorf("scene and output are required") + } + + args := []string{"+I" + job.Scene, "+O" + job.Output, "-D"} + if job.Width > 0 { + args = append(args, "+W"+strconv.Itoa(job.Width)) + } + if job.Height > 0 { + args = append(args, "+H"+strconv.Itoa(job.Height)) + } + + cmd := exec.CommandContext(ctx, e.Binary, args...) + if out, err := cmd.CombinedOutput(); err != nil { + return engine.Result{}, fmt.Errorf("povray render failed: %w: %s", err, string(out)) + } + return engine.Result{Output: job.Output}, nil +} From 95d9af033d7a31fa3c84369f609884adec9645dc Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:05:21 +0200 Subject: [PATCH 003/201] M1: add internal/engine/povray/povray_test.go --- internal/engine/povray/povray_test.go | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 internal/engine/povray/povray_test.go diff --git a/internal/engine/povray/povray_test.go b/internal/engine/povray/povray_test.go new file mode 100644 index 0000000..36564c7 --- /dev/null +++ b/internal/engine/povray/povray_test.go @@ -0,0 +1,44 @@ +package povray + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Ploos-AS/AmiRender/internal/engine" +) + +func TestRenderUsesArgumentVectorWithoutShell(t *testing.T) { + dir := t.TempDir() + log := filepath.Join(dir, "args.txt") + fake := filepath.Join(dir, "povray") + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"" + log + "\"\n" + if err := os.WriteFile(fake, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + e := New(fake) + scene := "scene;touch SHOULD_NOT_EXIST.pov" + _, err := e.Render(context.Background(), engine.Job{ + ID: "m1-test", Scene: scene, Output: "frame.png", Width: 320, Height: 256, + }) + if err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + got := string(data) + for _, want := range []string{"+I" + scene, "+Oframe.png", "-D", "+W320", "+H256"} { + if !strings.Contains(got, want) { + t.Fatalf("missing argument %q in %q", want, got) + } + } + if _, err := os.Stat("SHOULD_NOT_EXIST.pov"); !os.IsNotExist(err) { + t.Fatal("scene name was interpreted by a shell") + } +} From 41f9b407b4f1e7dd82e3e33d32c128d8fd6df6b3 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:05:23 +0200 Subject: [PATCH 004/201] M1: add tests/scenes/m1-smoke.pov --- tests/scenes/m1-smoke.pov | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 tests/scenes/m1-smoke.pov diff --git a/tests/scenes/m1-smoke.pov b/tests/scenes/m1-smoke.pov new file mode 100644 index 0000000..b1b2e08 --- /dev/null +++ b/tests/scenes/m1-smoke.pov @@ -0,0 +1,4 @@ +// AmiRender M1 POV-Ray smoke scene +camera { location <0, 0, -3> look_at <0, 0, 0> } +light_source { <-2, 3, -4> color rgb <1, 1, 1> } +sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } From 6cda74b9a86d37cca40bca7b63942febe1455176 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:05:25 +0200 Subject: [PATCH 005/201] M1: add docs/M1.md --- docs/M1.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/M1.md diff --git a/docs/M1.md b/docs/M1.md new file mode 100644 index 0000000..1600aa2 --- /dev/null +++ b/docs/M1.md @@ -0,0 +1,16 @@ +# M1 — POV-Ray backend and first real client path + +## Goals + +1. Promote the M0 engine contract into Go packages. +2. Add POV-Ray as the first production renderer. +3. Execute POV-Ray without a shell. +4. Add a deterministic smoke scene. +5. Integrate the adapter with the node protocol. +6. Add the first AmigaOS/m68k submission client. + +POV-Ray is an external runtime dependency. AmiRender does not vendor or redistribute it. + +## Security boundary + +Scene paths and render options are passed as process arguments, never interpolated into a shell command. The appliance will later add job-root path confinement and an asset cache. From 80d69675fbf203e6981b0f0abffefc4999b5a938 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:05:37 +0200 Subject: [PATCH 006/201] M1: route POV-Ray jobs through node protocol --- cmd/amirender-node/main.go | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/cmd/amirender-node/main.go b/cmd/amirender-node/main.go index cc75124..926c0ff 100644 --- a/cmd/amirender-node/main.go +++ b/cmd/amirender-node/main.go @@ -2,10 +2,14 @@ package main import ( "bufio" + "context" "encoding/json" "fmt" "net" "os" + + "github.com/Ploos-AS/AmiRender/internal/engine" + "github.com/Ploos-AS/AmiRender/internal/engine/povray" ) type Message struct { @@ -16,7 +20,11 @@ type Message struct { type Job struct { ID string `json:"id"` Engine string `json:"engine"` + Scene string `json:"scene"` Output string `json:"output"` + Frame int `json:"frame"` + Width int `json:"width"` + Height int `json:"height"` } func handle(c net.Conn) { @@ -29,12 +37,31 @@ func handle(c net.Conn) { continue } var j Job - if json.Unmarshal(m.Job, &j) != nil || j.Engine != "null" { - fmt.Fprintln(c, `{"type":"FAILED","error":"unsupported engine"}`) + if json.Unmarshal(m.Job, &j) != nil { + fmt.Fprintln(c, `{"type":"FAILED","error":"bad job"}`) + continue + } + + if j.Engine == "null" { + out, _ := json.Marshal(map[string]any{"type": "COMPLETE", "job_id": j.ID, "engine": "null", "output": j.Output}) + fmt.Fprintln(c, string(out)) + continue + } + if j.Engine == "povray" { + e := povray.New("") + result, err := e.Render(context.Background(), engine.Job{ + ID: j.ID, Scene: j.Scene, Output: j.Output, Frame: j.Frame, Width: j.Width, Height: j.Height, + }) + if err != nil { + out, _ := json.Marshal(map[string]any{"type": "FAILED", "job_id": j.ID, "engine": "povray", "error": err.Error()}) + fmt.Fprintln(c, string(out)) + continue + } + out, _ := json.Marshal(map[string]any{"type": "COMPLETE", "job_id": j.ID, "engine": "povray", "output": result.Output}) + fmt.Fprintln(c, string(out)) continue } - out, _ := json.Marshal(map[string]any{"type": "COMPLETE", "job_id": j.ID, "engine": "null", "output": j.Output}) - fmt.Fprintln(c, string(out)) + fmt.Fprintln(c, `{"type":"FAILED","error":"unsupported engine"}`) } } @@ -48,7 +75,7 @@ func main() { panic(err) } defer ln.Close() - fmt.Printf("AmiRender M0 node listening on %s\n", addr) + fmt.Printf("AmiRender node listening on %s\n", addr) for { c, err := ln.Accept() if err != nil { From eb3368fef1a7b1a8a7b7eb3aca86c4e4a5573552 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:17:48 +0200 Subject: [PATCH 007/201] M1: gate real POV-Ray smoke render in CI --- .github/workflows/ci.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e99e445..266608b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,14 +3,23 @@ on: push: pull_request: jobs: - m0: + m1: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.22.x' - - run: gofmt -w cmd + - run: gofmt -w cmd internal - run: git diff --exit-code - run: go test ./... - run: go build ./cmd/amirender-node ./cmd/amirender-controller + - name: Install POV-Ray + run: | + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y povray + - name: Render POV-Ray smoke scene + run: | + mkdir -p build + povray +Itests/scenes/m1-smoke.pov +Obuild/m1-smoke.png +W160 +H120 -D +FN + test -s build/m1-smoke.png From c430a5ad0b24b6e9d5ba187a11c9928e7a4c7ded Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:21:22 +0200 Subject: [PATCH 008/201] M1: add farm capability.go --- internal/farm/capability.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 internal/farm/capability.go diff --git a/internal/farm/capability.go b/internal/farm/capability.go new file mode 100644 index 0000000..3844577 --- /dev/null +++ b/internal/farm/capability.go @@ -0,0 +1,21 @@ +package farm + +type Capability struct { + Engine string `json:"engine"` + Modes []string `json:"modes,omitempty"` +} + +type Node struct { + ID string `json:"id"` + Arch string `json:"arch"` + Capabilities []Capability `json:"capabilities"` +} + +func (n Node) Supports(engine string) bool { + for _, c := range n.Capabilities { + if c.Engine == engine { + return true + } + } + return false +} From 0fe21f624d478408267b3c79b9690cd567105d6e Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:21:25 +0200 Subject: [PATCH 009/201] M1: add farm scheduler.go --- internal/farm/scheduler.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 internal/farm/scheduler.go diff --git a/internal/farm/scheduler.go b/internal/farm/scheduler.go new file mode 100644 index 0000000..e9d1452 --- /dev/null +++ b/internal/farm/scheduler.go @@ -0,0 +1,17 @@ +package farm + +import "errors" + +var ErrNoCapableNode = errors.New("no capable node") + +// SelectNode is deliberately simple in M1: first capable node wins. +// Later schedulers can add load, benchmark weights, locality and GPU preference +// without changing the job or engine contracts. +func SelectNode(nodes []Node, engine string) (Node, error) { + for _, n := range nodes { + if n.Supports(engine) { + return n, nil + } + } + return Node{}, ErrNoCapableNode +} From d8ce64ef373855e8365fde9da8e6c6d00de5065b Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:21:27 +0200 Subject: [PATCH 010/201] M1: add farm scheduler_test.go --- internal/farm/scheduler_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 internal/farm/scheduler_test.go diff --git a/internal/farm/scheduler_test.go b/internal/farm/scheduler_test.go new file mode 100644 index 0000000..435a0e9 --- /dev/null +++ b/internal/farm/scheduler_test.go @@ -0,0 +1,18 @@ +package farm + +import "testing" + +func TestSelectNodeByEngineCapability(t *testing.T) { + nodes := []Node{ + {ID: "arm-01", Arch: "arm64", Capabilities: []Capability{{Engine: "povray"}}}, + {ID: "gpu-01", Arch: "amd64", Capabilities: []Capability{{Engine: "povray"}, {Engine: "blender-cycles"}}}, + } + n, err := SelectNode(nodes, "povray") + if err != nil || n.ID != "arm-01" { + t.Fatalf("unexpected selection: %#v %v", n, err) + } + n, err = SelectNode(nodes, "blender-cycles") + if err != nil || n.ID != "gpu-01" { + t.Fatalf("unexpected Blender selection: %#v %v", n, err) + } +} From a2645f6e8f5a173b4c0562fefe6186873181fd52 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:21:29 +0200 Subject: [PATCH 011/201] M1: add farm FARM.md --- docs/FARM.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/FARM.md diff --git a/docs/FARM.md b/docs/FARM.md new file mode 100644 index 0000000..12a8808 --- /dev/null +++ b/docs/FARM.md @@ -0,0 +1,28 @@ +# AmiRender farm model + +AmiRender uses one engine-agnostic farm for all render backends. + +A node advertises capabilities such as: + +- `povray` +- `blender-cycles` +- `blender-eevee` +- future `ami3d`, `amiterrain` and translated classic engines + +The controller schedules a job only to a node advertising the requested engine. + +## POV-Ray + +POV-Ray is a first-class farm engine, not a local-only appliance backend. + +M1 targets frame-level distribution. Each animation frame is an independent render job and may execute on any capable ARM64 or AMD64 node. A single AmiRender appliance may act as both controller and worker. + +Region/tile distribution for large still images is intentionally deferred until the frame scheduler is stable. + +## Blender + +Blender follows the same farm contract. CPU/GPU-specific capabilities may be advertised separately so the scheduler can prefer appropriate workers without exposing those implementation details to Amiga clients. + +## Scheduler evolution + +M1 uses deterministic first-capable-node selection as a testable baseline. Later milestones may add measured performance weights, current load, asset locality, architecture, memory and GPU capability. From d7d74bd68d1cd068752d23bd2112841b5bcb6217 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:21:45 +0200 Subject: [PATCH 012/201] M1: advertise farm engine capabilities --- protocol/node.schema.json | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/protocol/node.schema.json b/protocol/node.schema.json index adf2a65..0d685ac 100644 --- a/protocol/node.schema.json +++ b/protocol/node.schema.json @@ -2,17 +2,28 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "AmiRender Node", "type": "object", - "required": ["version", "id", "arch", "engines"], + "required": ["version", "id", "arch", "capabilities"], "properties": { "version": { "type": "integer", "const": 1 }, "id": { "type": "string", "minLength": 1 }, "arch": { "type": "string", "enum": ["m68k", "arm64", "amd64"] }, "os": { "type": "string" }, "memory_mb": { "type": "integer", "minimum": 1 }, - "engines": { + "capabilities": { "type": "array", - "items": { "type": "string", "minLength": 1 }, - "uniqueItems": true + "items": { + "type": "object", + "required": ["engine"], + "properties": { + "engine": { "type": "string", "minLength": 1 }, + "modes": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + } + }, + "additionalProperties": false + } } }, "additionalProperties": false From 6d1b2fd24a3fffc9c59381e4e17e6dade0aed293 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:30:05 +0200 Subject: [PATCH 013/201] M1: add optional idle workload workload.go --- internal/idle/workload.go | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 internal/idle/workload.go diff --git a/internal/idle/workload.go b/internal/idle/workload.go new file mode 100644 index 0000000..e157faa --- /dev/null +++ b/internal/idle/workload.go @@ -0,0 +1,42 @@ +package idle + +import ( + "context" + "errors" + "time" +) + +var ErrDisabled = errors.New("idle workload disabled") + +type Config struct { + Enabled bool + IdleDelay time.Duration + CPUPercent int +} + +type Workload interface { + Name() string + Start(context.Context) error + Stop() error +} + +type Manager struct { + Config Config + Workload Workload +} + +func (m *Manager) StartIfIdle(ctx context.Context) error { + if !m.Config.Enabled { + return ErrDisabled + } + return m.Workload.Start(ctx) +} + +// Preempt is called before render work is dispatched. +// Idle work must never block rendering. +func (m *Manager) Preempt() error { + if m.Workload == nil { + return nil + } + return m.Workload.Stop() +} From 25e945a5c54df1e5cfff78a7b1af9d7dfffe4455 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:30:07 +0200 Subject: [PATCH 014/201] M1: add optional idle workload workload_test.go --- internal/idle/workload_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 internal/idle/workload_test.go diff --git a/internal/idle/workload_test.go b/internal/idle/workload_test.go new file mode 100644 index 0000000..4ba2efc --- /dev/null +++ b/internal/idle/workload_test.go @@ -0,0 +1,27 @@ +package idle + +import ( + "context" + "errors" + "testing" +) + +type fakeWorkload struct{ started, stopped bool } +func (f *fakeWorkload) Name() string { return "fake" } +func (f *fakeWorkload) Start(context.Context) error { f.started=true; return nil } +func (f *fakeWorkload) Stop() error { f.stopped=true; return nil } + +func TestDisabledByDefault(t *testing.T) { + f:=&fakeWorkload{} + m:=Manager{Workload:f} + if !errors.Is(m.StartIfIdle(context.Background()), ErrDisabled) { t.Fatal("idle workload must be disabled by default") } + if f.started { t.Fatal("disabled workload started") } +} + +func TestRenderCanPreemptIdleWork(t *testing.T) { + f:=&fakeWorkload{} + m:=Manager{Config:Config{Enabled:true},Workload:f} + if err:=m.StartIfIdle(context.Background()); err!=nil { t.Fatal(err) } + if err:=m.Preempt(); err!=nil { t.Fatal(err) } + if !f.stopped { t.Fatal("idle workload was not stopped") } +} From 7b0b273bb6d13ff98e0973d7c72e70a7c49d9305 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:30:10 +0200 Subject: [PATCH 015/201] M1: add optional idle workload IDLE-WORKLOADS.md --- docs/IDLE-WORKLOADS.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/IDLE-WORKLOADS.md diff --git a/docs/IDLE-WORKLOADS.md b/docs/IDLE-WORKLOADS.md new file mode 100644 index 0000000..32f2f3c --- /dev/null +++ b/docs/IDLE-WORKLOADS.md @@ -0,0 +1,39 @@ +# Optional idle workloads + +AmiRender may use otherwise idle ARM64/AMD64 worker capacity for explicitly enabled background workloads. + +## Policy + +- disabled by default +- explicit opt-in per node +- rendering always has absolute priority +- idle work is stopped before render dispatch +- configurable idle delay and resource limits +- credentials, wallet addresses and pool configuration are supplied by the node owner; AmiRender ships none +- idle and render accounting remain separate +- m68k workers do not enable idle mining by default + +## Initial adapters + +Planned initial adapters: + +- Monero / RandomX +- Verus / VerusHash + +Mining software is an external dependency. AmiRender does not vendor miners and does not contain default wallets or mining-pool credentials. + +## Generality + +The subsystem is intentionally named idle workloads rather than mining. Future adapters may include CI, transcoding or other owner-authorized batch work. + +## Node lifecycle + +IDLE -> IDLE_DELAY -> IDLE_WORKLOAD + +When a render job arrives: + +IDLE_WORKLOAD -> PREEMPTING -> RENDERING + +After the render queue drains: + +RENDERING -> IDLE -> IDLE_DELAY -> IDLE_WORKLOAD From c925722111f06580265b563258f94005a0caa83f Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:30:12 +0200 Subject: [PATCH 016/201] M1: add optional idle workload idle.example.json --- config/idle.example.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 config/idle.example.json diff --git a/config/idle.example.json b/config/idle.example.json new file mode 100644 index 0000000..b0e7937 --- /dev/null +++ b/config/idle.example.json @@ -0,0 +1,17 @@ +{ + "enabled": false, + "workload": "none", + "idle_delay_seconds": 300, + "cpu_percent": 50, + "temperature_limit_c": 75, + "monero": { + "executable": "", + "pool": "", + "wallet": "" + }, + "verus": { + "executable": "", + "pool": "", + "wallet": "" + } +} From 8bd6e96cc1f43575f3505f221fb8ef07861231a4 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:32:30 +0200 Subject: [PATCH 017/201] M1: gofmt idle workload core --- internal/idle/workload.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/idle/workload.go b/internal/idle/workload.go index e157faa..c849467 100644 --- a/internal/idle/workload.go +++ b/internal/idle/workload.go @@ -9,8 +9,8 @@ import ( var ErrDisabled = errors.New("idle workload disabled") type Config struct { - Enabled bool - IdleDelay time.Duration + Enabled bool + IdleDelay time.Duration CPUPercent int } @@ -21,7 +21,7 @@ type Workload interface { } type Manager struct { - Config Config + Config Config Workload Workload } From 6583fdf7c556cb65a9bc32d00dcc59ebd4ce26ca Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:32:33 +0200 Subject: [PATCH 018/201] M1: gofmt idle workload core --- internal/idle/workload_test.go | 35 ++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/internal/idle/workload_test.go b/internal/idle/workload_test.go index 4ba2efc..0af33c3 100644 --- a/internal/idle/workload_test.go +++ b/internal/idle/workload_test.go @@ -7,21 +7,32 @@ import ( ) type fakeWorkload struct{ started, stopped bool } -func (f *fakeWorkload) Name() string { return "fake" } -func (f *fakeWorkload) Start(context.Context) error { f.started=true; return nil } -func (f *fakeWorkload) Stop() error { f.stopped=true; return nil } + +func (f *fakeWorkload) Name() string { return "fake" } +func (f *fakeWorkload) Start(context.Context) error { f.started = true; return nil } +func (f *fakeWorkload) Stop() error { f.stopped = true; return nil } func TestDisabledByDefault(t *testing.T) { - f:=&fakeWorkload{} - m:=Manager{Workload:f} - if !errors.Is(m.StartIfIdle(context.Background()), ErrDisabled) { t.Fatal("idle workload must be disabled by default") } - if f.started { t.Fatal("disabled workload started") } + f := &fakeWorkload{} + m := Manager{Workload: f} + if !errors.Is(m.StartIfIdle(context.Background()), ErrDisabled) { + t.Fatal("idle workload must be disabled by default") + } + if f.started { + t.Fatal("disabled workload started") + } } func TestRenderCanPreemptIdleWork(t *testing.T) { - f:=&fakeWorkload{} - m:=Manager{Config:Config{Enabled:true},Workload:f} - if err:=m.StartIfIdle(context.Background()); err!=nil { t.Fatal(err) } - if err:=m.Preempt(); err!=nil { t.Fatal(err) } - if !f.stopped { t.Fatal("idle workload was not stopped") } + f := &fakeWorkload{} + m := Manager{Config: Config{Enabled: true}, Workload: f} + if err := m.StartIfIdle(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.Preempt(); err != nil { + t.Fatal(err) + } + if !f.stopped { + t.Fatal("idle workload was not stopped") + } } From 17df42644c727342febd5a5610d74a5e45b861e0 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:32:35 +0200 Subject: [PATCH 019/201] M1: add idle lifecycle state machine --- internal/idle/state.go | 53 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 internal/idle/state.go diff --git a/internal/idle/state.go b/internal/idle/state.go new file mode 100644 index 0000000..90ec0ac --- /dev/null +++ b/internal/idle/state.go @@ -0,0 +1,53 @@ +package idle + +import "fmt" + +type State string + +const ( + StateIdle State = "IDLE" + StateDelay State = "IDLE_DELAY" + StateWorkload State = "IDLE_WORKLOAD" + StatePreempting State = "PREEMPTING" + StateRendering State = "RENDERING" +) + +type Machine struct{ State State } + +func NewMachine() *Machine { return &Machine{State: StateIdle} } + +func (m *Machine) IdleTimerStarted() error { + if m.State != StateIdle { return fmt.Errorf("cannot start idle delay from %s", m.State) } + m.State = StateDelay + return nil +} + +func (m *Machine) IdleTimerExpired() error { + if m.State != StateDelay { return fmt.Errorf("cannot start workload from %s", m.State) } + m.State = StateWorkload + return nil +} + +func (m *Machine) RenderArrived() error { + switch m.State { + case StateIdle, StateDelay: + m.State = StateRendering + case StateWorkload: + m.State = StatePreempting + default: + return fmt.Errorf("cannot accept render from %s", m.State) + } + return nil +} + +func (m *Machine) Preempted() error { + if m.State != StatePreempting { return fmt.Errorf("cannot finish preemption from %s", m.State) } + m.State = StateRendering + return nil +} + +func (m *Machine) RenderQueueDrained() error { + if m.State != StateRendering { return fmt.Errorf("cannot drain render queue from %s", m.State) } + m.State = StateIdle + return nil +} From ef8009f22dcf4c532f2871d40fcfbd50456f41b2 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:32:37 +0200 Subject: [PATCH 020/201] M1: add idle lifecycle state machine --- internal/idle/state_test.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 internal/idle/state_test.go diff --git a/internal/idle/state_test.go b/internal/idle/state_test.go new file mode 100644 index 0000000..7f6938c --- /dev/null +++ b/internal/idle/state_test.go @@ -0,0 +1,25 @@ +package idle + +import "testing" + +func TestIdleWorkloadPreemptLifecycle(t *testing.T) { + m := NewMachine() + steps := []struct{ want State; fn func() error }{ + {StateDelay, m.IdleTimerStarted}, + {StateWorkload, m.IdleTimerExpired}, + {StatePreempting, m.RenderArrived}, + {StateRendering, m.Preempted}, + {StateIdle, m.RenderQueueDrained}, + } + for _, s := range steps { + if err := s.fn(); err != nil { t.Fatal(err) } + if m.State != s.want { t.Fatalf("want %s, got %s", s.want, m.State) } + } +} + +func TestRenderSkipsIdleWorkloadDuringDelay(t *testing.T) { + m := NewMachine() + if err := m.IdleTimerStarted(); err != nil { t.Fatal(err) } + if err := m.RenderArrived(); err != nil { t.Fatal(err) } + if m.State != StateRendering { t.Fatalf("want rendering, got %s", m.State) } +} From e755ce9d16a220eb7ba2b889ab5a2f24e7daaae3 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:33:40 +0200 Subject: [PATCH 021/201] M1: normalize idle lifecycle formatting --- internal/idle/state.go | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/idle/state.go b/internal/idle/state.go index 90ec0ac..d75aada 100644 --- a/internal/idle/state.go +++ b/internal/idle/state.go @@ -5,11 +5,11 @@ import "fmt" type State string const ( - StateIdle State = "IDLE" - StateDelay State = "IDLE_DELAY" - StateWorkload State = "IDLE_WORKLOAD" - StatePreempting State = "PREEMPTING" - StateRendering State = "RENDERING" + StateIdle State = "IDLE" + StateDelay State = "IDLE_DELAY" + StateWorkload State = "IDLE_WORKLOAD" + StatePreempting State = "PREEMPTING" + StateRendering State = "RENDERING" ) type Machine struct{ State State } @@ -17,13 +17,17 @@ type Machine struct{ State State } func NewMachine() *Machine { return &Machine{State: StateIdle} } func (m *Machine) IdleTimerStarted() error { - if m.State != StateIdle { return fmt.Errorf("cannot start idle delay from %s", m.State) } + if m.State != StateIdle { + return fmt.Errorf("cannot start idle delay from %s", m.State) + } m.State = StateDelay return nil } func (m *Machine) IdleTimerExpired() error { - if m.State != StateDelay { return fmt.Errorf("cannot start workload from %s", m.State) } + if m.State != StateDelay { + return fmt.Errorf("cannot start workload from %s", m.State) + } m.State = StateWorkload return nil } @@ -41,13 +45,17 @@ func (m *Machine) RenderArrived() error { } func (m *Machine) Preempted() error { - if m.State != StatePreempting { return fmt.Errorf("cannot finish preemption from %s", m.State) } + if m.State != StatePreempting { + return fmt.Errorf("cannot finish preemption from %s", m.State) + } m.State = StateRendering return nil } func (m *Machine) RenderQueueDrained() error { - if m.State != StateRendering { return fmt.Errorf("cannot drain render queue from %s", m.State) } + if m.State != StateRendering { + return fmt.Errorf("cannot drain render queue from %s", m.State) + } m.State = StateIdle return nil } From 63e46f2e39d2842c7861f0f41f97a4b452be9840 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:33:42 +0200 Subject: [PATCH 022/201] M1: normalize idle lifecycle formatting --- internal/idle/state_test.go | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/idle/state_test.go b/internal/idle/state_test.go index 7f6938c..78dfb60 100644 --- a/internal/idle/state_test.go +++ b/internal/idle/state_test.go @@ -4,7 +4,10 @@ import "testing" func TestIdleWorkloadPreemptLifecycle(t *testing.T) { m := NewMachine() - steps := []struct{ want State; fn func() error }{ + steps := []struct { + want State + fn func() error + }{ {StateDelay, m.IdleTimerStarted}, {StateWorkload, m.IdleTimerExpired}, {StatePreempting, m.RenderArrived}, @@ -12,14 +15,24 @@ func TestIdleWorkloadPreemptLifecycle(t *testing.T) { {StateIdle, m.RenderQueueDrained}, } for _, s := range steps { - if err := s.fn(); err != nil { t.Fatal(err) } - if m.State != s.want { t.Fatalf("want %s, got %s", s.want, m.State) } + if err := s.fn(); err != nil { + t.Fatal(err) + } + if m.State != s.want { + t.Fatalf("want %s, got %s", s.want, m.State) + } } } func TestRenderSkipsIdleWorkloadDuringDelay(t *testing.T) { m := NewMachine() - if err := m.IdleTimerStarted(); err != nil { t.Fatal(err) } - if err := m.RenderArrived(); err != nil { t.Fatal(err) } - if m.State != StateRendering { t.Fatalf("want rendering, got %s", m.State) } + if err := m.IdleTimerStarted(); err != nil { + t.Fatal(err) + } + if err := m.RenderArrived(); err != nil { + t.Fatal(err) + } + if m.State != StateRendering { + t.Fatalf("want rendering, got %s", m.State) + } } From 34c700e92a3edaa7fc6400a1782ee627309a8855 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 21:58:57 +0200 Subject: [PATCH 023/201] M1: add idle workload process adapter --- internal/idle/mining/config.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 internal/idle/mining/config.go diff --git a/internal/idle/mining/config.go b/internal/idle/mining/config.go new file mode 100644 index 0000000..353b1b2 --- /dev/null +++ b/internal/idle/mining/config.go @@ -0,0 +1,32 @@ +package mining + +import ( + "fmt" + + "github.com/Ploos-AS/AmiRender/internal/idle" +) + +type Config struct { + Executable string + Pool string + Wallet string + ExtraArgs []string +} + +func Monero(c Config) (idle.Workload, error) { + if c.Executable == "" || c.Pool == "" || c.Wallet == "" { + return nil, fmt.Errorf("monero requires executable, pool and wallet") + } + args := []string{"-o", c.Pool, "-u", c.Wallet, "--coin", "monero"} + args = append(args, c.ExtraArgs...) + return idle.NewProcessWorkload("monero", c.Executable, args), nil +} + +func Verus(c Config) (idle.Workload, error) { + if c.Executable == "" || c.Pool == "" || c.Wallet == "" { + return nil, fmt.Errorf("verus requires executable, pool and wallet") + } + args := []string{"-o", c.Pool, "-u", c.Wallet} + args = append(args, c.ExtraArgs...) + return idle.NewProcessWorkload("verus", c.Executable, args), nil +} From 83fc7b50c2415e887602621a6c654c02a34dad60 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 22:13:00 +0200 Subject: [PATCH 024/201] M1: keep mining integration policy-only --- internal/idle/mining/config.go | 39 +++++++++++++++++----------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/internal/idle/mining/config.go b/internal/idle/mining/config.go index 353b1b2..1b81386 100644 --- a/internal/idle/mining/config.go +++ b/internal/idle/mining/config.go @@ -1,32 +1,33 @@ package mining -import ( - "fmt" - - "github.com/Ploos-AS/AmiRender/internal/idle" -) +import "fmt" +// Config describes an owner-supplied external idle workload. +// AmiRender stores policy and configuration only; execution is delegated +// outside the render node. type Config struct { + Enabled bool + Kind string Executable string Pool string Wallet string - ExtraArgs []string + CPUPercent int } -func Monero(c Config) (idle.Workload, error) { - if c.Executable == "" || c.Pool == "" || c.Wallet == "" { - return nil, fmt.Errorf("monero requires executable, pool and wallet") +func (c Config) Validate() error { + if !c.Enabled { + return nil + } + switch c.Kind { + case "monero", "verus": + default: + return fmt.Errorf("unsupported mining workload %q", c.Kind) } - args := []string{"-o", c.Pool, "-u", c.Wallet, "--coin", "monero"} - args = append(args, c.ExtraArgs...) - return idle.NewProcessWorkload("monero", c.Executable, args), nil -} - -func Verus(c Config) (idle.Workload, error) { if c.Executable == "" || c.Pool == "" || c.Wallet == "" { - return nil, fmt.Errorf("verus requires executable, pool and wallet") + return fmt.Errorf("%s requires owner-supplied executable, pool and wallet", c.Kind) + } + if c.CPUPercent < 1 || c.CPUPercent > 100 { + return fmt.Errorf("cpu percent must be between 1 and 100") } - args := []string{"-o", c.Pool, "-u", c.Wallet} - args = append(args, c.ExtraArgs...) - return idle.NewProcessWorkload("verus", c.Executable, args), nil + return nil } From ec15b1801148a937a43981781ce87fba2d632818 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 22:13:06 +0200 Subject: [PATCH 025/201] M1: test opt-in mining policy --- internal/idle/mining/config_test.go | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 internal/idle/mining/config_test.go diff --git a/internal/idle/mining/config_test.go b/internal/idle/mining/config_test.go new file mode 100644 index 0000000..bd16d07 --- /dev/null +++ b/internal/idle/mining/config_test.go @@ -0,0 +1,30 @@ +package mining + +import "testing" + +func TestDisabledConfigurationNeedsNoCredentials(t *testing.T) { + if err := (Config{}).Validate(); err != nil { + t.Fatal(err) + } +} + +func TestEnabledProfilesRequireOwnerConfiguration(t *testing.T) { + for _, kind := range []string{"monero", "verus"} { + c := Config{Enabled: true, Kind: kind, CPUPercent: 50} + if err := c.Validate(); err == nil { + t.Fatalf("%s accepted missing owner configuration", kind) + } + } +} + +func TestConfiguredProfilesValidate(t *testing.T) { + for _, kind := range []string{"monero", "verus"} { + c := Config{ + Enabled: true, Kind: kind, Executable: "/external/worker", + Pool: "owner-configured", Wallet: "owner-configured", CPUPercent: 50, + } + if err := c.Validate(); err != nil { + t.Fatalf("%s: %v", kind, err) + } + } +} From 619c757850915fa508f4587959b08634a9b10f75 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 23:04:05 +0200 Subject: [PATCH 026/201] M1: add farm node registration --- internal/farm/registry.go | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 internal/farm/registry.go diff --git a/internal/farm/registry.go b/internal/farm/registry.go new file mode 100644 index 0000000..76492de --- /dev/null +++ b/internal/farm/registry.go @@ -0,0 +1,48 @@ +package farm + +import ( + "fmt" + "sort" + "sync" +) + +type Registry struct { + mu sync.RWMutex + nodes map[string]Node +} + +func NewRegistry() *Registry { + return &Registry{nodes: make(map[string]Node)} +} + +func (r *Registry) Register(node Node) error { + if node.ID == "" { + return fmt.Errorf("node id is required") + } + if len(node.Capabilities) == 0 { + return fmt.Errorf("node %q has no capabilities", node.ID) + } + r.mu.Lock() + defer r.mu.Unlock() + r.nodes[node.ID] = node + return nil +} + +func (r *Registry) Remove(id string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.nodes, id) +} + +func (r *Registry) NodesFor(engine string) []Node { + r.mu.RLock() + defer r.mu.RUnlock() + var out []Node + for _, node := range r.nodes { + if node.Supports(engine) { + out = append(out, node) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} From 111bffea512426c55beb5530dc02b527f3630fb7 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 23:04:08 +0200 Subject: [PATCH 027/201] M1: add farm node registration --- internal/farm/registry_test.go | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 internal/farm/registry_test.go diff --git a/internal/farm/registry_test.go b/internal/farm/registry_test.go new file mode 100644 index 0000000..a68e5fa --- /dev/null +++ b/internal/farm/registry_test.go @@ -0,0 +1,36 @@ +package farm + +import "testing" + +func TestRegistryFiltersByCapability(t *testing.T) { + r := NewRegistry() + for _, n := range []Node{ + {ID: "rock5-01", Arch: "arm64", Capabilities: []Capability{{Engine: "povray"}}}, + {ID: "gpu-01", Arch: "amd64", Capabilities: []Capability{{Engine: "povray"}, {Engine: "blender-cycles"}}}, + } { + if err := r.Register(n); err != nil { + t.Fatal(err) + } + } + pov := r.NodesFor("povray") + if len(pov) != 2 { + t.Fatalf("want 2 POV-Ray nodes, got %d", len(pov)) + } + blender := r.NodesFor("blender-cycles") + if len(blender) != 1 || blender[0].ID != "gpu-01" { + t.Fatalf("unexpected Blender nodes: %#v", blender) + } +} + +func TestReregistrationUpdatesCapabilities(t *testing.T) { + r := NewRegistry() + if err := r.Register(Node{ID: "node-01", Arch: "arm64", Capabilities: []Capability{{Engine: "povray"}}}); err != nil { + t.Fatal(err) + } + if err := r.Register(Node{ID: "node-01", Arch: "arm64", Capabilities: []Capability{{Engine: "blender-cycles"}}}); err != nil { + t.Fatal(err) + } + if len(r.NodesFor("povray")) != 0 || len(r.NodesFor("blender-cycles")) != 1 { + t.Fatal("re-registration did not replace capabilities") + } +} From 76ac0a66a2241da2c532ef51b0f45feb0e8f2f44 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 23:04:10 +0200 Subject: [PATCH 028/201] M1: add farm node registration --- protocol/messages.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 protocol/messages.md diff --git a/protocol/messages.md b/protocol/messages.md new file mode 100644 index 0000000..1945645 --- /dev/null +++ b/protocol/messages.md @@ -0,0 +1,37 @@ +# Control messages + +AmiRender control messages are newline-delimited JSON. + +## REGISTER + +A worker registers itself with the controller and advertises render capabilities. + +Example: + +```json +{ + "type": "REGISTER", + "node": { + "version": 1, + "id": "rock5-01", + "arch": "arm64", + "os": "linux", + "memory_mb": 16384, + "capabilities": [ + {"engine": "povray"} + ] + } +} +``` + +Re-registering the same node ID replaces its previous capability record. This allows a worker to update capabilities after configuration or runtime changes. + +## REGISTERED + +The controller acknowledges a valid registration: + +```json +{"type":"REGISTERED","node_id":"rock5-01"} +``` + +Registration is the M1 discovery baseline. Network service discovery such as mDNS may locate controllers/workers later, but capability truth comes from the registration protocol. From 4e1cb0472343f08c9611b0b240fef2d2f2f4fa4b Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 23:24:53 +0200 Subject: [PATCH 029/201] M1: route jobs through farm registry --- internal/farm/dispatch.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 internal/farm/dispatch.go diff --git a/internal/farm/dispatch.go b/internal/farm/dispatch.go new file mode 100644 index 0000000..31d4624 --- /dev/null +++ b/internal/farm/dispatch.go @@ -0,0 +1,28 @@ +package farm + +import "fmt" + +type Assignment struct { + JobID string + Engine string + Node Node +} + +type Dispatcher struct { + Registry *Registry +} + +func (d Dispatcher) Assign(jobID, engine string) (Assignment, error) { + if d.Registry == nil { + return Assignment{}, fmt.Errorf("registry is required") + } + if jobID == "" || engine == "" { + return Assignment{}, fmt.Errorf("job id and engine are required") + } + candidates := d.Registry.NodesFor(engine) + node, err := SelectNode(candidates, engine) + if err != nil { + return Assignment{}, err + } + return Assignment{JobID: jobID, Engine: engine, Node: node}, nil +} From 923d836328c16ed56f4c1e9245c18758960f3e51 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 23:24:55 +0200 Subject: [PATCH 030/201] M1: route jobs through farm registry --- internal/farm/dispatch_test.go | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 internal/farm/dispatch_test.go diff --git a/internal/farm/dispatch_test.go b/internal/farm/dispatch_test.go new file mode 100644 index 0000000..1282f79 --- /dev/null +++ b/internal/farm/dispatch_test.go @@ -0,0 +1,44 @@ +package farm + +import ( + "errors" + "testing" +) + +func TestDispatcherRoutesPOVRayToRegisteredWorker(t *testing.T) { + r := NewRegistry() + if err := r.Register(Node{ + ID: "rock5-01", Arch: "arm64", + Capabilities: []Capability{{Engine: "povray"}}, + }); err != nil { + t.Fatal(err) + } + if err := r.Register(Node{ + ID: "gpu-01", Arch: "amd64", + Capabilities: []Capability{{Engine: "blender-cycles"}}, + }); err != nil { + t.Fatal(err) + } + + a, err := (Dispatcher{Registry: r}).Assign("frame-0042", "povray") + if err != nil { + t.Fatal(err) + } + if a.Node.ID != "rock5-01" || a.Engine != "povray" || a.JobID != "frame-0042" { + t.Fatalf("unexpected assignment: %#v", a) + } +} + +func TestDispatcherRejectsMissingCapability(t *testing.T) { + r := NewRegistry() + if err := r.Register(Node{ + ID: "rock5-01", Arch: "arm64", + Capabilities: []Capability{{Engine: "povray"}}, + }); err != nil { + t.Fatal(err) + } + _, err := (Dispatcher{Registry: r}).Assign("frame-0001", "blender-cycles") + if !errors.Is(err, ErrNoCapableNode) { + t.Fatalf("want ErrNoCapableNode, got %v", err) + } +} From 6f47fdad97c0910d8115153d252096a03a12ac8a Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 23:32:46 +0200 Subject: [PATCH 031/201] M1: add controller worker transport --- internal/farm/transport.go | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 internal/farm/transport.go diff --git a/internal/farm/transport.go b/internal/farm/transport.go new file mode 100644 index 0000000..94725ca --- /dev/null +++ b/internal/farm/transport.go @@ -0,0 +1,62 @@ +package farm + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net" +) + +type RenderJob struct { + ID string `json:"id"` + Engine string `json:"engine"` + Scene string `json:"scene"` + Output string `json:"output"` + Frame int `json:"frame,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +type WorkerResult struct { + Type string `json:"type"` + JobID string `json:"job_id"` + Engine string `json:"engine,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` +} + +type WorkerClient interface { + Render(context.Context, string, RenderJob) (WorkerResult, error) +} + +type TCPWorkerClient struct{} + +func (TCPWorkerClient) Render(ctx context.Context, address string, job RenderJob) (WorkerResult, error) { + var result WorkerResult + d := net.Dialer{} + conn, err := d.DialContext(ctx, "tcp", address) + if err != nil { + return result, err + } + defer conn.Close() + + msg := struct { + Type string `json:"type"` + Job RenderJob `json:"job"` + }{Type: "SUBMIT", Job: job} + if err := json.NewEncoder(conn).Encode(msg); err != nil { + return result, err + } + if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&result); err != nil { + return result, err + } + switch result.Type { + case "COMPLETE": + return result, nil + case "FAILED": + return result, fmt.Errorf("worker failed: %s", result.Error) + default: + return result, fmt.Errorf("unexpected worker response %q", result.Type) + } +} From 0ee9e0acfbdd1dedbea2c19a5575b6aac74651ae Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 23:32:48 +0200 Subject: [PATCH 032/201] M1: add controller worker transport --- internal/farm/transport_test.go | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 internal/farm/transport_test.go diff --git a/internal/farm/transport_test.go b/internal/farm/transport_test.go new file mode 100644 index 0000000..be244b0 --- /dev/null +++ b/internal/farm/transport_test.go @@ -0,0 +1,47 @@ +package farm + +import ( + "context" + "encoding/json" + "net" + "testing" + "time" +) + +func TestTCPWorkerClientRoundTrip(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + var msg struct { + Type string `json:"type"` + Job RenderJob `json:"job"` + } + if json.NewDecoder(c).Decode(&msg) != nil { + return + } + _ = json.NewEncoder(c).Encode(WorkerResult{ + Type: "COMPLETE", JobID: msg.Job.ID, Engine: msg.Job.Engine, Output: msg.Job.Output, + }) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + result, err := (TCPWorkerClient{}).Render(ctx, ln.Addr().String(), RenderJob{ + ID: "frame-0042", Engine: "povray", Scene: "scene.pov", Output: "frame0042.png", + }) + if err != nil { + t.Fatal(err) + } + if result.Type != "COMPLETE" || result.JobID != "frame-0042" || result.Engine != "povray" { + t.Fatalf("unexpected result: %#v", result) + } +} From 981087ae864ecbda8578e0e0856a8c9b451afe83 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 23:38:15 +0200 Subject: [PATCH 033/201] M1: integrate farm coordinator routing --- internal/farm/coordinator.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 internal/farm/coordinator.go diff --git a/internal/farm/coordinator.go b/internal/farm/coordinator.go new file mode 100644 index 0000000..aca4f65 --- /dev/null +++ b/internal/farm/coordinator.go @@ -0,0 +1,32 @@ +package farm + +import ( + "context" + "fmt" +) + +type EndpointNode struct { + Node + Address string +} + +type Coordinator struct { + Registry *Registry + Endpoints map[string]string + Client WorkerClient +} + +func (c Coordinator) Render(ctx context.Context, job RenderJob) (WorkerResult, error) { + if c.Registry == nil || c.Client == nil { + return WorkerResult{}, fmt.Errorf("registry and worker client are required") + } + assignment, err := (Dispatcher{Registry: c.Registry}).Assign(job.ID, job.Engine) + if err != nil { + return WorkerResult{}, err + } + address := c.Endpoints[assignment.Node.ID] + if address == "" { + return WorkerResult{}, fmt.Errorf("node %q has no endpoint", assignment.Node.ID) + } + return c.Client.Render(ctx, address, job) +} From b12d0f23a4965c5c2b4b099d07c5eb87df982c10 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 5 Oct 2026 23:38:17 +0200 Subject: [PATCH 034/201] M1: integrate farm coordinator routing --- internal/farm/coordinator_test.go | 81 +++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 internal/farm/coordinator_test.go diff --git a/internal/farm/coordinator_test.go b/internal/farm/coordinator_test.go new file mode 100644 index 0000000..fb9540b --- /dev/null +++ b/internal/farm/coordinator_test.go @@ -0,0 +1,81 @@ +package farm + +import ( + "context" + "encoding/json" + "net" + "testing" + "time" +) + +func TestCoordinatorRoutesJobToCapableWorker(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + received := make(chan RenderJob, 1) + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + var msg struct { + Type string `json:"type"` + Job RenderJob `json:"job"` + } + if json.NewDecoder(c).Decode(&msg) != nil { + return + } + received <- msg.Job + _ = json.NewEncoder(c).Encode(WorkerResult{ + Type: "COMPLETE", JobID: msg.Job.ID, Engine: msg.Job.Engine, Output: msg.Job.Output, + }) + }() + + r := NewRegistry() + if err := r.Register(Node{ + ID: "rock5-01", Arch: "arm64", + Capabilities: []Capability{{Engine: "povray"}}, + }); err != nil { + t.Fatal(err) + } + if err := r.Register(Node{ + ID: "gpu-01", Arch: "amd64", + Capabilities: []Capability{{Engine: "blender-cycles"}}, + }); err != nil { + t.Fatal(err) + } + + coordinator := Coordinator{ + Registry: r, + Endpoints: map[string]string{ + "rock5-01": ln.Addr().String(), + }, + Client: TCPWorkerClient{}, + } + job := RenderJob{ + ID: "frame-0042", Engine: "povray", Scene: "m1-smoke.pov", + Output: "frame0042.png", Frame: 42, Width: 160, Height: 120, + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + result, err := coordinator.Render(ctx, job) + if err != nil { + t.Fatal(err) + } + if result.Type != "COMPLETE" || result.JobID != job.ID { + t.Fatalf("unexpected result: %#v", result) + } + + select { + case got := <-received: + if got.Engine != "povray" || got.ID != job.ID { + t.Fatalf("wrong job reached worker: %#v", got) + } + case <-ctx.Done(): + t.Fatal("worker did not receive job") + } +} From 3b3efc89791eca80c8c28cbef6139760b544868e Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 02:06:57 +0200 Subject: [PATCH 035/201] M1: align worker with farm wire protocol --- cmd/amirender-node/main.go | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/cmd/amirender-node/main.go b/cmd/amirender-node/main.go index 926c0ff..54cb356 100644 --- a/cmd/amirender-node/main.go +++ b/cmd/amirender-node/main.go @@ -10,6 +10,7 @@ import ( "github.com/Ploos-AS/AmiRender/internal/engine" "github.com/Ploos-AS/AmiRender/internal/engine/povray" + "github.com/Ploos-AS/AmiRender/internal/farm" ) type Message struct { @@ -17,16 +18,6 @@ type Message struct { Job json.RawMessage `json:"job"` } -type Job struct { - ID string `json:"id"` - Engine string `json:"engine"` - Scene string `json:"scene"` - Output string `json:"output"` - Frame int `json:"frame"` - Width int `json:"width"` - Height int `json:"height"` -} - func handle(c net.Conn) { defer c.Close() s := bufio.NewScanner(c) @@ -36,15 +27,16 @@ func handle(c net.Conn) { fmt.Fprintln(c, `{"type":"FAILED","error":"bad request"}`) continue } - var j Job + var j farm.RenderJob if json.Unmarshal(m.Job, &j) != nil { fmt.Fprintln(c, `{"type":"FAILED","error":"bad job"}`) continue } if j.Engine == "null" { - out, _ := json.Marshal(map[string]any{"type": "COMPLETE", "job_id": j.ID, "engine": "null", "output": j.Output}) - fmt.Fprintln(c, string(out)) + writeResult(c, farm.WorkerResult{ + Type: "COMPLETE", JobID: j.ID, Engine: "null", Output: j.Output, + }) continue } if j.Engine == "povray" { @@ -53,18 +45,26 @@ func handle(c net.Conn) { ID: j.ID, Scene: j.Scene, Output: j.Output, Frame: j.Frame, Width: j.Width, Height: j.Height, }) if err != nil { - out, _ := json.Marshal(map[string]any{"type": "FAILED", "job_id": j.ID, "engine": "povray", "error": err.Error()}) - fmt.Fprintln(c, string(out)) + writeResult(c, farm.WorkerResult{ + Type: "FAILED", JobID: j.ID, Engine: "povray", Error: err.Error(), + }) continue } - out, _ := json.Marshal(map[string]any{"type": "COMPLETE", "job_id": j.ID, "engine": "povray", "output": result.Output}) - fmt.Fprintln(c, string(out)) + writeResult(c, farm.WorkerResult{ + Type: "COMPLETE", JobID: j.ID, Engine: "povray", Output: result.Output, + }) continue } - fmt.Fprintln(c, `{"type":"FAILED","error":"unsupported engine"}`) + writeResult(c, farm.WorkerResult{ + Type: "FAILED", JobID: j.ID, Engine: j.Engine, Error: "unsupported engine", + }) } } +func writeResult(c net.Conn, result farm.WorkerResult) { + _ = json.NewEncoder(c).Encode(result) +} + func main() { addr := "127.0.0.1:6800" if len(os.Args) > 1 { From db7bfd4d3864e1073526c78bf9472d7f8afadff8 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:33:00 +0200 Subject: [PATCH 036/201] M1: extract testable node server --- internal/node/server.go | 77 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 internal/node/server.go diff --git a/internal/node/server.go b/internal/node/server.go new file mode 100644 index 0000000..f339052 --- /dev/null +++ b/internal/node/server.go @@ -0,0 +1,77 @@ +package node + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net" + + "github.com/Ploos-AS/AmiRender/internal/engine" + "github.com/Ploos-AS/AmiRender/internal/engine/povray" + "github.com/Ploos-AS/AmiRender/internal/farm" +) + +type message struct { + Type string `json:"type"` + Job json.RawMessage `json:"job"` +} + +func Handle(c net.Conn) { + defer c.Close() + s := bufio.NewScanner(c) + for s.Scan() { + var m message + if json.Unmarshal(s.Bytes(), &m) != nil || m.Type != "SUBMIT" { + writeResult(c, farm.WorkerResult{Type: "FAILED", Error: "bad request"}) + continue + } + var j farm.RenderJob + if json.Unmarshal(m.Job, &j) != nil { + writeResult(c, farm.WorkerResult{Type: "FAILED", Error: "bad job"}) + continue + } + switch j.Engine { + case "null": + writeResult(c, farm.WorkerResult{ + Type: "COMPLETE", JobID: j.ID, Engine: "null", Output: j.Output, + }) + case "povray": + renderPOVRay(c, j) + default: + writeResult(c, farm.WorkerResult{ + Type: "FAILED", JobID: j.ID, Engine: j.Engine, Error: "unsupported engine", + }) + } + } +} + +func renderPOVRay(c net.Conn, j farm.RenderJob) { + e := povray.New("") + result, err := e.Render(context.Background(), engine.Job{ + ID: j.ID, Scene: j.Scene, Output: j.Output, Frame: j.Frame, Width: j.Width, Height: j.Height, + }) + if err != nil { + writeResult(c, farm.WorkerResult{ + Type: "FAILED", JobID: j.ID, Engine: "povray", Error: err.Error(), + }) + return + } + writeResult(c, farm.WorkerResult{ + Type: "COMPLETE", JobID: j.ID, Engine: "povray", Output: result.Output, + }) +} + +func writeResult(c net.Conn, result farm.WorkerResult) { + _ = json.NewEncoder(c).Encode(result) +} + +func Serve(ln net.Listener) error { + for { + c, err := ln.Accept() + if err != nil { + return fmt.Errorf("accept: %w", err) + } + go Handle(c) + } +} From 0024d338091b120a34f73d1a6407021d155070c8 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:33:02 +0200 Subject: [PATCH 037/201] M1: extract testable node server --- internal/node/server_test.go | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 internal/node/server_test.go diff --git a/internal/node/server_test.go b/internal/node/server_test.go new file mode 100644 index 0000000..7c67030 --- /dev/null +++ b/internal/node/server_test.go @@ -0,0 +1,43 @@ +package node + +import ( + "context" + "net" + "testing" + "time" + + "github.com/Ploos-AS/AmiRender/internal/farm" +) + +func TestProductionHandlerNullRoundTrip(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { _ = Serve(ln) }() + + r := farm.NewRegistry() + if err := r.Register(farm.Node{ + ID: "node-01", Arch: "amd64", + Capabilities: []farm.Capability{{Engine: "null"}}, + }); err != nil { + t.Fatal(err) + } + c := farm.Coordinator{ + Registry: r, + Endpoints: map[string]string{"node-01": ln.Addr().String()}, + Client: farm.TCPWorkerClient{}, + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + result, err := c.Render(ctx, farm.RenderJob{ + ID: "m1-e2e", Engine: "null", Output: "result.dat", + }) + if err != nil { + t.Fatal(err) + } + if result.Type != "COMPLETE" || result.JobID != "m1-e2e" || result.Output != "result.dat" { + t.Fatalf("unexpected result: %#v", result) + } +} From dc41834cf205134a637bb9369c9a5c9fc8a3cac9 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 04:33:07 +0200 Subject: [PATCH 038/201] M1: use shared node server in daemon --- cmd/amirender-node/main.go | 67 ++------------------------------------ 1 file changed, 3 insertions(+), 64 deletions(-) diff --git a/cmd/amirender-node/main.go b/cmd/amirender-node/main.go index 54cb356..957cb8b 100644 --- a/cmd/amirender-node/main.go +++ b/cmd/amirender-node/main.go @@ -1,70 +1,13 @@ package main import ( - "bufio" - "context" - "encoding/json" "fmt" "net" "os" - "github.com/Ploos-AS/AmiRender/internal/engine" - "github.com/Ploos-AS/AmiRender/internal/engine/povray" - "github.com/Ploos-AS/AmiRender/internal/farm" + "github.com/Ploos-AS/AmiRender/internal/node" ) -type Message struct { - Type string `json:"type"` - Job json.RawMessage `json:"job"` -} - -func handle(c net.Conn) { - defer c.Close() - s := bufio.NewScanner(c) - for s.Scan() { - var m Message - if json.Unmarshal(s.Bytes(), &m) != nil || m.Type != "SUBMIT" { - fmt.Fprintln(c, `{"type":"FAILED","error":"bad request"}`) - continue - } - var j farm.RenderJob - if json.Unmarshal(m.Job, &j) != nil { - fmt.Fprintln(c, `{"type":"FAILED","error":"bad job"}`) - continue - } - - if j.Engine == "null" { - writeResult(c, farm.WorkerResult{ - Type: "COMPLETE", JobID: j.ID, Engine: "null", Output: j.Output, - }) - continue - } - if j.Engine == "povray" { - e := povray.New("") - result, err := e.Render(context.Background(), engine.Job{ - ID: j.ID, Scene: j.Scene, Output: j.Output, Frame: j.Frame, Width: j.Width, Height: j.Height, - }) - if err != nil { - writeResult(c, farm.WorkerResult{ - Type: "FAILED", JobID: j.ID, Engine: "povray", Error: err.Error(), - }) - continue - } - writeResult(c, farm.WorkerResult{ - Type: "COMPLETE", JobID: j.ID, Engine: "povray", Output: result.Output, - }) - continue - } - writeResult(c, farm.WorkerResult{ - Type: "FAILED", JobID: j.ID, Engine: j.Engine, Error: "unsupported engine", - }) - } -} - -func writeResult(c net.Conn, result farm.WorkerResult) { - _ = json.NewEncoder(c).Encode(result) -} - func main() { addr := "127.0.0.1:6800" if len(os.Args) > 1 { @@ -76,11 +19,7 @@ func main() { } defer ln.Close() fmt.Printf("AmiRender node listening on %s\n", addr) - for { - c, err := ln.Accept() - if err != nil { - panic(err) - } - go handle(c) + if err := node.Serve(ln); err != nil { + panic(err) } } From 03a8be9dd2a939697a62474e22e9bad7ef0a168c Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 05:06:32 +0200 Subject: [PATCH 039/201] M1: test distributed POV-Ray render end to end --- internal/node/server_test.go | 58 ++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 7c67030..90b5f97 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -3,32 +3,42 @@ package node import ( "context" "net" + "os" + "os/exec" + "path/filepath" "testing" "time" "github.com/Ploos-AS/AmiRender/internal/farm" ) -func TestProductionHandlerNullRoundTrip(t *testing.T) { +func testCoordinator(t *testing.T, engine string) (farm.Coordinator, func()) { + t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } - defer ln.Close() go func() { _ = Serve(ln) }() r := farm.NewRegistry() if err := r.Register(farm.Node{ ID: "node-01", Arch: "amd64", - Capabilities: []farm.Capability{{Engine: "null"}}, + Capabilities: []farm.Capability{{Engine: engine}}, }); err != nil { + ln.Close() t.Fatal(err) } - c := farm.Coordinator{ + return farm.Coordinator{ Registry: r, Endpoints: map[string]string{"node-01": ln.Addr().String()}, Client: farm.TCPWorkerClient{}, - } + }, func() { _ = ln.Close() } +} + +func TestProductionHandlerNullRoundTrip(t *testing.T) { + c, closeServer := testCoordinator(t, "null") + defer closeServer() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() result, err := c.Render(ctx, farm.RenderJob{ @@ -41,3 +51,41 @@ func TestProductionHandlerNullRoundTrip(t *testing.T) { t.Fatalf("unexpected result: %#v", result) } } + +func TestProductionHandlerPOVRayCreatesPNG(t *testing.T) { + if _, err := exec.LookPath("povray"); err != nil { + t.Skip("povray not installed") + } + + dir := t.TempDir() + scene := filepath.Join(dir, "scene.pov") + output := filepath.Join(dir, "frame.png") + source := `camera { location <0, 0, -3> look_at <0, 0, 0> } +light_source { <-2, 3, -4> color rgb <1, 1, 1> } +sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } +` + if err := os.WriteFile(scene, []byte(source), 0600); err != nil { + t.Fatal(err) + } + + c, closeServer := testCoordinator(t, "povray") + defer closeServer() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + result, err := c.Render(ctx, farm.RenderJob{ + ID: "pov-e2e", Engine: "povray", Scene: scene, Output: output, Width: 160, Height: 120, + }) + if err != nil { + t.Fatal(err) + } + if result.Type != "COMPLETE" || result.Output != output { + t.Fatalf("unexpected result: %#v", result) + } + info, err := os.Stat(output) + if err != nil { + t.Fatalf("render output missing: %v", err) + } + if info.Size() == 0 { + t.Fatal("render output is empty") + } +} From f4208072940c16143d54804f9cb86005124f9b60 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 05:06:41 +0200 Subject: [PATCH 040/201] CI: require POV-Ray during Go integration tests --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 266608b..2a9402c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,14 +10,14 @@ jobs: - uses: actions/setup-go@v5 with: go-version: '1.22.x' - - run: gofmt -w cmd internal - - run: git diff --exit-code - - run: go test ./... - - run: go build ./cmd/amirender-node ./cmd/amirender-controller - name: Install POV-Ray run: | sudo apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y povray + - run: gofmt -w cmd internal + - run: git diff --exit-code + - run: go test ./... + - run: go build ./cmd/amirender-node ./cmd/amirender-controller - name: Render POV-Ray smoke scene run: | mkdir -p build From e5a2cbf7e1ad0ccf6bf9e4e7b420cda46ddc94e1 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 08:37:44 +0200 Subject: [PATCH 041/201] CI: apply gofmt alignment to node integration test --- internal/node/server_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 90b5f97..e7236e2 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -29,9 +29,9 @@ func testCoordinator(t *testing.T, engine string) (farm.Coordinator, func()) { t.Fatal(err) } return farm.Coordinator{ - Registry: r, + Registry: r, Endpoints: map[string]string{"node-01": ln.Addr().String()}, - Client: farm.TCPWorkerClient{}, + Client: farm.TCPWorkerClient{}, }, func() { _ = ln.Close() } } From fb62172a7c9ed4c0d7aef4f70db0ae8994ce2584 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 08:43:44 +0200 Subject: [PATCH 042/201] M1: update legacy node test for shared server --- cmd/amirender-node/main_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/amirender-node/main_test.go b/cmd/amirender-node/main_test.go index 5596e83..e0b1178 100644 --- a/cmd/amirender-node/main_test.go +++ b/cmd/amirender-node/main_test.go @@ -6,6 +6,8 @@ import ( "net" "testing" "time" + + "github.com/Ploos-AS/AmiRender/internal/node" ) func TestNullEngineEndToEnd(t *testing.T) { @@ -18,7 +20,7 @@ func TestNullEngineEndToEnd(t *testing.T) { go func() { c, err := ln.Accept() if err == nil { - handle(c) + node.Handle(c) } }() From 1e4ed52b826d8d5f4af7a454827128bd6af7a362 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:36:05 +0200 Subject: [PATCH 043/201] M1: start portable Amiga submission client --- clients/amiga/include/amirender_submit.h | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 clients/amiga/include/amirender_submit.h diff --git a/clients/amiga/include/amirender_submit.h b/clients/amiga/include/amirender_submit.h new file mode 100644 index 0000000..568f0b7 --- /dev/null +++ b/clients/amiga/include/amirender_submit.h @@ -0,0 +1,18 @@ +#ifndef AMIRENDER_SUBMIT_H +#define AMIRENDER_SUBMIT_H + +#include + +struct amirender_job { + const char *id; + const char *engine; + const char *scene; + const char *output; + int frame; + int width; + int height; +}; + +int amirender_build_submit(char *buffer, size_t size, const struct amirender_job *job); + +#endif From a43e94b815c5dca982ada942920326578b1ee21e Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:36:07 +0200 Subject: [PATCH 044/201] M1: start portable Amiga submission client --- clients/amiga/src/submit.c | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 clients/amiga/src/submit.c diff --git a/clients/amiga/src/submit.c b/clients/amiga/src/submit.c new file mode 100644 index 0000000..4f4c962 --- /dev/null +++ b/clients/amiga/src/submit.c @@ -0,0 +1,42 @@ +#include "amirender_submit.h" + +#include + +static int safe_text(const char *s) +{ + const unsigned char *p = (const unsigned char *)s; + + if (s == NULL) { + return 0; + } + while (*p != '\0') { + if (*p < 0x20 || *p == '"' || *p == '\\') { + return 0; + } + ++p; + } + return 1; +} + +int amirender_build_submit(char *buffer, size_t size, const struct amirender_job *job) +{ + int n; + + if (buffer == NULL || size == 0 || job == NULL || + !safe_text(job->id) || !safe_text(job->engine) || + !safe_text(job->scene) || !safe_text(job->output)) { + return -1; + } + + n = snprintf( + buffer, size, + "{\"type\":\"SUBMIT\",\"job\":{\"id\":\"%s\",\"engine\":\"%s\"," + "\"scene\":\"%s\",\"output\":\"%s\",\"frame\":%d,\"width\":%d,\"height\":%d}}\n", + job->id, job->engine, job->scene, job->output, + job->frame, job->width, job->height); + + if (n < 0 || (size_t)n >= size) { + return -1; + } + return n; +} From e181dde524674921489b7d62ad3d9373f67d2bf7 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:36:10 +0200 Subject: [PATCH 045/201] M1: start portable Amiga submission client --- clients/amiga/tests/test_submit.c | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 clients/amiga/tests/test_submit.c diff --git a/clients/amiga/tests/test_submit.c b/clients/amiga/tests/test_submit.c new file mode 100644 index 0000000..b5e2e74 --- /dev/null +++ b/clients/amiga/tests/test_submit.c @@ -0,0 +1,30 @@ +#include "amirender_submit.h" + +#include +#include + +int main(void) +{ + char buffer[512]; + struct amirender_job job = { + "amiga-0001", "povray", "DH0:Scenes/demo.pov", + "RAM:frame.png", 0, 320, 256 + }; + const char *expected = + "{\"type\":\"SUBMIT\",\"job\":{\"id\":\"amiga-0001\",\"engine\":\"povray\"," + "\"scene\":\"DH0:Scenes/demo.pov\",\"output\":\"RAM:frame.png\"," + "\"frame\":0,\"width\":320,\"height\":256}}\n"; + + if (amirender_build_submit(buffer, sizeof(buffer), &job) < 0) { + return 1; + } + if (strcmp(buffer, expected) != 0) { + fprintf(stderr, "unexpected message: %s", buffer); + return 2; + } + job.scene = "bad\"scene.pov"; + if (amirender_build_submit(buffer, sizeof(buffer), &job) >= 0) { + return 3; + } + return 0; +} From a5c309b340241d06e0d84991e9ec0ce80f7e6b66 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 09:36:12 +0200 Subject: [PATCH 046/201] M1: start portable Amiga submission client --- clients/amiga/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 clients/amiga/README.md diff --git a/clients/amiga/README.md b/clients/amiga/README.md new file mode 100644 index 0000000..1c87835 --- /dev/null +++ b/clients/amiga/README.md @@ -0,0 +1,25 @@ +# AmiRender Amiga client + +This directory contains the first AmigaOS/m68k submission client components. + +M1 deliberately separates the portable wire-protocol builder from networking. The +portable C layer builds the same newline-delimited JSON `SUBMIT` message accepted +by `amirender-node`. The Amiga transport layer will use `bsdsocket.library`. + +The protocol builder rejects control characters, quotes and backslashes in text +fields rather than attempting incomplete JSON escaping. A later protocol utility +may add full escaping. + +Target integration: + +```text +Amiga application / CLI / ARexx + | + portable submit builder + | + bsdsocket.library + | + AmiRender node +``` + +No proprietary AmigaOS or Kickstart files belong in this repository. From 61899ac8bc6686e7602ba9c548d1025a6535a54f Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 12:42:44 +0200 Subject: [PATCH 047/201] CI: test portable Amiga submission client --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a9402c..8fc88d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,13 @@ jobs: - run: git diff --exit-code - run: go test ./... - run: go build ./cmd/amirender-node ./cmd/amirender-controller + - name: Test portable Amiga client + run: | + cc -std=c99 -Wall -Wextra -Werror -pedantic \ + -Iclients/amiga/include \ + clients/amiga/src/submit.c clients/amiga/tests/test_submit.c \ + -o build-amiga-client-test + ./build-amiga-client-test - name: Render POV-Ray smoke scene run: | mkdir -p build From 02a14c79d49aa1342bf4a33cbbb726d6aad47e11 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 12:43:58 +0200 Subject: [PATCH 048/201] M1: add portable Amiga client transport boundary --- clients/amiga/include/amirender_transport.h | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 clients/amiga/include/amirender_transport.h diff --git a/clients/amiga/include/amirender_transport.h b/clients/amiga/include/amirender_transport.h new file mode 100644 index 0000000..8050a01 --- /dev/null +++ b/clients/amiga/include/amirender_transport.h @@ -0,0 +1,18 @@ +#ifndef AMIRENDER_TRANSPORT_H +#define AMIRENDER_TRANSPORT_H + +#include + +struct amirender_transport { + void *context; + int (*send)(void *context, const char *data, size_t length); + int (*receive)(void *context, char *buffer, size_t size); +}; + +int amirender_submit_job( + struct amirender_transport *transport, + const struct amirender_job *job, + char *reply, + size_t reply_size); + +#endif From da3f1c7e7a0461018ac873ed567566bebca9c3f0 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 12:44:00 +0200 Subject: [PATCH 049/201] M1: add portable Amiga client transport boundary --- clients/amiga/src/client.c | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 clients/amiga/src/client.c diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c new file mode 100644 index 0000000..05ec6ee --- /dev/null +++ b/clients/amiga/src/client.c @@ -0,0 +1,39 @@ +#include "amirender_submit.h" +#include "amirender_transport.h" + +#include + +int amirender_submit_job( + struct amirender_transport *transport, + const struct amirender_job *job, + char *reply, + size_t reply_size) +{ + char request[1024]; + int length; + int received; + + if (transport == NULL || transport->send == NULL || transport->receive == NULL || + reply == NULL || reply_size < 2) { + return -1; + } + + length = amirender_build_submit(request, sizeof(request), job); + if (length < 0) { + return -1; + } + if (transport->send(transport->context, request, (size_t)length) != length) { + return -1; + } + + received = transport->receive(transport->context, reply, reply_size - 1); + if (received <= 0 || (size_t)received >= reply_size) { + return -1; + } + reply[received] = '\0'; + + if (strstr(reply, "\"type\":\"COMPLETE\"") == NULL) { + return -1; + } + return 0; +} From 0aae33c97d4b6750de5dc022a6220d3dc334f114 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 12:44:02 +0200 Subject: [PATCH 050/201] M1: add portable Amiga client transport boundary --- clients/amiga/tests/test_client.c | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 clients/amiga/tests/test_client.c diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c new file mode 100644 index 0000000..9767cd0 --- /dev/null +++ b/clients/amiga/tests/test_client.c @@ -0,0 +1,60 @@ +#include "amirender_submit.h" +#include "amirender_transport.h" + +#include +#include + +struct fake_transport { + char sent[1024]; +}; + +static int fake_send(void *context, const char *data, size_t length) +{ + struct fake_transport *fake = (struct fake_transport *)context; + + if (length >= sizeof(fake->sent)) { + return -1; + } + memcpy(fake->sent, data, length); + fake->sent[length] = '\0'; + return (int)length; +} + +static int fake_receive(void *context, char *buffer, size_t size) +{ + const char *reply = + "{\"type\":\"COMPLETE\",\"job_id\":\"amiga-0002\"," + "\"engine\":\"povray\",\"output\":\"RAM:frame.png\"}\n"; + size_t length = strlen(reply); + + (void)context; + if (length > size) { + return -1; + } + memcpy(buffer, reply, length); + return (int)length; +} + +int main(void) +{ + struct fake_transport fake = {{0}}; + struct amirender_transport transport = {&fake, fake_send, fake_receive}; + struct amirender_job job = { + "amiga-0002", "povray", "DH0:Scenes/demo.pov", + "RAM:frame.png", 0, 320, 256 + }; + char reply[512]; + + if (amirender_submit_job(&transport, &job, reply, sizeof(reply)) != 0) { + return 1; + } + if (strstr(fake.sent, "\"id\":\"amiga-0002\"") == NULL) { + fprintf(stderr, "job id missing from request\n"); + return 2; + } + if (strstr(reply, "\"type\":\"COMPLETE\"") == NULL) { + fprintf(stderr, "completion missing from reply\n"); + return 3; + } + return 0; +} From 2660e919444bee80a11a18eec6a6a408909bf85f Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 12:44:11 +0200 Subject: [PATCH 051/201] M1: declare Amiga job type at transport boundary --- clients/amiga/include/amirender_transport.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/clients/amiga/include/amirender_transport.h b/clients/amiga/include/amirender_transport.h index 8050a01..4cd32d6 100644 --- a/clients/amiga/include/amirender_transport.h +++ b/clients/amiga/include/amirender_transport.h @@ -3,6 +3,8 @@ #include +struct amirender_job; + struct amirender_transport { void *context; int (*send)(void *context, const char *data, size_t length); From 6d4c0573f861a05837a072dc11327a80542d7946 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 12:44:13 +0200 Subject: [PATCH 052/201] CI: test Amiga client transport boundary --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc88d3..ebdcf69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,12 @@ jobs: clients/amiga/src/submit.c clients/amiga/tests/test_submit.c \ -o build-amiga-client-test ./build-amiga-client-test + cc -std=c99 -Wall -Wextra -Werror -pedantic \ + -Iclients/amiga/include \ + clients/amiga/src/submit.c clients/amiga/src/client.c \ + clients/amiga/tests/test_client.c \ + -o build-amiga-transport-test + ./build-amiga-transport-test - name: Render POV-Ray smoke scene run: | mkdir -p build From a78887cd704c62cf032777f02d27d7d193805274 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 14:51:02 +0200 Subject: [PATCH 053/201] M1: add Amiga bsdsocket transport --- clients/amiga/include/amirender_bsdsocket.h | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 clients/amiga/include/amirender_bsdsocket.h diff --git a/clients/amiga/include/amirender_bsdsocket.h b/clients/amiga/include/amirender_bsdsocket.h new file mode 100644 index 0000000..744ac4a --- /dev/null +++ b/clients/amiga/include/amirender_bsdsocket.h @@ -0,0 +1,18 @@ +#ifndef AMIRENDER_BSDSOCKET_H +#define AMIRENDER_BSDSOCKET_H + +#include "amirender_transport.h" + +struct amirender_bsdsocket { + int socket_fd; +}; + +int amirender_bsdsocket_connect( + struct amirender_bsdsocket *socket, + const char *host, + unsigned short port, + struct amirender_transport *transport); + +void amirender_bsdsocket_close(struct amirender_bsdsocket *socket); + +#endif From 2774e343e133e397294c7335170878f1b4a39c84 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 14:51:05 +0200 Subject: [PATCH 054/201] M1: add Amiga bsdsocket transport --- clients/amiga/src/bsdsocket_amiga.c | 104 ++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 clients/amiga/src/bsdsocket_amiga.c diff --git a/clients/amiga/src/bsdsocket_amiga.c b/clients/amiga/src/bsdsocket_amiga.c new file mode 100644 index 0000000..069fea8 --- /dev/null +++ b/clients/amiga/src/bsdsocket_amiga.c @@ -0,0 +1,104 @@ +/* + * AmigaOS/m68k transport for AmiRender. + * + * This translation unit is intentionally not part of the host build. It is + * compiled by the Amiga/Bebbo toolchain where bsdsocket.library headers and + * stubs are available. + */ + +#include "amirender_bsdsocket.h" + +#include +#include + +#include +#include +#include +#include + +struct Library *SocketBase = NULL; + +static int socket_send(void *context, const char *data, size_t length) +{ + struct amirender_bsdsocket *state = (struct amirender_bsdsocket *)context; + size_t sent = 0; + + while (sent < length) { + int n = send(state->socket_fd, data + sent, length - sent, 0); + if (n <= 0) { + return -1; + } + sent += (size_t)n; + } + return (int)sent; +} + +static int socket_receive(void *context, char *buffer, size_t size) +{ + struct amirender_bsdsocket *state = (struct amirender_bsdsocket *)context; + return recv(state->socket_fd, buffer, size, 0); +} + +int amirender_bsdsocket_connect( + struct amirender_bsdsocket *state, + const char *host, + unsigned short port, + struct amirender_transport *transport) +{ + struct hostent *entry; + struct sockaddr_in address; + int fd; + + if (state == NULL || host == NULL || transport == NULL) { + return -1; + } + + SocketBase = OpenLibrary("bsdsocket.library", 4); + if (SocketBase == NULL) { + return -1; + } + + entry = gethostbyname(host); + if (entry == NULL || entry->h_addr_list == NULL || entry->h_addr_list[0] == NULL) { + CloseLibrary(SocketBase); + SocketBase = NULL; + return -1; + } + + fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + CloseLibrary(SocketBase); + SocketBase = NULL; + return -1; + } + + memset(&address, 0, sizeof(address)); + address.sin_family = AF_INET; + address.sin_port = htons(port); + memcpy(&address.sin_addr, entry->h_addr_list[0], sizeof(address.sin_addr)); + + if (connect(fd, (struct sockaddr *)&address, sizeof(address)) < 0) { + CloseSocket(fd); + CloseLibrary(SocketBase); + SocketBase = NULL; + return -1; + } + + state->socket_fd = fd; + transport->context = state; + transport->send = socket_send; + transport->receive = socket_receive; + return 0; +} + +void amirender_bsdsocket_close(struct amirender_bsdsocket *state) +{ + if (state != NULL && state->socket_fd >= 0) { + CloseSocket(state->socket_fd); + state->socket_fd = -1; + } + if (SocketBase != NULL) { + CloseLibrary(SocketBase); + SocketBase = NULL; + } +} From 44990355a68c0090cd3203f4ad492015706383a9 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 14:51:09 +0200 Subject: [PATCH 055/201] M1: add Amiga bsdsocket transport --- clients/amiga/docs/BUILD-AMIGA.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 clients/amiga/docs/BUILD-AMIGA.md diff --git a/clients/amiga/docs/BUILD-AMIGA.md b/clients/amiga/docs/BUILD-AMIGA.md new file mode 100644 index 0000000..71a70b8 --- /dev/null +++ b/clients/amiga/docs/BUILD-AMIGA.md @@ -0,0 +1,29 @@ +# Building the AmigaOS/m68k client + +The portable client code is tested by the normal Linux CI. The Amiga-specific +transport lives in `src/bsdsocket_amiga.c` and targets an AmigaOS-compatible +m68k toolchain with `bsdsocket.library` development headers. + +Expected source set: + +```text +src/submit.c +src/client.c +src/bsdsocket_amiga.c +``` + +The transport opens `bsdsocket.library` version 4, resolves the controller or +worker host with `gethostbyname()`, creates an IPv4 TCP socket and exposes it +through the portable `amirender_transport` interface. + +The default AmiRender TCP port is 6800. + +## Qualification + +M1 qualification should compile this source with the Ploos `amiga-dev` Bebbo +toolchain, then execute the resulting m68k client in `amiga-runtime` against a +real AmiRender node. Runtime qualification must use an m68k Amiga environment; +AROS/i386 is not a substitute for this gate. + +No Kickstart ROM, AmigaOS installation, or other proprietary system file is +stored in this repository. From ff8242521ed08580c42c1f5821986af83f62ce3b Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 20:40:08 +0200 Subject: [PATCH 056/201] M1: add AmigaOS submission CLI --- clients/amiga/src/main_amiga.c | 68 ++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 clients/amiga/src/main_amiga.c diff --git a/clients/amiga/src/main_amiga.c b/clients/amiga/src/main_amiga.c new file mode 100644 index 0000000..79f261c --- /dev/null +++ b/clients/amiga/src/main_amiga.c @@ -0,0 +1,68 @@ +#include "amirender_bsdsocket.h" +#include "amirender_submit.h" +#include "amirender_transport.h" + +#include +#include + +#define AMIRENDER_PORT 6800 +#define REPLY_SIZE 1024 + +static void usage(const char *program) +{ + fprintf(stderr, + "Usage: %s HOST SCENE OUTPUT [WIDTH HEIGHT]\n" + "Example: %s amirender.local DH0:Scenes/demo.pov RAM:frame.png 320 256\n", + program, program); +} + +int main(int argc, char **argv) +{ + struct amirender_bsdsocket socket_state = {-1}; + struct amirender_transport transport = {0}; + struct amirender_job job; + char reply[REPLY_SIZE]; + int width = 0; + int height = 0; + int rc; + + if (argc != 4 && argc != 6) { + usage(argv[0]); + return 20; + } + + if (argc == 6) { + width = atoi(argv[4]); + height = atoi(argv[5]); + if (width <= 0 || height <= 0) { + fprintf(stderr, "AmiRender: WIDTH and HEIGHT must be positive\n"); + return 20; + } + } + + job.id = "amiga-cli"; + job.engine = "povray"; + job.scene = argv[2]; + job.output = argv[3]; + job.frame = 0; + job.width = width; + job.height = height; + + if (amirender_bsdsocket_connect( + &socket_state, argv[1], AMIRENDER_PORT, &transport) != 0) { + fprintf(stderr, "AmiRender: cannot connect to %s:%d\n", + argv[1], AMIRENDER_PORT); + return 10; + } + + rc = amirender_submit_job(&transport, &job, reply, sizeof(reply)); + amirender_bsdsocket_close(&socket_state); + + if (rc != 0) { + fprintf(stderr, "AmiRender: render failed\n"); + return 10; + } + + printf("%s", reply); + return 0; +} From fb83d68bf725cc46f9c07125b22824ac10f2941d Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 20:40:10 +0200 Subject: [PATCH 057/201] M1: add AmigaOS submission CLI --- clients/amiga/Makefile | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 clients/amiga/Makefile diff --git a/clients/amiga/Makefile b/clients/amiga/Makefile new file mode 100644 index 0000000..9e2f2ff --- /dev/null +++ b/clients/amiga/Makefile @@ -0,0 +1,20 @@ +CC ?= m68k-amigaos-gcc +CFLAGS ?= -O2 -Wall -Wextra +CPPFLAGS ?= -Iinclude +TARGET ?= amirender + +SOURCES = \ + src/submit.c \ + src/client.c \ + src/bsdsocket_amiga.c \ + src/main_amiga.c + +.PHONY: all clean + +all: $(TARGET) + +$(TARGET): $(SOURCES) + $(CC) $(CPPFLAGS) $(CFLAGS) $(SOURCES) -o $@ + +clean: + rm -f $(TARGET) From c4d09c0e316c737347039220e9232c63f1e47980 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 21:04:16 +0200 Subject: [PATCH 058/201] CI: build Amiga client with amiga-dev --- .github/workflows/amiga.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/amiga.yml diff --git a/.github/workflows/amiga.yml b/.github/workflows/amiga.yml new file mode 100644 index 0000000..3eb6a71 --- /dev/null +++ b/.github/workflows/amiga.yml @@ -0,0 +1,37 @@ +name: Amiga build + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + amiga-build: + runs-on: ubuntu-latest + env: + AMIGA_DEV_IMAGE: ghcr.io/ploos-as/amiga-dev:edge + AMIGA_CPU_PROFILE: "68000" + steps: + - uses: actions/checkout@v4 + - name: Pull amiga-dev + run: docker pull "$AMIGA_DEV_IMAGE" + - name: Verify amiga-dev contract + run: docker run --rm "$AMIGA_DEV_IMAGE" amiga-check + - name: Build AmiRender Amiga client + run: | + docker run --rm \ + -e AMIGA_CPU_PROFILE="$AMIGA_CPU_PROFILE" \ + -v "$PWD:/workspace" \ + "$AMIGA_DEV_IMAGE" \ + amiga-build sh -ec 'cd clients/amiga && make clean && make CC=m68k-amigaos-gcc CFLAGS="$AMIGA_CPU_FLAGS -Os -Wall -Wextra -Werror"' + - name: Inspect m68k executable + run: | + docker run --rm \ + -v "$PWD:/workspace" \ + "$AMIGA_DEV_IMAGE" \ + amiga-inspect /workspace/clients/amiga/amirender + +# M1 uses :edge as the consumer-integration gate. Pin to a qualified immutable +# amiga-dev sha-* tag or digest before release qualification. From a230527344a85c962d9444680aead3e0f35225bd Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 21:09:41 +0200 Subject: [PATCH 059/201] Fix Bebbo bsdsocket compatibility --- clients/amiga/src/bsdsocket_amiga.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/clients/amiga/src/bsdsocket_amiga.c b/clients/amiga/src/bsdsocket_amiga.c index 069fea8..4db9ad0 100644 --- a/clients/amiga/src/bsdsocket_amiga.c +++ b/clients/amiga/src/bsdsocket_amiga.c @@ -8,6 +8,7 @@ #include "amirender_bsdsocket.h" +#include #include #include @@ -24,7 +25,7 @@ static int socket_send(void *context, const char *data, size_t length) size_t sent = 0; while (sent < length) { - int n = send(state->socket_fd, data + sent, length - sent, 0); + int n = send(state->socket_fd, (void *)(data + sent), length - sent, 0); if (n <= 0) { return -1; } @@ -58,7 +59,7 @@ int amirender_bsdsocket_connect( return -1; } - entry = gethostbyname(host); + entry = gethostbyname((char *)host); if (entry == NULL || entry->h_addr_list == NULL || entry->h_addr_list[0] == NULL) { CloseLibrary(SocketBase); SocketBase = NULL; From 489a4e7372eb034651afcede19b119f49e784f43 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 21:10:27 +0200 Subject: [PATCH 060/201] Fix NDK gethostbyname signedness boundary --- clients/amiga/src/bsdsocket_amiga.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/amiga/src/bsdsocket_amiga.c b/clients/amiga/src/bsdsocket_amiga.c index 4db9ad0..b304bb3 100644 --- a/clients/amiga/src/bsdsocket_amiga.c +++ b/clients/amiga/src/bsdsocket_amiga.c @@ -59,7 +59,7 @@ int amirender_bsdsocket_connect( return -1; } - entry = gethostbyname((char *)host); + entry = (struct hostent *)(void *)gethostbyname((char *)host); if (entry == NULL || entry->h_addr_list == NULL || entry->h_addr_list[0] == NULL) { CloseLibrary(SocketBase); SocketBase = NULL; From 1773e33478de33754e59cdc5f9452a484f8b03a0 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 21:20:18 +0200 Subject: [PATCH 061/201] Use IPv4 literals in M1 Amiga transport --- clients/amiga/src/bsdsocket_amiga.c | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/clients/amiga/src/bsdsocket_amiga.c b/clients/amiga/src/bsdsocket_amiga.c index b304bb3..ae1587d 100644 --- a/clients/amiga/src/bsdsocket_amiga.c +++ b/clients/amiga/src/bsdsocket_amiga.c @@ -12,7 +12,6 @@ #include #include -#include #include #include #include @@ -46,7 +45,6 @@ int amirender_bsdsocket_connect( unsigned short port, struct amirender_transport *transport) { - struct hostent *entry; struct sockaddr_in address; int fd; @@ -59,13 +57,6 @@ int amirender_bsdsocket_connect( return -1; } - entry = (struct hostent *)(void *)gethostbyname((char *)host); - if (entry == NULL || entry->h_addr_list == NULL || entry->h_addr_list[0] == NULL) { - CloseLibrary(SocketBase); - SocketBase = NULL; - return -1; - } - fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) { CloseLibrary(SocketBase); @@ -76,7 +67,13 @@ int amirender_bsdsocket_connect( memset(&address, 0, sizeof(address)); address.sin_family = AF_INET; address.sin_port = htons(port); - memcpy(&address.sin_addr, entry->h_addr_list[0], sizeof(address.sin_addr)); + address.sin_addr.s_addr = inet_addr((char *)host); + if (address.sin_addr.s_addr == INADDR_NONE) { + CloseSocket(fd); + CloseLibrary(SocketBase); + SocketBase = NULL; + return -1; + } if (connect(fd, (struct sockaddr *)&address, sizeof(address)) < 0) { CloseSocket(fd); From aae9506ea42d9fc777a4009295ba217c5ef4ef1a Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 22:07:52 +0200 Subject: [PATCH 062/201] Parse IPv4 locally in Amiga transport --- clients/amiga/src/bsdsocket_amiga.c | 46 +++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/clients/amiga/src/bsdsocket_amiga.c b/clients/amiga/src/bsdsocket_amiga.c index ae1587d..ca153fd 100644 --- a/clients/amiga/src/bsdsocket_amiga.c +++ b/clients/amiga/src/bsdsocket_amiga.c @@ -18,6 +18,47 @@ struct Library *SocketBase = NULL; + +static int parse_ipv4(const char *text, unsigned long *result) +{ + unsigned long value = 0; + int part; + + if (text == NULL || result == NULL) { + return -1; + } + + for (part = 0; part < 4; ++part) { + unsigned long octet = 0; + int digits = 0; + + while (*text >= '0' && *text <= '9') { + octet = octet * 10UL + (unsigned long)(*text - '0'); + if (octet > 255UL) { + return -1; + } + ++text; + ++digits; + } + if (digits == 0) { + return -1; + } + value = (value << 8) | octet; + if (part < 3) { + if (*text != '.') { + return -1; + } + ++text; + } + } + if (*text != '\0') { + return -1; + } + + *result = value; + return 0; +} + static int socket_send(void *context, const char *data, size_t length) { struct amirender_bsdsocket *state = (struct amirender_bsdsocket *)context; @@ -47,6 +88,7 @@ int amirender_bsdsocket_connect( { struct sockaddr_in address; int fd; + unsigned long ipv4; if (state == NULL || host == NULL || transport == NULL) { return -1; @@ -67,13 +109,13 @@ int amirender_bsdsocket_connect( memset(&address, 0, sizeof(address)); address.sin_family = AF_INET; address.sin_port = htons(port); - address.sin_addr.s_addr = inet_addr((char *)host); - if (address.sin_addr.s_addr == INADDR_NONE) { + if (parse_ipv4(host, &ipv4) != 0) { CloseSocket(fd); CloseLibrary(SocketBase); SocketBase = NULL; return -1; } + address.sin_addr.s_addr = htonl(ipv4); if (connect(fd, (struct sockaddr *)&address, sizeof(address)) < 0) { CloseSocket(fd); From a16f337ff04537b325dad570cf988f902da4a815 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 22:12:28 +0200 Subject: [PATCH 063/201] CI: link Amiga client outside bind mount --- .github/workflows/amiga.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/amiga.yml b/.github/workflows/amiga.yml index 3eb6a71..f6f4291 100644 --- a/.github/workflows/amiga.yml +++ b/.github/workflows/amiga.yml @@ -25,13 +25,13 @@ jobs: -e AMIGA_CPU_PROFILE="$AMIGA_CPU_PROFILE" \ -v "$PWD:/workspace" \ "$AMIGA_DEV_IMAGE" \ - amiga-build sh -ec 'cd clients/amiga && make clean && make CC=m68k-amigaos-gcc CFLAGS="$AMIGA_CPU_FLAGS -Os -Wall -Wextra -Werror"' + amiga-build sh -ec 'cd clients/amiga && make clean && make TARGET=/tmp/amirender CC=m68k-amigaos-gcc CFLAGS="$AMIGA_CPU_FLAGS -Os -Wall -Wextra -Werror" && cp /tmp/amirender /workspace/amirender-m68k' - name: Inspect m68k executable run: | docker run --rm \ -v "$PWD:/workspace" \ "$AMIGA_DEV_IMAGE" \ - amiga-inspect /workspace/clients/amiga/amirender + amiga-inspect /workspace/amirender-m68k # M1 uses :edge as the consumer-integration gate. Pin to a qualified immutable # amiga-dev sha-* tag or digest before release qualification. From 1eef23def99da2833dfd73c1a0b674f5a3689c53 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 22:18:19 +0200 Subject: [PATCH 064/201] CI: inspect Amiga binary inside build container --- .github/workflows/amiga.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/amiga.yml b/.github/workflows/amiga.yml index f6f4291..c40bcf2 100644 --- a/.github/workflows/amiga.yml +++ b/.github/workflows/amiga.yml @@ -25,13 +25,7 @@ jobs: -e AMIGA_CPU_PROFILE="$AMIGA_CPU_PROFILE" \ -v "$PWD:/workspace" \ "$AMIGA_DEV_IMAGE" \ - amiga-build sh -ec 'cd clients/amiga && make clean && make TARGET=/tmp/amirender CC=m68k-amigaos-gcc CFLAGS="$AMIGA_CPU_FLAGS -Os -Wall -Wextra -Werror" && cp /tmp/amirender /workspace/amirender-m68k' - - name: Inspect m68k executable - run: | - docker run --rm \ - -v "$PWD:/workspace" \ - "$AMIGA_DEV_IMAGE" \ - amiga-inspect /workspace/amirender-m68k + amiga-build sh -ec 'cd clients/amiga && make clean && make TARGET=/tmp/amirender CC=m68k-amigaos-gcc CFLAGS="$AMIGA_CPU_FLAGS -Os -Wall -Wextra -Werror" && test -s /tmp/amirender && amiga-inspect /tmp/amirender' # M1 uses :edge as the consumer-integration gate. Pin to a qualified immutable # amiga-dev sha-* tag or digest before release qualification. From a7966e52b922e983a220085149a4eb013a2c826b Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 6 Oct 2026 22:18:54 +0200 Subject: [PATCH 065/201] Amiga: default Makefile to m68k cross compiler --- clients/amiga/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/amiga/Makefile b/clients/amiga/Makefile index 9e2f2ff..0c74a57 100644 --- a/clients/amiga/Makefile +++ b/clients/amiga/Makefile @@ -1,4 +1,4 @@ -CC ?= m68k-amigaos-gcc +CC = m68k-amigaos-gcc CFLAGS ?= -O2 -Wall -Wextra CPPFLAGS ?= -Iinclude TARGET ?= amirender From 85c75766b3e3568398e76abc705635ad84fe13ca Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 00:47:48 +0200 Subject: [PATCH 066/201] Amiga: add classic runtime qualification contract --- clients/amiga/runtime/amiga-runtime.json | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 clients/amiga/runtime/amiga-runtime.json diff --git a/clients/amiga/runtime/amiga-runtime.json b/clients/amiga/runtime/amiga-runtime.json new file mode 100644 index 0000000..4ea619e --- /dev/null +++ b/clients/amiga/runtime/amiga-runtime.json @@ -0,0 +1,25 @@ +{ + "schema": 1, + "project": "AmiRender", + "milestone": "M1-amiga-client", + "architecture": "m68k", + "cpu": "68000", + "os": "AmigaOS", + "emulator_profiles": ["a500-os204", "a500plus-os2", "a1200-020-os3"], + "payload": { + "programs": ["C/amirender"], + "script": "S/run-test" + }, + "result": { + "logs": ["T/amirender.log"], + "required_lines": ["AMIRENDER_EXEC=PASS", "AMIRENDER_USAGE_RC=20"] + }, + "policy": { + "roms_in_repository": false, + "amigaos_files_in_repository": false + }, + "requirements": { + "network": false, + "bsdsocket_library": true + } +} From b099daab482ece30a9354f01d299da1ea727a911 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 00:47:50 +0200 Subject: [PATCH 067/201] Amiga: add classic runtime smoke script --- clients/amiga/runtime/run-test | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 clients/amiga/runtime/run-test diff --git a/clients/amiga/runtime/run-test b/clients/amiga/runtime/run-test new file mode 100644 index 0000000..84b9870 --- /dev/null +++ b/clients/amiga/runtime/run-test @@ -0,0 +1,14 @@ +; AmiRender M1 classic runtime smoke test +; The client is intentionally invoked without arguments. +; Exit code 20 proves the m68k executable reached its CLI validation path. + +C:amirender >T:amirender-cli.out +SET rc $RC + +ECHO "AMIRENDER_USAGE_RC=$rc" >T:amirender.log + +IF $rc EQ 20 + ECHO "AMIRENDER_EXEC=PASS" >>T:amirender.log +ELSE + ECHO "AMIRENDER_EXEC=FAIL" >>T:amirender.log +ENDIF From dfa9770fb3f4a15bea7da1116cfa30e079621739 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 00:48:04 +0200 Subject: [PATCH 068/201] CI: stage Amiga runtime qualification payload --- .github/workflows/amiga.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/amiga.yml b/.github/workflows/amiga.yml index c40bcf2..4e99514 100644 --- a/.github/workflows/amiga.yml +++ b/.github/workflows/amiga.yml @@ -19,13 +19,25 @@ jobs: run: docker pull "$AMIGA_DEV_IMAGE" - name: Verify amiga-dev contract run: docker run --rm "$AMIGA_DEV_IMAGE" amiga-check - - name: Build AmiRender Amiga client + - name: Build and stage AmiRender Amiga client run: | + mkdir -p amiga-runtime-payload/C amiga-runtime-payload/S + chmod 0777 amiga-runtime-payload/C + cp clients/amiga/runtime/amiga-runtime.json amiga-runtime-payload/ + cp clients/amiga/runtime/run-test amiga-runtime-payload/S/ docker run --rm \ -e AMIGA_CPU_PROFILE="$AMIGA_CPU_PROFILE" \ - -v "$PWD:/workspace" \ + -v "$PWD:/workspace:ro" \ + -v "$PWD/amiga-runtime-payload/C:/out" \ "$AMIGA_DEV_IMAGE" \ - amiga-build sh -ec 'cd clients/amiga && make clean && make TARGET=/tmp/amirender CC=m68k-amigaos-gcc CFLAGS="$AMIGA_CPU_FLAGS -Os -Wall -Wextra -Werror" && test -s /tmp/amirender && amiga-inspect /tmp/amirender' + amiga-build sh -ec 'cd /workspace/clients/amiga && make TARGET=/out/amirender CC=m68k-amigaos-gcc CFLAGS="$AMIGA_CPU_FLAGS -Os -Wall -Wextra -Werror" && test -s /out/amirender && amiga-inspect /out/amirender' + test -s amiga-runtime-payload/C/amirender + - name: Upload classic runtime payload + uses: actions/upload-artifact@v4 + with: + name: amirender-m1-amiga-runtime-payload + path: amiga-runtime-payload/ + if-no-files-found: error # M1 uses :edge as the consumer-integration gate. Pin to a qualified immutable # amiga-dev sha-* tag or digest before release qualification. From f70aa576f394f08f8f89e5560475c996cb96564e Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 02:17:13 +0200 Subject: [PATCH 069/201] Amiga: receive complete newline-framed replies --- clients/amiga/src/bsdsocket_amiga.c | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/clients/amiga/src/bsdsocket_amiga.c b/clients/amiga/src/bsdsocket_amiga.c index ca153fd..3fc2417 100644 --- a/clients/amiga/src/bsdsocket_amiga.c +++ b/clients/amiga/src/bsdsocket_amiga.c @@ -77,7 +77,29 @@ static int socket_send(void *context, const char *data, size_t length) static int socket_receive(void *context, char *buffer, size_t size) { struct amirender_bsdsocket *state = (struct amirender_bsdsocket *)context; - return recv(state->socket_fd, buffer, size, 0); + size_t received = 0; + + if (buffer == NULL || size == 0) { + return -1; + } + + while (received < size) { + int n = recv(state->socket_fd, buffer + received, size - received, 0); + size_t i; + + if (n <= 0) { + return received > 0 ? (int)received : -1; + } + + for (i = 0; i < (size_t)n; ++i) { + if (buffer[received + i] == '\n') { + return (int)(received + i + 1); + } + } + received += (size_t)n; + } + + return (int)received; } int amirender_bsdsocket_connect( From 594462f2a8d25afd9bb7d70ac61df2833208cfe0 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 02:45:51 +0200 Subject: [PATCH 070/201] Node: add bounded asset staging protocol --- internal/node/server.go | 47 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/internal/node/server.go b/internal/node/server.go index f339052..91673e1 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -3,18 +3,31 @@ package node import ( "bufio" "context" + "encoding/base64" "encoding/json" "fmt" "net" + "os" + "path/filepath" "github.com/Ploos-AS/AmiRender/internal/engine" "github.com/Ploos-AS/AmiRender/internal/engine/povray" "github.com/Ploos-AS/AmiRender/internal/farm" ) +const maxUploadBytes = 1024 * 1024 + type message struct { Type string `json:"type"` Job json.RawMessage `json:"job"` + Name string `json:"name,omitempty"` + Data string `json:"data,omitempty"` +} + +type stagedResult struct { + Type string `json:"type"` + Asset string `json:"asset,omitempty"` + Error string `json:"error,omitempty"` } func Handle(c net.Conn) { @@ -22,7 +35,15 @@ func Handle(c net.Conn) { s := bufio.NewScanner(c) for s.Scan() { var m message - if json.Unmarshal(s.Bytes(), &m) != nil || m.Type != "SUBMIT" { + if json.Unmarshal(s.Bytes(), &m) != nil { + writeResult(c, farm.WorkerResult{Type: "FAILED", Error: "bad request"}) + continue + } + if m.Type == "UPLOAD" { + handleUpload(c, m) + continue + } + if m.Type != "SUBMIT" { writeResult(c, farm.WorkerResult{Type: "FAILED", Error: "bad request"}) continue } @@ -46,6 +67,30 @@ func Handle(c net.Conn) { } } +func handleUpload(c net.Conn, m message) { + if filepath.Base(m.Name) != m.Name || m.Name == "." || m.Name == "" { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "unsafe asset name"}) + return + } + data, err := base64.StdEncoding.DecodeString(m.Data) + if err != nil || len(data) == 0 || len(data) > maxUploadBytes { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "invalid asset data"}) + return + } + dir, err := os.MkdirTemp("", "amirender-asset-") + if err != nil { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "staging failed"}) + return + } + path := filepath.Join(dir, m.Name) + if err := os.WriteFile(path, data, 0600); err != nil { + _ = os.RemoveAll(dir) + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "staging failed"}) + return + } + _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGED", Asset: path}) +} + func renderPOVRay(c net.Conn, j farm.RenderJob) { e := povray.New("") result, err := e.Render(context.Background(), engine.Job{ From 965210a1d9aca64e991cc881136e472d451bb84e Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 02:45:59 +0200 Subject: [PATCH 071/201] Test: cover node asset staging and traversal rejection --- internal/node/server_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index e7236e2..1fea844 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -1,7 +1,10 @@ package node import ( + "bufio" "context" + "encoding/base64" + "encoding/json" "net" "os" "os/exec" @@ -89,3 +92,31 @@ sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } t.Fatal("render output is empty") } } + +func TestUploadStagesAssetAndRejectsTraversal(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { t.Fatal(err) } + defer ln.Close() + go func() { _ = Serve(ln) }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { t.Fatal(err) } + defer conn.Close() + enc := json.NewEncoder(conn) + dec := json.NewDecoder(bufio.NewReader(conn)) + + data := base64.StdEncoding.EncodeToString([]byte("camera {}\n")) + if err := enc.Encode(map[string]string{"type":"UPLOAD", "name":"scene.pov", "data":data}); err != nil { t.Fatal(err) } + var staged stagedResult + if err := dec.Decode(&staged); err != nil { t.Fatal(err) } + if staged.Type != "STAGED" || staged.Asset == "" { t.Fatalf("unexpected staging result: %#v", staged) } + defer os.RemoveAll(filepath.Dir(staged.Asset)) + got, err := os.ReadFile(staged.Asset) + if err != nil { t.Fatal(err) } + if string(got) != "camera {}\n" { t.Fatalf("unexpected staged data %q", got) } + + if err := enc.Encode(map[string]string{"type":"UPLOAD", "name":"../escape.pov", "data":data}); err != nil { t.Fatal(err) } + var rejected stagedResult + if err := dec.Decode(&rejected); err != nil { t.Fatal(err) } + if rejected.Type != "FAILED" { t.Fatalf("traversal upload was not rejected: %#v", rejected) } +} From 9f96e549fa703d4336898b4fedfcb37002b6cc92 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 03:38:59 +0200 Subject: [PATCH 072/201] Test: gofmt asset staging coverage --- internal/node/server_test.go | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 1fea844..fd34240 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -95,28 +95,44 @@ sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } func TestUploadStagesAssetAndRejectsTraversal(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } defer ln.Close() go func() { _ = Serve(ln) }() conn, err := net.Dial("tcp", ln.Addr().String()) - if err != nil { t.Fatal(err) } + if err != nil { + t.Fatal(err) + } defer conn.Close() enc := json.NewEncoder(conn) dec := json.NewDecoder(bufio.NewReader(conn)) data := base64.StdEncoding.EncodeToString([]byte("camera {}\n")) - if err := enc.Encode(map[string]string{"type":"UPLOAD", "name":"scene.pov", "data":data}); err != nil { t.Fatal(err) } + if err := enc.Encode(map[string]string{"type": "UPLOAD", "name": "scene.pov", "data": data}); err != nil { + t.Fatal(err) + } var staged stagedResult if err := dec.Decode(&staged); err != nil { t.Fatal(err) } - if staged.Type != "STAGED" || staged.Asset == "" { t.Fatalf("unexpected staging result: %#v", staged) } + if staged.Type != "STAGED" || staged.Asset == "" { + t.Fatalf("unexpected staging result: %#v", staged) + } defer os.RemoveAll(filepath.Dir(staged.Asset)) got, err := os.ReadFile(staged.Asset) - if err != nil { t.Fatal(err) } - if string(got) != "camera {}\n" { t.Fatalf("unexpected staged data %q", got) } + if err != nil { + t.Fatal(err) + } + if string(got) != "camera {}\n" { + t.Fatalf("unexpected staged data %q", got) + } - if err := enc.Encode(map[string]string{"type":"UPLOAD", "name":"../escape.pov", "data":data}); err != nil { t.Fatal(err) } + if err := enc.Encode(map[string]string{"type": "UPLOAD", "name": "../escape.pov", "data": data}); err != nil { + t.Fatal(err) + } var rejected stagedResult if err := dec.Decode(&rejected); err != nil { t.Fatal(err) } - if rejected.Type != "FAILED" { t.Fatalf("traversal upload was not rejected: %#v", rejected) } + if rejected.Type != "FAILED" { + t.Fatalf("traversal upload was not rejected: %#v", rejected) + } } From 79c2664acc2f08aaa5222a6950dc53d8ccc4e7de Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 05:53:32 +0200 Subject: [PATCH 073/201] Test: finish gofmt cleanup for staging test --- internal/node/server_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index fd34240..fc8fe11 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -114,7 +114,7 @@ func TestUploadStagesAssetAndRejectsTraversal(t *testing.T) { t.Fatal(err) } var staged stagedResult - if err := dec.Decode(&staged); err != nil { t.Fatal(err) } + if err := dec.Decode(&staged); err != nil {\n\t\tt.Fatal(err)\n\t} if staged.Type != "STAGED" || staged.Asset == "" { t.Fatalf("unexpected staging result: %#v", staged) } @@ -131,7 +131,7 @@ func TestUploadStagesAssetAndRejectsTraversal(t *testing.T) { t.Fatal(err) } var rejected stagedResult - if err := dec.Decode(&rejected); err != nil { t.Fatal(err) } + if err := dec.Decode(&rejected); err != nil {\n\t\tt.Fatal(err)\n\t} if rejected.Type != "FAILED" { t.Fatalf("traversal upload was not rejected: %#v", rejected) } From babe70697552a4ee8a2da5665fd8d4d9e447c14b Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 05:58:31 +0200 Subject: [PATCH 074/201] Fix staging test source escaping --- internal/node/server_test.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index fc8fe11..3a21ba7 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -109,12 +109,14 @@ func TestUploadStagesAssetAndRejectsTraversal(t *testing.T) { enc := json.NewEncoder(conn) dec := json.NewDecoder(bufio.NewReader(conn)) - data := base64.StdEncoding.EncodeToString([]byte("camera {}\n")) + data := base64.StdEncoding.EncodeToString([]byte("camera {}\\n")) if err := enc.Encode(map[string]string{"type": "UPLOAD", "name": "scene.pov", "data": data}); err != nil { t.Fatal(err) } var staged stagedResult - if err := dec.Decode(&staged); err != nil {\n\t\tt.Fatal(err)\n\t} + if err := dec.Decode(&staged); err != nil { + t.Fatal(err) + } if staged.Type != "STAGED" || staged.Asset == "" { t.Fatalf("unexpected staging result: %#v", staged) } @@ -123,7 +125,7 @@ func TestUploadStagesAssetAndRejectsTraversal(t *testing.T) { if err != nil { t.Fatal(err) } - if string(got) != "camera {}\n" { + if string(got) != "camera {}\\n" { t.Fatalf("unexpected staged data %q", got) } @@ -131,7 +133,9 @@ func TestUploadStagesAssetAndRejectsTraversal(t *testing.T) { t.Fatal(err) } var rejected stagedResult - if err := dec.Decode(&rejected); err != nil {\n\t\tt.Fatal(err)\n\t} + if err := dec.Decode(&rejected); err != nil { + t.Fatal(err) + } if rejected.Type != "FAILED" { t.Fatalf("traversal upload was not rejected: %#v", rejected) } From 1f0b924650ee873b0fdeb1b012a6403eb0610089 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 05:59:12 +0200 Subject: [PATCH 075/201] Docs: define M1 asset staging protocol --- protocol/messages.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/protocol/messages.md b/protocol/messages.md index 1945645..4f39946 100644 --- a/protocol/messages.md +++ b/protocol/messages.md @@ -35,3 +35,33 @@ The controller acknowledges a valid registration: ``` Registration is the M1 discovery baseline. Network service discovery such as mDNS may locate controllers/workers later, but capability truth comes from the registration protocol. + + +## UPLOAD + +A client may stage one small render asset on a worker before submitting a job. +M1 uses base64 inside the existing newline-delimited JSON framing. + +```json +{"type":"UPLOAD","name":"scene.pov","data":"Y2FtZXJhIHt9Cg=="} +``` + +M1 constraints: + +- decoded payload size is limited to 1 MiB; +- `name` is a basename only; directory traversal and client-selected worker paths are rejected; +- the worker creates an isolated staging directory and chooses the filesystem path; +- staged files are private to the worker and are not a durable asset store; +- M1 initially targets a self-contained single-file POV-Ray scene. Includes, textures and multi-file bundles require a later asset-manifest protocol. + +## STAGED + +A successful upload returns the worker-selected asset reference: + +```json +{"type":"STAGED","asset":"/worker-selected/staging/path/scene.pov"} +``` + +The path is an opaque M1 asset reference from the client's perspective. Clients must not construct, modify, or predict it. A following `SUBMIT` may use this returned reference as its scene value. + +Invalid names, invalid base64, empty data, oversized data, or staging failures return `FAILED`. From 68d35b1fcd62197f43a1047137e42ab6b982c244 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 06:04:23 +0200 Subject: [PATCH 076/201] Amiga: define asset upload message builder --- clients/amiga/include/amirender_upload.h | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 clients/amiga/include/amirender_upload.h diff --git a/clients/amiga/include/amirender_upload.h b/clients/amiga/include/amirender_upload.h new file mode 100644 index 0000000..277e5c2 --- /dev/null +++ b/clients/amiga/include/amirender_upload.h @@ -0,0 +1,13 @@ +#ifndef AMIRENDER_UPLOAD_H +#define AMIRENDER_UPLOAD_H + +#include + +int amirender_build_upload( + char *buffer, + size_t size, + const char *name, + const unsigned char *data, + size_t data_size); + +#endif From 2970030e1d629f549dac834ed4a7919f0a160086 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 06:04:25 +0200 Subject: [PATCH 077/201] Amiga: add portable base64 upload builder --- clients/amiga/src/upload.c | 71 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 clients/amiga/src/upload.c diff --git a/clients/amiga/src/upload.c b/clients/amiga/src/upload.c new file mode 100644 index 0000000..3868420 --- /dev/null +++ b/clients/amiga/src/upload.c @@ -0,0 +1,71 @@ +#include "amirender_upload.h" + +#include +#include + +static const char base64_table[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +static int safe_name(const char *name) +{ + const unsigned char *p = (const unsigned char *)name; + + if (name == NULL || *name == '\0' || strcmp(name, ".") == 0 || strcmp(name, "..") == 0) { + return 0; + } + while (*p != '\0') { + if (*p < 0x20 || *p == '"' || *p == '\\' || *p == '/' || *p == ':') { + return 0; + } + ++p; + } + return 1; +} + +int amirender_build_upload( + char *buffer, + size_t size, + const char *name, + const unsigned char *data, + size_t data_size) +{ + size_t encoded_size; + size_t required; + size_t i; + size_t out; + int prefix; + + if (buffer == NULL || size == 0 || !safe_name(name) || data == NULL || data_size == 0) { + return -1; + } + if (data_size > (size_t)-1 - 2) { + return -1; + } + encoded_size = ((data_size + 2) / 3) * 4; + prefix = snprintf(buffer, size, "{\"type\":\"UPLOAD\",\"name\":\"%s\",\"data\":\"", name); + if (prefix < 0 || (size_t)prefix >= size) { + return -1; + } + required = (size_t)prefix + encoded_size + 3; + if (required > size) { + return -1; + } + + out = (size_t)prefix; + for (i = 0; i < data_size; i += 3) { + unsigned long v = (unsigned long)data[i] << 16; + size_t remain = data_size - i; + if (remain > 1) v |= (unsigned long)data[i + 1] << 8; + if (remain > 2) v |= data[i + 2]; + + buffer[out++] = base64_table[(v >> 18) & 63]; + buffer[out++] = base64_table[(v >> 12) & 63]; + buffer[out++] = remain > 1 ? base64_table[(v >> 6) & 63] : '='; + buffer[out++] = remain > 2 ? base64_table[v & 63] : '='; + } + buffer[out++] = '"'; + buffer[out++] = '}'; + buffer[out++] = '\n'; + buffer[out] = '\0'; + return (int)out; +} From 9d82ecf838a5b78f0cbf8ec34fb32a64fa909f71 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 06:04:27 +0200 Subject: [PATCH 078/201] Test: cover Amiga upload message builder --- clients/amiga/tests/test_upload.c | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 clients/amiga/tests/test_upload.c diff --git a/clients/amiga/tests/test_upload.c b/clients/amiga/tests/test_upload.c new file mode 100644 index 0000000..ea9eefc --- /dev/null +++ b/clients/amiga/tests/test_upload.c @@ -0,0 +1,21 @@ +#include "amirender_upload.h" + +#include +#include + +int main(void) +{ + char buffer[256]; + const unsigned char scene[] = "camera {}\n"; + const char *expected = + "{\"type\":\"UPLOAD\",\"name\":\"scene.pov\",\"data\":\"Y2FtZXJhIHt9Cg==\"}\n"; + + if (amirender_build_upload(buffer, sizeof(buffer), "scene.pov", scene, sizeof(scene) - 1) < 0) return 1; + if (strcmp(buffer, expected) != 0) { + fprintf(stderr, "unexpected upload: %s", buffer); + return 2; + } + if (amirender_build_upload(buffer, sizeof(buffer), "../scene.pov", scene, sizeof(scene) - 1) >= 0) return 3; + if (amirender_build_upload(buffer, 16, "scene.pov", scene, sizeof(scene) - 1) >= 0) return 4; + return 0; +} From 0e94475bb3be10141e2d4c06dd26f6e46d9e55b3 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 06:05:33 +0200 Subject: [PATCH 079/201] Amiga: link upload builder into client --- clients/amiga/Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/clients/amiga/Makefile b/clients/amiga/Makefile index 0c74a57..9bbc1cf 100644 --- a/clients/amiga/Makefile +++ b/clients/amiga/Makefile @@ -5,6 +5,7 @@ TARGET ?= amirender SOURCES = \ src/submit.c \ + src/upload.c \ src/client.c \ src/bsdsocket_amiga.c \ src/main_amiga.c From 318d7a5d0dbd751753e4928fff4b1f4eef4e1090 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 06:05:36 +0200 Subject: [PATCH 080/201] CI: run portable Amiga upload tests --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebdcf69..8025541 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,11 @@ jobs: clients/amiga/src/submit.c clients/amiga/tests/test_submit.c \ -o build-amiga-client-test ./build-amiga-client-test + cc -std=c99 -Wall -Wextra -Werror -pedantic \ + -Iclients/amiga/include \ + clients/amiga/src/upload.c clients/amiga/tests/test_upload.c \ + -o build-amiga-upload-test + ./build-amiga-upload-test cc -std=c99 -Wall -Wextra -Werror -pedantic \ -Iclients/amiga/include \ clients/amiga/src/submit.c clients/amiga/src/client.c \ From c3c486e01c89e07a82c892949611dd3b4aabfb2b Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 06:06:19 +0200 Subject: [PATCH 081/201] Amiga: expose asset upload transport API --- clients/amiga/include/amirender_transport.h | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/clients/amiga/include/amirender_transport.h b/clients/amiga/include/amirender_transport.h index 4cd32d6..d4fa6b9 100644 --- a/clients/amiga/include/amirender_transport.h +++ b/clients/amiga/include/amirender_transport.h @@ -11,6 +11,14 @@ struct amirender_transport { int (*receive)(void *context, char *buffer, size_t size); }; +int amirender_upload_asset( + struct amirender_transport *transport, + const char *name, + const unsigned char *data, + size_t data_size, + char *asset, + size_t asset_size); + int amirender_submit_job( struct amirender_transport *transport, const struct amirender_job *job, From d4ac345fecdb49761cf397c040c1b099a5be29d9 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 06:06:29 +0200 Subject: [PATCH 082/201] Amiga: add upload and STAGED response handling --- clients/amiga/src/client.c | 55 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index 05ec6ee..2be5039 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -1,8 +1,63 @@ #include "amirender_submit.h" #include "amirender_transport.h" +#include "amirender_upload.h" #include +int amirender_upload_asset( + struct amirender_transport *transport, + const char *name, + const unsigned char *data, + size_t data_size, + char *asset, + size_t asset_size) +{ + char request[8192]; + char reply[1024]; + const char *key = "\"asset\":\""; + const char *start; + const char *end; + size_t length; + int request_length; + int received; + + if (transport == NULL || transport->send == NULL || transport->receive == NULL || + asset == NULL || asset_size < 2) { + return -1; + } + request_length = amirender_build_upload(request, sizeof(request), name, data, data_size); + if (request_length < 0) { + return -1; + } + if (transport->send(transport->context, request, (size_t)request_length) != request_length) { + return -1; + } + received = transport->receive(transport->context, reply, sizeof(reply) - 1); + if (received <= 0 || (size_t)received >= sizeof(reply)) { + return -1; + } + reply[received] = '\0'; + if (strstr(reply, "\"type\":\"STAGED\"") == NULL) { + return -1; + } + start = strstr(reply, key); + if (start == NULL) { + return -1; + } + start += strlen(key); + end = strchr(start, '"'); + if (end == NULL) { + return -1; + } + length = (size_t)(end - start); + if (length == 0 || length >= asset_size) { + return -1; + } + memcpy(asset, start, length); + asset[length] = '\0'; + return 0; +} + int amirender_submit_job( struct amirender_transport *transport, const struct amirender_job *job, From 3cd8f864eadc8f182798ac0462ccbaa03da43c52 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 06:07:06 +0200 Subject: [PATCH 083/201] Amiga: upload local scene before render submit --- clients/amiga/src/main_amiga.c | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/clients/amiga/src/main_amiga.c b/clients/amiga/src/main_amiga.c index 79f261c..c9dbe8c 100644 --- a/clients/amiga/src/main_amiga.c +++ b/clients/amiga/src/main_amiga.c @@ -7,6 +7,49 @@ #define AMIRENDER_PORT 6800 #define REPLY_SIZE 1024 +#define ASSET_SIZE 1024 +#define MAX_SCENE_SIZE 6000 + +static const char *base_name(const char *path) +{ + const char *name = path; + const char *p; + + for (p = path; *p != '\0'; ++p) { + if (*p == '/' || *p == ':') { + name = p + 1; + } + } + return name; +} + +static int read_scene(const char *path, unsigned char *buffer, size_t capacity, size_t *length) +{ + FILE *file; + long size; + + file = fopen(path, "rb"); + if (file == NULL) { + return -1; + } + if (fseek(file, 0, SEEK_END) != 0) { + fclose(file); + return -1; + } + size = ftell(file); + if (size <= 0 || (unsigned long)size > (unsigned long)capacity) { + fclose(file); + return -1; + } + if (fseek(file, 0, SEEK_SET) != 0 || + fread(buffer, 1, (size_t)size, file) != (size_t)size) { + fclose(file); + return -1; + } + fclose(file); + *length = (size_t)size; + return 0; +} static void usage(const char *program) { @@ -22,6 +65,9 @@ int main(int argc, char **argv) struct amirender_transport transport = {0}; struct amirender_job job; char reply[REPLY_SIZE]; + char asset[ASSET_SIZE]; + unsigned char scene_data[MAX_SCENE_SIZE]; + size_t scene_size; int width = 0; int height = 0; int rc; @@ -40,6 +86,11 @@ int main(int argc, char **argv) } } + if (read_scene(argv[2], scene_data, sizeof(scene_data), &scene_size) != 0) { + fprintf(stderr, "AmiRender: cannot read scene or scene exceeds %d bytes\n", MAX_SCENE_SIZE); + return 10; + } + job.id = "amiga-cli"; job.engine = "povray"; job.scene = argv[2]; @@ -55,6 +106,15 @@ int main(int argc, char **argv) return 10; } + rc = amirender_upload_asset( + &transport, base_name(argv[2]), scene_data, scene_size, asset, sizeof(asset)); + if (rc != 0) { + amirender_bsdsocket_close(&socket_state); + fprintf(stderr, "AmiRender: scene upload failed\n"); + return 10; + } + + job.scene = asset; rc = amirender_submit_job(&transport, &job, reply, sizeof(reply)); amirender_bsdsocket_close(&socket_state); From f5721c202f349c77dce97142e0b695f2a5df0bda Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 06:07:37 +0200 Subject: [PATCH 084/201] Test: cover Amiga UPLOAD and STAGED transport --- clients/amiga/tests/test_client.c | 34 ++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 9767cd0..9ed2574 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -6,6 +6,7 @@ struct fake_transport { char sent[1024]; + const char *reply; }; static int fake_send(void *context, const char *data, size_t length) @@ -22,12 +23,10 @@ static int fake_send(void *context, const char *data, size_t length) static int fake_receive(void *context, char *buffer, size_t size) { - const char *reply = - "{\"type\":\"COMPLETE\",\"job_id\":\"amiga-0002\"," - "\"engine\":\"povray\",\"output\":\"RAM:frame.png\"}\n"; + struct fake_transport *fake = (struct fake_transport *)context; + const char *reply = fake->reply; size_t length = strlen(reply); - (void)context; if (length > size) { return -1; } @@ -37,14 +36,18 @@ static int fake_receive(void *context, char *buffer, size_t size) int main(void) { - struct fake_transport fake = {{0}}; + struct fake_transport fake = {{0}, NULL}; struct amirender_transport transport = {&fake, fake_send, fake_receive}; struct amirender_job job = { "amiga-0002", "povray", "DH0:Scenes/demo.pov", "RAM:frame.png", 0, 320, 256 }; char reply[512]; + char asset[256]; + const unsigned char scene[] = "camera {}\n"; + fake.reply = "{\"type\":\"COMPLETE\",\"job_id\":\"amiga-0002\"," + "\"engine\":\"povray\",\"output\":\"RAM:frame.png\"}\n"; if (amirender_submit_job(&transport, &job, reply, sizeof(reply)) != 0) { return 1; } @@ -56,5 +59,26 @@ int main(void) fprintf(stderr, "completion missing from reply\n"); return 3; } + + fake.reply = "{\"type\":\"STAGED\",\"asset\":\"/tmp/amirender/scene.pov\"}\n"; + if (amirender_upload_asset( + &transport, "scene.pov", scene, sizeof(scene) - 1, asset, sizeof(asset)) != 0) { + return 4; + } + if (strcmp(asset, "/tmp/amirender/scene.pov") != 0) { + fprintf(stderr, "unexpected staged asset: %s\n", asset); + return 5; + } + if (strstr(fake.sent, "\"type\":\"UPLOAD\"") == NULL || + strstr(fake.sent, "Y2FtZXJhIHt9Cg==") == NULL) { + fprintf(stderr, "upload request missing expected payload\n"); + return 6; + } + + fake.reply = "{\"type\":\"FAILED\",\"error\":\"staging failed\"}\n"; + if (amirender_upload_asset( + &transport, "scene.pov", scene, sizeof(scene) - 1, asset, sizeof(asset)) == 0) { + return 7; + } return 0; } From 5b726d6e80303970ea48b5ada17d029c41cae03d Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 06:07:44 +0200 Subject: [PATCH 085/201] CI: link upload builder in transport test From fcdc01bf5fbf5df681bfb9324e26c66cdda91f4f Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 06:09:32 +0200 Subject: [PATCH 086/201] CI: link upload implementation in client transport test --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8025541..817ce39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: ./build-amiga-upload-test cc -std=c99 -Wall -Wextra -Werror -pedantic \ -Iclients/amiga/include \ - clients/amiga/src/submit.c clients/amiga/src/client.c \ + clients/amiga/src/submit.c clients/amiga/src/upload.c clients/amiga/src/client.c \ clients/amiga/tests/test_client.c \ -o build-amiga-transport-test ./build-amiga-transport-test From c9890b9264c4dc0eff5c588565d41e7c81954836 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 06:10:09 +0200 Subject: [PATCH 087/201] Node: allow bounded large upload messages --- internal/node/server.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/node/server.go b/internal/node/server.go index 91673e1..fb06a4b 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -16,6 +16,7 @@ import ( ) const maxUploadBytes = 1024 * 1024 +const maxMessageBytes = 2 * 1024 * 1024 type message struct { Type string `json:"type"` @@ -33,6 +34,7 @@ type stagedResult struct { func Handle(c net.Conn) { defer c.Close() s := bufio.NewScanner(c) + s.Buffer(make([]byte, 64*1024), maxMessageBytes) for s.Scan() { var m message if json.Unmarshal(s.Bytes(), &m) != nil { From 1007fbc5f25c181fde83a0247e6d1b66de4ffbcc Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 07:23:56 +0200 Subject: [PATCH 088/201] Node: add bounded staged asset download --- internal/node/server.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/internal/node/server.go b/internal/node/server.go index fb06a4b..4329802 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -9,6 +9,7 @@ import ( "net" "os" "path/filepath" + "strings" "github.com/Ploos-AS/AmiRender/internal/engine" "github.com/Ploos-AS/AmiRender/internal/engine/povray" @@ -23,12 +24,14 @@ type message struct { Job json.RawMessage `json:"job"` Name string `json:"name,omitempty"` Data string `json:"data,omitempty"` + Asset string `json:"asset,omitempty"` } type stagedResult struct { Type string `json:"type"` Asset string `json:"asset,omitempty"` Error string `json:"error,omitempty"` + Data string `json:"data,omitempty"` } func Handle(c net.Conn) { @@ -45,6 +48,10 @@ func Handle(c net.Conn) { handleUpload(c, m) continue } + if m.Type == "DOWNLOAD" { + handleDownload(c, m) + continue + } if m.Type != "SUBMIT" { writeResult(c, farm.WorkerResult{Type: "FAILED", Error: "bad request"}) continue @@ -93,6 +100,23 @@ func handleUpload(c net.Conn, m message) { _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGED", Asset: path}) } +func handleDownload(c net.Conn, m message) { + clean := filepath.Clean(m.Asset) + dir := filepath.Dir(clean) + if m.Asset == "" || !strings.HasPrefix(filepath.Base(dir), "amirender-asset-") { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "unsafe asset reference"}) + return + } + data, err := os.ReadFile(clean) + if err != nil || len(data) == 0 || len(data) > maxUploadBytes { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "asset unavailable"}) + return + } + _ = json.NewEncoder(c).Encode(stagedResult{ + Type: "DATA", Asset: clean, Data: base64.StdEncoding.EncodeToString(data), + }) +} + func renderPOVRay(c net.Conn, j farm.RenderJob) { e := povray.New("") result, err := e.Render(context.Background(), engine.Job{ From bbada5739cb3fdeb06ffc10c7cb9a0ad4fea868b Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 07:24:03 +0200 Subject: [PATCH 089/201] Test: cover staged asset download --- internal/node/server_test.go | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 3a21ba7..109d7dd 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -93,6 +93,55 @@ sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } } } + +func TestDownloadReturnsStagedAsset(t *testing.T) { + dir, err := os.MkdirTemp("", "amirender-asset-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "frame.png") + want := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a} + if err := os.WriteFile(path, want, 0600); err != nil { + t.Fatal(err) + } + + server, client := net.Pipe() + defer client.Close() + go Handle(server) + + enc := json.NewEncoder(client) + dec := json.NewDecoder(bufio.NewReader(client)) + if err := enc.Encode(map[string]string{"type": "DOWNLOAD", "asset": path}); err != nil { + t.Fatal(err) + } + var got stagedResult + if err := dec.Decode(&got); err != nil { + t.Fatal(err) + } + if got.Type != "DATA" || got.Asset != path { + t.Fatalf("unexpected download result: %#v", got) + } + decoded, err := base64.StdEncoding.DecodeString(got.Data) + if err != nil { + t.Fatal(err) + } + if string(decoded) != string(want) { + t.Fatalf("download mismatch: %v", decoded) + } + + if err := enc.Encode(map[string]string{"type": "DOWNLOAD", "asset": "/etc/passwd"}); err != nil { + t.Fatal(err) + } + var rejected stagedResult + if err := dec.Decode(&rejected); err != nil { + t.Fatal(err) + } + if rejected.Type != "FAILED" { + t.Fatalf("unsafe download was not rejected: %#v", rejected) + } +} + func TestUploadStagesAssetAndRejectsTraversal(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { From 81be6dc752c170469351e64a9dc6fe5f92f96991 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 07:25:07 +0200 Subject: [PATCH 090/201] Format staged download implementation --- internal/node/server.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index 4329802..ae6f86f 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -20,10 +20,10 @@ const maxUploadBytes = 1024 * 1024 const maxMessageBytes = 2 * 1024 * 1024 type message struct { - Type string `json:"type"` - Job json.RawMessage `json:"job"` - Name string `json:"name,omitempty"` - Data string `json:"data,omitempty"` + Type string `json:"type"` + Job json.RawMessage `json:"job"` + Name string `json:"name,omitempty"` + Data string `json:"data,omitempty"` Asset string `json:"asset,omitempty"` } From 2d17f9dbdcd3449288850225b18ae5702af91d20 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 07:25:10 +0200 Subject: [PATCH 091/201] Format staged download implementation --- internal/node/server_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 109d7dd..629f9bc 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -93,7 +93,6 @@ sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } } } - func TestDownloadReturnsStagedAsset(t *testing.T) { dir, err := os.MkdirTemp("", "amirender-asset-") if err != nil { From f88d2903ba659fc76099535543a2bcaab65983ce Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 07:25:49 +0200 Subject: [PATCH 092/201] Amiga: expose staged asset download API --- clients/amiga/include/amirender_transport.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/clients/amiga/include/amirender_transport.h b/clients/amiga/include/amirender_transport.h index d4fa6b9..9a7156f 100644 --- a/clients/amiga/include/amirender_transport.h +++ b/clients/amiga/include/amirender_transport.h @@ -19,6 +19,13 @@ int amirender_upload_asset( char *asset, size_t asset_size); +int amirender_download_asset( + struct amirender_transport *transport, + const char *asset, + unsigned char *data, + size_t data_size, + size_t *received_size); + int amirender_submit_job( struct amirender_transport *transport, const struct amirender_job *job, From e37897ce9a455019b580f1f08545e7a745edc735 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 07:25:59 +0200 Subject: [PATCH 093/201] Amiga: implement staged asset download --- clients/amiga/src/client.c | 67 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index 2be5039..eeb6b8e 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -58,6 +58,73 @@ int amirender_upload_asset( return 0; } +static int base64_value(char ch) +{ + if (ch >= 'A' && ch <= 'Z') return ch - 'A'; + if (ch >= 'a' && ch <= 'z') return ch - 'a' + 26; + if (ch >= '0' && ch <= '9') return ch - '0' + 52; + if (ch == '+') return 62; + if (ch == '/') return 63; + return -1; +} + +int amirender_download_asset( + struct amirender_transport *transport, + const char *asset, + unsigned char *data, + size_t data_size, + size_t *received_size) +{ + char request[1200]; + char reply[8192]; + const char *key = "\"data\":\""; + const char *p; + const char *end; + size_t out = 0; + int received; + + if (transport == NULL || transport->send == NULL || transport->receive == NULL || + asset == NULL || data == NULL || received_size == NULL) return -1; + if (strchr(asset, '\"') != NULL || strchr(asset, '\\') != NULL) return -1; + if (snprintf(request, sizeof(request), "{\"type\":\"DOWNLOAD\",\"asset\":\"%s\"}\n", asset) < 0 || + strlen(request) >= sizeof(request)) return -1; + if (transport->send(transport->context, request, strlen(request)) != (int)strlen(request)) return -1; + received = transport->receive(transport->context, reply, sizeof(reply) - 1); + if (received <= 0 || (size_t)received >= sizeof(reply)) return -1; + reply[received] = '\0'; + if (strstr(reply, "\"type\":\"DATA\"") == NULL) return -1; + p = strstr(reply, key); + if (p == NULL) return -1; + p += strlen(key); + end = strchr(p, '\"'); + if (end == NULL) return -1; + + while (p < end) { + int a, b, d, e; + unsigned int v; + if (end - p < 4) return -1; + a = base64_value(p[0]); b = base64_value(p[1]); + d = p[2] == '=' ? 0 : base64_value(p[2]); + e = p[3] == '=' ? 0 : base64_value(p[3]); + if (a < 0 || b < 0 || d < 0 || e < 0) return -1; + v = ((unsigned int)a << 18) | ((unsigned int)b << 12) | + ((unsigned int)d << 6) | (unsigned int)e; + if (out >= data_size) return -1; + data[out++] = (unsigned char)(v >> 16); + if (p[2] != '=') { + if (out >= data_size) return -1; + data[out++] = (unsigned char)(v >> 8); + } + if (p[3] != '=') { + if (out >= data_size) return -1; + data[out++] = (unsigned char)v; + } + p += 4; + } + *received_size = out; + return 0; +} + int amirender_submit_job( struct amirender_transport *transport, const struct amirender_job *job, From ac44898c75fe7ffb98f3e90bbc9a3a86e8b630e2 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 07:27:15 +0200 Subject: [PATCH 094/201] Amiga: include stdio for download request formatting --- clients/amiga/src/client.c | 1 + 1 file changed, 1 insertion(+) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index eeb6b8e..4b403e4 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -2,6 +2,7 @@ #include "amirender_transport.h" #include "amirender_upload.h" +#include #include int amirender_upload_asset( From 8337832e55da805b7462910603c69f2cf69328cf Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 07:27:23 +0200 Subject: [PATCH 095/201] Test: cover Amiga staged asset download --- clients/amiga/tests/test_client.c | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 9ed2574..5a92467 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -44,6 +44,8 @@ int main(void) }; char reply[512]; char asset[256]; + unsigned char downloaded[16]; + size_t downloaded_size = 0; const unsigned char scene[] = "camera {}\n"; fake.reply = "{\"type\":\"COMPLETE\",\"job_id\":\"amiga-0002\"," @@ -80,5 +82,31 @@ int main(void) &transport, "scene.pov", scene, sizeof(scene) - 1, asset, sizeof(asset)) == 0) { return 7; } + + fake.reply = "{\"type\":\"DATA\",\"asset\":\"/tmp/amirender-asset-x/frame.png\"," + "\"data\":\"iVBORw0K\"}\n"; + if (amirender_download_asset( + &transport, "/tmp/amirender-asset-x/frame.png", + downloaded, sizeof(downloaded), &downloaded_size) != 0) { + return 8; + } + if (downloaded_size != 6 || + downloaded[0] != 0x89 || downloaded[1] != 'P' || + downloaded[2] != 'N' || downloaded[3] != 'G' || + downloaded[4] != 0x0d || downloaded[5] != 0x0a) { + fprintf(stderr, "downloaded data mismatch\n"); + return 9; + } + if (strstr(fake.sent, "\"type\":\"DOWNLOAD\"") == NULL) { + fprintf(stderr, "download request missing\n"); + return 10; + } + + fake.reply = "{\"type\":\"FAILED\",\"error\":\"asset unavailable\"}\n"; + if (amirender_download_asset( + &transport, "/tmp/amirender-asset-x/frame.png", + downloaded, sizeof(downloaded), &downloaded_size) == 0) { + return 11; + } return 0; } From 370c1a46608e2cede64169fe0d455c021d792d69 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 10:36:31 +0200 Subject: [PATCH 096/201] Node: stage POV-Ray output on worker --- internal/node/server.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/node/server.go b/internal/node/server.go index ae6f86f..097720f 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -118,11 +118,20 @@ func handleDownload(c net.Conn, m message) { } func renderPOVRay(c net.Conn, j farm.RenderJob) { + dir, err := os.MkdirTemp("", "amirender-asset-") + if err != nil { + writeResult(c, farm.WorkerResult{ + Type: "FAILED", JobID: j.ID, Engine: "povray", Error: "output staging failed", + }) + return + } + output := filepath.Join(dir, "frame.png") e := povray.New("") result, err := e.Render(context.Background(), engine.Job{ - ID: j.ID, Scene: j.Scene, Output: j.Output, Frame: j.Frame, Width: j.Width, Height: j.Height, + ID: j.ID, Scene: j.Scene, Output: output, Frame: j.Frame, Width: j.Width, Height: j.Height, }) if err != nil { + _ = os.RemoveAll(dir) writeResult(c, farm.WorkerResult{ Type: "FAILED", JobID: j.ID, Engine: "povray", Error: err.Error(), }) From b1b5540b2377770b49198df484debb9c925da6d6 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 10:37:17 +0200 Subject: [PATCH 097/201] Amiga: download rendered output to local path --- clients/amiga/src/main_amiga.c | 53 +++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/clients/amiga/src/main_amiga.c b/clients/amiga/src/main_amiga.c index c9dbe8c..7a3f79e 100644 --- a/clients/amiga/src/main_amiga.c +++ b/clients/amiga/src/main_amiga.c @@ -4,11 +4,13 @@ #include #include +#include #define AMIRENDER_PORT 6800 #define REPLY_SIZE 1024 #define ASSET_SIZE 1024 #define MAX_SCENE_SIZE 6000 +#define MAX_OUTPUT_SIZE 6000 static const char *base_name(const char *path) { @@ -51,11 +53,40 @@ static int read_scene(const char *path, unsigned char *buffer, size_t capacity, return 0; } +static int extract_output(const char *reply, char *output, size_t output_size) +{ + const char *key = "\"output\":\""; + const char *start = strstr(reply, key); + const char *end; + size_t length; + + if (start == NULL) return -1; + start += strlen(key); + end = strchr(start, '\"'); + if (end == NULL) return -1; + length = (size_t)(end - start); + if (length == 0 || length >= output_size) return -1; + memcpy(output, start, length); + output[length] = '\0'; + return 0; +} + +static int write_output(const char *path, const unsigned char *data, size_t length) +{ + FILE *file = fopen(path, "wb"); + if (file == NULL) return -1; + if (fwrite(data, 1, length, file) != length) { + fclose(file); + return -1; + } + return fclose(file) == 0 ? 0 : -1; +} + static void usage(const char *program) { fprintf(stderr, "Usage: %s HOST SCENE OUTPUT [WIDTH HEIGHT]\n" - "Example: %s amirender.local DH0:Scenes/demo.pov RAM:frame.png 320 256\n", + "Example: %s 192.168.1.50 DH0:Scenes/demo.pov RAM:frame.png 320 256\n", program, program); } @@ -66,8 +97,11 @@ int main(int argc, char **argv) struct amirender_job job; char reply[REPLY_SIZE]; char asset[ASSET_SIZE]; + char output_asset[ASSET_SIZE]; unsigned char scene_data[MAX_SCENE_SIZE]; + unsigned char output_data[MAX_OUTPUT_SIZE]; size_t scene_size; + size_t output_size; int width = 0; int height = 0; int rc; @@ -116,13 +150,24 @@ int main(int argc, char **argv) job.scene = asset; rc = amirender_submit_job(&transport, &job, reply, sizeof(reply)); - amirender_bsdsocket_close(&socket_state); + if (rc != 0 || extract_output(reply, output_asset, sizeof(output_asset)) != 0) { + amirender_bsdsocket_close(&socket_state); + fprintf(stderr, "AmiRender: render failed\n"); + return 10; + } + rc = amirender_download_asset( + &transport, output_asset, output_data, sizeof(output_data), &output_size); + amirender_bsdsocket_close(&socket_state); if (rc != 0) { - fprintf(stderr, "AmiRender: render failed\n"); + fprintf(stderr, "AmiRender: output download failed or exceeds %d bytes\n", MAX_OUTPUT_SIZE); + return 10; + } + if (write_output(argv[3], output_data, output_size) != 0) { + fprintf(stderr, "AmiRender: cannot write output %s\n", argv[3]); return 10; } - printf("%s", reply); + printf("AmiRender: wrote %lu bytes to %s\n", (unsigned long)output_size, argv[3]); return 0; } From 8a768b6ffd0bd32b4204863d763fa7f0570b73d1 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 10:55:03 +0200 Subject: [PATCH 098/201] Test: expect worker-staged POV-Ray output --- internal/node/server_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 629f9bc..a969c62 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "time" @@ -81,10 +82,14 @@ sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } if err != nil { t.Fatal(err) } - if result.Type != "COMPLETE" || result.Output != output { + if result.Type != "COMPLETE" || result.Output == "" || result.Output == output { t.Fatalf("unexpected result: %#v", result) } - info, err := os.Stat(output) + if !strings.HasPrefix(filepath.Base(filepath.Dir(result.Output)), "amirender-asset-") { + t.Fatalf("output is not worker-staged: %q", result.Output) + } + defer os.RemoveAll(filepath.Dir(result.Output)) + info, err := os.Stat(result.Output) if err != nil { t.Fatalf("render output missing: %v", err) } From 7c5936371a90b6219bb210fa6987b25530af3398 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 11:08:08 +0200 Subject: [PATCH 099/201] Docs: define retro preservation and modern farm scope --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0779140..2ba62d0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AmiRender -A render accelerator appliance and distributed rendering system for classic Amiga workflows. +A render accelerator appliance, distributed Linux render farm, and preservation bridge for classic 3D workflows. The Amiga is the workstation. AmiRender provides the modern compute. @@ -28,6 +28,24 @@ AmiRender is intended to support, over time: - AmiTerrain - Ami3D - Blender/Cycles/Eevee as appliance/farm backends +- Atari ST CAD-3D / Cyber Studio family +- Freescape / 3D Construction Kit scenes from C64, Atari ST, and Amiga + +## Render modes + +AmiRender keeps preservation and enhancement as separate, explicit goals: + +- `original`: use the original renderer/software where practical, on original hardware or a qualified emulator/VM, preserving native scene and output formats. +- `authentic`: reproduce the original platform renderer, palette, resolution, shading, and relevant limitations in an automatable AmiRender backend. +- `enhanced`: preserve the source scene semantics in AmiRender IR and render with modern backends for the best practical result. + +Importers must preserve original values and metadata alongside normalized IR data. Import must not destructively "improve" source assets. + +Native formats are first-class preservation inputs/outputs. Planned examples include Atari CAD-3D/Cyber formats and Freescape/3D Construction Kit data. Modern interchange formats are additional outputs, not replacements for native-format support. + +## Render farm + +POV-Ray and Blender are first-class modern Linux farm backends. The scheduler is intended to support amd64 and ARM64 CPU workers and GPU-capable workers where the backend supports them. Retro scene import is independent of the renderer: a preserved classic scene may be rendered in original/authentic mode or sent through the same modern farm in enhanced mode. ## User experience From f7855ca0e1c57d2aa4031b65ac32be889642dd14 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 11:10:30 +0200 Subject: [PATCH 100/201] Test: cover full POV-Ray wire round trip --- internal/node/server_test.go | 78 ++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index a969c62..da2887e 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -98,6 +98,84 @@ sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } } } + +func TestPOVRayUploadRenderDownloadRoundTrip(t *testing.T) { + if _, err := exec.LookPath("povray"); err != nil { + t.Skip("povray not installed") + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { _ = Serve(ln) }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + enc := json.NewEncoder(conn) + dec := json.NewDecoder(bufio.NewReader(conn)) + + source := `camera { location <0, 0, -3> look_at <0, 0, 0> } +light_source { <-2, 3, -4> color rgb <1, 1, 1> } +sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } +` + if err := enc.Encode(map[string]string{ + "type": "UPLOAD", "name": "scene.pov", + "data": base64.StdEncoding.EncodeToString([]byte(source)), + }); err != nil { + t.Fatal(err) + } + var staged stagedResult + if err := dec.Decode(&staged); err != nil { + t.Fatal(err) + } + if staged.Type != "STAGED" || staged.Asset == "" { + t.Fatalf("unexpected staging result: %#v", staged) + } + defer os.RemoveAll(filepath.Dir(staged.Asset)) + + if err := enc.Encode(map[string]any{ + "type": "SUBMIT", + "job": map[string]any{ + "id": "pov-wire-e2e", "engine": "povray", "scene": staged.Asset, + "output": "RAM:frame.png", "width": 64, "height": 48, + }, + }); err != nil { + t.Fatal(err) + } + var rendered stagedResult + if err := dec.Decode(&rendered); err != nil { + t.Fatal(err) + } + if rendered.Type != "COMPLETE" || rendered.Output == "" || rendered.Output == "RAM:frame.png" { + t.Fatalf("unexpected render result: %#v", rendered) + } + defer os.RemoveAll(filepath.Dir(rendered.Output)) + + if err := enc.Encode(map[string]string{"type": "DOWNLOAD", "asset": rendered.Output}); err != nil { + t.Fatal(err) + } + var downloaded stagedResult + if err := dec.Decode(&downloaded); err != nil { + t.Fatal(err) + } + if downloaded.Type != "DATA" { + t.Fatalf("unexpected download result: %#v", downloaded) + } + png, err := base64.StdEncoding.DecodeString(downloaded.Data) + if err != nil { + t.Fatal(err) + } + signature := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a} + if len(png) <= len(signature) || string(png[:len(signature)]) != string(signature) { + t.Fatalf("download is not a PNG: %x", png[:min(len(png), len(signature))]) + } +} + func TestDownloadReturnsStagedAsset(t *testing.T) { dir, err := os.MkdirTemp("", "amirender-asset-") if err != nil { From 2d37c77a44c23931e89b9220b85865e1898b1577 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 11:17:54 +0200 Subject: [PATCH 101/201] Docs: add landscape rendering roadmap --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 2ba62d0..60948a1 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,18 @@ Native formats are first-class preservation inputs/outputs. Planned examples inc POV-Ray and Blender are first-class modern Linux farm backends. The scheduler is intended to support amd64 and ARM64 CPU workers and GPU-capable workers where the backend supports them. Retro scene import is independent of the renderer: a preserved classic scene may be rendered in original/authentic mode or sent through the same modern farm in enhanced mode. +## Landscape roadmap + +Landscape generation is a first-class render source, with AmiTerrain owning terrain semantics and format compatibility. + +- Preserve Vista/VistaPro, Scenery Animator, World Construction Set and other supported classic landscape inputs through AmiTerrain. +- Render preserved landscape scenes in `original`, `authentic`, or `enhanced` mode. +- Accept AmiTerrain Terrain IR without discarding native source metadata. +- Use POV-Ray and Blender/Cycles/Eevee as modern Linux farm targets for enhanced landscape rendering. +- Support modern open-source terrain workflows through AmiTerrain interchange, including TerraForge3D, Blender terrain/erosion workflows, generic heightmaps, meshes and DEM/GIS-derived terrain. +- Keep reverse conversion possible where representable: modern terrain may be reduced/quantized into formats usable by original classic landscape software. +- AmiRender does not replace AmiTerrain's importers or generators; it schedules and renders their outputs. + ## User experience From AmigaOS, AmiRender should feel like an external render coprocessor, not like a Linux server. From 77446777e38a86d25eb35d85be026ceb4ba18384 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 11:21:43 +0200 Subject: [PATCH 102/201] Test: gofmt POV-Ray wire round trip --- internal/node/server_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index da2887e..6d0cc49 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -98,7 +98,6 @@ sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } } } - func TestPOVRayUploadRenderDownloadRoundTrip(t *testing.T) { if _, err := exec.LookPath("povray"); err != nil { t.Skip("povray not installed") From 4260a787979f21537d66dee843229fd37f4af5cb Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 11:24:00 +0200 Subject: [PATCH 103/201] Test: decode render result with worker contract --- internal/node/server_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 6d0cc49..441b0ab 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -146,7 +146,7 @@ sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } }); err != nil { t.Fatal(err) } - var rendered stagedResult + var rendered farm.WorkerResult if err := dec.Decode(&rendered); err != nil { t.Fatal(err) } From 8f1f155e7560c460597f77f8e7914a27e7258a04 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 14:53:02 +0200 Subject: [PATCH 104/201] Docs: define remote render asset return flow --- protocol/messages.md | 62 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/protocol/messages.md b/protocol/messages.md index 4f39946..a8a1058 100644 --- a/protocol/messages.md +++ b/protocol/messages.md @@ -65,3 +65,65 @@ A successful upload returns the worker-selected asset reference: The path is an opaque M1 asset reference from the client's perspective. Clients must not construct, modify, or predict it. A following `SUBMIT` may use this returned reference as its scene value. Invalid names, invalid base64, empty data, oversized data, or staging failures return `FAILED`. + + +## SUBMIT and remote output + +A remote render job may use a staged asset reference as its scene. The client's requested output path is a client-side destination hint; it is not a filesystem path that a Linux worker may write directly. + +For engines that produce files remotely, the worker creates its own staging directory and returns the staged result in `COMPLETE.output`. + +Example: + +```json +{"type":"SUBMIT","job":{"id":"frame-1","engine":"povray","scene":"","output":"RAM:frame.png","width":320,"height":256}} +``` + +```json +{"type":"COMPLETE","job_id":"frame-1","engine":"povray","output":""} +``` + +Clients must treat `COMPLETE.output` as an opaque worker asset reference in remote mode. In particular, a worker must not interpret an Amiga path such as `RAM:frame.png` or `DH0:...` as a local Linux path. + +## DOWNLOAD + +A client retrieves a staged result by sending the asset reference returned by the worker: + +```json +{"type":"DOWNLOAD","asset":""} +``` + +M1 uses the same bounded base64 transfer model as `UPLOAD`. The requested asset must be a worker-controlled staged asset; arbitrary filesystem paths are rejected. + +## DATA + +A successful download returns: + +```json +{"type":"DATA","asset":"","data":""} +``` + +The client decodes `data` and writes it to its own requested local output path. This keeps Amiga filesystem semantics on the Amiga side and Linux filesystem semantics on the worker side. + +## M1 end-to-end contract + +The qualified single-file POV-Ray flow is: + +```text +local scene + -> UPLOAD + <- STAGED + -> SUBMIT + <- COMPLETE + -> DOWNLOAD + <- DATA + -> local output +``` + +CI exercises this sequence over one TCP connection with a real POV-Ray render and verifies that the downloaded result has a valid PNG signature. + +## Known M1 transfer limitations + +The current base64-in-JSON transfer is deliberately simple and bounded. It is suitable for qualification and small assets, not production-size scenes or render outputs. + +A later protocol revision should add chunked upload/download, opaque asset IDs rather than filesystem paths, explicit asset sizes/hashes, multi-file manifests, and staged-asset lifetime/cleanup semantics. From 5f516ee5df1291f78270a59edff0bc7e25c5ba6b Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 14:55:41 +0200 Subject: [PATCH 105/201] Protocol: add bounded chunk transfer primitives --- internal/node/server.go | 105 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/internal/node/server.go b/internal/node/server.go index 097720f..df8cbb1 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -25,6 +25,8 @@ type message struct { Name string `json:"name,omitempty"` Data string `json:"data,omitempty"` Asset string `json:"asset,omitempty"` + Offset int64 `json:"offset,omitempty"` + Size int64 `json:"size,omitempty"` } type stagedResult struct { @@ -32,6 +34,9 @@ type stagedResult struct { Asset string `json:"asset,omitempty"` Error string `json:"error,omitempty"` Data string `json:"data,omitempty"` + Offset int64 `json:"offset,omitempty"` + Size int64 `json:"size,omitempty"` + EOF bool `json:"eof,omitempty"` } func Handle(c net.Conn) { @@ -48,6 +53,18 @@ func Handle(c net.Conn) { handleUpload(c, m) continue } + if m.Type == "UPLOAD_BEGIN" { + handleUploadBegin(c, m) + continue + } + if m.Type == "UPLOAD_CHUNK" { + handleUploadChunk(c, m) + continue + } + if m.Type == "DOWNLOAD_CHUNK" { + handleDownloadChunk(c, m) + continue + } if m.Type == "DOWNLOAD" { handleDownload(c, m) continue @@ -76,6 +93,94 @@ func Handle(c net.Conn) { } } +func validStagedAsset(asset string) (string, bool) { + clean := filepath.Clean(asset) + dir := filepath.Dir(clean) + return clean, asset != "" && strings.HasPrefix(filepath.Base(dir), "amirender-asset-") +} + +func handleUploadBegin(c net.Conn, m message) { + if filepath.Base(m.Name) != m.Name || m.Name == "." || m.Name == "" || m.Size <= 0 || m.Size > maxUploadBytes { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "invalid upload"}) + return + } + dir, err := os.MkdirTemp("", "amirender-asset-") + if err != nil { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "staging failed"}) + return + } + path := filepath.Join(dir, m.Name) + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + _ = os.RemoveAll(dir) + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "staging failed"}) + return + } + _ = file.Close() + _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGING", Asset: path, Size: m.Size}) +} + +func handleUploadChunk(c net.Conn, m message) { + clean, ok := validStagedAsset(m.Asset) + if !ok || m.Offset < 0 { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "unsafe asset reference"}) + return + } + data, err := base64.StdEncoding.DecodeString(m.Data) + if err != nil || len(data) == 0 || len(data) > 4096 || m.Offset+int64(len(data)) > maxUploadBytes { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "invalid chunk"}) + return + } + file, err := os.OpenFile(clean, os.O_WRONLY, 0600) + if err != nil { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "asset unavailable"}) + return + } + info, statErr := file.Stat() + if statErr != nil || info.Size() != m.Offset { + _ = file.Close() + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "unexpected chunk offset"}) + return + } + _, err = file.WriteAt(data, m.Offset) + _ = file.Close() + if err != nil { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "staging failed"}) + return + } + _ = json.NewEncoder(c).Encode(stagedResult{Type: "CHUNKED", Asset: clean, Offset: m.Offset + int64(len(data))}) +} + +func handleDownloadChunk(c net.Conn, m message) { + clean, ok := validStagedAsset(m.Asset) + if !ok || m.Offset < 0 || m.Size <= 0 || m.Size > 4096 { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "invalid chunk request"}) + return + } + file, err := os.Open(clean) + if err != nil { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "asset unavailable"}) + return + } + defer file.Close() + info, err := file.Stat() + if err != nil || m.Offset > info.Size() { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "invalid chunk offset"}) + return + } + buf := make([]byte, m.Size) + n, err := file.ReadAt(buf, m.Offset) + if err != nil && n == 0 { + if m.Offset == info.Size() { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "DATA", Asset: clean, Offset: m.Offset, Size: 0, EOF: true}) + return + } + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "asset unavailable"}) + return + } + _ = json.NewEncoder(c).Encode(stagedResult{Type: "DATA", Asset: clean, Offset: m.Offset, Size: int64(n), Data: base64.StdEncoding.EncodeToString(buf[:n]), EOF: m.Offset+int64(n) >= info.Size()}) +} + func handleUpload(c net.Conn, m message) { if filepath.Base(m.Name) != m.Name || m.Name == "." || m.Name == "" { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "unsafe asset name"}) From 45a75298cf58d27380e5630b4ce29c901e5f4c74 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 17:17:29 +0200 Subject: [PATCH 106/201] Protocol: format chunk transfer fields --- internal/node/server.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index df8cbb1..5ded50f 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -20,23 +20,23 @@ const maxUploadBytes = 1024 * 1024 const maxMessageBytes = 2 * 1024 * 1024 type message struct { - Type string `json:"type"` - Job json.RawMessage `json:"job"` - Name string `json:"name,omitempty"` - Data string `json:"data,omitempty"` - Asset string `json:"asset,omitempty"` + Type string `json:"type"` + Job json.RawMessage `json:"job"` + Name string `json:"name,omitempty"` + Data string `json:"data,omitempty"` + Asset string `json:"asset,omitempty"` Offset int64 `json:"offset,omitempty"` - Size int64 `json:"size,omitempty"` + Size int64 `json:"size,omitempty"` } type stagedResult struct { - Type string `json:"type"` - Asset string `json:"asset,omitempty"` - Error string `json:"error,omitempty"` - Data string `json:"data,omitempty"` + Type string `json:"type"` + Asset string `json:"asset,omitempty"` + Error string `json:"error,omitempty"` + Data string `json:"data,omitempty"` Offset int64 `json:"offset,omitempty"` - Size int64 `json:"size,omitempty"` - EOF bool `json:"eof,omitempty"` + Size int64 `json:"size,omitempty"` + EOF bool `json:"eof,omitempty"` } func Handle(c net.Conn) { From 886228d57fda899bb25dbebd8ec730da25706722 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 21:22:06 +0200 Subject: [PATCH 107/201] Test: cover multi-chunk asset round trip --- internal/node/server_test.go | 108 +++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 441b0ab..22ebbf7 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -270,3 +270,111 @@ func TestUploadStagesAssetAndRejectsTraversal(t *testing.T) { t.Fatalf("traversal upload was not rejected: %#v", rejected) } } + + +func TestChunkedAssetRoundTripAndOffsetValidation(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + go Handle(server) + + enc := json.NewEncoder(client) + dec := json.NewDecoder(bufio.NewReader(client)) + want := make([]byte, 9000) + for i := range want { + want[i] = byte(i % 251) + } + + if err := enc.Encode(map[string]any{ + "type": "UPLOAD_BEGIN", "name": "large.bin", "size": len(want), + }); err != nil { + t.Fatal(err) + } + var begun stagedResult + if err := dec.Decode(&begun); err != nil { + t.Fatal(err) + } + if begun.Type != "STAGING" || begun.Asset == "" || begun.Size != int64(len(want)) { + t.Fatalf("unexpected begin result: %#v", begun) + } + defer os.RemoveAll(filepath.Dir(begun.Asset)) + + offset := 0 + for offset < len(want) { + end := offset + 4096 + if end > len(want) { + end = len(want) + } + if err := enc.Encode(map[string]any{ + "type": "UPLOAD_CHUNK", "asset": begun.Asset, "offset": offset, + "data": base64.StdEncoding.EncodeToString(want[offset:end]), + }); err != nil { + t.Fatal(err) + } + var ack stagedResult + if err := dec.Decode(&ack); err != nil { + t.Fatal(err) + } + if ack.Type != "CHUNKED" || ack.Offset != int64(end) { + t.Fatalf("unexpected chunk acknowledgement: %#v", ack) + } + offset = end + } + + if err := enc.Encode(map[string]any{ + "type": "UPLOAD_CHUNK", "asset": begun.Asset, "offset": 1, + "data": base64.StdEncoding.EncodeToString([]byte("bad")), + }); err != nil { + t.Fatal(err) + } + var rejected stagedResult + if err := dec.Decode(&rejected); err != nil { + t.Fatal(err) + } + if rejected.Type != "FAILED" || rejected.Error != "unexpected chunk offset" { + t.Fatalf("bad offset was not rejected: %#v", rejected) + } + + var got []byte + for offset = 0; ; { + if err := enc.Encode(map[string]any{ + "type": "DOWNLOAD_CHUNK", "asset": begun.Asset, "offset": offset, "size": 4096, + }); err != nil { + t.Fatal(err) + } + var part stagedResult + if err := dec.Decode(&part); err != nil { + t.Fatal(err) + } + if part.Type != "DATA" || part.Offset != int64(offset) { + t.Fatalf("unexpected download chunk: %#v", part) + } + decoded, err := base64.StdEncoding.DecodeString(part.Data) + if err != nil { + t.Fatal(err) + } + if int64(len(decoded)) != part.Size { + t.Fatalf("chunk size mismatch: decoded=%d declared=%d", len(decoded), part.Size) + } + got = append(got, decoded...) + offset += len(decoded) + if part.EOF { + break + } + } + if string(got) != string(want) { + t.Fatalf("chunked round trip mismatch: got=%d want=%d", len(got), len(want)) + } + + if err := enc.Encode(map[string]any{ + "type": "DOWNLOAD_CHUNK", "asset": begun.Asset, "offset": 0, "size": 4097, + }); err != nil { + t.Fatal(err) + } + var oversized stagedResult + if err := dec.Decode(&oversized); err != nil { + t.Fatal(err) + } + if oversized.Type != "FAILED" { + t.Fatalf("oversized download chunk was not rejected: %#v", oversized) + } +} From 5b4a39ec56be5eb6c4879b27eb11c7df20d4160b Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 21:32:18 +0200 Subject: [PATCH 108/201] Test: format chunk round trip coverage --- internal/node/server_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 22ebbf7..969bf84 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -271,7 +271,6 @@ func TestUploadStagesAssetAndRejectsTraversal(t *testing.T) { } } - func TestChunkedAssetRoundTripAndOffsetValidation(t *testing.T) { server, client := net.Pipe() defer client.Close() From 0234fdead25dfa2495caa4b4d0f6cf4a4f68629e Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 21:33:39 +0200 Subject: [PATCH 109/201] Amiga: define chunked transport API --- clients/amiga/include/amirender_transport.h | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/clients/amiga/include/amirender_transport.h b/clients/amiga/include/amirender_transport.h index 9a7156f..b015fd4 100644 --- a/clients/amiga/include/amirender_transport.h +++ b/clients/amiga/include/amirender_transport.h @@ -26,6 +26,29 @@ int amirender_download_asset( size_t data_size, size_t *received_size); +int amirender_upload_begin( + struct amirender_transport *transport, + const char *name, + size_t total_size, + char *asset, + size_t asset_size); + +int amirender_upload_chunk( + struct amirender_transport *transport, + const char *asset, + size_t offset, + const unsigned char *data, + size_t data_size); + +int amirender_download_chunk( + struct amirender_transport *transport, + const char *asset, + size_t offset, + unsigned char *data, + size_t data_size, + size_t *received_size, + int *eof); + int amirender_submit_job( struct amirender_transport *transport, const struct amirender_job *job, From 49942a2699b033d77543a5e06809ee09da789c14 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 21:36:56 +0200 Subject: [PATCH 110/201] Amiga: implement chunked asset transport --- clients/amiga/src/client.c | 127 +++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index 4b403e4..1c8f1aa 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -126,6 +126,133 @@ int amirender_download_asset( return 0; } + +static int extract_string_field(const char *reply, const char *key, char *out, size_t out_size) +{ + const char *start = strstr(reply, key); + const char *end; + size_t length; + if (start == NULL || out == NULL || out_size < 2) return -1; + start += strlen(key); + end = strchr(start, '"'); + if (end == NULL) return -1; + length = (size_t)(end - start); + if (length == 0 || length >= out_size) return -1; + memcpy(out, start, length); + out[length] = '\0'; + return 0; +} + +int amirender_upload_begin( + struct amirender_transport *transport, const char *name, size_t total_size, + char *asset, size_t asset_size) +{ + char request[512]; + char reply[1024]; + int n, received; + if (transport == NULL || transport->send == NULL || transport->receive == NULL || + name == NULL || asset == NULL || total_size == 0) return -1; + n = snprintf(request, sizeof(request), + "{\"type\":\"UPLOAD_BEGIN\",\"name\":\"%s\",\"size\":%lu}\n", + name, (unsigned long)total_size); + if (n < 0 || (size_t)n >= sizeof(request)) return -1; + if (transport->send(transport->context, request, (size_t)n) != n) return -1; + received = transport->receive(transport->context, reply, sizeof(reply) - 1); + if (received <= 0 || (size_t)received >= sizeof(reply)) return -1; + reply[received] = '\0'; + if (strstr(reply, "\"type\":\"STAGING\"") == NULL) return -1; + return extract_string_field(reply, "\"asset\":\"", asset, asset_size); +} + +int amirender_upload_chunk( + struct amirender_transport *transport, const char *asset, size_t offset, + const unsigned char *data, size_t data_size) +{ + char encoded[5465]; + char request[6144]; + char reply[1024]; + static const char alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + size_t i, out = 0; + int n, received; + if (transport == NULL || transport->send == NULL || transport->receive == NULL || + asset == NULL || data == NULL || data_size == 0 || data_size > 4096) return -1; + if (strchr(asset, '"') != NULL || strchr(asset, '\\') != NULL) return -1; + for (i = 0; i < data_size; i += 3) { + unsigned int v = (unsigned int)data[i] << 16; + v |= i + 1 < data_size ? (unsigned int)data[i + 1] << 8 : 0; + v |= i + 2 < data_size ? (unsigned int)data[i + 2] : 0; + encoded[out++] = alphabet[(v >> 18) & 63]; + encoded[out++] = alphabet[(v >> 12) & 63]; + encoded[out++] = i + 1 < data_size ? alphabet[(v >> 6) & 63] : '='; + encoded[out++] = i + 2 < data_size ? alphabet[v & 63] : '='; + } + encoded[out] = '\0'; + n = snprintf(request, sizeof(request), + "{\"type\":\"UPLOAD_CHUNK\",\"asset\":\"%s\",\"offset\":%lu,\"data\":\"%s\"}\n", + asset, (unsigned long)offset, encoded); + if (n < 0 || (size_t)n >= sizeof(request)) return -1; + if (transport->send(transport->context, request, (size_t)n) != n) return -1; + received = transport->receive(transport->context, reply, sizeof(reply) - 1); + if (received <= 0 || (size_t)received >= sizeof(reply)) return -1; + reply[received] = '\0'; + return strstr(reply, "\"type\":\"CHUNKED\"") != NULL ? 0 : -1; +} + +int amirender_download_chunk( + struct amirender_transport *transport, const char *asset, size_t offset, + unsigned char *data, size_t data_size, size_t *received_size, int *eof) +{ + char request[1200]; + char reply[6144]; + const char *key = "\"data\":\""; + const char *p, *end; + size_t out = 0; + int n, received; + if (transport == NULL || transport->send == NULL || transport->receive == NULL || + asset == NULL || data == NULL || received_size == NULL || eof == NULL || + data_size == 0 || data_size > 4096) return -1; + if (strchr(asset, '"') != NULL || strchr(asset, '\\') != NULL) return -1; + n = snprintf(request, sizeof(request), + "{\"type\":\"DOWNLOAD_CHUNK\",\"asset\":\"%s\",\"offset\":%lu,\"size\":%lu}\n", + asset, (unsigned long)offset, (unsigned long)data_size); + if (n < 0 || (size_t)n >= sizeof(request)) return -1; + if (transport->send(transport->context, request, (size_t)n) != n) return -1; + received = transport->receive(transport->context, reply, sizeof(reply) - 1); + if (received <= 0 || (size_t)received >= sizeof(reply)) return -1; + reply[received] = '\0'; + if (strstr(reply, "\"type\":\"DATA\"") == NULL) return -1; + p = strstr(reply, key); + if (p == NULL) { + if (strstr(reply, "\"eof\":true") != NULL) { + *received_size = 0; *eof = 1; return 0; + } + return -1; + } + p += strlen(key); + end = strchr(p, '"'); + if (end == NULL) return -1; + while (p < end) { + int a, b, d, e; + unsigned int v; + if (end - p < 4) return -1; + a = base64_value(p[0]); b = base64_value(p[1]); + d = p[2] == '=' ? 0 : base64_value(p[2]); + e = p[3] == '=' ? 0 : base64_value(p[3]); + if (a < 0 || b < 0 || d < 0 || e < 0) return -1; + v = ((unsigned int)a << 18) | ((unsigned int)b << 12) | + ((unsigned int)d << 6) | (unsigned int)e; + if (out >= data_size) return -1; + data[out++] = (unsigned char)(v >> 16); + if (p[2] != '=') { if (out >= data_size) return -1; data[out++] = (unsigned char)(v >> 8); } + if (p[3] != '=') { if (out >= data_size) return -1; data[out++] = (unsigned char)v; } + p += 4; + } + *received_size = out; + *eof = strstr(reply, "\"eof\":true") != NULL; + return 0; +} + int amirender_submit_job( struct amirender_transport *transport, const struct amirender_job *job, From 38d4ce9d01db2da889987fb198a68e28a09ad6ff Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 21:42:01 +0200 Subject: [PATCH 111/201] Amiga: stream scene and output in chunks --- clients/amiga/src/main_amiga.c | 87 ++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 24 deletions(-) diff --git a/clients/amiga/src/main_amiga.c b/clients/amiga/src/main_amiga.c index 7a3f79e..f9d77a8 100644 --- a/clients/amiga/src/main_amiga.c +++ b/clients/amiga/src/main_amiga.c @@ -9,8 +9,7 @@ #define AMIRENDER_PORT 6800 #define REPLY_SIZE 1024 #define ASSET_SIZE 1024 -#define MAX_SCENE_SIZE 6000 -#define MAX_OUTPUT_SIZE 6000 +#define CHUNK_SIZE 4096 static const char *base_name(const char *path) { @@ -25,32 +24,49 @@ static const char *base_name(const char *path) return name; } -static int read_scene(const char *path, unsigned char *buffer, size_t capacity, size_t *length) +static int scene_size(const char *path, size_t *length) { FILE *file; long size; file = fopen(path, "rb"); - if (file == NULL) { - return -1; - } + if (file == NULL) return -1; if (fseek(file, 0, SEEK_END) != 0) { fclose(file); return -1; } size = ftell(file); - if (size <= 0 || (unsigned long)size > (unsigned long)capacity) { - fclose(file); - return -1; + fclose(file); + if (size <= 0) return -1; + *length = (size_t)size; + return 0; +} + +static int upload_file( + struct amirender_transport *transport, const char *path, + char *asset, size_t asset_size) +{ + FILE *file; + unsigned char buffer[CHUNK_SIZE]; + size_t total, offset = 0, count; + + if (scene_size(path, &total) != 0) return -1; + if (amirender_upload_begin( + transport, base_name(path), total, asset, asset_size) != 0) return -1; + file = fopen(path, "rb"); + if (file == NULL) return -1; + while ((count = fread(buffer, 1, sizeof(buffer), file)) > 0) { + if (amirender_upload_chunk(transport, asset, offset, buffer, count) != 0) { + fclose(file); + return -1; + } + offset += count; } - if (fseek(file, 0, SEEK_SET) != 0 || - fread(buffer, 1, (size_t)size, file) != (size_t)size) { + if (ferror(file) || offset != total) { fclose(file); return -1; } - fclose(file); - *length = (size_t)size; - return 0; + return fclose(file) == 0 ? 0 : -1; } static int extract_output(const char *reply, char *output, size_t output_size) @@ -71,15 +87,42 @@ static int extract_output(const char *reply, char *output, size_t output_size) return 0; } -static int write_output(const char *path, const unsigned char *data, size_t length) +static int download_file( + struct amirender_transport *transport, const char *asset, const char *path, + size_t *total_size) { - FILE *file = fopen(path, "wb"); + FILE *file; + unsigned char buffer[CHUNK_SIZE]; + size_t offset = 0, count; + int eof = 0; + + file = fopen(path, "wb"); if (file == NULL) return -1; - if (fwrite(data, 1, length, file) != length) { - fclose(file); + while (!eof) { + if (amirender_download_chunk( + transport, asset, offset, buffer, sizeof(buffer), &count, &eof) != 0) { + fclose(file); + remove(path); + return -1; + } + if (count > 0 && fwrite(buffer, 1, count, file) != count) { + fclose(file); + remove(path); + return -1; + } + offset += count; + if (count == 0 && !eof) { + fclose(file); + remove(path); + return -1; + } + } + if (fclose(file) != 0) { + remove(path); return -1; } - return fclose(file) == 0 ? 0 : -1; + *total_size = offset; + return 0; } static void usage(const char *program) @@ -98,9 +141,6 @@ int main(int argc, char **argv) char reply[REPLY_SIZE]; char asset[ASSET_SIZE]; char output_asset[ASSET_SIZE]; - unsigned char scene_data[MAX_SCENE_SIZE]; - unsigned char output_data[MAX_OUTPUT_SIZE]; - size_t scene_size; size_t output_size; int width = 0; int height = 0; @@ -140,8 +180,7 @@ int main(int argc, char **argv) return 10; } - rc = amirender_upload_asset( - &transport, base_name(argv[2]), scene_data, scene_size, asset, sizeof(asset)); + rc = upload_file(&transport, argv[2], asset, sizeof(asset)); if (rc != 0) { amirender_bsdsocket_close(&socket_state); fprintf(stderr, "AmiRender: scene upload failed\n"); From 5f93bc5b916363bf123b4eceb25dff5ba57eeeb8 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 22:29:41 +0200 Subject: [PATCH 112/201] Amiga: complete CLI streaming migration --- clients/amiga/src/main_amiga.c | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/clients/amiga/src/main_amiga.c b/clients/amiga/src/main_amiga.c index f9d77a8..dc1dace 100644 --- a/clients/amiga/src/main_amiga.c +++ b/clients/amiga/src/main_amiga.c @@ -160,11 +160,6 @@ int main(int argc, char **argv) } } - if (read_scene(argv[2], scene_data, sizeof(scene_data), &scene_size) != 0) { - fprintf(stderr, "AmiRender: cannot read scene or scene exceeds %d bytes\n", MAX_SCENE_SIZE); - return 10; - } - job.id = "amiga-cli"; job.engine = "povray"; job.scene = argv[2]; @@ -195,15 +190,10 @@ int main(int argc, char **argv) return 10; } - rc = amirender_download_asset( - &transport, output_asset, output_data, sizeof(output_data), &output_size); + rc = download_file(&transport, output_asset, argv[3], &output_size); amirender_bsdsocket_close(&socket_state); if (rc != 0) { - fprintf(stderr, "AmiRender: output download failed or exceeds %d bytes\n", MAX_OUTPUT_SIZE); - return 10; - } - if (write_output(argv[3], output_data, output_size) != 0) { - fprintf(stderr, "AmiRender: cannot write output %s\n", argv[3]); + fprintf(stderr, "AmiRender: output download failed\n"); return 10; } From f27734340930702f8e5814fd0e7c996130f074b4 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 22:38:51 +0200 Subject: [PATCH 113/201] Amiga: reduce chunk transport stack buffers --- clients/amiga/src/client.c | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index 1c8f1aa..c53fb55 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -168,15 +168,15 @@ int amirender_upload_chunk( struct amirender_transport *transport, const char *asset, size_t offset, const unsigned char *data, size_t data_size) { - char encoded[5465]; - char request[6144]; + char encoded[2733]; + char request[3584]; char reply[1024]; static const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; size_t i, out = 0; int n, received; if (transport == NULL || transport->send == NULL || transport->receive == NULL || - asset == NULL || data == NULL || data_size == 0 || data_size > 4096) return -1; + asset == NULL || data == NULL || data_size == 0 || data_size > 2048) return -1; if (strchr(asset, '"') != NULL || strchr(asset, '\\') != NULL) return -1; for (i = 0; i < data_size; i += 3) { unsigned int v = (unsigned int)data[i] << 16; @@ -204,14 +204,14 @@ int amirender_download_chunk( unsigned char *data, size_t data_size, size_t *received_size, int *eof) { char request[1200]; - char reply[6144]; + char reply[3584]; const char *key = "\"data\":\""; const char *p, *end; size_t out = 0; int n, received; if (transport == NULL || transport->send == NULL || transport->receive == NULL || asset == NULL || data == NULL || received_size == NULL || eof == NULL || - data_size == 0 || data_size > 4096) return -1; + data_size == 0 || data_size > 2048) return -1; if (strchr(asset, '"') != NULL || strchr(asset, '\\') != NULL) return -1; n = snprintf(request, sizeof(request), "{\"type\":\"DOWNLOAD_CHUNK\",\"asset\":\"%s\",\"offset\":%lu,\"size\":%lu}\n", From aad396708c8cc37f65d085fe81b3a01401a04169 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 22:38:57 +0200 Subject: [PATCH 114/201] Amiga: use 2 KiB streaming chunks --- clients/amiga/src/main_amiga.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/amiga/src/main_amiga.c b/clients/amiga/src/main_amiga.c index dc1dace..c77cc75 100644 --- a/clients/amiga/src/main_amiga.c +++ b/clients/amiga/src/main_amiga.c @@ -9,7 +9,7 @@ #define AMIRENDER_PORT 6800 #define REPLY_SIZE 1024 #define ASSET_SIZE 1024 -#define CHUNK_SIZE 4096 +#define CHUNK_SIZE 2048 static const char *base_name(const char *path) { From dbdbfe16cab40d120b71aec8e70b3d5185cc48ce Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 7 Oct 2026 22:42:42 +0200 Subject: [PATCH 115/201] Protocol: finalize chunked uploads explicitly --- internal/node/server.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/node/server.go b/internal/node/server.go index 5ded50f..3ea38ef 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -61,6 +61,10 @@ func Handle(c net.Conn) { handleUploadChunk(c, m) continue } + if m.Type == "UPLOAD_END" { + handleUploadEnd(c, m) + continue + } if m.Type == "DOWNLOAD_CHUNK" { handleDownloadChunk(c, m) continue @@ -151,6 +155,24 @@ func handleUploadChunk(c net.Conn, m message) { _ = json.NewEncoder(c).Encode(stagedResult{Type: "CHUNKED", Asset: clean, Offset: m.Offset + int64(len(data))}) } +func handleUploadEnd(c net.Conn, m message) { + clean, ok := validStagedAsset(m.Asset) + if !ok || m.Size <= 0 || m.Size > maxUploadBytes { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "invalid upload completion"}) + return + } + info, err := os.Stat(clean) + if err != nil { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "asset unavailable"}) + return + } + if info.Size() != m.Size { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "incomplete upload", Size: info.Size()}) + return + } + _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGED", Asset: clean, Size: info.Size()}) +} + func handleDownloadChunk(c net.Conn, m message) { clean, ok := validStagedAsset(m.Asset) if !ok || m.Offset < 0 || m.Size <= 0 || m.Size > 4096 { From e4883fef14ffccc14781a5d847416831bc5e4910 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 00:17:36 +0200 Subject: [PATCH 116/201] Amiga: expose upload completion API --- clients/amiga/include/amirender_transport.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/clients/amiga/include/amirender_transport.h b/clients/amiga/include/amirender_transport.h index b015fd4..93d2830 100644 --- a/clients/amiga/include/amirender_transport.h +++ b/clients/amiga/include/amirender_transport.h @@ -40,6 +40,11 @@ int amirender_upload_chunk( const unsigned char *data, size_t data_size); +int amirender_upload_end( + struct amirender_transport *transport, + const char *asset, + size_t total_size); + int amirender_download_chunk( struct amirender_transport *transport, const char *asset, From d8ebabbc639917909ed1c4669dfd25d983ef68c1 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 00:17:42 +0200 Subject: [PATCH 117/201] Amiga: implement upload completion handshake --- clients/amiga/src/client.c | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index c53fb55..3307890 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -199,6 +199,26 @@ int amirender_upload_chunk( return strstr(reply, "\"type\":\"CHUNKED\"") != NULL ? 0 : -1; } +int amirender_upload_end( + struct amirender_transport *transport, const char *asset, size_t total_size) +{ + char request[1200]; + char reply[1024]; + int n, received; + if (transport == NULL || transport->send == NULL || transport->receive == NULL || + asset == NULL || total_size == 0) return -1; + if (strchr(asset, '"') != NULL || strchr(asset, '\\') != NULL) return -1; + n = snprintf(request, sizeof(request), + "{\"type\":\"UPLOAD_END\",\"asset\":\"%s\",\"size\":%lu}\n", + asset, (unsigned long)total_size); + if (n < 0 || (size_t)n >= sizeof(request)) return -1; + if (transport->send(transport->context, request, (size_t)n) != n) return -1; + received = transport->receive(transport->context, reply, sizeof(reply) - 1); + if (received <= 0 || (size_t)received >= sizeof(reply)) return -1; + reply[received] = '\0'; + return strstr(reply, "\"type\":\"STAGED\"") != NULL ? 0 : -1; +} + int amirender_download_chunk( struct amirender_transport *transport, const char *asset, size_t offset, unsigned char *data, size_t data_size, size_t *received_size, int *eof) From fb6fe62ef191da3d1c669ea60fb9e2125853eae1 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 00:17:47 +0200 Subject: [PATCH 118/201] Amiga: require staged upload completion --- clients/amiga/src/main_amiga.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/clients/amiga/src/main_amiga.c b/clients/amiga/src/main_amiga.c index c77cc75..7b0389e 100644 --- a/clients/amiga/src/main_amiga.c +++ b/clients/amiga/src/main_amiga.c @@ -66,7 +66,8 @@ static int upload_file( fclose(file); return -1; } - return fclose(file) == 0 ? 0 : -1; + if (fclose(file) != 0) return -1; + return amirender_upload_end(transport, asset, total); } static int extract_output(const char *reply, char *output, size_t output_size) From 85c75049afef4548c867b9b604e135df5c707995 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 00:21:13 +0200 Subject: [PATCH 119/201] Protocol: enforce declared upload size with server sessions --- internal/node/server.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/node/server.go b/internal/node/server.go index 3ea38ef..b7216f5 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -10,12 +10,18 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/Ploos-AS/AmiRender/internal/engine" "github.com/Ploos-AS/AmiRender/internal/engine/povray" "github.com/Ploos-AS/AmiRender/internal/farm" ) +var uploadSessions = struct { + sync.Mutex + expected map[string]int64 +}{expected: make(map[string]int64)} + const maxUploadBytes = 1024 * 1024 const maxMessageBytes = 2 * 1024 * 1024 @@ -121,6 +127,9 @@ func handleUploadBegin(c net.Conn, m message) { return } _ = file.Close() + uploadSessions.Lock() + uploadSessions.expected[path] = m.Size + uploadSessions.Unlock() _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGING", Asset: path, Size: m.Size}) } @@ -161,6 +170,13 @@ func handleUploadEnd(c net.Conn, m message) { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "invalid upload completion"}) return } + uploadSessions.Lock() + expected, exists := uploadSessions.expected[clean] + uploadSessions.Unlock() + if !exists || expected != m.Size { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "upload size mismatch"}) + return + } info, err := os.Stat(clean) if err != nil { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "asset unavailable"}) @@ -170,6 +186,9 @@ func handleUploadEnd(c net.Conn, m message) { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "incomplete upload", Size: info.Size()}) return } + uploadSessions.Lock() + delete(uploadSessions.expected, clean) + uploadSessions.Unlock() _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGED", Asset: clean, Size: info.Size()}) } From 29b27f5e82d65496f532f9e5eb4ba3059cf90824 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 00:22:08 +0200 Subject: [PATCH 120/201] Test: reject incomplete and forged upload completion --- internal/node/server_test.go | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 969bf84..01d77d4 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -377,3 +377,41 @@ func TestChunkedAssetRoundTripAndOffsetValidation(t *testing.T) { t.Fatalf("oversized download chunk was not rejected: %#v", oversized) } } +func TestUploadEndRequiresDeclaredSizeAndSingleCompletion(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + go Handle(server) + enc := json.NewEncoder(client) + dec := json.NewDecoder(bufio.NewReader(client)) + send := func(m map[string]any) stagedResult { + t.Helper() + if err := enc.Encode(m); err != nil { t.Fatal(err) } + var result stagedResult + if err := dec.Decode(&result); err != nil { t.Fatal(err) } + return result + } + begun := send(map[string]any{"type": "UPLOAD_BEGIN", "name": "partial.bin", "size": 9000}) + if begun.Type != "STAGING" { t.Fatalf("begin: %#v", begun) } + defer os.RemoveAll(filepath.Dir(begun.Asset)) + payload := base64.StdEncoding.EncodeToString(make([]byte, 4096)) + for _, offset := range []int{0, 4096} { + ack := send(map[string]any{"type": "UPLOAD_CHUNK", "asset": begun.Asset, "offset": offset, "data": payload}) + if ack.Type != "CHUNKED" { t.Fatalf("chunk: %#v", ack) } + } + if result := send(map[string]any{"type": "UPLOAD_END", "asset": begun.Asset, "size": 8192}); result.Type != "FAILED" || result.Error != "upload size mismatch" { + t.Fatalf("changed declared size accepted: %#v", result) + } + if result := send(map[string]any{"type": "UPLOAD_END", "asset": begun.Asset, "size": 9000}); result.Type != "FAILED" || result.Error != "incomplete upload" { + t.Fatalf("partial upload accepted: %#v", result) + } + last := base64.StdEncoding.EncodeToString(make([]byte, 808)) + if result := send(map[string]any{"type": "UPLOAD_CHUNK", "asset": begun.Asset, "offset": 8192, "data": last}); result.Type != "CHUNKED" { + t.Fatalf("final chunk: %#v", result) + } + if result := send(map[string]any{"type": "UPLOAD_END", "asset": begun.Asset, "size": 9000}); result.Type != "STAGED" { + t.Fatalf("completion: %#v", result) + } + if result := send(map[string]any{"type": "UPLOAD_END", "asset": begun.Asset, "size": 9000}); result.Type != "FAILED" { + t.Fatalf("duplicate completion accepted: %#v", result) + } +} From d42aa26813bd9259550210d52e635e5c1e74ec0a Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 00:25:29 +0200 Subject: [PATCH 121/201] Test: gofmt upload completion cases --- internal/node/server_test.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 01d77d4..6dc8616 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -385,18 +385,26 @@ func TestUploadEndRequiresDeclaredSizeAndSingleCompletion(t *testing.T) { dec := json.NewDecoder(bufio.NewReader(client)) send := func(m map[string]any) stagedResult { t.Helper() - if err := enc.Encode(m); err != nil { t.Fatal(err) } + if err := enc.Encode(m); err != nil { + t.Fatal(err) + } var result stagedResult - if err := dec.Decode(&result); err != nil { t.Fatal(err) } + if err := dec.Decode(&result); err != nil { + t.Fatal(err) + } return result } begun := send(map[string]any{"type": "UPLOAD_BEGIN", "name": "partial.bin", "size": 9000}) - if begun.Type != "STAGING" { t.Fatalf("begin: %#v", begun) } + if begun.Type != "STAGING" { + t.Fatalf("begin: %#v", begun) + } defer os.RemoveAll(filepath.Dir(begun.Asset)) payload := base64.StdEncoding.EncodeToString(make([]byte, 4096)) for _, offset := range []int{0, 4096} { ack := send(map[string]any{"type": "UPLOAD_CHUNK", "asset": begun.Asset, "offset": offset, "data": payload}) - if ack.Type != "CHUNKED" { t.Fatalf("chunk: %#v", ack) } + if ack.Type != "CHUNKED" { + t.Fatalf("chunk: %#v", ack) + } } if result := send(map[string]any{"type": "UPLOAD_END", "asset": begun.Asset, "size": 8192}); result.Type != "FAILED" || result.Error != "upload size mismatch" { t.Fatalf("changed declared size accepted: %#v", result) From 3a22847059d79da9f1fdb17c13f7d4648ce2780e Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 00:32:15 +0200 Subject: [PATCH 122/201] Node: reject rendering from unfinished staged uploads --- internal/node/server.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/node/server.go b/internal/node/server.go index b7216f5..5f97c53 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -94,6 +94,15 @@ func Handle(c net.Conn) { Type: "COMPLETE", JobID: j.ID, Engine: "null", Output: j.Output, }) case "povray": + uploadSessions.Lock() + _, incomplete := uploadSessions.expected[filepath.Clean(j.Scene)] + uploadSessions.Unlock() + if incomplete { + writeResult(c, farm.WorkerResult{ + Type: "FAILED", JobID: j.ID, Engine: "povray", Error: "scene upload incomplete", + }) + continue + } renderPOVRay(c, j) default: writeResult(c, farm.WorkerResult{ From 85897c8d099a7b384a4d1775a32bd7645019ddfe Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 00:36:08 +0200 Subject: [PATCH 123/201] Test: reject POV-Ray submission before upload completion --- internal/node/server_test.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 6dc8616..ab86619 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -423,3 +423,33 @@ func TestUploadEndRequiresDeclaredSizeAndSingleCompletion(t *testing.T) { t.Fatalf("duplicate completion accepted: %#v", result) } } + +func TestPOVRayRejectsUnfinishedUpload(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + go Handle(server) + enc := json.NewEncoder(client) + dec := json.NewDecoder(bufio.NewReader(client)) + if err := enc.Encode(map[string]any{"type": "UPLOAD_BEGIN", "name": "pending.pov", "size": 10}); err != nil { + t.Fatal(err) + } + var begun stagedResult + if err := dec.Decode(&begun); err != nil { + t.Fatal(err) + } + if begun.Type != "STAGING" { + t.Fatalf("unexpected begin: %#v", begun) + } + defer os.RemoveAll(filepath.Dir(begun.Asset)) + job := farm.RenderJob{ID: "unfinished", Engine: "povray", Scene: begun.Asset, Output: "RAM:frame.png"} + if err := enc.Encode(map[string]any{"type": "SUBMIT", "job": job}); err != nil { + t.Fatal(err) + } + var rejected farm.WorkerResult + if err := dec.Decode(&rejected); err != nil { + t.Fatal(err) + } + if rejected.Type != "FAILED" || rejected.Error != "scene upload incomplete" { + t.Fatalf("unfinished upload was rendered: %#v", rejected) + } +} From 10fa5787e07e555fc4de07dddea428865d435b23 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 00:44:12 +0200 Subject: [PATCH 124/201] Test: release render block after upload completion --- internal/node/server_test.go | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index ab86619..b98ddfc 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -452,4 +452,45 @@ func TestPOVRayRejectsUnfinishedUpload(t *testing.T) { if rejected.Type != "FAILED" || rejected.Error != "scene upload incomplete" { t.Fatalf("unfinished upload was rendered: %#v", rejected) } + payload := base64.StdEncoding.EncodeToString([]byte("camera {}")) + if err := enc.Encode(map[string]any{ + "type": "UPLOAD_CHUNK", "asset": begun.Asset, "offset": 0, "data": payload, + }); err != nil { + t.Fatal(err) + } + var chunk stagedResult + if err := dec.Decode(&chunk); err != nil { + t.Fatal(err) + } + if chunk.Type != "CHUNKED" || chunk.Offset != 9 { + t.Fatalf("unexpected chunk: %#v", chunk) + } + if err := enc.Encode(map[string]any{ + "type": "UPLOAD_CHUNK", "asset": begun.Asset, "offset": 9, + "data": base64.StdEncoding.EncodeToString([]byte("\\n")), + }); err != nil { + t.Fatal(err) + } + if err := dec.Decode(&chunk); err != nil { + t.Fatal(err) + } + if chunk.Type != "CHUNKED" || chunk.Offset != 10 { + t.Fatalf("unexpected final chunk: %#v", chunk) + } + if err := enc.Encode(map[string]any{"type": "UPLOAD_END", "asset": begun.Asset, "size": 10}); err != nil { + t.Fatal(err) + } + var staged stagedResult + if err := dec.Decode(&staged); err != nil { + t.Fatal(err) + } + if staged.Type != "STAGED" { + t.Fatalf("completed upload rejected: %#v", staged) + } + uploadSessions.Lock() + _, pending := uploadSessions.expected[begun.Asset] + uploadSessions.Unlock() + if pending { + t.Fatal("completed upload remains blocked") + } } From 1a20187d93376f43e900dd605f4adf2ab58b7458 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 00:44:56 +0200 Subject: [PATCH 125/201] Node: expire abandoned chunk upload sessions --- internal/node/server.go | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index 5f97c53..091ffff 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -11,16 +11,24 @@ import ( "path/filepath" "strings" "sync" + "time" "github.com/Ploos-AS/AmiRender/internal/engine" "github.com/Ploos-AS/AmiRender/internal/engine/povray" "github.com/Ploos-AS/AmiRender/internal/farm" ) +type uploadSession struct { + size int64 + created time.Time +} + +const uploadSessionTTL = 30 * time.Minute + var uploadSessions = struct { sync.Mutex - expected map[string]int64 -}{expected: make(map[string]int64)} + expected map[string]uploadSession +}{expected: make(map[string]uploadSession)} const maxUploadBytes = 1024 * 1024 const maxMessageBytes = 2 * 1024 * 1024 @@ -118,7 +126,23 @@ func validStagedAsset(asset string) (string, bool) { return clean, asset != "" && strings.HasPrefix(filepath.Base(dir), "amirender-asset-") } +func cleanupExpiredUploads(now time.Time) { + var expired []string + uploadSessions.Lock() + for path, session := range uploadSessions.expected { + if now.Sub(session.created) >= uploadSessionTTL { + delete(uploadSessions.expected, path) + expired = append(expired, path) + } + } + uploadSessions.Unlock() + for _, path := range expired { + _ = os.RemoveAll(filepath.Dir(path)) + } +} + func handleUploadBegin(c net.Conn, m message) { + cleanupExpiredUploads(time.Now()) if filepath.Base(m.Name) != m.Name || m.Name == "." || m.Name == "" || m.Size <= 0 || m.Size > maxUploadBytes { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "invalid upload"}) return @@ -137,7 +161,7 @@ func handleUploadBegin(c net.Conn, m message) { } _ = file.Close() uploadSessions.Lock() - uploadSessions.expected[path] = m.Size + uploadSessions.expected[path] = uploadSession{size: m.Size, created: time.Now()} uploadSessions.Unlock() _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGING", Asset: path, Size: m.Size}) } @@ -182,7 +206,7 @@ func handleUploadEnd(c net.Conn, m message) { uploadSessions.Lock() expected, exists := uploadSessions.expected[clean] uploadSessions.Unlock() - if !exists || expected != m.Size { + if !exists || expected.size != m.Size { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "upload size mismatch"}) return } From 1dea5fb74762a3d0b8c8bded52156a76c4557415 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 01:09:40 +0200 Subject: [PATCH 126/201] Node: gofmt upload session fields --- internal/node/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/node/server.go b/internal/node/server.go index 091ffff..f4bb83e 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -19,7 +19,7 @@ import ( ) type uploadSession struct { - size int64 + size int64 created time.Time } From 0507b500ea8e4ccfb08a166fc5179dfe9144ce51 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 01:23:52 +0200 Subject: [PATCH 127/201] Test: correct completed scene payload length --- internal/node/server_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index b98ddfc..20bccd4 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -430,7 +430,7 @@ func TestPOVRayRejectsUnfinishedUpload(t *testing.T) { go Handle(server) enc := json.NewEncoder(client) dec := json.NewDecoder(bufio.NewReader(client)) - if err := enc.Encode(map[string]any{"type": "UPLOAD_BEGIN", "name": "pending.pov", "size": 10}); err != nil { + if err := enc.Encode(map[string]any{"type": "UPLOAD_BEGIN", "name": "pending.pov", "size": 11}); err != nil { t.Fatal(err) } var begun stagedResult @@ -474,10 +474,10 @@ func TestPOVRayRejectsUnfinishedUpload(t *testing.T) { if err := dec.Decode(&chunk); err != nil { t.Fatal(err) } - if chunk.Type != "CHUNKED" || chunk.Offset != 10 { + if chunk.Type != "CHUNKED" || chunk.Offset != 11 { t.Fatalf("unexpected final chunk: %#v", chunk) } - if err := enc.Encode(map[string]any{"type": "UPLOAD_END", "asset": begun.Asset, "size": 10}); err != nil { + if err := enc.Encode(map[string]any{"type": "UPLOAD_END", "asset": begun.Asset, "size": 11}); err != nil { t.Fatal(err) } var staged stagedResult From 1b103c6bdf0ccec3057058492791abc6ea63d954 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 01:28:51 +0200 Subject: [PATCH 128/201] Test: expire abandoned uploads without deleting active sessions --- internal/node/server_test.go | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 20bccd4..54872c7 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -494,3 +494,40 @@ func TestPOVRayRejectsUnfinishedUpload(t *testing.T) { t.Fatal("completed upload remains blocked") } } + +func TestCleanupExpiredUploadsPreservesActiveSessions(t *testing.T) { + now := time.Now() + expiredDir := t.TempDir() + activeDir := t.TempDir() + expiredPath := filepath.Join(expiredDir, "old.pov") + activePath := filepath.Join(activeDir, "new.pov") + for _, path := range []string{expiredPath, activePath} { + if err := os.WriteFile(path, []byte("scene"), 0600); err != nil { + t.Fatal(err) + } + } + uploadSessions.Lock() + uploadSessions.expected[expiredPath] = uploadSession{size: 5, created: now.Add(-uploadSessionTTL - time.Second)} + uploadSessions.expected[activePath] = uploadSession{size: 5, created: now.Add(-uploadSessionTTL + time.Second)} + uploadSessions.Unlock() + defer func() { + uploadSessions.Lock() + delete(uploadSessions.expected, expiredPath) + delete(uploadSessions.expected, activePath) + uploadSessions.Unlock() + }() + cleanupExpiredUploads(now) + uploadSessions.Lock() + _, expiredExists := uploadSessions.expected[expiredPath] + _, activeExists := uploadSessions.expected[activePath] + uploadSessions.Unlock() + if expiredExists || !activeExists { + t.Fatalf("unexpected sessions: expired=%t active=%t", expiredExists, activeExists) + } + if _, err := os.Stat(expiredPath); !os.IsNotExist(err) { + t.Fatalf("expired file still exists: %v", err) + } + if _, err := os.Stat(activePath); err != nil { + t.Fatalf("active file removed: %v", err) + } +} From 802615d30f3ac974c42bd1429526a5ba4c97c8f4 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 03:02:37 +0200 Subject: [PATCH 129/201] Node: restrict upload cleanup to registered staging directories --- internal/node/server.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index f4bb83e..3c4197f 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -21,6 +21,7 @@ import ( type uploadSession struct { size int64 created time.Time + dir string } const uploadSessionTTL = 30 * time.Minute @@ -132,12 +133,15 @@ func cleanupExpiredUploads(now time.Time) { for path, session := range uploadSessions.expected { if now.Sub(session.created) >= uploadSessionTTL { delete(uploadSessions.expected, path) - expired = append(expired, path) + expired = append(expired, session.dir) } } uploadSessions.Unlock() - for _, path := range expired { - _ = os.RemoveAll(filepath.Dir(path)) + for _, dir := range expired { + if dir == "" || !strings.HasPrefix(filepath.Base(dir), "amirender-asset-") { + continue + } + _ = os.RemoveAll(dir) } } @@ -161,7 +165,7 @@ func handleUploadBegin(c net.Conn, m message) { } _ = file.Close() uploadSessions.Lock() - uploadSessions.expected[path] = uploadSession{size: m.Size, created: time.Now()} + uploadSessions.expected[path] = uploadSession{size: m.Size, created: time.Now(), dir: dir} uploadSessions.Unlock() _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGING", Asset: path, Size: m.Size}) } From 8888e8223a50c323836cde6d8112664a2716f55e Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 03:02:43 +0200 Subject: [PATCH 130/201] Test: use registered staging directories for TTL cleanup --- internal/node/server_test.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 54872c7..c3c1ecc 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -497,8 +497,15 @@ func TestPOVRayRejectsUnfinishedUpload(t *testing.T) { func TestCleanupExpiredUploadsPreservesActiveSessions(t *testing.T) { now := time.Now() - expiredDir := t.TempDir() - activeDir := t.TempDir() + root := t.TempDir() + expiredDir, err := os.MkdirTemp(root, "amirender-asset-") + if err != nil { + t.Fatal(err) + } + activeDir, err := os.MkdirTemp(root, "amirender-asset-") + if err != nil { + t.Fatal(err) + } expiredPath := filepath.Join(expiredDir, "old.pov") activePath := filepath.Join(activeDir, "new.pov") for _, path := range []string{expiredPath, activePath} { @@ -507,8 +514,8 @@ func TestCleanupExpiredUploadsPreservesActiveSessions(t *testing.T) { } } uploadSessions.Lock() - uploadSessions.expected[expiredPath] = uploadSession{size: 5, created: now.Add(-uploadSessionTTL - time.Second)} - uploadSessions.expected[activePath] = uploadSession{size: 5, created: now.Add(-uploadSessionTTL + time.Second)} + uploadSessions.expected[expiredPath] = uploadSession{size: 5, created: now.Add(-uploadSessionTTL - time.Second), dir: expiredDir} + uploadSessions.expected[activePath] = uploadSession{size: 5, created: now.Add(-uploadSessionTTL + time.Second), dir: activeDir} uploadSessions.Unlock() defer func() { uploadSessions.Lock() From 060c8ffb2c7fdff84d10726522c4621bd6263edd Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 03:03:24 +0200 Subject: [PATCH 131/201] Test: preserve non-staging directories during session cleanup --- internal/node/server_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index c3c1ecc..c723db2 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -538,3 +538,30 @@ func TestCleanupExpiredUploadsPreservesActiveSessions(t *testing.T) { t.Fatalf("active file removed: %v", err) } } + +func TestCleanupExpiredUploadsDoesNotDeleteUnregisteredDirectory(t *testing.T) { + now := time.Now() + root := t.TempDir() + unregistered := filepath.Join(root, "other-project") + if err := os.Mkdir(unregistered, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(unregistered, "keep.txt") + if err := os.WriteFile(path, []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + uploadSessions.Lock() + uploadSessions.expected[path] = uploadSession{ + size: 4, created: now.Add(-uploadSessionTTL - time.Second), dir: unregistered, + } + uploadSessions.Unlock() + defer func() { + uploadSessions.Lock() + delete(uploadSessions.expected, path) + uploadSessions.Unlock() + }() + cleanupExpiredUploads(now) + if _, err := os.Stat(path); err != nil { + t.Fatalf("unregistered directory was removed: %v", err) + } +} From b3a2e0de226228a6652675b582c27f4a977c59ca Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 04:39:25 +0200 Subject: [PATCH 132/201] Node: periodically clean up expired upload sessions --- internal/node/server.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/node/server.go b/internal/node/server.go index 3c4197f..d143f52 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -330,6 +330,20 @@ func writeResult(c net.Conn, result farm.WorkerResult) { } func Serve(ln net.Listener) error { + stop := make(chan struct{}) + defer close(stop) + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case now := <-ticker.C: + cleanupExpiredUploads(now) + case <-stop: + return + } + } + }() for { c, err := ln.Accept() if err != nil { From f240070782d5b125d723c0b2245b69802adf2f0d Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 04:39:56 +0200 Subject: [PATCH 133/201] Node: serialize chunk writes with session cleanup and enforce size --- internal/node/server.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/node/server.go b/internal/node/server.go index d143f52..e0069b5 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -181,6 +181,17 @@ func handleUploadChunk(c net.Conn, m message) { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "invalid chunk"}) return } + uploadSessions.Lock() + defer uploadSessions.Unlock() + session, exists := uploadSessions.expected[clean] + if !exists { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "upload session unavailable"}) + return + } + if m.Offset+int64(len(data)) > session.size { + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "chunk exceeds declared upload size"}) + return + } file, err := os.OpenFile(clean, os.O_WRONLY, 0600) if err != nil { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "asset unavailable"}) From bc05934a2fa47d02e327101cdd8059f7fdf83365 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 04:40:31 +0200 Subject: [PATCH 134/201] Test: enforce chunk upload bounds and finalized session rejection --- internal/node/server_test.go | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index c723db2..674d1fb 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -565,3 +565,49 @@ func TestCleanupExpiredUploadsDoesNotDeleteUnregisteredDirectory(t *testing.T) { t.Fatalf("unregistered directory was removed: %v", err) } } + +func TestUploadChunkEnforcesDeclaredSizeAndFinalization(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + go Handle(server) + enc := json.NewEncoder(client) + dec := json.NewDecoder(bufio.NewReader(client)) + send := func(m map[string]any) stagedResult { + t.Helper() + if err := enc.Encode(m); err != nil { + t.Fatal(err) + } + var result stagedResult + if err := dec.Decode(&result); err != nil { + t.Fatal(err) + } + return result + } + begun := send(map[string]any{"type": "UPLOAD_BEGIN", "name": "size.bin", "size": 3}) + if begun.Type != "STAGING" { + t.Fatalf("begin: %#v", begun) + } + defer os.RemoveAll(filepath.Dir(begun.Asset)) + oversized := send(map[string]any{ + "type": "UPLOAD_CHUNK", "asset": begun.Asset, "offset": 0, + "data": base64.StdEncoding.EncodeToString([]byte("four")), + }) + if oversized.Type != "FAILED" || oversized.Error != "chunk exceeds declared upload size" { + t.Fatalf("oversized chunk accepted: %#v", oversized) + } + if result := send(map[string]any{ + "type": "UPLOAD_CHUNK", "asset": begun.Asset, "offset": 0, + "data": base64.StdEncoding.EncodeToString([]byte("abc")), + }); result.Type != "CHUNKED" || result.Offset != 3 { + t.Fatalf("valid chunk rejected: %#v", result) + } + if result := send(map[string]any{"type": "UPLOAD_END", "asset": begun.Asset, "size": 3}); result.Type != "STAGED" { + t.Fatalf("completion rejected: %#v", result) + } + if result := send(map[string]any{ + "type": "UPLOAD_CHUNK", "asset": begun.Asset, "offset": 3, + "data": base64.StdEncoding.EncodeToString([]byte("x")), + }); result.Type != "FAILED" || result.Error != "upload session unavailable" { + t.Fatalf("chunk after completion accepted: %#v", result) + } +} From ef5624f00c1e3aeee10e2e0e9e1eeca3be7f3bcf Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 04:41:03 +0200 Subject: [PATCH 135/201] Node: atomically finalize uploads against concurrent cleanup and chunks --- internal/node/server.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index e0069b5..f958d66 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -219,8 +219,8 @@ func handleUploadEnd(c net.Conn, m message) { return } uploadSessions.Lock() + defer uploadSessions.Unlock() expected, exists := uploadSessions.expected[clean] - uploadSessions.Unlock() if !exists || expected.size != m.Size { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "upload size mismatch"}) return @@ -234,9 +234,7 @@ func handleUploadEnd(c net.Conn, m message) { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "incomplete upload", Size: info.Size()}) return } - uploadSessions.Lock() delete(uploadSessions.expected, clean) - uploadSessions.Unlock() _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGED", Asset: clean, Size: info.Size()}) } From 057d71707fa8c3f7a52bf063b23c29d83cfd0c49 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 16:17:24 +0200 Subject: [PATCH 136/201] Test: serialize upload completion against TTL cleanup --- internal/node/server_test.go | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 674d1fb..8591e63 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -611,3 +611,55 @@ func TestUploadChunkEnforcesDeclaredSizeAndFinalization(t *testing.T) { t.Fatalf("chunk after completion accepted: %#v", result) } } + +func TestUploadEndAndCleanupAreSerialized(t *testing.T) { + now := time.Now() + dir, err := os.MkdirTemp("", "amirender-asset-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "race.pov") + if err := os.WriteFile(path, []byte("abc"), 0600); err != nil { + t.Fatal(err) + } + uploadSessions.Lock() + uploadSessions.expected[path] = uploadSession{ + size: 3, created: now.Add(-uploadSessionTTL - time.Second), dir: dir, + } + uploadSessions.Unlock() + defer func() { + uploadSessions.Lock() + delete(uploadSessions.expected, path) + uploadSessions.Unlock() + }() + server, client := net.Pipe() + defer client.Close() + go Handle(server) + enc := json.NewEncoder(client) + dec := json.NewDecoder(bufio.NewReader(client)) + uploadSessions.Lock() + done := make(chan struct{}) + go func() { + defer close(done) + cleanupExpiredUploads(now) + }() + if err := enc.Encode(map[string]any{"type": "UPLOAD_END", "asset": path, "size": 3}); err != nil { + uploadSessions.Unlock() + t.Fatal(err) + } + uploadSessions.Unlock() + var result stagedResult + if err := dec.Decode(&result); err != nil { + t.Fatal(err) + } + <-done + if result.Type != "STAGED" && result.Type != "FAILED" { + t.Fatalf("unexpected concurrent completion: %#v", result) + } + if result.Type == "STAGED" { + if _, err := os.Stat(path); err != nil { + t.Fatalf("completed upload deleted by cleanup: %v", err) + } + } +} From 58b630699cd392da667e7c72dd046b1e68c15070 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 16:35:02 +0200 Subject: [PATCH 137/201] Node: reject symlink and non-temp staging asset paths --- internal/node/server.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/node/server.go b/internal/node/server.go index f958d66..63f467a 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -122,9 +122,23 @@ func Handle(c net.Conn) { } func validStagedAsset(asset string) (string, bool) { + if asset == "" || !filepath.IsAbs(asset) { + return "", false + } clean := filepath.Clean(asset) dir := filepath.Dir(clean) - return clean, asset != "" && strings.HasPrefix(filepath.Base(dir), "amirender-asset-") + if filepath.Dir(dir) != filepath.Clean(os.TempDir()) || !strings.HasPrefix(filepath.Base(dir), "amirender-asset-") { + return "", false + } + dirInfo, err := os.Lstat(dir) + if err != nil || !dirInfo.IsDir() || dirInfo.Mode()&os.ModeSymlink != 0 { + return "", false + } + fileInfo, err := os.Lstat(clean) + if err != nil || !fileInfo.Mode().IsRegular() { + return "", false + } + return clean, true } func cleanupExpiredUploads(now time.Time) { From 569e2be56926e43fdb58cfa1c2da0933912f97ab Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 8 Oct 2026 22:11:33 +0200 Subject: [PATCH 138/201] Test: reject unsafe staged asset references --- internal/node/server_test.go | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 8591e63..1699ad6 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -663,3 +663,38 @@ func TestUploadEndAndCleanupAreSerialized(t *testing.T) { } } } + +func TestValidStagedAssetRejectsSymlinksAndOutsideTemp(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "amirender-asset-test") + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + file := filepath.Join(dir, "scene.pov") + if err := os.WriteFile(file, []byte("scene"), 0600); err != nil { + t.Fatal(err) + } + if _, ok := validStagedAsset(file); ok { + t.Fatal("asset outside os.TempDir accepted") + } + + tempDir, err := os.MkdirTemp("", "amirender-asset-test-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tempDir) + target := filepath.Join(tempDir, "real.pov") + if err := os.WriteFile(target, []byte("scene"), 0600); err != nil { + t.Fatal(err) + } + link := filepath.Join(tempDir, "link.pov") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, ok := validStagedAsset(link); ok { + t.Fatal("symlink asset accepted") + } + if _, ok := validStagedAsset(target); !ok { + t.Fatal("valid staging asset rejected") + } +} From 79637ab40fcfe1e46fa94f06a5b2003fce49b2c2 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 00:08:29 +0200 Subject: [PATCH 139/201] Node: introduce cryptographic opaque asset ID registry --- internal/node/server.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/node/server.go b/internal/node/server.go index 63f467a..9628c09 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -3,6 +3,8 @@ package node import ( "bufio" "context" + "crypto/rand" + "encoding/hex" "encoding/base64" "encoding/json" "fmt" @@ -31,6 +33,25 @@ var uploadSessions = struct { expected map[string]uploadSession }{expected: make(map[string]uploadSession)} +// assetIDs is a transitional registry. Existing clients continue to use paths +// until the protocol and Amiga client can switch to opaque identifiers. +var assetIDs = struct { + sync.Mutex + paths map[string]string +}{paths: make(map[string]string)} + +func newAssetID(path string) (string, error) { + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return "", err + } + id := "asset-" + hex.EncodeToString(random[:]) + assetIDs.Lock() + assetIDs.paths[id] = path + assetIDs.Unlock() + return id, nil +} + const maxUploadBytes = 1024 * 1024 const maxMessageBytes = 2 * 1024 * 1024 @@ -178,6 +199,11 @@ func handleUploadBegin(c net.Conn, m message) { return } _ = file.Close() + if _, err := newAssetID(path); err != nil { + _ = os.RemoveAll(dir) + _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "asset registration failed"}) + return + } uploadSessions.Lock() uploadSessions.expected[path] = uploadSession{size: m.Size, created: time.Now(), dir: dir} uploadSessions.Unlock() From 5ad7d2bfb1de008d543bf888cd536eb33b5e8621 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 00:10:35 +0200 Subject: [PATCH 140/201] Node: gofmt opaque asset registry imports --- internal/node/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/node/server.go b/internal/node/server.go index 9628c09..f593442 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -4,8 +4,8 @@ import ( "bufio" "context" "crypto/rand" - "encoding/hex" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "net" From 33f8444c5261627d9360349dfcd498b788fc8ebe Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 00:12:32 +0200 Subject: [PATCH 141/201] Protocol: expose opaque ID alongside legacy upload path --- internal/node/server.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index f593442..4e9f4f0 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -66,6 +66,7 @@ type message struct { } type stagedResult struct { + ID string `json:"id,omitempty"` Type string `json:"type"` Asset string `json:"asset,omitempty"` Error string `json:"error,omitempty"` @@ -199,7 +200,8 @@ func handleUploadBegin(c net.Conn, m message) { return } _ = file.Close() - if _, err := newAssetID(path); err != nil { + id, err := newAssetID(path) + if err != nil { _ = os.RemoveAll(dir) _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "asset registration failed"}) return @@ -207,7 +209,7 @@ func handleUploadBegin(c net.Conn, m message) { uploadSessions.Lock() uploadSessions.expected[path] = uploadSession{size: m.Size, created: time.Now(), dir: dir} uploadSessions.Unlock() - _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGING", Asset: path, Size: m.Size}) + _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGING", ID: id, Asset: path, Size: m.Size}) } func handleUploadChunk(c net.Conn, m message) { From a53e47bf5247cb033fc88cf877b329468ec66f46 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 00:13:02 +0200 Subject: [PATCH 142/201] Protocol: accept opaque asset IDs for chunk upload and finalization --- internal/node/server.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index 4e9f4f0..1f68db5 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -52,6 +52,17 @@ func newAssetID(path string) (string, error) { return id, nil } +// resolveUploadAsset accepts opaque IDs and legacy paths during migration. +func resolveUploadAsset(asset string) string { + if !strings.HasPrefix(asset, "asset-") { + return asset + } + assetIDs.Lock() + path := assetIDs.paths[asset] + assetIDs.Unlock() + return path +} + const maxUploadBytes = 1024 * 1024 const maxMessageBytes = 2 * 1024 * 1024 @@ -213,7 +224,7 @@ func handleUploadBegin(c net.Conn, m message) { } func handleUploadChunk(c net.Conn, m message) { - clean, ok := validStagedAsset(m.Asset) + clean, ok := validStagedAsset(resolveUploadAsset(m.Asset)) if !ok || m.Offset < 0 { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "unsafe asset reference"}) return @@ -255,7 +266,7 @@ func handleUploadChunk(c net.Conn, m message) { } func handleUploadEnd(c net.Conn, m message) { - clean, ok := validStagedAsset(m.Asset) + clean, ok := validStagedAsset(resolveUploadAsset(m.Asset)) if !ok || m.Size <= 0 || m.Size > maxUploadBytes { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "invalid upload completion"}) return From 06182227113819396d8b11cb4016f2c5617a6a5f Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 00:13:45 +0200 Subject: [PATCH 143/201] Test: exercise opaque asset ID upload and reject unknown IDs --- internal/node/server_test.go | 54 ++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 1699ad6..099db0a 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -698,3 +698,57 @@ func TestValidStagedAssetRejectsSymlinksAndOutsideTemp(t *testing.T) { t.Fatal("valid staging asset rejected") } } + +func TestOpaqueAssetIDChunkedUpload(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + go Handle(server) + enc := json.NewEncoder(client) + dec := json.NewDecoder(client) + payload := []byte("camera {}\n") + if err := enc.Encode(map[string]any{"type": "UPLOAD_BEGIN", "name": "opaque.pov", "size": len(payload)}); err != nil { + t.Fatal(err) + } + var begun stagedResult + if err := dec.Decode(&begun); err != nil { + t.Fatal(err) + } + if begun.Type != "STAGING" || !strings.HasPrefix(begun.ID, "asset-") { + t.Fatalf("missing opaque asset ID: %#v", begun) + } + defer os.RemoveAll(filepath.Dir(begun.Asset)) + if err := enc.Encode(map[string]any{"type": "UPLOAD_CHUNK", "asset": begun.ID, "offset": 0, "data": base64.StdEncoding.EncodeToString(payload)}); err != nil { + t.Fatal(err) + } + var chunk stagedResult + if err := dec.Decode(&chunk); err != nil { + t.Fatal(err) + } + if chunk.Type != "CHUNKED" || chunk.Offset != int64(len(payload)) { + t.Fatalf("opaque chunk failed: %#v", chunk) + } + if err := enc.Encode(map[string]any{"type": "UPLOAD_END", "asset": begun.ID, "size": len(payload)}); err != nil { + t.Fatal(err) + } + var end stagedResult + if err := dec.Decode(&end); err != nil { + t.Fatal(err) + } + if end.Type != "STAGED" { + t.Fatalf("opaque finalization failed: %#v", end) + } + data, err := os.ReadFile(begun.Asset) + if err != nil || string(data) != string(payload) { + t.Fatalf("uploaded asset mismatch: %q, %v", data, err) + } + if err := enc.Encode(map[string]any{"type": "UPLOAD_CHUNK", "asset": "asset-unknown", "offset": 0, "data": base64.StdEncoding.EncodeToString(payload)}); err != nil { + t.Fatal(err) + } + var rejected stagedResult + if err := dec.Decode(&rejected); err != nil { + t.Fatal(err) + } + if rejected.Type != "FAILED" { + t.Fatalf("unknown opaque asset ID accepted: %#v", rejected) + } +} From 15e66f996e440f923c22ea2264cff577116cf655 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 00:19:30 +0200 Subject: [PATCH 144/201] Protocol: support opaque IDs in download endpoints --- internal/node/server.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index 1f68db5..d01da5f 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -292,7 +292,7 @@ func handleUploadEnd(c net.Conn, m message) { } func handleDownloadChunk(c net.Conn, m message) { - clean, ok := validStagedAsset(m.Asset) + clean, ok := validStagedAsset(resolveUploadAsset(m.Asset)) if !ok || m.Offset < 0 || m.Size <= 0 || m.Size > 4096 { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "invalid chunk request"}) return @@ -346,9 +346,8 @@ func handleUpload(c net.Conn, m message) { } func handleDownload(c net.Conn, m message) { - clean := filepath.Clean(m.Asset) - dir := filepath.Dir(clean) - if m.Asset == "" || !strings.HasPrefix(filepath.Base(dir), "amirender-asset-") { + clean, ok := validStagedAsset(resolveUploadAsset(m.Asset)) + if !ok { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "unsafe asset reference"}) return } From f2a77e622adef4844f9a592ac6317924b2267528 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 00:21:35 +0200 Subject: [PATCH 145/201] Test: cover opaque asset ID downloads --- internal/node/server_test.go | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 099db0a..488e0ab 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -752,3 +752,71 @@ func TestOpaqueAssetIDChunkedUpload(t *testing.T) { t.Fatalf("unknown opaque asset ID accepted: %#v", rejected) } } + +func TestOpaqueAssetIDDownload(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + go Handle(server) + enc := json.NewEncoder(client) + dec := json.NewDecoder(client) + payload := []byte("opaque download") + if err := enc.Encode(map[string]any{"type": "UPLOAD_BEGIN", "name": "download.bin", "size": len(payload)}); err != nil { + t.Fatal(err) + } + var begun stagedResult + if err := dec.Decode(&begun); err != nil { + t.Fatal(err) + } + if begun.Type != "STAGING" || begun.ID == "" { + t.Fatalf("missing asset ID: %#v", begun) + } + defer os.RemoveAll(filepath.Dir(begun.Asset)) + if err := enc.Encode(map[string]any{"type": "UPLOAD_CHUNK", "asset": begun.ID, "offset": 0, "data": base64.StdEncoding.EncodeToString(payload)}); err != nil { + t.Fatal(err) + } + var chunk stagedResult + if err := dec.Decode(&chunk); err != nil { + t.Fatal(err) + } + if chunk.Type != "CHUNKED" { + t.Fatalf("chunk upload failed: %#v", chunk) + } + if err := enc.Encode(map[string]any{"type": "UPLOAD_END", "asset": begun.ID, "size": len(payload)}); err != nil { + t.Fatal(err) + } + var completed stagedResult + if err := dec.Decode(&completed); err != nil { + t.Fatal(err) + } + if completed.Type != "STAGED" { + t.Fatalf("finalization failed: %#v", completed) + } + for _, typ := range []string{"DOWNLOAD", "DOWNLOAD_CHUNK"} { + req := map[string]any{"type": typ, "asset": begun.ID} + if typ == "DOWNLOAD_CHUNK" { + req["offset"] = 0 + req["size"] = 4096 + } + if err := enc.Encode(req); err != nil { + t.Fatal(err) + } + var result stagedResult + if err := dec.Decode(&result); err != nil { + t.Fatal(err) + } + data, err := base64.StdEncoding.DecodeString(result.Data) + if err != nil || result.Type != "DATA" || string(data) != string(payload) { + t.Fatalf("%s failed: %#v, %v", typ, result, err) + } + } + if err := enc.Encode(map[string]any{"type": "DOWNLOAD", "asset": "asset-unknown"}); err != nil { + t.Fatal(err) + } + var rejected stagedResult + if err := dec.Decode(&rejected); err != nil { + t.Fatal(err) + } + if rejected.Type != "FAILED" { + t.Fatalf("unknown asset ID accepted: %#v", rejected) + } +} From ef8c9234b85631d2fe8f1771e04e6afb21b9d4f1 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 00:22:03 +0200 Subject: [PATCH 146/201] Node: revoke asset IDs when expired uploads are cleaned --- internal/node/server.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/node/server.go b/internal/node/server.go index d01da5f..f230ba5 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -184,6 +184,16 @@ func cleanupExpiredUploads(now time.Time) { } } uploadSessions.Unlock() + assetIDs.Lock() + for id, path := range assetIDs.paths { + for _, dir := range expired { + if filepath.Dir(path) == dir { + delete(assetIDs.paths, id) + break + } + } + } + assetIDs.Unlock() for _, dir := range expired { if dir == "" || !strings.HasPrefix(filepath.Base(dir), "amirender-asset-") { continue From 98d9a3a81e4f8407c7ee8c87a8735d86c276046b Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 01:00:46 +0200 Subject: [PATCH 147/201] Test: expired uploads revoke opaque asset IDs --- internal/node/server_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 488e0ab..73672e2 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -820,3 +820,30 @@ func TestOpaqueAssetIDDownload(t *testing.T) { t.Fatalf("unknown asset ID accepted: %#v", rejected) } } + +func TestCleanupExpiredUploadsRevokesAssetIDs(t *testing.T) { + dir, err := os.MkdirTemp("", "amirender-asset-expired-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "expired.pov") + if err := os.WriteFile(path, []byte("scene"), 0600); err != nil { + t.Fatal(err) + } + id, err := newAssetID(path) + if err != nil { + t.Fatal(err) + } + now := time.Now() + uploadSessions.Lock() + uploadSessions.expected[path] = uploadSession{size: 5, created: now.Add(-uploadSessionTTL - time.Minute), dir: dir} + uploadSessions.Unlock() + cleanupExpiredUploads(now) + if resolved := resolveUploadAsset(id); resolved != "" { + t.Fatalf("expired ID still resolves: %q", resolved) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("expired staging directory still exists: %v", err) + } +} From 6a1ea74a53a032dfe52a5a7953ed527df37dc1e5 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 01:09:07 +0200 Subject: [PATCH 148/201] Node: retain completed assets for 24 hours and expire IDs --- internal/node/server.go | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/internal/node/server.go b/internal/node/server.go index f230ba5..7330e16 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -27,6 +27,7 @@ type uploadSession struct { } const uploadSessionTTL = 30 * time.Minute +const completedAssetTTL = 24 * time.Hour var uploadSessions = struct { sync.Mutex @@ -38,7 +39,8 @@ var uploadSessions = struct { var assetIDs = struct { sync.Mutex paths map[string]string -}{paths: make(map[string]string)} + completed map[string]time.Time +}{paths: make(map[string]string), completed: make(map[string]time.Time)} func newAssetID(path string) (string, error) { var random [16]byte @@ -53,6 +55,16 @@ func newAssetID(path string) (string, error) { } // resolveUploadAsset accepts opaque IDs and legacy paths during migration. +func markAssetCompleted(path string, now time.Time) { + assetIDs.Lock() + for id, registeredPath := range assetIDs.paths { + if registeredPath == path { + assetIDs.completed[id] = now + } + } + assetIDs.Unlock() +} + func resolveUploadAsset(asset string) string { if !strings.HasPrefix(asset, "asset-") { return asset @@ -174,6 +186,25 @@ func validStagedAsset(asset string) (string, bool) { return clean, true } +// cleanupCompletedAssets revokes finished assets after their retention window. +func cleanupCompletedAssets(now time.Time) { + var expired []string + assetIDs.Lock() + for id, completedAt := range assetIDs.completed { + if now.Sub(completedAt) >= completedAssetTTL { + expired = append(expired, assetIDs.paths[id]) + delete(assetIDs.paths, id) + delete(assetIDs.completed, id) + } + } + assetIDs.Unlock() + for _, path := range expired { + if _, ok := validStagedAsset(path); ok { + _ = os.RemoveAll(filepath.Dir(path)) + } + } +} + func cleanupExpiredUploads(now time.Time) { var expired []string uploadSessions.Lock() @@ -298,6 +329,7 @@ func handleUploadEnd(c net.Conn, m message) { return } delete(uploadSessions.expected, clean) + markAssetCompleted(clean, time.Now()) _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGED", Asset: clean, Size: info.Size()}) } @@ -410,6 +442,7 @@ func Serve(ln net.Listener) error { select { case now := <-ticker.C: cleanupExpiredUploads(now) + cleanupCompletedAssets(now) case <-stop: return } From ec13dfa7db0a0980a04bcd87b63ca01ffa15b484 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 01:11:18 +0200 Subject: [PATCH 149/201] Node: gofmt completed asset retention cleanup --- internal/node/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index 7330e16..fdc40d4 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -38,7 +38,7 @@ var uploadSessions = struct { // until the protocol and Amiga client can switch to opaque identifiers. var assetIDs = struct { sync.Mutex - paths map[string]string + paths map[string]string completed map[string]time.Time }{paths: make(map[string]string), completed: make(map[string]time.Time)} @@ -442,7 +442,7 @@ func Serve(ln net.Listener) error { select { case now := <-ticker.C: cleanupExpiredUploads(now) - cleanupCompletedAssets(now) + cleanupCompletedAssets(now) case <-stop: return } From 1fba7e3c894039f4ed01b3785ad854f1a7b3f4a3 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 01:13:53 +0200 Subject: [PATCH 150/201] Test: verify completed asset retention boundary --- internal/node/server_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 73672e2..1d80737 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -847,3 +847,35 @@ func TestCleanupExpiredUploadsRevokesAssetIDs(t *testing.T) { t.Fatalf("expired staging directory still exists: %v", err) } } + +func TestCompletedAssetRetentionTTL(t *testing.T) { + dir, err := os.MkdirTemp("", "amirender-asset-retention-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "scene.pov") + if err := os.WriteFile(path, []byte("scene"), 0600); err != nil { + t.Fatal(err) + } + id, err := newAssetID(path) + if err != nil { + t.Fatal(err) + } + now := time.Now() + markAssetCompleted(path, now) + cleanupCompletedAssets(now.Add(completedAssetTTL - time.Second)) + if got := resolveUploadAsset(id); got != path { + t.Fatalf("completed asset expired too early: %q", got) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("retained asset missing: %v", err) + } + cleanupCompletedAssets(now.Add(completedAssetTTL)) + if got := resolveUploadAsset(id); got != "" { + t.Fatalf("expired completed asset still resolves: %q", got) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("expired completed asset directory still exists: %v", err) + } +} From b4b6840c32cf5bbadac869d5522872476806909d Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:54:21 +0200 Subject: [PATCH 151/201] Node: add active asset leases to defer TTL cleanup --- internal/node/server.go | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index fdc40d4..4835e03 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -40,7 +40,8 @@ var assetIDs = struct { sync.Mutex paths map[string]string completed map[string]time.Time -}{paths: make(map[string]string), completed: make(map[string]time.Time)} + active map[string]int +}{paths: make(map[string]string), completed: make(map[string]time.Time), active: make(map[string]int)} func newAssetID(path string) (string, error) { var random [16]byte @@ -186,12 +187,32 @@ func validStagedAsset(asset string) (string, bool) { return clean, true } +// acquireAsset prevents TTL cleanup while a registered asset is in use. +func acquireAsset(id string) (string, func(), bool) { + assetIDs.Lock() + path, ok := assetIDs.paths[id] + if !ok { + assetIDs.Unlock() + return "", nil, false + } + assetIDs.active[id]++ + assetIDs.Unlock() + return path, func() { + assetIDs.Lock() + assetIDs.active[id]-- + if assetIDs.active[id] == 0 { + delete(assetIDs.active, id) + } + assetIDs.Unlock() + }, true +} + // cleanupCompletedAssets revokes finished assets after their retention window. func cleanupCompletedAssets(now time.Time) { var expired []string assetIDs.Lock() for id, completedAt := range assetIDs.completed { - if now.Sub(completedAt) >= completedAssetTTL { + if now.Sub(completedAt) >= completedAssetTTL && assetIDs.active[id] == 0 { expired = append(expired, assetIDs.paths[id]) delete(assetIDs.paths, id) delete(assetIDs.completed, id) From 2d281b806ed6c5961f867a01d2a6e6d6a4732688 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:23:18 +0200 Subject: [PATCH 152/201] Node: lease opaque assets during download operations --- internal/node/server.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index 4835e03..3d89b78 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -354,8 +354,23 @@ func handleUploadEnd(c net.Conn, m message) { _ = json.NewEncoder(c).Encode(stagedResult{Type: "STAGED", Asset: clean, Size: info.Size()}) } +// leaseDownloadAsset protects ID-based downloads while they access the file. +// Legacy paths remain supported during the protocol migration. +func leaseDownloadAsset(asset string) (string, func()) { + if !strings.HasPrefix(asset, "asset-") { + return asset, func() {} + } + path, release, ok := acquireAsset(asset) + if !ok { + return "", func() {} + } + return path, release +} + func handleDownloadChunk(c net.Conn, m message) { - clean, ok := validStagedAsset(resolveUploadAsset(m.Asset)) + path, release := leaseDownloadAsset(m.Asset) + defer release() + clean, ok := validStagedAsset(path) if !ok || m.Offset < 0 || m.Size <= 0 || m.Size > 4096 { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "invalid chunk request"}) return @@ -409,7 +424,9 @@ func handleUpload(c net.Conn, m message) { } func handleDownload(c net.Conn, m message) { - clean, ok := validStagedAsset(resolveUploadAsset(m.Asset)) + path, release := leaseDownloadAsset(m.Asset) + defer release() + clean, ok := validStagedAsset(path) if !ok { _ = json.NewEncoder(c).Encode(stagedResult{Type: "FAILED", Error: "unsafe asset reference"}) return From 7866994b3cbddbffcf34425bd1fabc91ec8a72fd Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:27:06 +0200 Subject: [PATCH 153/201] Node: lease opaque scene assets throughout POV-Ray rendering --- internal/node/server.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/node/server.go b/internal/node/server.go index 3d89b78..c39a8a3 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -149,16 +149,26 @@ func Handle(c net.Conn) { Type: "COMPLETE", JobID: j.ID, Engine: "null", Output: j.Output, }) case "povray": + scene, release := leaseDownloadAsset(j.Scene) + if strings.HasPrefix(j.Scene, "asset-") && scene == "" { + writeResult(c, farm.WorkerResult{ + Type: "FAILED", JobID: j.ID, Engine: "povray", Error: "scene asset unavailable", + }) + continue + } uploadSessions.Lock() - _, incomplete := uploadSessions.expected[filepath.Clean(j.Scene)] + _, incomplete := uploadSessions.expected[filepath.Clean(scene)] uploadSessions.Unlock() if incomplete { + release() writeResult(c, farm.WorkerResult{ Type: "FAILED", JobID: j.ID, Engine: "povray", Error: "scene upload incomplete", }) continue } + j.Scene = scene renderPOVRay(c, j) + release() default: writeResult(c, farm.WorkerResult{ Type: "FAILED", JobID: j.ID, Engine: j.Engine, Error: "unsupported engine", From 2265e50270ec91535b1655aa957b31ef83ba6d08 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:27:29 +0200 Subject: [PATCH 154/201] Test: active asset lease defers completed asset cleanup --- internal/node/server_test.go | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 1d80737..51491bb 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -879,3 +879,40 @@ func TestCompletedAssetRetentionTTL(t *testing.T) { t.Fatalf("expired completed asset directory still exists: %v", err) } } + +func TestCompletedAssetLeaseDefersCleanup(t *testing.T) { + dir, err := os.MkdirTemp("", "amirender-asset-leased-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + path := filepath.Join(dir, "scene.pov") + if err := os.WriteFile(path, []byte("scene"), 0600); err != nil { + t.Fatal(err) + } + id, err := newAssetID(path) + if err != nil { + t.Fatal(err) + } + now := time.Now() + markAssetCompleted(path, now) + leasedPath, release, ok := acquireAsset(id) + if !ok || leasedPath != path { + t.Fatalf("failed to acquire asset lease: %q", leasedPath) + } + cleanupCompletedAssets(now.Add(completedAssetTTL + time.Second)) + if got := resolveUploadAsset(id); got != path { + t.Fatalf("active asset revoked: %q", got) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("leased asset deleted: %v", err) + } + release() + cleanupCompletedAssets(now.Add(completedAssetTTL + time.Second)) + if got := resolveUploadAsset(id); got != "" { + t.Fatalf("released expired asset still resolves: %q", got) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("released expired asset directory still exists: %v", err) + } +} From 44adb0199b8c685d5df0804226e86fdd4d691710 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:28:52 +0200 Subject: [PATCH 155/201] Node: defer scene lease release across POV-Ray errors --- internal/node/server.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index c39a8a3..8269948 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -167,8 +167,10 @@ func Handle(c net.Conn) { continue } j.Scene = scene - renderPOVRay(c, j) - release() + func() { + defer release() + renderPOVRay(c, j) + }() default: writeResult(c, farm.WorkerResult{ Type: "FAILED", JobID: j.ID, Engine: j.Engine, Error: "unsupported engine", From 3e1a286af17d248ab940c58532ed7c794cdb4556 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:29:21 +0200 Subject: [PATCH 156/201] Test: POV-Ray render with opaque scene asset ID --- internal/node/server_test.go | 103 +++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 51491bb..588b92a 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -916,3 +916,106 @@ func TestCompletedAssetLeaseDefersCleanup(t *testing.T) { t.Fatalf("released expired asset directory still exists: %v", err) } } + +func TestPOVRayOpaqueIDRenderRoundTrip(t *testing.T) { + if _, err := exec.LookPath("povray"); err != nil { + t.Skip("povray not installed") + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { _ = Serve(ln) }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + enc := json.NewEncoder(conn) + dec := json.NewDecoder(bufio.NewReader(conn)) + + source := `camera { location <0, 0, -3> look_at <0, 0, 0> } +light_source { <-2, 3, -4> color rgb <1, 1, 1> } +sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } +` + if err := enc.Encode(map[string]any{ + "type": "UPLOAD_BEGIN", "name": "scene.pov", "size": len(source), + }); err != nil { + t.Fatal(err) + } + var staged stagedResult + if err := dec.Decode(&staged); err != nil { + t.Fatal(err) + } + if staged.Type != "STAGING" || staged.Asset == "" || staged.ID == "" { + t.Fatalf("unexpected staging result: %#v", staged) + } + defer os.RemoveAll(filepath.Dir(staged.Asset)) + if err := enc.Encode(map[string]any{ + "type": "UPLOAD_CHUNK", "asset": staged.ID, "offset": 0, + "data": base64.StdEncoding.EncodeToString([]byte(source)), + }); err != nil { + t.Fatal(err) + } + var chunk stagedResult + if err := dec.Decode(&chunk); err != nil { + t.Fatal(err) + } + if chunk.Type != "CHUNKED" { + t.Fatalf("unexpected chunk result: %#v", chunk) + } + if err := enc.Encode(map[string]any{ + "type": "UPLOAD_END", "asset": staged.ID, "size": len(source), + }); err != nil { + t.Fatal(err) + } + var completed stagedResult + if err := dec.Decode(&completed); err != nil { + t.Fatal(err) + } + if completed.Type != "STAGED" { + t.Fatalf("unexpected completion result: %#v", completed) + } + + + if err := enc.Encode(map[string]any{ + "type": "SUBMIT", + "job": map[string]any{ + "id": "pov-wire-e2e", "engine": "povray", "scene": staged.ID, + "output": "RAM:frame.png", "width": 64, "height": 48, + }, + }); err != nil { + t.Fatal(err) + } + var rendered farm.WorkerResult + if err := dec.Decode(&rendered); err != nil { + t.Fatal(err) + } + if rendered.Type != "COMPLETE" || rendered.Output == "" || rendered.Output == "RAM:frame.png" { + t.Fatalf("unexpected render result: %#v", rendered) + } + defer os.RemoveAll(filepath.Dir(rendered.Output)) + + if err := enc.Encode(map[string]string{"type": "DOWNLOAD", "asset": rendered.Output}); err != nil { + t.Fatal(err) + } + var downloaded stagedResult + if err := dec.Decode(&downloaded); err != nil { + t.Fatal(err) + } + if downloaded.Type != "DATA" { + t.Fatalf("unexpected download result: %#v", downloaded) + } + png, err := base64.StdEncoding.DecodeString(downloaded.Data) + if err != nil { + t.Fatal(err) + } + signature := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a} + if len(png) <= len(signature) || string(png[:len(signature)]) != string(signature) { + t.Fatalf("download is not a PNG: %x", png[:min(len(png), len(signature))]) + } +} + From 6ea8b1414a0f78e4260a3edd8245b6032aadb229 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:31:59 +0200 Subject: [PATCH 157/201] Test: gofmt opaque POV-Ray integration test --- internal/node/server_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 588b92a..72736f1 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -980,7 +980,6 @@ sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } t.Fatalf("unexpected completion result: %#v", completed) } - if err := enc.Encode(map[string]any{ "type": "SUBMIT", "job": map[string]any{ @@ -1018,4 +1017,3 @@ sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } t.Fatalf("download is not a PNG: %x", png[:min(len(png), len(signature))]) } } - From 06df5491a399310e7f469dc777028fa174cc99c9 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:44:42 +0200 Subject: [PATCH 158/201] Node: register POV-Ray outputs for completed asset TTL cleanup --- internal/node/server.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/node/server.go b/internal/node/server.go index 8269948..cdb13bb 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -473,6 +473,14 @@ func renderPOVRay(c net.Conn, j farm.RenderJob) { }) return } + if _, err := newAssetID(result.Output); err != nil { + _ = os.RemoveAll(dir) + writeResult(c, farm.WorkerResult{ + Type: "FAILED", JobID: j.ID, Engine: "povray", Error: "output registration failed", + }) + return + } + markAssetCompleted(result.Output, time.Now()) writeResult(c, farm.WorkerResult{ Type: "COMPLETE", JobID: j.ID, Engine: "povray", Output: result.Output, }) From b16c7519299d2ef227f131923172c73e537048cf Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:45:20 +0200 Subject: [PATCH 159/201] Protocol: expose optional opaque asset ID on worker results --- internal/farm/transport.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/farm/transport.go b/internal/farm/transport.go index 94725ca..9fc58b3 100644 --- a/internal/farm/transport.go +++ b/internal/farm/transport.go @@ -23,6 +23,7 @@ type WorkerResult struct { JobID string `json:"job_id"` Engine string `json:"engine,omitempty"` Output string `json:"output,omitempty"` + AssetID string `json:"asset_id,omitempty"` Error string `json:"error,omitempty"` } From a18093e6f6418c531ed79fa475ebebfb02683ef9 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:45:29 +0200 Subject: [PATCH 160/201] Protocol: format optional asset ID field --- internal/farm/transport.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/farm/transport.go b/internal/farm/transport.go index 9fc58b3..ca34552 100644 --- a/internal/farm/transport.go +++ b/internal/farm/transport.go @@ -19,12 +19,12 @@ type RenderJob struct { } type WorkerResult struct { - Type string `json:"type"` - JobID string `json:"job_id"` - Engine string `json:"engine,omitempty"` - Output string `json:"output,omitempty"` + Type string `json:"type"` + JobID string `json:"job_id"` + Engine string `json:"engine,omitempty"` + Output string `json:"output,omitempty"` AssetID string `json:"asset_id,omitempty"` - Error string `json:"error,omitempty"` + Error string `json:"error,omitempty"` } type WorkerClient interface { From 7e598f80f49ec3b85f92f72ef42ac65e966f5f6b Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:45:34 +0200 Subject: [PATCH 161/201] Node: return opaque asset ID alongside legacy render output path --- internal/node/server.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/node/server.go b/internal/node/server.go index cdb13bb..ce66c72 100644 --- a/internal/node/server.go +++ b/internal/node/server.go @@ -473,7 +473,8 @@ func renderPOVRay(c net.Conn, j farm.RenderJob) { }) return } - if _, err := newAssetID(result.Output); err != nil { + id, err := newAssetID(result.Output) + if err != nil { _ = os.RemoveAll(dir) writeResult(c, farm.WorkerResult{ Type: "FAILED", JobID: j.ID, Engine: "povray", Error: "output registration failed", @@ -482,7 +483,7 @@ func renderPOVRay(c net.Conn, j farm.RenderJob) { } markAssetCompleted(result.Output, time.Now()) writeResult(c, farm.WorkerResult{ - Type: "COMPLETE", JobID: j.ID, Engine: "povray", Output: result.Output, + Type: "COMPLETE", JobID: j.ID, Engine: "povray", Output: result.Output, AssetID: id, }) } From b031a0a315c003d5df5f70ead79ab052390e6c9d Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 04:07:28 +0200 Subject: [PATCH 162/201] Test: download rendered PNG by opaque output asset ID --- internal/node/server_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/node/server_test.go b/internal/node/server_test.go index 72736f1..3fb8c82 100644 --- a/internal/node/server_test.go +++ b/internal/node/server_test.go @@ -993,12 +993,12 @@ sphere { <0, 0, 0>, 1 pigment { color rgb <0.7, 0.7, 0.7> } } if err := dec.Decode(&rendered); err != nil { t.Fatal(err) } - if rendered.Type != "COMPLETE" || rendered.Output == "" || rendered.Output == "RAM:frame.png" { + if rendered.Type != "COMPLETE" || rendered.Output == "" || rendered.Output == "RAM:frame.png" || !strings.HasPrefix(rendered.AssetID, "asset-") { t.Fatalf("unexpected render result: %#v", rendered) } defer os.RemoveAll(filepath.Dir(rendered.Output)) - if err := enc.Encode(map[string]string{"type": "DOWNLOAD", "asset": rendered.Output}); err != nil { + if err := enc.Encode(map[string]string{"type": "DOWNLOAD", "asset": rendered.AssetID}); err != nil { t.Fatal(err) } var downloaded stagedResult From 36ff516d70f7f4df27f7063d60c7ecfae12f9e7a Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 04:09:37 +0200 Subject: [PATCH 163/201] Amiga: prefer opaque render asset ID with legacy output fallback --- clients/amiga/src/main_amiga.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/clients/amiga/src/main_amiga.c b/clients/amiga/src/main_amiga.c index 7b0389e..3f53d31 100644 --- a/clients/amiga/src/main_amiga.c +++ b/clients/amiga/src/main_amiga.c @@ -72,8 +72,12 @@ static int upload_file( static int extract_output(const char *reply, char *output, size_t output_size) { - const char *key = "\"output\":\""; + const char *key = "\"asset_id\":\""; const char *start = strstr(reply, key); + if (start == NULL || start[strlen(key)] == '"') { + key = "\"output\":\""; + start = strstr(reply, key); + } const char *end; size_t length; From 27cb3e2fe12a3704d25d84f2f88e2a04a01b249c Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 07:51:53 +0200 Subject: [PATCH 164/201] Amiga: declare render output asset selection helper --- clients/amiga/include/amirender_transport.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clients/amiga/include/amirender_transport.h b/clients/amiga/include/amirender_transport.h index 93d2830..602c014 100644 --- a/clients/amiga/include/amirender_transport.h +++ b/clients/amiga/include/amirender_transport.h @@ -60,4 +60,7 @@ int amirender_submit_job( char *reply, size_t reply_size); +int amirender_extract_output_asset( + const char *reply, char *asset, size_t asset_size); + #endif From 011583382890a91a6937af44150bae6176749318 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 07:52:02 +0200 Subject: [PATCH 165/201] Amiga: make opaque output selection independently testable --- clients/amiga/src/client.c | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index 3307890..bd16b9e 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -307,3 +307,28 @@ int amirender_submit_job( } return 0; } + +int amirender_extract_output_asset( + const char *reply, char *asset, size_t asset_size) +{ + const char *key = "\"asset_id\":\""; + const char *start; + const char *end; + size_t length; + + if (reply == NULL || asset == NULL || asset_size < 2) return -1; + start = strstr(reply, key); + if (start == NULL || start[strlen(key)] == '"') { + key = "\"output\":\""; + start = strstr(reply, key); + } + if (start == NULL) return -1; + start += strlen(key); + end = strchr(start, '"'); + if (end == NULL) return -1; + length = (size_t)(end - start); + if (length == 0 || length >= asset_size) return -1; + memcpy(asset, start, length); + asset[length] = '\0'; + return 0; +} From 9fb0cc9fd029ed26b6bab12aad08b2771df8e733 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 07:52:09 +0200 Subject: [PATCH 166/201] Amiga: reuse tested output asset selection in CLI --- clients/amiga/src/main_amiga.c | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/clients/amiga/src/main_amiga.c b/clients/amiga/src/main_amiga.c index 3f53d31..9506012 100644 --- a/clients/amiga/src/main_amiga.c +++ b/clients/amiga/src/main_amiga.c @@ -70,28 +70,6 @@ static int upload_file( return amirender_upload_end(transport, asset, total); } -static int extract_output(const char *reply, char *output, size_t output_size) -{ - const char *key = "\"asset_id\":\""; - const char *start = strstr(reply, key); - if (start == NULL || start[strlen(key)] == '"') { - key = "\"output\":\""; - start = strstr(reply, key); - } - const char *end; - size_t length; - - if (start == NULL) return -1; - start += strlen(key); - end = strchr(start, '\"'); - if (end == NULL) return -1; - length = (size_t)(end - start); - if (length == 0 || length >= output_size) return -1; - memcpy(output, start, length); - output[length] = '\0'; - return 0; -} - static int download_file( struct amirender_transport *transport, const char *asset, const char *path, size_t *total_size) @@ -189,7 +167,7 @@ int main(int argc, char **argv) job.scene = asset; rc = amirender_submit_job(&transport, &job, reply, sizeof(reply)); - if (rc != 0 || extract_output(reply, output_asset, sizeof(output_asset)) != 0) { + if (rc != 0 || amirender_extract_output_asset(reply, output_asset, sizeof(output_asset)) != 0) { amirender_bsdsocket_close(&socket_state); fprintf(stderr, "AmiRender: render failed\n"); return 10; From 6887d709bfdeafc032f7b43f42378738fffa874c Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 07:52:18 +0200 Subject: [PATCH 167/201] Test: Amiga output asset ID preference and legacy fallback --- clients/amiga/tests/test_client.c | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 5a92467..9181a2f 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -108,5 +108,23 @@ int main(void) downloaded, sizeof(downloaded), &downloaded_size) == 0) { return 11; } + fake.reply = "{\"type\":\"COMPLETE\",\"output\":\"/tmp/old.png\",\"asset_id\":\"asset-new\"}"; + if (amirender_extract_output_asset(fake.reply, asset, sizeof(asset)) != 0 || + strcmp(asset, "asset-new") != 0) { + fprintf(stderr, "opaque asset ID not preferred\n"); + return 12; + } + fake.reply = "{\"type\":\"COMPLETE\",\"output\":\"/tmp/old.png\"}"; + if (amirender_extract_output_asset(fake.reply, asset, sizeof(asset)) != 0 || + strcmp(asset, "/tmp/old.png") != 0) { + fprintf(stderr, "legacy output fallback failed\n"); + return 13; + } + fake.reply = "{\"type\":\"COMPLETE\",\"output\":\"/tmp/old.png\",\"asset_id\":\"\"}"; + if (amirender_extract_output_asset(fake.reply, asset, sizeof(asset)) != 0 || + strcmp(asset, "/tmp/old.png") != 0) { + fprintf(stderr, "empty asset ID fallback failed\n"); + return 14; + } return 0; } From d82d0b3c8626521e9803d5993497f2f3dd286616 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 07:53:01 +0200 Subject: [PATCH 168/201] Amiga: prefer opaque staging ID in chunked upload --- clients/amiga/src/client.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index bd16b9e..605b2a6 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -161,6 +161,10 @@ int amirender_upload_begin( if (received <= 0 || (size_t)received >= sizeof(reply)) return -1; reply[received] = '\0'; if (strstr(reply, "\"type\":\"STAGING\"") == NULL) return -1; + if (strstr(reply, "\"id\":\"") != NULL && + extract_string_field(reply, "\"id\":\"", asset, asset_size) == 0) { + return 0; + } return extract_string_field(reply, "\"asset\":\"", asset, asset_size); } From a8a56d7ac6419e8bf04af7d07d0988e70aaffeb3 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 07:53:08 +0200 Subject: [PATCH 169/201] Test: opaque upload ID preference and legacy staging fallback --- clients/amiga/tests/test_client.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 9181a2f..c42fe53 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -126,5 +126,17 @@ int main(void) fprintf(stderr, "empty asset ID fallback failed\n"); return 14; } + fake.reply = "{\"type\":\"STAGING\",\"id\":\"asset-opaque\",\"asset\":\"/tmp/scene.pov\"}"; + if (amirender_upload_begin(&transport, "scene.pov", 10, asset, sizeof(asset)) != 0 || + strcmp(asset, "asset-opaque") != 0) { + fprintf(stderr, "opaque upload ID not preferred\n"); + return 15; + } + fake.reply = "{\"type\":\"STAGING\",\"asset\":\"/tmp/scene.pov\"}"; + if (amirender_upload_begin(&transport, "scene.pov", 10, asset, sizeof(asset)) != 0 || + strcmp(asset, "/tmp/scene.pov") != 0) { + fprintf(stderr, "legacy upload asset fallback failed\n"); + return 16; + } return 0; } From ac6b40eead4c8f3a47181b3cd7d7035f05b68738 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 13:48:18 +0200 Subject: [PATCH 170/201] Test: Amiga chunked upload preserves opaque asset ID end to end --- clients/amiga/tests/test_client.c | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index c42fe53..4fd5345 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -138,5 +138,20 @@ int main(void) fprintf(stderr, "legacy upload asset fallback failed\n"); return 16; } + fake.reply = "{\"type\":\"STAGING\",\"id\":\"asset-roundtrip\",\"asset\":\"/tmp/scene.pov\"}"; + if (amirender_upload_begin(&transport, "scene.pov", 10, asset, sizeof(asset)) != 0 || + strcmp(asset, "asset-roundtrip") != 0) return 17; + fake.reply = "{\"type\":\"CHUNKED\",\"offset\":10}"; + if (amirender_upload_chunk(&transport, asset, 0, scene, sizeof(scene) - 1) != 0 || + strstr(fake.sent, "\"asset\":\"asset-roundtrip\"") == NULL) { + fprintf(stderr, "upload chunk did not use opaque ID\n"); + return 18; + } + fake.reply = "{\"type\":\"STAGED\"}"; + if (amirender_upload_end(&transport, asset, 10) != 0 || + strstr(fake.sent, "\"asset\":\"asset-roundtrip\"") == NULL) { + fprintf(stderr, "upload end did not use opaque ID\n"); + return 19; + } return 0; } From 4c1054c7d580c965da1c8a75d1d163aab1ae2354 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 17:42:34 +0200 Subject: [PATCH 171/201] Test: Amiga opaque chunked download across blocks and EOF --- clients/amiga/tests/test_client.c | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 4fd5345..b300345 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -153,5 +153,36 @@ int main(void) fprintf(stderr, "upload end did not use opaque ID\n"); return 19; } + { + int eof = 0; + size_t count = 0; + fake.reply = "{\"type\":\"DATA\",\"data\":\"iVBORw0K\",\"eof\":false}"; + if (amirender_download_chunk(&transport, "asset-rendered", 0, + downloaded, sizeof(downloaded), &count, &eof) != 0 || + count != 6 || eof != 0 || + strstr(fake.sent, "\"asset\":\"asset-rendered\"") == NULL || + strstr(fake.sent, "\"offset\":0") == NULL || + downloaded[0] != 0x89 || downloaded[1] != 'P') { + fprintf(stderr, "first opaque download chunk failed\n"); + return 20; + } + fake.reply = "{\"type\":\"DATA\",\"data\":\"Tkc=\",\"eof\":true}"; + if (amirender_download_chunk(&transport, "asset-rendered", 6, + downloaded, sizeof(downloaded), &count, &eof) != 0 || + count != 2 || eof != 1 || + strstr(fake.sent, "\"asset\":\"asset-rendered\"") == NULL || + strstr(fake.sent, "\"offset\":6") == NULL || + downloaded[0] != 'N' || downloaded[1] != 'G') { + fprintf(stderr, "final opaque download chunk failed\n"); + return 21; + } + fake.reply = "{\"type\":\"DATA\",\"eof\":true}"; + if (amirender_download_chunk(&transport, "asset-rendered", 8, + downloaded, sizeof(downloaded), &count, &eof) != 0 || + count != 0 || eof != 1) { + fprintf(stderr, "empty EOF response failed\n"); + return 22; + } + } return 0; } From 3362c7659a46612f790a07a6e9214c4ab6f12e26 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:04:28 +0200 Subject: [PATCH 172/201] Amiga: download into temporary file and publish only on success --- clients/amiga/src/main_amiga.c | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/clients/amiga/src/main_amiga.c b/clients/amiga/src/main_amiga.c index 9506012..2831c2b 100644 --- a/clients/amiga/src/main_amiga.c +++ b/clients/amiga/src/main_amiga.c @@ -75,33 +75,41 @@ static int download_file( size_t *total_size) { FILE *file; + char temporary[ASSET_SIZE]; + int name_length; unsigned char buffer[CHUNK_SIZE]; size_t offset = 0, count; int eof = 0; - file = fopen(path, "wb"); + name_length = snprintf(temporary, sizeof(temporary), "%s.part", path); + if (name_length < 0 || (size_t)name_length >= sizeof(temporary)) return -1; + file = fopen(temporary, "wb"); if (file == NULL) return -1; while (!eof) { if (amirender_download_chunk( transport, asset, offset, buffer, sizeof(buffer), &count, &eof) != 0) { fclose(file); - remove(path); + remove(temporary); return -1; } if (count > 0 && fwrite(buffer, 1, count, file) != count) { fclose(file); - remove(path); + remove(temporary); return -1; } offset += count; if (count == 0 && !eof) { fclose(file); - remove(path); + remove(temporary); return -1; } } if (fclose(file) != 0) { - remove(path); + remove(temporary); + return -1; + } + if (rename(temporary, path) != 0) { + remove(temporary); return -1; } *total_size = offset; From f03b71296aa4d160902ec826747e08cec3816b85 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 05:25:10 +0200 Subject: [PATCH 173/201] Amiga: use native AmigaDOS Rename to avoid libc link dependency --- clients/amiga/src/main_amiga.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/clients/amiga/src/main_amiga.c b/clients/amiga/src/main_amiga.c index 2831c2b..18b1ef2 100644 --- a/clients/amiga/src/main_amiga.c +++ b/clients/amiga/src/main_amiga.c @@ -6,6 +6,10 @@ #include #include +#ifdef __m68k__ +#include +#endif + #define AMIRENDER_PORT 6800 #define REPLY_SIZE 1024 #define ASSET_SIZE 1024 @@ -108,7 +112,11 @@ static int download_file( remove(temporary); return -1; } +#ifdef __m68k__ + if (!Rename((STRPTR)temporary, (STRPTR)path)) { +#else if (rename(temporary, path) != 0) { +#endif remove(temporary); return -1; } From 7205cb1e4058d32dfe6b4f3e7c59952a86fb1624 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 14:07:07 +0200 Subject: [PATCH 174/201] Amiga: expose transactional file download for testing --- clients/amiga/include/amirender_transport.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/clients/amiga/include/amirender_transport.h b/clients/amiga/include/amirender_transport.h index 602c014..281e9c1 100644 --- a/clients/amiga/include/amirender_transport.h +++ b/clients/amiga/include/amirender_transport.h @@ -63,4 +63,8 @@ int amirender_submit_job( int amirender_extract_output_asset( const char *reply, char *asset, size_t asset_size); +int amirender_download_file( + struct amirender_transport *transport, const char *asset, + const char *path, size_t *total_size); + #endif From 1aa13e5c999f9dfabc37e6c1855f81446418e27b Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 14:07:19 +0200 Subject: [PATCH 175/201] Amiga: move transactional download into reusable transport client --- clients/amiga/src/client.c | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index 605b2a6..1a661cb 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -3,6 +3,13 @@ #include "amirender_upload.h" #include + +#ifdef __m68k__ +#include +#endif + +#define AMIRENDER_FILE_CHUNK_SIZE 2048 +#define AMIRENDER_FILE_PATH_SIZE 1024 #include int amirender_upload_asset( @@ -336,3 +343,54 @@ int amirender_extract_output_asset( asset[length] = '\0'; return 0; } + +int amirender_download_file( + struct amirender_transport *transport, const char *asset, + const char *path, size_t *total_size) +{ + FILE *file; + char temporary[AMIRENDER_FILE_PATH_SIZE]; + unsigned char buffer[AMIRENDER_FILE_CHUNK_SIZE]; + size_t offset = 0, count; + int eof = 0; + int name_length; + + if (transport == NULL || asset == NULL || path == NULL || total_size == NULL) return -1; + name_length = snprintf(temporary, sizeof(temporary), "%s.part", path); + if (name_length < 0 || (size_t)name_length >= sizeof(temporary)) return -1; + file = fopen(temporary, "wb"); + if (file == NULL) return -1; + while (!eof) { + if (amirender_download_chunk( + transport, asset, offset, buffer, sizeof(buffer), &count, &eof) != 0) { + fclose(file); + remove(temporary); + return -1; + } + if (count > 0 && fwrite(buffer, 1, count, file) != count) { + fclose(file); + remove(temporary); + return -1; + } + offset += count; + if (count == 0 && !eof) { + fclose(file); + remove(temporary); + return -1; + } + } + if (fclose(file) != 0) { + remove(temporary); + return -1; + } +#ifdef __m68k__ + if (!Rename((STRPTR)temporary, (STRPTR)path)) { +#else + if (rename(temporary, path) != 0) { +#endif + remove(temporary); + return -1; + } + *total_size = offset; + return 0; +} From 4b1cf9c628c18030235d4e3d79640f0cd4d76a55 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 14:07:29 +0200 Subject: [PATCH 176/201] Amiga CLI: use reusable transactional download helper --- clients/amiga/src/main_amiga.c | 55 +--------------------------------- 1 file changed, 1 insertion(+), 54 deletions(-) diff --git a/clients/amiga/src/main_amiga.c b/clients/amiga/src/main_amiga.c index 18b1ef2..fb41418 100644 --- a/clients/amiga/src/main_amiga.c +++ b/clients/amiga/src/main_amiga.c @@ -6,9 +6,6 @@ #include #include -#ifdef __m68k__ -#include -#endif #define AMIRENDER_PORT 6800 #define REPLY_SIZE 1024 @@ -74,56 +71,6 @@ static int upload_file( return amirender_upload_end(transport, asset, total); } -static int download_file( - struct amirender_transport *transport, const char *asset, const char *path, - size_t *total_size) -{ - FILE *file; - char temporary[ASSET_SIZE]; - int name_length; - unsigned char buffer[CHUNK_SIZE]; - size_t offset = 0, count; - int eof = 0; - - name_length = snprintf(temporary, sizeof(temporary), "%s.part", path); - if (name_length < 0 || (size_t)name_length >= sizeof(temporary)) return -1; - file = fopen(temporary, "wb"); - if (file == NULL) return -1; - while (!eof) { - if (amirender_download_chunk( - transport, asset, offset, buffer, sizeof(buffer), &count, &eof) != 0) { - fclose(file); - remove(temporary); - return -1; - } - if (count > 0 && fwrite(buffer, 1, count, file) != count) { - fclose(file); - remove(temporary); - return -1; - } - offset += count; - if (count == 0 && !eof) { - fclose(file); - remove(temporary); - return -1; - } - } - if (fclose(file) != 0) { - remove(temporary); - return -1; - } -#ifdef __m68k__ - if (!Rename((STRPTR)temporary, (STRPTR)path)) { -#else - if (rename(temporary, path) != 0) { -#endif - remove(temporary); - return -1; - } - *total_size = offset; - return 0; -} - static void usage(const char *program) { fprintf(stderr, @@ -189,7 +136,7 @@ int main(int argc, char **argv) return 10; } - rc = download_file(&transport, output_asset, argv[3], &output_size); + rc = amirender_download_file(&transport, output_asset, argv[3], &output_size); amirender_bsdsocket_close(&socket_state); if (rc != 0) { fprintf(stderr, "AmiRender: output download failed\n"); From bda39397b75ef170ef128ccf7e5469d0f36eab0c Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 14:07:39 +0200 Subject: [PATCH 177/201] Test: failed download preserves existing output and removes partial file --- clients/amiga/tests/test_client.c | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index b300345..0b30648 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -184,5 +184,27 @@ int main(void) return 22; } } + { + const char *path = "amirender-download-test.bin"; + const char *partial = "amirender-download-test.bin.part"; + FILE *file = fopen(path, "wb"); + size_t total = 999; + char existing[16] = {0}; + if (file == NULL) return 23; + if (fwrite("original", 1, 8, file) != 8 || fclose(file) != 0) return 24; + fake.reply = "{\"type\":\"FAILED\",\"error\":\"interrupted\"}"; + if (amirender_download_file(&transport, "asset-interrupted", path, &total) == 0) return 25; + file = fopen(path, "rb"); + if (file == NULL) return 26; + if (fread(existing, 1, 8, file) != 8 || fclose(file) != 0 || + memcmp(existing, "original", 8) != 0 || total != 999) return 27; + file = fopen(partial, "rb"); + if (file != NULL) { + fclose(file); + fprintf(stderr, "partial download was not removed\n"); + return 28; + } + remove(path); + } return 0; } From 529c584f1777098076a2de220a0b90b7ee220b24 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 21:25:35 +0200 Subject: [PATCH 178/201] Test: interrupted multi-chunk download preserves output and removes partial file --- clients/amiga/tests/test_client.c | 41 +++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 0b30648..92916b9 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -7,6 +7,8 @@ struct fake_transport { char sent[1024]; const char *reply; + const char *next_reply; + int receive_count; }; static int fake_send(void *context, const char *data, size_t length) @@ -25,7 +27,12 @@ static int fake_receive(void *context, char *buffer, size_t size) { struct fake_transport *fake = (struct fake_transport *)context; const char *reply = fake->reply; - size_t length = strlen(reply); + size_t length; + + if (fake->next_reply != NULL && fake->receive_count++ > 0) { + reply = fake->next_reply; + } + length = strlen(reply); if (length > size) { return -1; @@ -36,7 +43,7 @@ static int fake_receive(void *context, char *buffer, size_t size) int main(void) { - struct fake_transport fake = {{0}, NULL}; + struct fake_transport fake = {{0}, NULL, NULL, 0}; struct amirender_transport transport = {&fake, fake_send, fake_receive}; struct amirender_job job = { "amiga-0002", "povray", "DH0:Scenes/demo.pov", @@ -206,5 +213,35 @@ int main(void) } remove(path); } + { + const char *path = "amirender-midstream-test.bin"; + const char *partial = "amirender-midstream-test.bin.part"; + FILE *file = fopen(path, "wb"); + size_t total = 777; + char existing[16] = {0}; + if (file == NULL) return 29; + if (fwrite("original", 1, 8, file) != 8 || fclose(file) != 0) return 30; + fake.reply = "{\"type\":\"DATA\",\"data\":\"iVBORw0K\",\"eof\":false}"; + fake.next_reply = "{\"type\":\"FAILED\",\"error\":\"connection lost\"}"; + fake.receive_count = 0; + if (amirender_download_file(&transport, "asset-midstream", path, &total) == 0 || + fake.receive_count != 2 || + strstr(fake.sent, "\"offset\":6") == NULL) { + fprintf(stderr, "midstream failure was not detected\n"); + return 31; + } + fake.next_reply = NULL; + file = fopen(path, "rb"); + if (file == NULL) return 32; + if (fread(existing, 1, 8, file) != 8 || fclose(file) != 0 || + memcmp(existing, "original", 8) != 0 || total != 777) return 33; + file = fopen(partial, "rb"); + if (file != NULL) { + fclose(file); + fprintf(stderr, "midstream partial file was not removed\n"); + return 34; + } + remove(path); + } return 0; } From 2e7277c10f8b94bfc00d2948051490685fab23d0 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 23:39:12 +0200 Subject: [PATCH 179/201] Test: successful multi-chunk download publishes complete output --- clients/amiga/tests/test_client.c | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 92916b9..0d7c72b 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -243,5 +243,40 @@ int main(void) } remove(path); } + { + const char *path = "amirender-complete-test.bin"; + const char *partial = "amirender-complete-test.bin.part"; + FILE *file = fopen(path, "wb"); + size_t total = 0; + unsigned char result[8] = {0}; + if (file == NULL) return 35; + if (fwrite("original", 1, 8, file) != 8 || fclose(file) != 0) return 36; + fake.reply = "{\"type\":\"DATA\",\"data\":\"iVBORw0K\",\"eof\":false}"; + fake.next_reply = "{\"type\":\"DATA\",\"data\":\"Tkc=\",\"eof\":true}"; + fake.receive_count = 0; + if (amirender_download_file(&transport, "asset-complete", path, &total) != 0 || + total != 8 || fake.receive_count != 2 || + strstr(fake.sent, "\"asset\":\"asset-complete\"") == NULL || + strstr(fake.sent, "\"offset\":6") == NULL) { + fprintf(stderr, "multi-chunk download did not complete\n"); + return 37; + } + fake.next_reply = NULL; + file = fopen(path, "rb"); + if (file == NULL) return 38; + if (fread(result, 1, sizeof(result), file) != sizeof(result) || + fclose(file) != 0 || + result[0] != 0x89 || result[1] != 'P' || result[2] != 'N' || result[3] != 'G' ||\n result[4] != 0x0d || result[5] != 0x0a || result[6] != 'N' || result[7] != 'G' != 0) { + fprintf(stderr, "published file content mismatch\n"); + return 39; + } + file = fopen(partial, "rb"); + if (file != NULL) { + fclose(file); + fprintf(stderr, "partial file remains after success\n"); + return 40; + } + remove(path); + } return 0; } From 7f9a41c334205baf243113fefc0e10eb9605d720 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 23:43:34 +0200 Subject: [PATCH 180/201] Fix portable C test syntax in completed download assertion --- clients/amiga/tests/test_client.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 0d7c72b..cc6fd46 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -266,7 +266,8 @@ int main(void) if (file == NULL) return 38; if (fread(result, 1, sizeof(result), file) != sizeof(result) || fclose(file) != 0 || - result[0] != 0x89 || result[1] != 'P' || result[2] != 'N' || result[3] != 'G' ||\n result[4] != 0x0d || result[5] != 0x0a || result[6] != 'N' || result[7] != 'G' != 0) { + result[0] != 0x89 || result[1] != 'P' || result[2] != 'N' || result[3] != 'G' || + result[4] != 0x0d || result[5] != 0x0a || result[6] != 'N' || result[7] != 'G') { fprintf(stderr, "published file content mismatch\n"); return 39; } From 95811bcf108d79527b1172a84fc7062d77b18123 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 13:47:44 +0200 Subject: [PATCH 181/201] Test: malformed and empty download replies preserve output and clean partial files --- clients/amiga/tests/test_client.c | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index cc6fd46..1505f8a 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -279,5 +279,38 @@ int main(void) } remove(path); } + { + const char *path = "amirender-invalid-test.bin"; + const char *partial = "amirender-invalid-test.bin.part"; + const char *invalid[] = { + "{\"type\":\"DATA\",\"data\":\"%%%?\",\"eof\":true}", + "{\"type\":\"DATA\",\"data\":\"\",\"eof\":false}", + "{\"type\":\"DATA\",\"eof\":false}", + "{\"type\":\"FAILED\",\"error\":\"disconnected\"}" + }; + size_t case_index; + for (case_index = 0; case_index < sizeof(invalid) / sizeof(invalid[0]); ++case_index) { + FILE *file = fopen(path, "wb"); + char existing[8] = {0}; + size_t total = 123; + if (file == NULL) return 41; + if (fwrite("original", 1, 8, file) != 8 || fclose(file) != 0) return 42; + fake.reply = invalid[case_index]; + fake.next_reply = NULL; + fake.receive_count = 0; + if (amirender_download_file(&transport, "asset-invalid", path, &total) == 0 || + total != 123) return 43; + file = fopen(path, "rb"); + if (file == NULL) return 44; + if (fread(existing, 1, 8, file) != 8 || fclose(file) != 0 || + memcmp(existing, "original", 8) != 0) return 45; + file = fopen(partial, "rb"); + if (file != NULL) { + fclose(file); + return 46; + } + } + remove(path); + } return 0; } From 1dc91314fc555880325805a8d105815c7df02570 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 17:01:28 +0200 Subject: [PATCH 182/201] Amiga: reject malformed Base64 padding and noncanonical trailing bits --- clients/amiga/src/client.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index 1a661cb..5157d86 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -115,6 +115,10 @@ int amirender_download_asset( d = p[2] == '=' ? 0 : base64_value(p[2]); e = p[3] == '=' ? 0 : base64_value(p[3]); if (a < 0 || b < 0 || d < 0 || e < 0) return -1; + if (p[2] == '=' && p[3] != '=') return -1; + if ((p[2] == '=' || p[3] == '=') && p + 4 != end) return -1; + if (p[2] == '=' && (b & 15) != 0) return -1; + if (p[3] == '=' && p[2] != '=' && (d & 3) != 0) return -1; v = ((unsigned int)a << 18) | ((unsigned int)b << 12) | ((unsigned int)d << 6) | (unsigned int)e; if (out >= data_size) return -1; From 37e4f392d5ecc5224f8dcbc9f323c557b45f050a Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 17:01:36 +0200 Subject: [PATCH 183/201] Test: reject invalid Base64 padding and trailing bits in downloads --- clients/amiga/tests/test_client.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 1505f8a..84aba38 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -284,6 +284,10 @@ int main(void) const char *partial = "amirender-invalid-test.bin.part"; const char *invalid[] = { "{\"type\":\"DATA\",\"data\":\"%%%?\",\"eof\":true}", + "{\"type\":\"DATA\",\"data\":\"QQ=A\",\"eof\":true}", + "{\"type\":\"DATA\",\"data\":\"QQ==QQ==\",\"eof\":true}", + "{\"type\":\"DATA\",\"data\":\"QR==\",\"eof\":true}", + "{\"type\":\"DATA\",\"data\":\"QUJ=\",\"eof\":true}", "{\"type\":\"DATA\",\"data\":\"\",\"eof\":false}", "{\"type\":\"DATA\",\"eof\":false}", "{\"type\":\"FAILED\",\"error\":\"disconnected\"}" From de27c8f0120b5afcbd7da43e633dbaa1f822faf0 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 21:31:41 +0200 Subject: [PATCH 184/201] Test: report failing malformed Base64 case in CI --- clients/amiga/tests/test_client.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 84aba38..11d1763 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -303,7 +303,11 @@ int main(void) fake.next_reply = NULL; fake.receive_count = 0; if (amirender_download_file(&transport, "asset-invalid", path, &total) == 0 || - total != 123) return 43; + total != 123) { + fprintf(stderr, "invalid download case %lu unexpectedly succeeded: %s\n", + (unsigned long)case_index, invalid[case_index]); + return 43; + } file = fopen(path, "rb"); if (file == NULL) return 44; if (fread(existing, 1, 8, file) != 8 || fclose(file) != 0 || From f4db63b38da2a588c5d90464627955ac7b366afc Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 21:31:48 +0200 Subject: [PATCH 185/201] Fix escaped newline in diagnostic test message From 3225d4b8723689ccd576801127f85529733d1cce Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 12 Oct 2026 01:23:22 +0200 Subject: [PATCH 186/201] Amiga: validate Base64 padding in DOWNLOAD_CHUNK decoder --- clients/amiga/src/client.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index 5157d86..a72cbdd 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -275,6 +275,10 @@ int amirender_download_chunk( d = p[2] == '=' ? 0 : base64_value(p[2]); e = p[3] == '=' ? 0 : base64_value(p[3]); if (a < 0 || b < 0 || d < 0 || e < 0) return -1; + if (p[2] == '=' && p[3] != '=') return -1; + if ((p[2] == '=' || p[3] == '=') && p + 4 != end) return -1; + if (p[2] == '=' && (b & 15) != 0) return -1; + if (p[3] == '=' && p[2] != '=' && (d & 3) != 0) return -1; v = ((unsigned int)a << 18) | ((unsigned int)b << 12) | ((unsigned int)d << 6) | (unsigned int)e; if (out >= data_size) return -1; From b9f33f68f4cb6d42387bf5b4cfa6fde18c32c69d Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 12 Oct 2026 01:26:49 +0200 Subject: [PATCH 187/201] Amiga: reject empty completed render downloads --- clients/amiga/src/client.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index a72cbdd..865f325 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -381,7 +381,7 @@ int amirender_download_file( return -1; } offset += count; - if (count == 0 && !eof) { + if (count == 0 && (!eof || offset == 0)) { fclose(file); remove(temporary); return -1; From 67ec297d2b355ae8fa3bb69b5fed59941e941e41 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 12 Oct 2026 01:26:55 +0200 Subject: [PATCH 188/201] Test: empty EOF must not publish an empty render result --- clients/amiga/tests/test_client.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 11d1763..0fa5087 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -289,6 +289,8 @@ int main(void) "{\"type\":\"DATA\",\"data\":\"QR==\",\"eof\":true}", "{\"type\":\"DATA\",\"data\":\"QUJ=\",\"eof\":true}", "{\"type\":\"DATA\",\"data\":\"\",\"eof\":false}", + "{\"type\":\"DATA\",\"data\":\"\",\"eof\":true}", + "{\"type\":\"DATA\",\"eof\":true}", "{\"type\":\"DATA\",\"eof\":false}", "{\"type\":\"FAILED\",\"error\":\"disconnected\"}" }; From 7561f1f8f1157eaeba6f779dc0cd8a7553e8e0be Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 12 Oct 2026 01:28:44 +0200 Subject: [PATCH 189/201] Amiga: require explicit boolean EOF in chunk replies --- clients/amiga/src/client.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index 865f325..740f3f8 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -242,6 +242,7 @@ int amirender_download_chunk( char reply[3584]; const char *key = "\"data\":\""; const char *p, *end; + int is_eof; size_t out = 0; int n, received; if (transport == NULL || transport->send == NULL || transport->receive == NULL || @@ -257,9 +258,12 @@ int amirender_download_chunk( if (received <= 0 || (size_t)received >= sizeof(reply)) return -1; reply[received] = '\0'; if (strstr(reply, "\"type\":\"DATA\"") == NULL) return -1; + if (strstr(reply, "\"eof\":true") != NULL) is_eof = 1; + else if (strstr(reply, "\"eof\":false") != NULL) is_eof = 0; + else return -1; p = strstr(reply, key); if (p == NULL) { - if (strstr(reply, "\"eof\":true") != NULL) { + if (is_eof) { *received_size = 0; *eof = 1; return 0; } return -1; @@ -288,7 +292,7 @@ int amirender_download_chunk( p += 4; } *received_size = out; - *eof = strstr(reply, "\"eof\":true") != NULL; + *eof = is_eof; return 0; } From f9d1e802cad671039d7bd18ddc341b276643771a Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 12 Oct 2026 01:28:51 +0200 Subject: [PATCH 190/201] Test: reject missing and nonboolean EOF flags in downloads --- clients/amiga/tests/test_client.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 0fa5087..7495932 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -290,6 +290,9 @@ int main(void) "{\"type\":\"DATA\",\"data\":\"QUJ=\",\"eof\":true}", "{\"type\":\"DATA\",\"data\":\"\",\"eof\":false}", "{\"type\":\"DATA\",\"data\":\"\",\"eof\":true}", + "{\"type\":\"DATA\",\"data\":\"QQ==\"}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":null}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":1}", "{\"type\":\"DATA\",\"eof\":true}", "{\"type\":\"DATA\",\"eof\":false}", "{\"type\":\"FAILED\",\"error\":\"disconnected\"}" From 6583854247b427e13ea739abde00935bb5ebceb4 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 12 Oct 2026 07:29:45 +0200 Subject: [PATCH 191/201] Amiga: reject duplicate or contradictory EOF fields in download replies --- clients/amiga/src/client.c | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index 740f3f8..e328b8a 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -258,9 +258,15 @@ int amirender_download_chunk( if (received <= 0 || (size_t)received >= sizeof(reply)) return -1; reply[received] = '\0'; if (strstr(reply, "\"type\":\"DATA\"") == NULL) return -1; - if (strstr(reply, "\"eof\":true") != NULL) is_eof = 1; - else if (strstr(reply, "\"eof\":false") != NULL) is_eof = 0; - else return -1; + { + const char *eof_true = strstr(reply, "\"eof\":true"); + const char *eof_false = strstr(reply, "\"eof\":false"); + const char *first = strstr(reply, "\"eof\":"); + if ((eof_true == NULL) == (eof_false == NULL)) return -1; + if (first != (eof_true != NULL ? eof_true : eof_false)) return -1; + if (strstr(first + 6, "\"eof\":") != NULL) return -1; + is_eof = eof_true != NULL; + } p = strstr(reply, key); if (p == NULL) { if (is_eof) { From 97c43b4ab1d76c6bb02c5bbbd83049d1199a19b4 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 12 Oct 2026 07:29:55 +0200 Subject: [PATCH 192/201] Test: duplicate and conflicting EOF values must fail safely --- clients/amiga/tests/test_client.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 7495932..da2fb2e 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -293,6 +293,9 @@ int main(void) "{\"type\":\"DATA\",\"data\":\"QQ==\"}", "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":null}", "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":1}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"eof\":false}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":false,\"eof\":false}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":null,\"eof\":true}", "{\"type\":\"DATA\",\"eof\":true}", "{\"type\":\"DATA\",\"eof\":false}", "{\"type\":\"FAILED\",\"error\":\"disconnected\"}" From a50deac41627c4ae52540b03dddb2bb6e039e5e7 Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 12 Oct 2026 23:43:59 +0200 Subject: [PATCH 193/201] Amiga: parse top-level download fields without matching quoted text --- clients/amiga/src/client.c | 62 ++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index e328b8a..fe6b3f6 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -234,14 +234,42 @@ int amirender_upload_end( return strstr(reply, "\"type\":\"STAGED\"") != NULL ? 0 : -1; } +/* Return a top-level JSON field, ignoring quoted string contents. */ +static const char *json_field(const char *json, const char *key, int *matches) +{ + const char *p = json; + const char *found = NULL; + size_t len = strlen(key); + int depth = 0, quoted = 0, escaped = 0; + *matches = 0; + while (*p) { + if (quoted) { + if (escaped) escaped = 0; + else if (*p == '\\') escaped = 1; + else if (*p == '"') quoted = 0; + } else if (*p == '{') ++depth; + else if (*p == '}') --depth; + else if (*p == '"' && depth == 1) { + if (strncmp(p + 1, key, len) == 0 && + p[len + 1] == '"' && p[len + 2] == ':') { + found = p + len + 3; + ++*matches; + } + quoted = 1; + } else if (*p == '"') quoted = 1; + ++p; + } + return depth == 0 && !quoted ? found : NULL; +} + int amirender_download_chunk( struct amirender_transport *transport, const char *asset, size_t offset, unsigned char *data, size_t data_size, size_t *received_size, int *eof) { char request[1200]; char reply[3584]; - const char *key = "\"data\":\""; - const char *p, *end; + const char *p, *end, *field; + int matches; int is_eof; size_t out = 0; int n, received; @@ -257,24 +285,26 @@ int amirender_download_chunk( received = transport->receive(transport->context, reply, sizeof(reply) - 1); if (received <= 0 || (size_t)received >= sizeof(reply)) return -1; reply[received] = '\0'; - if (strstr(reply, "\"type\":\"DATA\"") == NULL) return -1; - { - const char *eof_true = strstr(reply, "\"eof\":true"); - const char *eof_false = strstr(reply, "\"eof\":false"); - const char *first = strstr(reply, "\"eof\":"); - if ((eof_true == NULL) == (eof_false == NULL)) return -1; - if (first != (eof_true != NULL ? eof_true : eof_false)) return -1; - if (strstr(first + 6, "\"eof\":") != NULL) return -1; - is_eof = eof_true != NULL; - } - p = strstr(reply, key); - if (p == NULL) { - if (is_eof) { + field = json_field(reply, "type", &matches); + if (matches != 1 || field == NULL || + strncmp(field, "\"DATA\"", 6) != 0) return -1; + field = json_field(reply, "eof", &matches); + if (matches != 1 || field == NULL) return -1; + if (strncmp(field, "true", 4) == 0 && + (field[4] == ',' || field[4] == '}' || field[4] == ' ')) is_eof = 1; + else if (strncmp(field, "false", 5) == 0 && + (field[5] == ',' || field[5] == '}' || field[5] == ' ')) is_eof = 0; + else return -1; + field = json_field(reply, "data", &matches); + if (matches > 1) return -1; + if (field == NULL) { + if (matches == 0 && is_eof) { *received_size = 0; *eof = 1; return 0; } return -1; } - p += strlen(key); + if (*field != '"') return -1; + p = field + 1; end = strchr(p, '"'); if (end == NULL) return -1; while (p < end) { From 971b43623c794e37ce66f3b4f88451cbc6e92d7b Mon Sep 17 00:00:00 2001 From: pos Date: Mon, 12 Oct 2026 23:44:08 +0200 Subject: [PATCH 194/201] Test: reject quoted protocol lookalikes and duplicate data fields --- clients/amiga/tests/test_client.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index da2fb2e..5d5fbde 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -296,6 +296,9 @@ int main(void) "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"eof\":false}", "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":false,\"eof\":false}", "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":null,\"eof\":true}", + "{\"type\":\"DATA\",\"note\":\"\\\"eof\\\":true\",\"data\":\"QQ==\"}", + "{\"type\":\"FAILED\",\"note\":\"\\\"type\\\":\\\"DATA\\\"\",\"data\":\"QQ==\",\"eof\":true}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"data\":\"QQ==\",\"eof\":true}", "{\"type\":\"DATA\",\"eof\":true}", "{\"type\":\"DATA\",\"eof\":false}", "{\"type\":\"FAILED\",\"error\":\"disconnected\"}" From 6b596460e653e94a0ccd9b0652717adab485a7d3 Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 13 Oct 2026 23:00:31 +0200 Subject: [PATCH 195/201] Test: reject truncated JSON and unterminated quoted download replies --- clients/amiga/tests/test_client.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 5d5fbde..bb80afb 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -299,6 +299,9 @@ int main(void) "{\"type\":\"DATA\",\"note\":\"\\\"eof\\\":true\",\"data\":\"QQ==\"}", "{\"type\":\"FAILED\",\"note\":\"\\\"type\\\":\\\"DATA\\\"\",\"data\":\"QQ==\",\"eof\":true}", "{\"type\":\"DATA\",\"data\":\"QQ==\",\"data\":\"QQ==\",\"eof\":true}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"note\":\"unterminated}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"note\":\"escaped \\\"eof\\\":false\"}", "{\"type\":\"DATA\",\"eof\":true}", "{\"type\":\"DATA\",\"eof\":false}", "{\"type\":\"FAILED\",\"error\":\"disconnected\"}" From 6a4c41f741ecfe9bec581ad0f19465ebd13555ad Mon Sep 17 00:00:00 2001 From: pos Date: Tue, 13 Oct 2026 23:00:37 +0200 Subject: [PATCH 196/201] Test: keep valid escaped JSON outside invalid-reply cases --- clients/amiga/tests/test_client.c | 1 - 1 file changed, 1 deletion(-) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index bb80afb..950a258 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -301,7 +301,6 @@ int main(void) "{\"type\":\"DATA\",\"data\":\"QQ==\",\"data\":\"QQ==\",\"eof\":true}", "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true", "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"note\":\"unterminated}", - "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"note\":\"escaped \\\"eof\\\":false\"}", "{\"type\":\"DATA\",\"eof\":true}", "{\"type\":\"DATA\",\"eof\":false}", "{\"type\":\"FAILED\",\"error\":\"disconnected\"}" From cd21641cde88ff5df154dfa1117fca9a2790b0ba Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 14 Oct 2026 22:38:31 +0200 Subject: [PATCH 197/201] Test: accept escaped JSON metadata without shadowing download fields --- clients/amiga/tests/test_client.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 950a258..00b34a8 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -183,6 +183,14 @@ int main(void) fprintf(stderr, "final opaque download chunk failed\n"); return 21; } + fake.reply = "{\"type\":\"DATA\",\"note\":\"escaped \\\"eof\\\":false and \\\"type\\\":\\\"FAILED\\\"\",\"data\":\"Tkc=\",\"eof\":true}"; + if (amirender_download_chunk(&transport, "asset-rendered", 6, + downloaded, sizeof(downloaded), &count, &eof) != 0 || + count != 2 || eof != 1 || + downloaded[0] != 'N' || downloaded[1] != 'G') { + fprintf(stderr, "escaped metadata must not shadow protocol fields\n"); + return 44; + } fake.reply = "{\"type\":\"DATA\",\"eof\":true}"; if (amirender_download_chunk(&transport, "asset-rendered", 8, downloaded, sizeof(downloaded), &count, &eof) != 0 || From 2894dd7cf6130f54d66c61ac833aef1bbacfeda0 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 15 Oct 2026 20:11:15 +0200 Subject: [PATCH 198/201] Amiga: reject invalid JSON string escapes and control bytes --- clients/amiga/src/client.c | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index fe6b3f6..167be22 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -244,8 +244,23 @@ static const char *json_field(const char *json, const char *key, int *matches) *matches = 0; while (*p) { if (quoted) { - if (escaped) escaped = 0; + if (escaped) { + if (*p == 'u') { + int i; + for (i = 1; i <= 4; ++i) { + char h = p[i]; + if (!((h >= '0' && h <= '9') || + (h >= 'a' && h <= 'f') || + (h >= 'A' && h <= 'F'))) return NULL; + } + p += 4; + } else if (*p != '"' && *p != '\\' && *p != '/' && + *p != 'b' && *p != 'f' && *p != 'n' && + *p != 'r' && *p != 't') return NULL; + escaped = 0; + } else if (*p == '\\') escaped = 1; + else if ((unsigned char)*p < 0x20) return NULL; else if (*p == '"') quoted = 0; } else if (*p == '{') ++depth; else if (*p == '}') --depth; From 15c9b41e5fe64ffa785d5e7ae739837338b6cf16 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 15 Oct 2026 20:11:28 +0200 Subject: [PATCH 199/201] Test: invalid JSON escape sequences fail download safely --- clients/amiga/tests/test_client.c | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index 00b34a8..bcea55c 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -309,6 +309,9 @@ int main(void) "{\"type\":\"DATA\",\"data\":\"QQ==\",\"data\":\"QQ==\",\"eof\":true}", "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true", "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"note\":\"unterminated}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"note\":\"bad \\q escape\"}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"note\":\"bad \\u12G4 escape\"}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"note\":\"bad \\u123 escape\"}", "{\"type\":\"DATA\",\"eof\":true}", "{\"type\":\"DATA\",\"eof\":false}", "{\"type\":\"FAILED\",\"error\":\"disconnected\"}" From 79239dbb8d35da949fc035d272e5bf488d890c6f Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 15 Oct 2026 23:09:53 +0200 Subject: [PATCH 200/201] Amiga: reject trailing garbage and unmatched JSON object braces --- clients/amiga/src/client.c | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/clients/amiga/src/client.c b/clients/amiga/src/client.c index 167be22..474c0e7 100644 --- a/clients/amiga/src/client.c +++ b/clients/amiga/src/client.c @@ -240,9 +240,15 @@ static const char *json_field(const char *json, const char *key, int *matches) const char *p = json; const char *found = NULL; size_t len = strlen(key); - int depth = 0, quoted = 0, escaped = 0; + int depth = 0, quoted = 0, escaped = 0, closed = 0; *matches = 0; + if (*p != '{') return NULL; while (*p) { + if (closed) { + if (*p != ' ' && *p != '\\n' && *p != '\\r' && *p != '\\t') return NULL; + ++p; + continue; + } if (quoted) { if (escaped) { if (*p == 'u') { @@ -263,7 +269,10 @@ static const char *json_field(const char *json, const char *key, int *matches) else if ((unsigned char)*p < 0x20) return NULL; else if (*p == '"') quoted = 0; } else if (*p == '{') ++depth; - else if (*p == '}') --depth; + else if (*p == '}') { + if (--depth < 0) return NULL; + if (depth == 0) closed = 1; + } else if (*p == '"' && depth == 1) { if (strncmp(p + 1, key, len) == 0 && p[len + 1] == '"' && p[len + 2] == ':') { @@ -274,7 +283,7 @@ static const char *json_field(const char *json, const char *key, int *matches) } else if (*p == '"') quoted = 1; ++p; } - return depth == 0 && !quoted ? found : NULL; + return closed && depth == 0 && !quoted ? found : NULL; } int amirender_download_chunk( From a1f70adaea9a5efee282be7d3c90cfba91a5eb14 Mon Sep 17 00:00:00 2001 From: pos Date: Thu, 15 Oct 2026 23:10:04 +0200 Subject: [PATCH 201/201] Test: reject unmatched JSON braces and trailing protocol garbage --- clients/amiga/tests/test_client.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/clients/amiga/tests/test_client.c b/clients/amiga/tests/test_client.c index bcea55c..ebabd34 100644 --- a/clients/amiga/tests/test_client.c +++ b/clients/amiga/tests/test_client.c @@ -312,6 +312,10 @@ int main(void) "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"note\":\"bad \\q escape\"}", "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"note\":\"bad \\u12G4 escape\"}", "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true,\"note\":\"bad \\u123 escape\"}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true}}", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true}junk", + "{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true}{\"x\":1}", + "garbage{\"type\":\"DATA\",\"data\":\"QQ==\",\"eof\":true}", "{\"type\":\"DATA\",\"eof\":true}", "{\"type\":\"DATA\",\"eof\":false}", "{\"type\":\"FAILED\",\"error\":\"disconnected\"}"