Skip to content

surface: make a view's subscription token unique across connections - #294

Closed
pcarrier wants to merge 1 commit into
mainfrom
pc/surface-view-token-unique
Closed

pcarrier wants to merge 1 commit into
mainfrom
pc/surface-view-token-unique

Conversation

@pcarrier

@pcarrier pcarrier commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

A surface view mints its subscription token once and keeps it for the life of its mount — including across BlitSurfaceCanvas.setConnectionId, which re-points a canvas at another server without re-minting. But the token came from a per-connection counter with no connection prefix:

allocSurfaceViewId(): string { return `s${++this.surfaceViewIdCounter}`; }

so it was only unique within the connection that issued it. A canvas carrying s3 from connection A onto connection B collided with B's own s3, and SurfaceSub.views is keyed on that string alone: two views, one entry, last writer wins.

Needs two or more connections (local + a remote) to bite. With a single connection the target/fps derivation converges on every transition.

Why a canvas changes connection in place

  • The foreground pane lives in a non-keyed <Show when={focusedSurfaceId()}>, and focusSurfaceById writes non-null → non-null, so the branch isn't re-created — the canvas just gets setSurfaceId + setConnectionId.
  • BSP leaves take connectionId=/surfaceId= as props.
  • Dock cards use an index-keyed <Index>, so a card's canvas is handed a different surface whenever offScreenSurfaces() shifts — any focus change or surface close.

Two ways it hurts

Both read to a user as "this surface went laggy out of the blue, and reloading fixed it".

A live pane pinned to 15 fps at a thumbnail size. The pane's subscribe writes {target: null, maxFps: 0} at the shared key, overwriting the card's {512x256, 15fps} — nothing looks wrong yet. Then the card's box moves and its refreshScaledTarget() puts the thumbnail request back, now speaking for the pane. effectiveSurfaceTarget/effectiveSurfaceMaxFps see only that, so the pane decodes a 512x256, 15 fps stream and cannot take it back: serverSubscribe early-returns once _subscribedSurface is set, and refreshScaledTarget only runs off the pane's own box or a _displaySize null boundary — neither of which a surface resize touches.

The trigger is cheap: a dock card's height is derived from the surface's aspect (aspect-ratio: surface.width / surface.height; height: auto), so any server-side surface resize moves its box. That style is deliberate — it's what broke the encoder feedback loop that used to segfault libnvcuvid — so it isn't the bug, it's what makes the collision fire often.

Or the pane's stream is deleted. The card scrolls out of the dock (overflow-y: auto) and its sendSurfaceUnsubscribe does sub.views.delete(token) — deleting the pane's registration. views.size drops to 0, the deferred wire UNSUBSCRIBE fires, and the pane freezes on its last frame with no subscription, never re-registering.

The same token also keys surfaceViewSizes, so a collision corrupted size mediation the same way — reintroducing the defect the comment above offerSurfaceViewSize exists to prevent.

The fix

Prefix the token with the connection id. It never reaches the wire — it only keys surfaceSubs' views and surfaceViewSizes' views — so this costs nothing but the string, and session ids are already built this way.

Prefixing rather than re-minting in setConnectionId, because serverUnsubscribe() needs the old token against the old connection first; making the token globally unique avoids that ordering question entirely and fixes the surfaceViewSizes keying in the same stroke.

Tests

Two, both of which fail without the prefix:

  • mints view tokens no other connection can collide with — the invariant. Fails with expected 's1' not to be 's1'.
  • keeps a pane's request when a view that arrived from another connection shares the surface — the behaviour: a foreign-token card sharing the surface must not speak for the pane, through its initial subscribe, a later setSurfaceViewTarget (its ResizeObserver firing), and its unsubscribe. Fails with expected 15 to be +0 — the reported symptom exactly.

Full JS suites pass (core 1208, ui 425, solid 23, react 12); tsc --noEmit clean across all packages.

Not in this PR

Two independent contributors found in the same investigation, both left alone because they're separate changes with more risk:

  • The server-side adaptive quality controller degrades 8× faster than it recovers (+max(q/8,12) per 250 ms vs -6 per 1000 ms), down to q=200 — worse than any user-selectable preset — and two of its three congestion triggers are wrong: decoder_pressure_depth > 4 when the same file says 5–6 is a healthy decoder's standing depth, and a connection-wide write_blocked_us that counts terminal frames and fs/git/LSP replies (the same class of input surface: stop a busy terminal from stranding video quality at the floor #262 removed from this exact function).
  • blitFromStore → applyLayout → syncImeTarget forces a synchronous layout per decoded frame once a guest text field is focused, and handleWheel forces two more per wheel event. Also onChange re-blits every mounted view on a title change.

A surface view mints its token once and keeps it for the life of its
mount, including across `BlitSurfaceCanvas.setConnectionId` — which
re-points a canvas at another server without re-minting. But the token
came from a per-connection counter with no connection prefix, so it was
only unique within the connection that issued it. A canvas carrying `s3`
from connection A onto connection B collided with B's own `s3`, and
`SurfaceSub.views` is keyed on that string alone: two views, one entry,
last writer wins.

Canvases do change connection in place. The foreground pane lives in a
non-keyed `<Show when={focusedSurfaceId()}>`, BSP leaves take
`connectionId`/`surfaceId` as props, and dock cards use an index-keyed
`<Index>`, so a card's canvas is handed a different surface whenever
`offScreenSurfaces()` shifts — any focus change or surface close.

Two ways that hurt, both of which a user reads as "this surface went
laggy out of the blue and reloading fixed it":

  - The pane's subscribe writes `{target: null, maxFps: 0}` at the shared
    key, overwriting the card's `{512x256, 15fps}`. Nothing looks wrong
    until the card's box moves and its `refreshScaledTarget()` puts the
    thumbnail request back — now speaking for the pane. The live pane
    decodes a 512x256, 15fps stream and cannot take it back:
    `serverSubscribe` early-returns once `_subscribedSurface` is set, and
    `refreshScaledTarget` only runs off the pane's own box or a
    `_displaySize` null boundary, neither of which a *surface* resize
    touches. The trigger is cheap, because a card's height is derived
    from the surface's aspect, so any server-side resize moves its box.

  - The card scrolls out of the dock and its `sendSurfaceUnsubscribe`
    deletes the *pane's* registration, dropping `views` to empty and
    taking the pane's stream with it. The pane freezes on its last frame
    and never re-registers.

The same token also keys `surfaceViewSizes`, so a collision corrupted
size mediation the same way.

Prefix the token with the connection id. It never reaches the wire — it
only keys those two maps — so this costs nothing but the string, and
session ids are already built this way. Tests cover the mint invariant
and the pane-keeps-its-request behaviour; both fail without the prefix,
the second with exactly the reported symptom (`expected 15 to be +0`).

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

indent Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
PR Summary

Makes a surface view's subscription token unique across connections so a canvas re-pointed at another server keeps a token that can't collide with the target connection's own.

  • allocSurfaceViewId() now returns ${this.id}:s${n} instead of a bare s${n} counter, matching how session ids are already built.
  • Fixes a bug where a canvas carrying token s3 from connection A onto connection B aliased B's own s3 in SurfaceSub.views, so a dock card's {512x256, 15fps} request overrode a live pane's and the card's unsubscribe tore down the pane's stream (user-visible as a surface going laggy until reload).
  • Adds two tests: tokens from two connections at the same ordinal differ, and a foreign card sharing a surface no longer clobbers the local pane's request or stream.
  • Token stays a purely local map key and never reaches the wire, so the prefix has no protocol impact.

Issues

Review closed.

View session

@github-actions

Copy link
Copy Markdown

🔗 Preview: https://blit-p5jsuu3dz-indent.vercel.app

@github-actions

Copy link
Copy Markdown

Coverage

Crate Lines Functions Regions
alacritty-driver 75.7% (934/1234) 78.7% (74/94) 79.0% (1521/1926)
browser 0.0% (0/825) 0.0% (0/69) 0.0% (0/1404)
cli 39.1% (5731/14667) 45.4% (571/1258) 39.9% (8942/22384)
compositor 35.1% (6042/17209) 49.7% (501/1009) 34.9% (8306/23786)
desktop 77.6% (4164/5366) 70.4% (367/521) 74.0% (5772/7804)
fonts 85.3% (756/886) 89.9% (71/79) 86.4% (1485/1719)
fssync 92.6% (5517/5961) 94.4% (501/531) 92.8% (10185/10981)
gateway 34.0% (669/1966) 40.2% (68/169) 31.4% (1029/3279)
git 87.7% (4642/5295) 90.2% (378/419) 87.5% (7408/8463)
guest 82.2% (2217/2697) 80.0% (252/315) 81.1% (3718/4584)
lsp 77.2% (2688/3483) 79.4% (262/330) 74.9% (4210/5619)
proxy 19.2% (172/898) 20.5% (26/127) 21.0% (293/1392)
remote 91.6% (16924/18477) 94.4% (1190/1261) 89.1% (27520/30882)
sd-notify 73.9% (68/92) 100.0% (6/6) 83.2% (109/131)
server 56.2% (32123/57159) 63.3% (2761/4360) 57.5% (48685/84730)
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 8.5% (238/2805) 10.7% (22/205) 6.3% (289/4595)
webserver 64.4% (1250/1941) 67.6% (173/256) 66.6% (2099/3151)
Total 59.3% (84691/142720) 64.9% (7305/11263) 60.3% (132629/219947)

@pcarrier pcarrier closed this Aug 19, 2026
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