0032 — engine progress side-channel (0031 phase B)¶
Status: IMPLEMENTED — engine half in ffmpeg-wasi (vocab v9, released n8.1.2-9); afmpeg
surfacing half landed (the /dev/afmpeg-progress vfs device + reporter merge). Decision-bearing
questions resolved with the user 2026-07-12 (D-B1 device, D-B3 vocab-v9 gating); the impl-time forks
are recorded under §5 (Resolved during implementation).
Date: 2026-07-12
Parent: 0031 (implements its phase B / decision D0);
0003 (the vfs device seam this rides); 0007
(the job-spec vocabulary this bumps)
Owns: R-PROGRESS-B — engine-emitted frame/time/speed progress, behind the 0031 Progress stream.
1. Why¶
0031 phase A shipped host-side progress by watching bytes cross
the afero.Fs bridge (Fraction = bytes_read / input_size). It works and needs no engine change,
but it is byte progress, not time (0031 §4.3): no frame/out_time/speed, no accurate ETA,
and Fraction == -1 for generative/lavfi inputs and short ops. keyrx wants the richer readout.
The engine already produces exactly these numbers internally — it just never surfaces them (the
driver is headless; the encode loop in ffmpeg-wasi/src/process.c emits nothing per frame). Phase B
gives the driver a cheap, one-way side-channel to report them, and afmpeg surfaces them on the
same WithProgress channel — no public API change (0031 §6.1 reserved Frame/OutTime/Speed
for exactly this).
2. Scope¶
In:
- ffmpeg-wasi: the process op emits periodic progress records from its encode/mux loop.
- afmpeg: a /dev/afmpeg-progress vfs device that parses those records and feeds the existing
progressReporter; Progress.Frame/OutTime/Speed populated; Fraction derived from
out_time / duration when available.
- The job-spec vocabulary bump that gates engine emission on host opt-in.
Out (referenced, not owned):
- The public Progress type / WithProgress delivery / back-pressure — unchanged from
0031 (this only adds a richer source).
- The frames and probe ops (short; no progress — 0031 D6).
- Native backend parity — the native driver reports over its own IPC framing (0031 D7); this spec
is the WASM side. The record schema is shared so native can adopt it later.
3. Design — the /dev/afmpeg-progress device (transport B-i)¶
The vfs already serves synthetic devices (/dev/null discards writes, /dev/urandom serves reads;
internal/vfs/null.go, random.go). Phase B adds a write-only device by the same pattern:
- Engine: when the job asks for progress (D-B3), the driver opens
/dev/afmpeg-progressand, from the encode loop, writes one NDJSON record per flush interval:{"frame":N,"out_time_us":T,"total_size":B}(\n-terminated;speedis host-derived, see D-B5). Best-effort: if the open fails, the driver proceeds silently (it never blocks the encode on progress). - afmpeg:
progressFile(a newexperimentalsys.File) whoseWriteparses complete NDJSON lines and calls a per-invocation callback; partial lines buffer until their newline. The callback updates the sameprogressReporter(0031) that already owns the drain goroutine, non-blocking send, and monotonic clamp. - Precedence (D-B4): when engine records arrive, they supersede the observed-fs byte-fraction —
Fraction = out_time / duration(duration from the input probe), andFrame/OutTime/Speedare filled. The observed-fs path (phase A) remains as the fallback when no engine record has arrived yet (early startup) or for a runtime/engine too old to emit.
This reuses the entire phase-A host pipeline; the only genuinely new host code is the device + line parser.
4. Decisions¶
- D-B1 — Transport = a
/dev/afmpeg-progressvfs write-device (0031 §5 B-i), not a host-import function (B-ii) or anav_logstderr scrape (B-iii). It reuses the proven device seam, needs no new host-ABI beside setjmp, and keeps the engine one-way and non-blocking. Resolved 2026-07-12 (user). - D-B2 — Emission cadence. The driver emits at most once per ~100 ms of media time (and one final record at EOF), not per frame — enough for a smooth bar, cheap enough to ignore. afmpeg coalesces further (0031 D2 newest-wins). Proposed (final value picked during the engine change).
- D-B3 — Gating + vocab bump to v9. The driver emits only when the job spec sets
progress: true(a new top-level field). afmpeg sets it only whenWithProgressis active and the engine's vocab ≥ v9 (the existing preflight handshake knows the engine's version), and only then serves the device. This avoids an older host having/dev/afmpeg-progressrouted to its real backing fs (a spurious file), and an older engine rejecting a too-new spec. Additive, version-gated, one vocab increment. Resolved 2026-07-12 (user). - D-B4 — Engine numbers win when present; observed-fs byte-fraction is the fallback (§3). Proposed.
- D-B5 — Record schema:
frame(int),out_time_us(int µs),total_size(int bytes, → a refinedOutputBytes).speedis derived host-side, not emitted: the WASI engine has no advancing wall clock (the same reasonav_get_random_seed'sclock()-jitter loop hangs — seeinternal/vfs/random.go), so it cannot measure ×realtime. afmpeg computesSpeed = OutTime / Elapsedfrom the engine'sout_timeand its own wall clock (the reporter already tracksElapsed). Schema shared with the future native side (D7); unknown/absent fields tolerated. Refined during impl 2026-07-12: speed moved host-side (engine is clockless).
5. Open questions¶
Resolved during implementation (2026-07-12):
- D-B1 confirm — the device, as recommended.
internal/vfs/progress.goserves/dev/afmpeg-progress(write-only,WithProgressSink), overlaid only whenWithProgressis active. Resolved. out_timeorigin for multi-output jobs — the engine emits a single monotonicout_timeacross the encode loop (max across mux sites viapmux); afmpeg consumes it as-is. No per-output fan-out — one bar per job. Resolved (engine behaviour).- Cadence value — 100 ms media-time throttle plus a final record (engine
EMIT_INTERVAL_US); afmpeg coalesces further (0031 D2 newest-wins). Confirmed smooth: a 20 s audio transcode yields ~230 samples. Resolved. - Does
total_sizedeprecate phase A's observed output bytes? No. Measured: the engine'stotal_sizeis an encoded-payload sum (e.g. 1074 B) and undercounts the real muxed file (2941 B, which includes container structure).OutputBytestherefore stays the observed-fs value — the true bytes written — andtotal_sizeis parsed but not surfaced. Resolved (empirical). Fraction = out_time/durationneeds a duration the engine record lacks. Then8.1.2-9record carriesframe/out_time_us/total_sizebut no duration, so afmpeg cannot formout_time/durationagainst it. Chosen path: the afmpeg reporter parses an optionalduration_usfield (forward-compatible) — when present it drivesFraction(incl. generative/R-PROGRESS-B2); when absent (today)Fractionfalls back to the phase-A byte ratio, andFrame/OutTime/Speedstill light up. Lighting upFractionfor generative inputs needs a one-field engine follow-up (emitduration_us); the afmpeg side is done and needs no further change to consume it. Resolved: ship forward-compatible, defer the engineduration_usemission to a follow-up.
6. Requirements & acceptance¶
- R-PROGRESS-B1 — for a multi-second WASM
processjob withWithProgressactive against a v9+ engine, samples carry monotonically risingOutTimeand non-zeroFrame/Speed, andFractionderives fromout_time/duration(verified within tolerance against the known input duration). - R-PROGRESS-B2 — a generative/lavfi input (no readable input file,
Fraction == -1under phase A) now reports a realFractionfromout_time/duration. - R-PROGRESS-B3 — compatibility: an engine below v9 (or
WithProgressinactive) behaves exactly as today — no/dev/afmpeg-progressserved, noprogressfield sent, phase-A fallback intact, identicalResult. - R-PROGRESS-B4 — the side-channel never blocks or fails the job: a malformed/partial record is dropped, and progress I/O is best-effort on both sides (0031 D2).
- Cross-repo: the engine change lands in ffmpeg-wasi (new engine build, vocab v9) and is
consumed by afmpeg via
WithModuleRelease; afmpeg tests gate onAFMPEG_TEST_FFMPEG_WASIpointing at a v9 build. Test-first from these contracts; ≥90 % on newpkg/code.
As-shipped status (2026-07-12):
- R-PROGRESS-B1 — ✅
OutTimerises monotonically andSpeedis derived and non-zero, verified againstn8.1.2-9(TestIntegration_ProgressPhaseB, ~230 samples over a 20 s transcode). Two refinements vs the literal wording:Frameis video-only (an audio-only job legitimately keepsFrame == 0— the engine'sframes++is guarded onis_video); and theFraction = out_time/durationclause is pending the engineduration_usfollow-up (see §5) — until thenFractionis the phase-A byte ratio. - R-PROGRESS-B2 — ⏳ pending the engine
duration_usfollow-up. The afmpeg side is complete and forward-compatible (unit-verified with a syntheticduration_usrecord,TestRun_threadsEngineSinkToChannel): the moment the engine emitsduration_us, a generative input reports a realFractionwith no afmpeg change. - R-PROGRESS-B3 — ✅ compatibility intact: a pre-v9 engine is rejected at
Newby the vocab gate (verified — the local v8 build is refused);WithProgressinactive → no sink, no device, noprogressfield, phase-A path byte-identical. - R-PROGRESS-B4 — ✅ best-effort throughout: malformed/partial lines dropped, a runaway newline-less stream is capped, non-draining channel never blocks (unit-covered).