Skip to content

surface: give the playout margin a refresh of headroom, so a clean 60 fps stream stops dropping frames - #263

Merged
pcarrier merged 2 commits into
mainfrom
pc/surface-playout-margin
Aug 7, 2026
Merged

surface: give the playout margin a refresh of headroom, so a clean 60 fps stream stops dropping frames#263
pcarrier merged 2 commits into
mainfrom
pc/surface-playout-margin

Conversation

@pcarrier

@pcarrier pcarrier commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What

One line in updateSchedule: the presentation offset now targets the 0.95 arrival-offset quantile plus one display refresh, instead of the quantile alone. Two doc comments updated to say why.

Why

Chasing a "blit misses frames / micro-hangs" report on surfaces. The server turned out to be fine; the client was discarding frames it had already decoded.

Server delivers cleanly. Recording surface 3 straight off the server's unix socket — 30 s, AV1, 1924x1258:

fps=60.1  gap ms: p50=16.7 p90=17.8 p99=18.8 max=23.0

Zero gaps above 25 ms.

The browser drops 3-19% of decoded frames. Same server, overlapping windows, real UI in Chromium. blit's own counter:

[blit-video] recv=300 decoded=300 output=300 presented=264 dropped=37

rAF p50 16.7 / p99 17 ms, zero long tasks. Nothing on the main thread is janking and nothing upstream is stalling — the frames arrive, decode, and are thrown away at present time. That is two to six visible hitches a second.

Mechanism. A frame's due time was the 0.95 quantile of observed arrival offsets. That is a jitter estimate, not a due time: a frame can only be painted on a vsync, so aiming at the quantile leaves it a fraction of a refresh to actually land, and by construction 5% of a steady stream arrives after its own due time. Each one that does costs two frames — the refresh that repeats its predecessor, and itself, skipped as overdue by presentIndex. Measured margin settled at 4-15 ms, under one 16.7 ms refresh.

The old comment on PRESENT_QUANTILE asserted the tail "costs nothing and is what should happen to a rare outlier". The first half is wrong, and at 5% of a 60 fps stream it is not rare. Both comments corrected.

Measurement

Paired runs through the same harness (vite dev -> gateway -> live server, same surface, minutes apart):

dropped draw gaps >25 ms p95 draw gap playout latency p50
before 249/2400 (10.4%) 230 33.4 ms 8.6 ms
after 100/2398 (4.2%) 99 20.5 ms 27.9 ms

The +19 ms comes out of the budget PRESENT_DELAY_MAX_MS (50 ms) already reserved for exactly this, so the existing latency ceiling is unchanged.

Method: Playwright-driven Chromium against the real UI, hooking EncodedVideoChunk construction (arrival), CanvasRenderingContext2D.drawImage with a VideoFrame source (present), and rAF deltas. A frame counts as dropped when its PTS arrived and was never drawn.

Reviewer notes

  • The repeated A/B was not clean. hound is in active use; surfaces come and go and #s=local:<id> did not pin the view, so arrival counts swung 450-1800 between runs. The patched arm won every round (13.8/19/8.6% -> 0.5/10.6/0.3%) but the magnitudes from those runs are not trustworthy. The table above is the one clean paired comparison. Worth re-running on a quiet box before trusting the exact numbers.
  • 4.2% residual. Something else remains, most likely arrival bursts / PTS spacing from the server. Not chased here.
  • this.refreshMs is the measured display period (noteRafInterval), so this scales with the panel rather than assuming 60 Hz. It falls back to 1/60 before any rAF delta is seen.
  • Typechecks clean (tsc --noEmit -p js/core). No behavioural test added — the effect is statistical over hundreds of frames and needs a real display; there is no unit-test shape for it.

Out of scope, found on the way

  • Two real latency hazards in the server tick, neither of which explains this: lib.rs:7473 sleeps 50 ms per exited PTY inside the delivery loop, and lib.rs:7822 can block for seconds on an audio-pipeline restart while holding the session mutex.

… fps stream stops dropping frames

The presenter scheduled each frame to become due at the 0.95 quantile of
observed arrival offsets.  That is a jitter estimate, not a due time: a
frame can only be painted on a vsync, so aiming at the quantile leaves it
a fraction of a refresh to land, and by construction 5% of a steady
stream arrives after its own due time.  Each one that does costs two
frames — the refresh that repeats its predecessor, and itself, skipped as
overdue by presentIndex.

Measured against the server delivering a clean 60 fps (p99 inter-frame
gap 19-26 ms at the socket, no stalls) to a 60 Hz display: the margin
settled at 4-15 ms, under one refresh, and 3-19% of decoded frames never
reached the canvas.  recv=300 decoded=300 output=300 presented=264
dropped=37 in a 5 s window, with zero long tasks and rAF p99 of 17 ms —
two to six visible hitches a second that nothing on the server side or
the main thread accounts for.

Adding one display refresh to the target buys the headroom out of the
latency PRESENT_DELAY_MAX_MS was already budgeting for.  Paired runs
through the same harness: 10.4% -> 4.2% dropped, draw gaps over 25 ms
230 -> 99, p95 draw gap 33.4 -> 20.5 ms, for +19 ms of playout latency.

Co-Authored-By: Claude <noreply@anthropic.com>
@indent

indent Bot commented Aug 7, 2026

Copy link
Copy Markdown
PR Summary

Fixes client-side frame drops on continuous 60 fps surface streams by giving the playout schedule a display refresh of headroom, so tail-latency frames land on their intended vsync instead of arriving overdue and being skipped (which costs both a repeated and a dropped frame).

  • In updateSchedule (js/core/src/SurfaceStore.ts), the presentation target is now quantile(0.95) + refreshMs instead of the 0.95 arrival-offset quantile alone; the existing fastOffsetMs + PRESENT_DELAY_MAX_MS cap is unchanged, so the 50 ms margin and queue-depth invariants still hold.
  • Updated the PRESENT_QUANTILE and updateSchedule doc comments to explain why the overdue tail is not free and why a full refresh of headroom is bought.

Issues

All clear! No issues remaining. 🎉

2 issues already resolved
  • The scheduling change breaks 3 existing tests in js/core/src/tests/SurfaceStore.test.ts, which the PR does not update, so CI's test job (nix/tasks.nix:775 → pnpm --filter @blit-sh/core run test) will fail. Verified 39/39 pass on main, 3 fail on this head: "does not let one outlier pin the margin" (clean-stream margin is now ~16.7ms, not <5), "recovers its added latency quickly after a single stall" (margin floors at ~16.7ms, never returns <5), and "is never worse than newest-wins: steady jitter". (fixed by commit c5592b4)
  • The added refresh makes the presenter momentarily worse than plain newest-wins under steady jitter, violating the "vs newest-wins control" guardrail. On the steady jitter trace the scheduled path opens a single ~50 ms draw gap (vs newest-wins' 33.3 ms) right when smoothing engages (~frame 8), because presentOffsetMs is set directly to the new, larger target in one step. It is a one-time transient at stream start (steady state improves: 197 vs 155 frames presented), but it fails the one invariant that test block exists to protect. (fixed by commit c5592b4)

View session

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Coverage

Crate Lines Functions Regions
alacritty-driver 73.1% (825/1128) 75.9% (66/87) 76.6% (1339/1747)
browser 0.0% (0/822) 0.0% (0/68) 0.0% (0/1401)
cli 24.7% (2436/9843) 36.2% (300/829) 27.4% (4128/15091)
compositor 17.9% (2178/12176) 29.5% (184/623) 19.1% (3209/16776)
fonts 81.4% (721/886) 88.6% (70/79) 83.0% (1427/1719)
fssync 92.6% (4907/5301) 94.3% (449/476) 92.6% (8971/9691)
gateway 25.5% (375/1469) 29.9% (38/127) 19.2% (470/2448)
git 87.7% (4267/4867) 89.1% (343/385) 87.3% (6755/7734)
lsp 76.2% (2513/3300) 78.5% (248/316) 74.1% (3906/5274)
proxy 19.2% (172/898) 20.5% (26/127) 21.0% (293/1392)
remote 90.4% (9367/10356) 93.2% (686/736) 88.5% (15774/17816)
sd-notify 73.9% (68/92) 100.0% (6/6) 83.2% (109/131)
server 42.6% (9057/21283) 57.1% (911/1595) 44.9% (15141/33687)
ssh 32.2% (165/512) 48.2% (27/56) 31.4% (261/830)
upsidedown 31.4% (391/1247) 27.8% (55/198) 34.8% (797/2287)
webrtc-forwarder 5.6% (153/2723) 8.5% (17/200) 4.6% (207/4516)
webserver 62.1% (1133/1825) 65.9% (164/249) 64.5% (1912/2964)
Total 49.2% (38728/78728) 58.3% (3590/6157) 51.6% (64699/125504)

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

🔗 Preview: https://blit-6nmqijfgl-indent.vercel.app

Comment thread js/core/src/SurfaceStore.ts Outdated
Comment thread js/core/src/SurfaceStore.ts Outdated
…rebase the tests on the new baseline

Review catch, both parts fair.

Seeding `presentOffsetMs` directly from `target` was fine while the target
was the bare jitter quantile — near zero on a clean stream, so the step
was invisible.  With a refresh of headroom folded in, that same seed
became a one-step latency jump at the moment smoothing engages, and a
step in the offset is a hole of exactly that size in the output: three
refreshes of nothing, once, at the start of every stream.  It tripped the
one invariant the newest-wins control block exists to protect.  Seed at
the jitter quantile alone and let the existing grow slew walk the
headroom in over ~8 frames, which is what MARGIN_GROW_MS is for.

The other two failures were tests encoding the old "clean stream settles
near zero margin" assumption, which this series deliberately changes.
Rebased on the new baseline — one refresh, expressed as REFRESH rather
than a bare constant so they keep meaning what they meant.

js/core: 750 passed, 10 skipped, 32 files.

Co-Authored-By: Claude <noreply@anthropic.com>
@pcarrier
pcarrier merged commit 1da87fe into main Aug 7, 2026
11 checks passed
@pcarrier
pcarrier deleted the pc/surface-playout-margin branch August 7, 2026 23:42
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