Skip to content

feat(gateway): off-loop JPEG encode + rgba_raw fail-loud (gate #5 gateway half) - #10

Merged
AlexSKuznetsov merged 2 commits into
mainfrom
feat/gateway-offloop-encode
Aug 27, 2026
Merged

AlexSKuznetsov merged 2 commits into
mainfrom
feat/gateway-offloop-encode

Conversation

@AlexSKuznetsov

Copy link
Copy Markdown
Collaborator

What

Closes the gateway half of gate item #5 (docs/19-Post-Spike-Build-Plan.md), deferred out of
PR #9 which shipped the glasses H.265 publish. Two independent commits:

  • 15d — an rgba_raw subscriber that can't keep up now fails loud instead of silently
    latest-wins-dropping ~2 of 3 frames.
  • 15b — the JPEG encode moves off the asyncio event loop.

Design, and the decision that changed during implementation, in docs/20-Gateway-Off-Loop-Encode-Design.md.

15b — off-loop encode

The JPEG encode (~8.5 ms at 720p, up to 13.7 ms) ran inline on the single event loop, blocking every
other subscriber, control message and audio chunk while it ran — once per frame. It now runs on a
thread via an injectable encode_runner (default asyncio.to_thread); PIL releases the GIL across the
libjpeg encode, so it overlaps the loop.

Measured (720p, quality 92):

on-loop (before) off-loop (after)
longest single unbroken loop block / frame 13.7 ms 6.1 ms
loop-blocks >2 ms over 90 frames 89 — one full encode per frame smaller pieces, none a full encode
encode itself 8.5 ms mean 9.1 ms mean (now overlapping the loop)

We didn't make the encode faster; we stopped it from holding the loop hostage. The ~6 ms residual is
PIL's GIL-held setup (frombuffer/convert) and is conservative — the benchmark's hot spin contends
for the GIL harder than the real I/O-bound loop will.

Why teardown is untouched (the design decision)

The first design (docs/20 "Rejected") deferred EpochEnded to the sampler so the tail frame survived.
Implementation surfaced a reconnect ordering hazard: on the "new track SID = new epoch" reconnect,
EpochStarted(new) could publish while the old epoch's sampler was still draining, so a consumer would
see EpochStarted(new) before EpochEnded(old) — reversing the boundary it resets tracker state on.

The chosen design keeps EpochEnded synchronous, exactly as today. A cancel during the off-loop
encode raises CancelledError at the await before publish_video, so the in-flight frame is
dropped rather than published late — ordering is preserved by construction in every case (graceful
end, session end, reconnect, shutdown), with no teardown changes. The cost is one frame at a real track
end, immaterial on a continuous stream.

15d — rgba_raw fail-loud

rgba_raw exists for pixel-exact work but was latest-wins-dropped like JPEG. At 720p a raw frame is
3.7 MB — too big to flush at sample rate — so ~2 of 3 were dropped silently, corrupting the one use
case it serves. It now closes the subscriber with a dedicated RGBA_RAW_BACKPRESSURE reason (surfaced
to the websocket as a close code + reason), so a pixel-exact consumer sees a failure, not bad data.
dropped is not incremented. JPEG preview is unaffected — for lossy preview, latest-wins is correct.

Verification

  • Full gateway suite green (uv run pytest), ruff + pyright clean.
  • The existing pixel-exact test_rgba_raw… passes unchanged — the test harness injects an inline
    encode runner so relay stays deterministic (a real thread hop composes badly with the lockstep pacer,
    which is what drove the earlier "zero frames" failure).
  • Added: a guard test that the production default runner is asyncio.to_thread, and
    test_rgba_raw_subscriber_is_closed_rather_than_silently_dropped.

🤖 Generated with Claude Code

AlexSKuznetsov and others added 2 commits August 26, 2026 17:46
…ing (15d)

rgba_raw exists for pixel-exact work, but offer_frame latest-wins-evicts a
pending frame exactly like JPEG. At 720p an rgba_raw frame is 3.7 MB, too big
to flush at sample rate, so ~2 of every 3 were dropped silently -- corrupting
the one use case the encoding serves.

Close the subscriber loudly with a dedicated RGBA_RAW_BACKPRESSURE reason
instead (same shape as the audio-backpressure path, surfaced to the websocket
as a close code + reason), so a pixel-exact consumer sees a failure rather than
an invisibly holed sequence. `dropped` is not incremented -- a closed-loud
frame is not a silent drop. JPEG preview subscribers are unaffected.

Gate item #5 (15d) in docs/19; design in docs/20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The JPEG encode (~8.5 ms at 720p, up to 13.7 ms) ran inline on the asyncio
event loop, blocking every other subscriber, control message and audio chunk
while it ran -- one such block per frame.

Move it off the loop: MediaPipeline takes an injectable encode_runner
defaulting to asyncio.to_thread; _relay_video becomes async and awaits it per
encoding. PIL releases the GIL across the libjpeg encode, so it overlaps the
loop. Measured (720p q92): longest single unbroken loop block per frame drops
13.7 ms -> 6.1 ms, and the full encode no longer blocks the loop on every frame
(only PIL's GIL-held setup remains, and less than that under real I/O load).

Teardown is deliberately unchanged: _finish_epoch still hard-cancels the
sampler and emits EpochEnded synchronously. A cancel during the off-loop encode
raises CancelledError before publish_video, so the in-flight frame is dropped
rather than published after EpochEnded -- ordering is preserved in every case
(graceful end, session end, reconnect, shutdown) with no new code. The cost is
one frame at a real track end, immaterial on a continuous stream. This avoids
the reconnect ordering hazard of the deferred-EpochEnded design (docs/20).

Tests stay deterministic: to_thread composes badly with the lockstep pacer
(the earlier "zero frames" failure), so the harness injects an inline runner
that awaits without yielding; a guard test asserts the production default is
asyncio.to_thread. Full gateway suite green; ruff + pyright clean.

Gate item #5 (15b) in docs/19; design and decision in docs/20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@AlexSKuznetsov
AlexSKuznetsov merged commit d932e67 into main Aug 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant