From d5dc7b9cf80f3e05ee7005d715cd500bbf7354d8 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:19:41 -0400 Subject: [PATCH] feat(mobile): budget the terminal snapshot on serialized bytes and hold live output instead of ending the stream (OTA phase C, C7.3) (#21785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): budget the mobile terminal snapshot on the bytes it serializes to (OTA phase C, C7.3, ruling 1) The desktop trims a mobile snapshot to 512 KiB of raw terminal text. A client reading it through the page bridge measures the serialized event against a 640 KiB frame cap, and an ANSI snapshot is mostly ESC bytes, each of which JSON spends six on. Measured here on a colour-dense 80-column screen: the raw budget hands back 465,766 bytes that serialize to 669,268 — 102.1% of the cap — so `deliver` answers `cancel(id, 'overflow')` and the terminal is dead before its first live byte, with no recovery that does not reproduce it. `terminal.subscribe` gains an optional `snapshotByteBudget`. A subscriber that sends one is trimmed against the JSON its payload will really cost: the escaped text, plus the metadata it cannot bound from its own side — a path, the OSC-link list, the pending escape tail. A subscriber that sends none, which is every socket client and every older page, keeps the raw byte rule exactly. No negotiation, and none is needed: the field is additive and optional, so an older desktop ignores it and trims as it always did. The page then still has a snapshot over its cap, the shell still ends the stream with `overflow` (C0.3 stands), and the terminal renders its stream-error state rather than a blank pane. The page derives the number from the cap less the event envelope rather than writing it down, so a cap that moves takes the budget with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): hold and coalesce terminal output instead of ending the stream on the window (OTA phase C, C7.3, ruling 2) The shell's backpressure window ends a stream when the page falls 4 MiB behind. That is right for a stream whose reader can survive a gap and wrong for a terminal, whose reader cannot see the hole a dropped chunk leaves — and the window does not wait for a page to go wrong. Measured by the design: the host produces 70.3 MiB/s of JSON and real xterm applies 2.2 MiB/s, so an ordinary `cat` crosses the window in 62 ms. Replayed here through the real ledger against a page draining at that rate, a 5 MB transcript ends the stream after 85 of 107 chunks plain and after 40 of 107 under `grep --color`. Keyed by method on the shell, since the page cannot pick its own window, `terminal.subscribe` now holds what it cannot send, merges consecutive output in escaped bytes under the frame cap, and delivers as the page acks. Nothing is dropped: merging concatenates, and the only exit that loses bytes is ending the stream, which the page is told about. Both transcripts now arrive whole and in order, in 104 and 81 frames, with the largest frame at 622,551 bytes against the 655,360-byte cap. It ends only on the two things that are not slowness: a page that has acked nothing for 20 s, an order of magnitude above the 1.9 s a full window takes to drain, and a backlog past 32 MiB, which at that drain is about 15 s of catching up. Both reach the page as `overflow`, because the shell is the installed app and its page comes from the desktop, so a reason the page's reader has never heard of is a frame it drops rather than an end it acts on. Which one fired, the coalesced-frame count and the peak pending bytes go to the diagnostic log, which is the device proof's only oracle for any of this. Every other stream keeps the byte window exactly, and an event over the frame cap still ends any stream, terminal or not (C0.3). The landed window cases now name a stream the window still governs, so the two rules are never read off each other. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): narrow the event arm the backlog replay reads A binary event carries no `payload`, so the tests-typecheck ratchet refused the reach into it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): narrow the snapshot serializer to the buffer source it reads The changed-code casting gate refused the test's stub runtime, and it was right to: a service-wide type for a function that calls one method is what made the stub need an assertion. The parameter now says what it needs, and the fixture path is no longer one a machine-path grep reads as a leaked local checkout. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): measure the snapshot budget by building the payload, not by summing fields (OTA phase C, C7.3, ruling 14) Round one summed the escaped text and four metadata fields. The payload a bridged client assembles carries nine more — `kind`, `cols`, `rows`, `requestId`, `displayMode`, `reason`, `seq` and both truncation flags — plus the `type` and `streamId` it adds, the `serialized` key and the object's own braces. So a snapshot this host accepted at exactly the budget, with `truncatedByByteBudget` false because nothing had trimmed it, published over the cap and the stream ended with `overflow` before a byte was painted. Measured here on a screen sized to land exactly on round one's budget: the published payload is 655,446 bytes against a 655,273-byte budget, 173 over, and the frame it makes is over the 640 KiB cap by the same amount. The metadata is now built by one function that `sendSnapshotFrames` and the budget both call, and the budget stringifies the payload that function produces. Nothing is summed and nothing is estimated, so a field added to the frame is paid for by the budget the moment it is sent. Where a value is not yet known — the truncation flags, and `seq` or `requestId` at a site that has not fixed them — it is measured at the widest `JSON.stringify` can write it, which is a bound rather than a guess, and forcing `seq` to a number also opens the three fields it gates so those are counted too. The budget therefore travels with the publication fields, because the payload cannot be built without them. On the page, the event envelope is now derived in one place in the protocol module and read by both the snapshot budget and the shell's own merge budget, so the two cannot drift; the page pins the number it sends and the host's cases name that pin, since the two programs cannot import from each other. The case that re-implemented the host's measure is gone: it could not have seen this, because it was the same arithmetic twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): arm a held terminal's silence clock only while something is pending (OTA phase C, C7.3) The invariant is "armed implies waiting on the page", and round one broke it in the one direction that kills: an ack re-armed the clock and the drain that followed emptied the queue without clearing it. A terminal that had delivered every byte and gone quiet — which is what a terminal does between commands — would die on `overflow` twenty seconds later. The clock is now synchronised after every change to the queue, so it is armed exactly while something is held. A rule that only ever arms is a rule that only ever ends more streams. Red-first: with round one's arming, an idle stream whose queue has drained still reports its clock armed, and firing it ends a healthy terminal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the held-stream cases the rulings name (OTA phase C, C7.3) Six cases nothing covered. Two subscriptions on one shell keep separate backlogs, so a busy terminal cannot end a quiet one. A stream the page unsubscribed mid- backlog posts nothing after, and neither does one that has already ended, however much was still held. A payload that is not output breaks a merge run and keeps its place, because a resize is state the reader applies in order. And the budget boundary is checked on the side that enforces it: a payload at exactly the number the page asks the desktop for is delivered inside the cap, and one the cap cannot hold ends the stream under C0.3. The replay no longer acks unconditionally in its catch-up loop. That was the page behaving better than a page can — it acks on reading frames — and it is what hid the silence clock left armed over an empty queue. The held-stream cases close the window on its frame count rather than on four megabytes of string work. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the event-envelope derivation its own module (OTA phase C, C7.3) `bridge-envelope.ts` is at its line cap and is the protocol's schemas; what a frame costs around its payload is a derivation over them, and two budgets read it — the snapshot the page asks the desktop for, and the output the shell merges. One module, so they cannot drift and neither file is pushed over its limit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test: narrow the budget fixtures instead of asserting them The changed-code casting gate refused six `as NonNullable<...>` in the new budget cases, and it was right to: a fixture that serialized nothing is a broken case rather than a null to assert away. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor: give the snapshot payload shape its own module (OTA phase C, C7.3) `terminal-snapshot-publication.ts` crossed the root config's 300-line cap, which mobile's own lint does not apply and CI does. The frame's shape and what it costs a client reading it as one payload is a description the budget and the sender both need, so it is the part that leaves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix: empty a snapshot the budget cannot fit instead of posting it over (OTA phase C, C7.3, ruling 15) Both trimming loops published the zero-row candidate whatever it measured, and zero scrollback is not a small screen: a wide colour-dense viewport still carries its 24 live rows. A capped subscriber could get one frame over its cap, end the stream on `overflow` and paint nothing — worse than a blank terminal, because a blank one repaints on the next byte of output and a stream that never opened does not reopen. Ruling 15: a budgeted subscriber gets that frame with its text emptied and `truncatedByByteBudget` true, never over and never refused. The raw rule keeps its fallback, so an older page and every socket client are served exactly what they were before. Below the metadata the frame must carry there is nothing left to give up, and that boundary is pinned rather than claimed away. The renderer loop is the same walk reached by a different caller and had no test at all; its runtime parameter is narrowed to the two methods it reads so a case can stub it without a cast. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report what a held terminal stream did instead of calling it an outlived view The backlog report had no branch in the reporter, so it fell through to the "a view outlived its host" warn and every field it exists to carry was discarded. The key made it worse: keyed by kind alone, one backlog per host was ever logged, and a shell holds one stream per open terminal. That report is the only oracle the coalescing rule has. Nothing crosses to the page saying how much was held or how many frames its bytes arrived inside, and both ways a held stream dies reach the page as `overflow`, because a reason its reader has never heard of is a frame it drops. In production the two rules were indistinguishable. They are now a line each, per stream. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test: give the renderer fixture the source its serializer returns `serializeRendererTerminalBuffer` answers `renderer`, and vitest does not typecheck, so the stub's `headless` passed every run and failed the node typecheck instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix: budget the frame the publication actually sends (OTA phase C, C7.3) The budget and the publication were written out twice, five lines apart, and had drifted at every site: a budget for `{kind:'scrollback'}` approved a frame sent as `kind:'resized'` with a `reason` beside it, and the live module budgeted `pending-output-overflow` while sending `renderer-mount-ready`. It held only because the padded `requestId` and `seq` are absent from those frames and more than covered the difference. Each site now builds one object and hands it to both. `displayMode` cannot travel that way and was a third under-measure nobody had named: the subscribe flow re-reads it from the runtime after the snapshot is serialized and before the frame is sent, so no caller can tell the budget which mode the publication will carry. It joins `seq`, `requestId` and the truncation flags as a field taken at its widest. The mode list resolves the constant to `never` if the runtime gains a mode it does not carry, so a new one is weighed here rather than found on a phone. Red-first needed a second attempt: the first fixture had trimming slack, so three extra bytes fit and the probe could not see the defect it was written for. The case now budgets a fixed screen at exactly its `auto` measure, where the margin is the whole of the test. One figure for the overshoot everywhere, with its basis: 169 bytes over the 655,360-byte cap on a frame carrying an 8-character request id, 247 with a 24-character one. Three places said 169 and one said 173. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): delete two backlog guards no input can reach Both survived mutation because neither is reachable, and neither became reachable when I tried to write a case for it. `next` narrowed the merge ceiling to one frame, but its only caller, `drainTerminalBacklog`, has already narrowed it: the parameter is what one payload may occupy, not what the window holds, so the second narrowing could never change the answer. The parameter now says so and the class no longer needs the frame size at all. The bound still lives in the caller and is still covered: removing it there reds a delivery case. The merge run also compared stream ids, but a backlog belongs to one subscription and every `data` payload on it carries that subscription's single stream id, so the comparison could not fail. The run still stops at anything that is not output, which is reachable and pinned. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): record the invariant the deleted stream-id guard rested on The merge run compares no stream ids because it cannot need to: a backlog belongs to one subscription and every `data` payload reaching it carries that subscription's single stream id. Written down where the run is, because the thing that would break it is a change made somewhere else — multiplexing two streams onto one record would merge their output into one payload under the first id. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../bridge-diagnostic-log.test.ts | 56 ++ .../mobile-web-shell/bridge-diagnostic-log.ts | 23 +- .../mobile-web-shell/bridge-host-contract.ts | 25 + .../bridge-host-subscriptions.ts | 132 ++++- .../bridge-host-test-harness.ts | 6 +- .../src/mobile-web-shell/bridge-host.test.ts | 26 +- mobile/src/mobile-web-shell/bridge-host.ts | 6 +- .../bridge-terminal-output-backlog.test.ts | 488 ++++++++++++++++++ .../bridge-terminal-output-backlog.ts | 325 ++++++++++++ .../bridge/bridge-event-envelope-bytes.ts | 47 ++ .../mobile-session-route-parity.test.ts | 7 +- .../session/terminal-snapshot-byte-budget.ts | 14 + .../terminal-snapshot-byte-budget.web.test.ts | 89 ++++ .../terminal-snapshot-byte-budget.web.ts | 25 + ...se-mobile-session-terminal-subscription.ts | 9 +- mobile/web-entry/web-overrides.json | 4 + .../terminal-legacy-subscribe-live.ts | 21 +- .../terminal-legacy-subscribe-snapshot.ts | 46 +- ...terminal-snapshot-json-byte-budget.test.ts | 450 ++++++++++++++++ .../terminal/terminal-snapshot-payload.ts | 85 +++ .../terminal/terminal-snapshot-publication.ts | 257 ++++++--- .../rpc-contract/terminal-stream-params.ts | 16 +- .../terminal-stream-json-byte-length.test.ts | 53 ++ .../terminal-stream-json-byte-length.ts | 64 +++ 24 files changed, 2187 insertions(+), 87 deletions(-) create mode 100644 mobile/src/mobile-web-shell/bridge-terminal-output-backlog.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge-terminal-output-backlog.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-event-envelope-bytes.ts create mode 100644 mobile/src/session/terminal-snapshot-byte-budget.ts create mode 100644 mobile/src/session/terminal-snapshot-byte-budget.web.test.ts create mode 100644 mobile/src/session/terminal-snapshot-byte-budget.web.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts create mode 100644 src/shared/terminal-stream-json-byte-length.test.ts create mode 100644 src/shared/terminal-stream-json-byte-length.ts diff --git a/mobile/src/mobile-web-shell/bridge-diagnostic-log.test.ts b/mobile/src/mobile-web-shell/bridge-diagnostic-log.test.ts index b95d2d9b87a..c7f96afedf4 100644 --- a/mobile/src/mobile-web-shell/bridge-diagnostic-log.test.ts +++ b/mobile/src/mobile-web-shell/bridge-diagnostic-log.test.ts @@ -67,4 +67,60 @@ describe('the bridge diagnostic log', () => { expect(lines()[1]).toContain('no view') expect(lines()[2]).toContain('the listener threw') }) + + /** + * The backlog report, which is the only oracle the coalescing rule has. + * + * Nothing crosses to the page saying how much was held, and both ways a held stream dies reach it + * as `overflow`, because a reason its reader has never heard of is a frame it drops. So the four + * numbers and `ended` are the whole evidence, and without a branch of its own the report fell + * through to the "a view outlived its host" warn with every field discarded. + */ + it('reports what a held terminal stream did, rather than calling it an outlived view', () => { + const report = createBridgeDiagnosticReporter() + report({ + kind: 'terminal-backlog', + id: 'stream-1', + coalescedFrames: 9, + deliveredFrames: 4, + peakPendingBytes: 131_072, + ended: 'ack-silence' + }) + expect(lines()[0]).not.toContain('outlived') + for (const part of ['stream-1', '9', '4', '131072', 'ack-silence']) { + expect(lines()[0]).toContain(part) + } + }) + + it('keeps one line per stream, so a second terminal is not buried by the first', () => { + // Keyed by kind alone, one backlog per host was reported and every other stream was silent — + // which is the case the report exists for, since a shell holds a stream per open terminal. + const report = createBridgeDiagnosticReporter() + report({ + kind: 'terminal-backlog', + id: 'stream-1', + coalescedFrames: 1, + deliveredFrames: 1, + peakPendingBytes: 10, + ended: null + }) + report({ + kind: 'terminal-backlog', + id: 'stream-2', + coalescedFrames: 2, + deliveredFrames: 2, + peakPendingBytes: 20, + ended: 'pending-ceiling' + }) + report({ + kind: 'terminal-backlog', + id: 'stream-1', + coalescedFrames: 3, + deliveredFrames: 3, + peakPendingBytes: 30, + ended: null + }) + expect(lines()).toHaveLength(2) + expect(lines()[1]).toContain('stream-2') + }) }) diff --git a/mobile/src/mobile-web-shell/bridge-diagnostic-log.ts b/mobile/src/mobile-web-shell/bridge-diagnostic-log.ts index 065a0d99a30..34ae56fa24b 100644 --- a/mobile/src/mobile-web-shell/bridge-diagnostic-log.ts +++ b/mobile/src/mobile-web-shell/bridge-diagnostic-log.ts @@ -5,11 +5,15 @@ import type { BridgeHostDiagnostic } from './bridge-host' * * The kind on its own for everything the host reports once per cause. Not for a refused `notify`: * a page that was told nothing and a page reaching past what it was told are different faults, and - * the first would otherwise bury the second for the life of the host. + * the first would otherwise bury the second for the life of the host. Not for a backlog either: a + * shell holds one stream per open terminal, and under the kind alone only the first ever reported. */ function diagnosticKey(diagnostic: BridgeHostDiagnostic): string { - return diagnostic.kind === 'notify-refused' || diagnostic.kind === 'navigate-back-refused' - ? `${diagnostic.kind}:${diagnostic.why}` + if (diagnostic.kind === 'notify-refused' || diagnostic.kind === 'navigate-back-refused') { + return `${diagnostic.kind}:${diagnostic.why}` + } + return diagnostic.kind === 'terminal-backlog' + ? `${diagnostic.kind}:${diagnostic.id}` : diagnostic.kind } @@ -87,6 +91,19 @@ export function createBridgeDiagnosticReporter(): (diagnostic: BridgeHostDiagnos }) return } + if (diagnostic.kind === 'terminal-backlog') { + // All five, because the rule is only readable in their ratio: frames coalesced against frames + // delivered is what the holding bought, the peak is what it cost, and `ended` is the only + // place the two ways a held stream dies are told apart — both reach the page as `overflow`. + console.warn('[web-shell-bridge] a held terminal stream was retired', { + id: diagnostic.id, + coalescedFrames: diagnostic.coalescedFrames, + deliveredFrames: diagnostic.deliveredFrames, + peakPendingBytes: diagnostic.peakPendingBytes, + ended: diagnostic.ended + }) + return + } console.warn('[web-shell-bridge] a view outlived its host and is still posting') } } diff --git a/mobile/src/mobile-web-shell/bridge-host-contract.ts b/mobile/src/mobile-web-shell/bridge-host-contract.ts index 5f5c90e1431..8fb78fd9328 100644 --- a/mobile/src/mobile-web-shell/bridge-host-contract.ts +++ b/mobile/src/mobile-web-shell/bridge-host-contract.ts @@ -1,3 +1,4 @@ +import type { TerminalBacklogEnd, TerminalBacklogTimers } from './bridge-terminal-output-backlog' import type { RpcClient } from '../transport/rpc-client' import type { BridgeRefusal } from './bridge/bridge-caps' import type { BridgeInitHost, BridgeInitRoute } from './bridge/bridge-envelope' @@ -48,6 +49,22 @@ export type BridgeHostDiagnostic = * rather than ending the stream, so this line and the count beside it are the only evidence * the frame existed. `bytes` is the whole event, which is what was measured against the cap. */ | { kind: 'binary-frame-dropped'; id: string; bytes: number; dropped: number } + /** + * What one terminal stream's held output did, once the stream is retired. + * + * The only oracle there is for the coalescing rule: nothing crosses to the page saying how much + * was held or how many frames its bytes arrived inside, and `ended` is the only place the two + * ways a held stream dies are told apart — both reach the page as `overflow`, because a reason + * the page's reader has never heard of is a frame it drops. + */ + | { + kind: 'terminal-backlog' + id: string + coalescedFrames: number + deliveredFrames: number + peakPendingBytes: number + ended: TerminalBacklogEnd | null + } export type BridgeHostOptions = { client: RpcClient @@ -153,4 +170,12 @@ export type BridgeHostOptions = { * second and a stream that lost one look the same. */ onBinaryFramesDropped?: (total: number) => void + /** + * The timer a held terminal stream arms for the page's silence, injected only by tests. + * + * A real shell uses `setTimeout`; a test that waited the silence bound out would be twenty + * seconds long per case, and one that shortened the constant would be checking a number nothing + * ships. + */ + terminalTimers?: TerminalBacklogTimers } diff --git a/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts b/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts index 0b72d2de66c..ab5430f15f6 100644 --- a/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts +++ b/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts @@ -5,6 +5,14 @@ import { bridgeScreencastFrameHeader, encodeBridgeScreencastFrame } from './bridge/bridge-screencast-encoder' +import { + BridgeTerminalOutputBacklog, + drainTerminalBacklog, + holdsTerminalOutput, + terminalStreamMaxPayloadBytes, + type TerminalBacklogEnd, + type TerminalBacklogTimers +} from './bridge-terminal-output-backlog' import type { BridgeBinaryEvent } from './bridge/bridge-screencast-binary' import type { BrowserScreencastFrame } from '../transport/browser-screencast-protocol' import type { RpcClient } from '../transport/rpc-client' @@ -33,6 +41,20 @@ type OpenSubscription = { /** Binary frames this stream could not carry. Per stream, which is what tells a stream losing * frames steadily from one that lost a single burst. */ droppedFrames: number + /** Terminal output held while the page catches up, or null for a stream on the byte window. */ + backlog: BridgeTerminalOutputBacklog | null +} + +/** What one terminal stream's held output did, reported when the stream is retired. */ +export type BridgeTerminalBacklogReport = { + id: string + /** Frames whose bytes went out inside another one, so the page never had to read them. */ + coalescedFrames: number + /** Frames this backlog handed over, which with the above is the ratio a device proof reads. */ + deliveredFrames: number + peakPendingBytes: number + /** Which rule ended the stream, or null when it ended for a reason that is not the backlog's. */ + ended: TerminalBacklogEnd | null } /** A screencast frame the shell could not deliver, as the host reports it. */ @@ -61,6 +83,10 @@ export class BridgeHostSubscriptions { post: (json: string) => void /** One call per dropped screencast frame. The host reports it and raises the total. */ onBinaryFrameDropped: (dropped: BridgeDroppedBinaryFrame) => void + /** One call per retired terminal stream that ever held anything. */ + onTerminalBacklog?: (report: BridgeTerminalBacklogReport) => void + /** Injected so a test drives the silence clock rather than waiting twenty seconds on it. */ + terminalTimers?: TerminalBacklogTimers } ) {} @@ -96,7 +122,15 @@ export class BridgeHostSubscriptions { seq: 0, unacked: [], unackedBytes: 0, - droppedFrames: 0 + droppedFrames: 0, + backlog: holdsTerminalOutput(method) + ? new BridgeTerminalOutputBacklog({ + // The page has stopped answering, which is not slowness and is the one thing a held + // stream cannot wait out. + onAckSilence: () => this.endHeldStream(id, 'ack-silence'), + timers: this.options.terminalTimers + }) + : null } this.open.set(id, record) let unsubscribe: () => void @@ -134,6 +168,8 @@ export class BridgeHostSubscriptions { acked += 1 } record.unacked.splice(0, acked) + record.backlog?.noteAck() + this.drainBacklog(id, record) } /** `null` tears the stream down without telling the page, for a page that already said goodbye. */ @@ -143,6 +179,7 @@ export class BridgeHostSubscriptions { return } this.open.delete(id) + this.reportBacklog(id, record, null) try { record.unsubscribe() } catch { @@ -166,6 +203,16 @@ export class BridgeHostSubscriptions { if (record === undefined) { return } + const backlog = record.backlog + // Held before it is serialized, because the reason to hold it is that there is nowhere to put + // it: a terminal stream behind its window or behind its own queue takes this path, and the + // window rule below never sees the payload at all. + if (backlog !== null && (backlog.held || !this.windowHasRoom(record))) { + if (!backlog.hold(payload)) { + this.endHeldStream(id, 'pending-ceiling') + } + return + } const seq = record.seq + 1 let json: string try { @@ -218,6 +265,81 @@ export class BridgeHostSubscriptions { return JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'event', id, seq, binary }) } + /** Whether this stream may send one more frame at all, ignoring how large it is. */ + private windowHasRoom(record: OpenSubscription): boolean { + return ( + record.unacked.length < BRIDGE_MAX_UNACKED_FRAMES && + record.unackedBytes < BRIDGE_MAX_UNACKED_BYTES + ) + } + + /** The ledger's half of the drain: what one frame costs, and whether it retired the stream. */ + private drainBacklog(id: string, record: OpenSubscription): void { + if (record.backlog === null) { + return + } + drainTerminalBacklog({ + backlog: record.backlog, + maxPayloadBytes: terminalStreamMaxPayloadBytes(id), + windowHasRoom: () => this.windowHasRoom(record), + availableWindowBytes: () => BRIDGE_MAX_UNACKED_BYTES - record.unackedBytes, + windowEmpty: () => record.unacked.length === 0, + send: (payload) => { + const seq = record.seq + 1 + let json: string + try { + json = JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'event', id, seq, payload }) + } catch { + this.cancel(id, 'closed') + return 'retired' + } + this.sendEvent(id, record, seq, json, false) + // `sendEvent` can retire the stream under C0.3, and the record is then not the ledger's. + return this.open.get(id) === record ? 'sent' : 'retired' + } + }) + } + + /** + * The two ends a held stream has, which the page hears as the one reason the protocol carries. + * + * `overflow` rather than a new reason: the page is served by the desktop and the shell is the + * installed app, so a shell newer than its page is the ordinary state, and a reason the page's + * reader has never heard of is a frame it drops — which would leave the stream hanging instead of + * ending. Which of the two fired is in the log beside the counters. + */ + private endHeldStream(id: string, why: TerminalBacklogEnd): void { + const record = this.open.get(id) + if (record === undefined) { + return + } + this.reportBacklog(id, record, why) + this.cancel(id, 'overflow') + } + + /** One line per retired terminal stream that ever held anything, with the oracle in it. */ + private reportBacklog( + id: string, + record: OpenSubscription, + ended: TerminalBacklogEnd | null + ): void { + const backlog = record.backlog + if (backlog === null) { + return + } + record.backlog = null + if (backlog.peakPendingBytes > 0) { + this.options.onTerminalBacklog?.({ + id, + coalescedFrames: backlog.coalescedFrames, + deliveredFrames: backlog.deliveredFrames, + peakPendingBytes: backlog.peakPendingBytes, + ended + }) + } + backlog.dispose() + } + /** One rule for what a stream can carry right now, read before a screencast frame is encoded and * again on the frame that was. */ private canCarry(record: OpenSubscription, bytes: number): boolean { @@ -254,7 +376,13 @@ export class BridgeHostSubscriptions { // An event is never chunked, so one over the frame cap would be refused by the page's reader // and leave a hole nothing reports. Over the window, or too big to carry: same verdict, because // both mean this frame cannot be delivered whole. - if (!this.canCarry(record, bytes)) { + // + // A held stream is the exception, and only to the window half. Its pacing is the backlog — the + // caller does not reach here unless the page has made room — so a frame that crosses the + // window by its own size goes out rather than killing a terminal for being one chunk wide. + // The cap half stands for every stream, which is C0.3. + const heldStream = record.backlog !== null + if (heldStream ? bytes > BRIDGE_MAX_MESSAGE_BYTES : !this.canCarry(record, bytes)) { if (binary) { this.dropBinaryFrame(id, record, bytes) return diff --git a/mobile/src/mobile-web-shell/bridge-host-test-harness.ts b/mobile/src/mobile-web-shell/bridge-host-test-harness.ts index fc83293c65d..bbf5ba596e7 100644 --- a/mobile/src/mobile-web-shell/bridge-host-test-harness.ts +++ b/mobile/src/mobile-web-shell/bridge-host-test-harness.ts @@ -19,6 +19,7 @@ import { type BridgeHostMessage, type BridgeInitRoute } from './bridge/bridge-envelope' +import type { TerminalBacklogTimers } from './bridge-terminal-output-backlog' import type { BridgeErrorCapture } from './bridge/bridge-error-capture' export const ID = bridgeId(1) @@ -75,6 +76,8 @@ export function harness( clipboardText?: string /** Replaces the whole verb handler, for the arm where a device call fails. */ serveNativeVerb?: (verb: BridgeNativeVerb, params: unknown) => Promise + /** Drives the held-stream silence clock, so a case fires it instead of waiting on it. */ + terminalTimers?: TerminalBacklogTimers } = {} ): Harness { const client = options.client ?? createFakeRpcClient() @@ -132,7 +135,8 @@ export function harness( options.onPageFault?.(error) }, onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), - onBinaryFramesDropped: (total) => droppedBinaryFrames.push(total) + onBinaryFramesDropped: (total) => droppedBinaryFrames.push(total), + terminalTimers: options.terminalTimers }) if (options.ready === true) { host.receive(clientFrame({ type: 'ready' })) diff --git a/mobile/src/mobile-web-shell/bridge-host.test.ts b/mobile/src/mobile-web-shell/bridge-host.test.ts index 2a8a7a230af..988a9eda375 100644 --- a/mobile/src/mobile-web-shell/bridge-host.test.ts +++ b/mobile/src/mobile-web-shell/bridge-host.test.ts @@ -309,6 +309,16 @@ describe('subscriptions', () => { }) describe('backpressure', () => { + /** + * A stream on the byte window, which after C7.3 means any stream but a terminal's. + * + * Named rather than left to `subscribeFrame`'s default: that default is `terminal.subscribe`, and + * a terminal's output is held and coalesced rather than ending the stream. These cases are about + * the window itself, so they subscribe to something the window still governs; the terminal's + * exception has its own file, and neither should be read off the other. + */ + const WINDOWED = 'session.tabs.subscribe' + function fill(bridge: Harness, frames: number): void { for (let index = 0; index < frames; index += 1) { bridge.client.streams[0]?.emit({ n: index }) @@ -317,7 +327,7 @@ describe('backpressure', () => { it('sends exactly the unacked frame window and then ends with overflow', () => { const bridge = harness({ ready: true }) - bridge.host.receive(subscribeFrame(ID)) + bridge.host.receive(subscribeFrame(ID, WINDOWED)) fill(bridge, BRIDGE_MAX_UNACKED_FRAMES) expect(bridge.frames().filter((frame) => frame.type === 'event')).toHaveLength( BRIDGE_MAX_UNACKED_FRAMES @@ -329,7 +339,7 @@ describe('backpressure', () => { it('reopens the window on ack', () => { const bridge = harness({ ready: true }) - bridge.host.receive(subscribeFrame(ID)) + bridge.host.receive(subscribeFrame(ID, WINDOWED)) fill(bridge, BRIDGE_MAX_UNACKED_FRAMES) bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: BRIDGE_MAX_UNACKED_FRAMES })) fill(bridge, 1) @@ -340,7 +350,7 @@ describe('backpressure', () => { it('acks only up to the seq it was given', () => { const bridge = harness({ ready: true }) - bridge.host.receive(subscribeFrame(ID)) + bridge.host.receive(subscribeFrame(ID, WINDOWED)) fill(bridge, BRIDGE_MAX_UNACKED_FRAMES) bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: 1 })) fill(bridge, 1) @@ -353,7 +363,7 @@ describe('backpressure', () => { it('ends on the unacked byte window well before the frame window is reached', () => { const bridge = harness({ ready: true }) - bridge.host.receive(subscribeFrame(ID)) + bridge.host.receive(subscribeFrame(ID, WINDOWED)) const chunk = 'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES - 1024) const ended = (): boolean => (bridge.posted.at(-1) ?? '').includes('"type":"end"') for (let index = 0; index < BRIDGE_MAX_UNACKED_FRAMES && !ended(); index += 1) { @@ -372,7 +382,7 @@ describe('backpressure', () => { it('reopens the byte window on ack, not just the frame window', () => { const bridge = harness({ ready: true }) - bridge.host.receive(subscribeFrame(ID)) + bridge.host.receive(subscribeFrame(ID, WINDOWED)) const chunk = 'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES - 1024) // What fits under the byte window, which leaves the next frame of this size to overflow it. const fits = Math.floor(BRIDGE_MAX_UNACKED_BYTES / (chunk.length + 128)) @@ -395,15 +405,15 @@ describe('backpressure', () => { it('ends rather than posting an event the page would refuse as oversized', () => { const bridge = harness({ ready: true }) - bridge.host.receive(subscribeFrame(ID)) + bridge.host.receive(subscribeFrame(ID, WINDOWED)) bridge.client.streams[0]?.emit('z'.repeat(BRIDGE_MAX_MESSAGE_BYTES)) expect(bridge.last()).toEqual({ v: 1, type: 'end', id: ID, reason: 'overflow' }) }) it('keeps each stream on its own window', () => { const bridge = harness({ ready: true }) - bridge.host.receive(subscribeFrame(ID)) - bridge.host.receive(subscribeFrame(OTHER)) + bridge.host.receive(subscribeFrame(ID, WINDOWED)) + bridge.host.receive(subscribeFrame(OTHER, WINDOWED)) for (let index = 0; index <= BRIDGE_MAX_UNACKED_FRAMES; index += 1) { bridge.client.streams[0]?.emit({ n: index }) } diff --git a/mobile/src/mobile-web-shell/bridge-host.ts b/mobile/src/mobile-web-shell/bridge-host.ts index 56da47972cf..b06255979db 100644 --- a/mobile/src/mobile-web-shell/bridge-host.ts +++ b/mobile/src/mobile-web-shell/bridge-host.ts @@ -111,7 +111,11 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost { onBinaryFrameDropped: ({ id, bytes, droppedOnStream }) => { options.onDiagnostic?.({ kind: 'binary-frame-dropped', id, bytes, dropped: droppedOnStream }) options.onBinaryFramesDropped?.(subscriptions.droppedBinaryFrames) - } + }, + onTerminalBacklog: (report) => { + options.onDiagnostic?.({ kind: 'terminal-backlog', ...report }) + }, + terminalTimers: options.terminalTimers }) /** `state` is the event's own value: a listener can run before the getter it mirrors is updated. */ diff --git a/mobile/src/mobile-web-shell/bridge-terminal-output-backlog.test.ts b/mobile/src/mobile-web-shell/bridge-terminal-output-backlog.test.ts new file mode 100644 index 00000000000..72e03c21949 --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge-terminal-output-backlog.test.ts @@ -0,0 +1,488 @@ +import { describe, expect, it } from 'vitest' +import { BRIDGE_MAX_MESSAGE_BYTES } from './bridge/bridge-caps' +import { + BRIDGE_ACK_INTERVAL_BYTES, + BRIDGE_ACK_INTERVAL_FRAMES +} from './bridge/bridge-client-subscriptions' +import { clientFrame, createFakeRpcClient } from './bridge-host-test-fakes' +import { harness, ID, OTHER } from './bridge-host-test-harness' +import { TERMINAL_STREAM_MAX_PENDING_BYTES } from './bridge-terminal-output-backlog' +import { mobileTerminalSnapshotByteBudget } from '../session/terminal-snapshot-byte-budget.web' +import type { TerminalBacklogTimers } from './bridge-terminal-output-backlog' + +/** + * A terminal stream against a page that reads slower than the host writes, which is every terminal. + * + * The byte window ends a stream when the page falls 4 MiB behind. For a terminal that is not a page + * that went wrong: C7's design measured the host producing 70.3 MiB/s of JSON while real xterm in a + * browser applies 2.2 MiB/s, so an ordinary 5 MB `cat` crosses the window in 62 ms and the pane + * dies before it has painted anything. + * + * The timelines below are rebuilt from that design's measured parameters rather than replayed from + * its capture files, which this lane does not carry: the host's batcher flushes at 64 KiB or 5 ms + * and `iterateTerminalOutputFrameChunks` splits at 48 KiB, so the frame sizes are the ones those + * two rules produce, and the JSON expansion is measured here on the bytes rather than assumed — + * plain output escapes little, and `grep --color` is SGR sequences whose ESC bytes cost six each. + */ + +/** The chunker's split, which is what bounds one output payload before any of this runs. */ +const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024 + +/** What the page applies per second, measured by the design against real xterm with WebGL. */ +const PAGE_DRAIN_BYTES_PER_SECOND = 2.2 * 1024 * 1024 + +/** A 5 MB transcript, which is the `cat` the design ran. */ +const TRANSCRIPT_BYTES = 5 * 1024 * 1024 + +function plainChunk(index: number): string { + // Ordinary program output: printable ASCII, so JSON costs it almost nothing. + return `${String(index).padStart(6, '0')} `.repeat(Math.floor(TERMINAL_STREAM_CHUNK_BYTES / 7)) +} + +function colouredChunk(index: number): string { + // `grep --color`: an SGR pair around every match, and every ESC costs six bytes as JSON. + const cell = `\u001b[01;31m\u001b[K${index % 10}\u001b[m\u001b[K` + return cell.repeat(Math.floor(TERMINAL_STREAM_CHUNK_BYTES / cell.length)) +} + +function transcript(chunkOf: (index: number) => string): string[] { + const chunks: string[] = [] + let bytes = 0 + for (let index = 0; bytes < TRANSCRIPT_BYTES; index += 1) { + const chunk = chunkOf(index) + chunks.push(chunk) + bytes += chunk.length + } + return chunks +} + +/** A clock a case drives, so the twenty-second silence bound costs a test nothing to reach. */ +function manualTimers(): TerminalBacklogTimers & { fire: () => void; armed: () => boolean } { + let handler: (() => void) | null = null + return { + set: (next) => { + handler = next + return 1 + }, + clear: () => { + handler = null + }, + armed: () => handler !== null, + fire: () => { + const pending = handler + handler = null + pending?.() + } + } +} + +type Replay = { + /** Every chunk the page's listener was handed, in the order it was handed them. */ + delivered: string[] + /** The largest event frame the shell posted, which must never be over the cap. */ + largestFrameBytes: number + /** Frames the page had to read, against the chunks the host produced. */ + frames: number + ended: boolean +} + +/** + * One transcript through the real ledger, against a page that acks on the real intervals. + * + * Time is simulated rather than waited on: the page is credited with drain time between emits at + * the rate measured above, and acks whenever it has read a full interval's worth. That is the same + * order a device runs in — the shell emits, the page reads, the page acks — and it is what decides + * whether the window was ever the thing that ended the stream. + */ +function replay(chunks: readonly string[], options: { method?: string } = {}): Replay { + const client = createFakeRpcClient() + const timers = manualTimers() + const bridge = harness({ client, ready: true, terminalTimers: timers }) + bridge.host.receive( + clientFrame({ + type: 'subscribe', + id: ID, + method: options.method ?? 'terminal.subscribe', + params: { terminal: 't' } + }) + ) + const stream = client.streams[0] + const delivered: string[] = [] + let readFrames = 0 + let lastReadSeq = 0 + let unreadFrames = 0 + let unreadBytes = 0 + let drainCreditBytes = 0 + + /** One frame off the page's queue, or false when it has caught up. This is the drain. */ + const readOne = (): boolean => { + const events = bridge.frames().filter((frame) => frame.type === 'event' && frame.id === ID) + const frame = events[readFrames] + if (frame === undefined || frame.type !== 'event') { + return false + } + readFrames += 1 + lastReadSeq = frame.seq + // A binary event carries no `payload` at all, so the arm is narrowed rather than reached into. + const payload = 'payload' in frame ? frame.payload : null + let applied = 0 + if (payload !== null && typeof payload === 'object' && 'chunk' in payload) { + const chunk = payload.chunk + if (typeof chunk === 'string') { + delivered.push(chunk) + applied = chunk.length + } + } + drainCreditBytes -= applied + unreadFrames += 1 + unreadBytes += JSON.stringify(frame).length + if (unreadFrames >= BRIDGE_ACK_INTERVAL_FRAMES || unreadBytes >= BRIDGE_ACK_INTERVAL_BYTES) { + unreadFrames = 0 + unreadBytes = 0 + bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: lastReadSeq })) + } + return true + } + + for (const chunk of chunks) { + stream.emit({ type: 'data', streamId: 1, chunk }) + // The host's batcher flushes at 64 KiB or 5 ms, so one chunk is about 5 ms of wall clock and + // the page has that long to apply what it can. + drainCreditBytes += (PAGE_DRAIN_BYTES_PER_SECOND * 5) / 1000 + while (drainCreditBytes > 0 && readOne()) { + // The page reads until its time is spent, which is what makes it 31x slower than the host. + } + } + // Then it catches up with no producer in front of it. No ack beyond the ones `readOne` already + // sends: acking every frame here was the page behaving better than a page can, and it hid a + // backlog left armed after its queue emptied. + while (readOne()) { + // The page reads; the interval acks inside `readOne` are the only ones it sends. + } + const events = bridge.frames().filter((frame) => frame.type === 'event' && frame.id === ID) + return { + delivered, + largestFrameBytes: Math.max(0, ...bridge.frames().map((frame) => JSON.stringify(frame).length)), + frames: events.length, + ended: bridge.frames().some((frame) => frame.type === 'end' && frame.id === ID) + } +} + +describe.each([ + ['a plain 5 MB cat', transcript(plainChunk)], + ['grep --color over the same file', transcript(colouredChunk)] +])('%s, against a page draining at 2.2 MiB/s', (_label, chunks) => { + it('is what the byte window kills, which is the defect', () => { + // The control, on a stream the hold rule is not keyed to: same timeline, same page, same + // window. Without this an assertion that the terminal survives says nothing about why. + const other = replay(chunks, { method: 'session.tabs.subscribe' }) + expect(other.ended).toBe(true) + expect(other.delivered.length).toBeLessThan(chunks.length) + }) + + it('lives, with every byte delivered in order', () => { + const run = replay(chunks) + expect(run.ended).toBe(false) + expect(run.delivered.join('')).toBe(chunks.join('')) + }) + + it('delivers it in fewer frames than it was produced in', () => { + const run = replay(chunks) + expect(run.frames).toBeLessThan(chunks.length) + }) + + it('never posts a frame over the cap, however much it merged', () => { + const run = replay(chunks) + expect(run.largestFrameBytes).toBeLessThanOrEqual(BRIDGE_MAX_MESSAGE_BYTES) + }) +}) + +describe('the two ends a held terminal stream has', () => { + function held(): ReturnType & { timers: ReturnType } { + const timers = manualTimers() + const bridge = harness({ ready: true, terminalTimers: timers }) + bridge.host.receive( + clientFrame({ type: 'subscribe', id: ID, method: 'terminal.subscribe', params: {} }) + ) + return Object.assign(bridge, { timers }) + } + + it('ends once when the page has acked nothing for the silence bound', () => { + const bridge = held() + const client = bridge.client + const stream = client.streams[0] + // Enough to close the window, so the stream starts holding and arms the clock. + for (let index = 0; index < 200; index += 1) { + stream.emit({ type: 'data', streamId: 1, chunk: 'x'.repeat(64 * 1024) }) + } + expect(bridge.timers.armed()).toBe(true) + expect(bridge.frames().some((frame) => frame.type === 'end')).toBe(false) + bridge.timers.fire() + const ends = bridge.frames().filter((frame) => frame.type === 'end') + expect(ends).toHaveLength(1) + expect(ends[0]).toMatchObject({ reason: 'overflow' }) + const reports = bridge.diagnostics.filter((entry) => entry.kind === 'terminal-backlog') + expect(reports).toHaveLength(1) + expect(reports[0]).toMatchObject({ ended: 'ack-silence' }) + }) + + it('ends once when the backlog passes the ceiling, and says how far it got', () => { + const bridge = held() + const stream = bridge.client.streams[0] + const chunk = 'y'.repeat(512 * 1024) + const chunks = Math.ceil(TERMINAL_STREAM_MAX_PENDING_BYTES / chunk.length) + 16 + for (let index = 0; index < chunks; index += 1) { + stream.emit({ type: 'data', streamId: 1, chunk }) + } + const ends = bridge.frames().filter((frame) => frame.type === 'end') + expect(ends).toHaveLength(1) + expect(ends[0]).toMatchObject({ reason: 'overflow' }) + const reports = bridge.diagnostics.filter((entry) => entry.kind === 'terminal-backlog') + expect(reports).toHaveLength(1) + expect(reports[0]).toMatchObject({ ended: 'pending-ceiling' }) + expect(reports[0]).toHaveProperty('peakPendingBytes') + }) + + it('still ends on one event over the frame cap, which is C0.3 and is not slowness', () => { + // The rule that does not move: an event the page's own reader would refuse leaves a hole its + // reader cannot see, and holding it would only postpone the same verdict. + const bridge = held() + bridge.client.streams[0].emit({ + type: 'data', + streamId: 1, + chunk: 'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES + 1) + }) + const ends = bridge.frames().filter((frame) => frame.type === 'end') + expect(ends).toHaveLength(1) + expect(ends[0]).toMatchObject({ reason: 'overflow' }) + }) +}) + +describe('a held terminal stream that has caught up', () => { + /** + * The invariant the first round broke: armed must mean waiting on the page. + * + * An ack re-armed the clock and the drain that followed emptied the queue without clearing it, so + * a terminal that had delivered every byte and gone quiet — which is what a terminal does between + * commands — died on `overflow` twenty seconds later. The suite could not see it because its + * replay acked every frame in the catch-up loop, which no page does. + */ + it('does not die on the silence bound once its queue is empty', () => { + const timers = manualTimers() + const bridge = harness({ ready: true, terminalTimers: timers }) + bridge.host.receive( + clientFrame({ type: 'subscribe', id: ID, method: 'terminal.subscribe', params: {} }) + ) + const stream = bridge.client.streams[0] + const chunk = 'x'.repeat(1024) + const emitted = 300 + // Past the frame window, so output is held and the clock is armed. + for (let index = 0; index < emitted; index += 1) { + stream.emit({ type: 'data', streamId: 1, chunk }) + } + expect(timers.armed()).toBe(true) + + /** Every byte the page has been handed, which is how it knows it has caught up. */ + const deliveredBytes = (): number => + bridge + .frames() + .filter((frame) => frame.type === 'event' && frame.id === ID) + .reduce((total, frame) => { + const payload = 'payload' in frame ? frame.payload : null + return payload !== null && + typeof payload === 'object' && + 'chunk' in payload && + typeof payload.chunk === 'string' + ? total + payload.chunk.length + : total + }, 0) + + // Acks stop at the one that completes delivery. A page sends no ack after that: it acks on + // reading frames, and there are no more frames to read. Acking once more is what hid this — + // that extra ack finds an empty queue and clears the clock by accident. + const total = emitted * chunk.length + for (let pass = 0; pass < 2_000 && deliveredBytes() < total; pass += 1) { + const events = bridge.frames().filter((frame) => frame.type === 'event' && frame.id === ID) + const last = events.at(-1) + if (last === undefined || last.type !== 'event') { + break + } + bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: last.seq })) + } + expect(deliveredBytes()).toBe(total) + + // An idle terminal: nothing pending, nothing owed, and no clock that could end it. + expect(timers.armed()).toBe(false) + timers.fire() + expect(bridge.frames().some((frame) => frame.type === 'end')).toBe(false) + }) +}) + +describe('the cases the rulings name', () => { + function subscribed( + ids: readonly string[] + ): ReturnType & { timers: ReturnType } { + const timers = manualTimers() + const bridge = harness({ ready: true, terminalTimers: timers }) + for (const id of ids) { + bridge.host.receive( + clientFrame({ type: 'subscribe', id, method: 'terminal.subscribe', params: {} }) + ) + } + return Object.assign(bridge, { timers }) + } + + /** + * Enough frames to close the window on its frame count rather than its byte count. + * + * The two limits are the same state to this module and one of them is 4 MiB of string work per + * case. `BRIDGE_MAX_UNACKED_FRAMES` is 256, so this closes it with a few hundred kilobytes. + */ + const SMALL_CHUNK = 'x'.repeat(1024) + const OVER_FRAME_WINDOW = 300 + + /** The page reading and acking until the shell has nothing left to hand it. */ + function drain(bridge: ReturnType, id: string): void { + for (let pass = 0; pass < 2_000; pass += 1) { + const events = bridge.frames().filter((frame) => frame.type === 'event' && frame.id === id) + const last = events.at(-1) + if (last === undefined || last.type !== 'event') { + return + } + const before = bridge.frames().length + bridge.host.receive(clientFrame({ type: 'ack', id, seq: last.seq })) + if (bridge.frames().length === before) { + return + } + } + } + + it('keeps one backlog per subscription, so a busy terminal cannot end a quiet one', () => { + const bridge = subscribed([ID, OTHER]) + for (let index = 0; index < OVER_FRAME_WINDOW; index += 1) { + bridge.client.streams[0].emit({ type: 'data', streamId: 1, chunk: SMALL_CHUNK }) + } + bridge.client.streams[1].emit({ type: 'data', streamId: 2, chunk: 'quiet' }) + // The second stream is nowhere near its own window, so its one frame went out at once. + const other = bridge.frames().filter((frame) => frame.type === 'event' && frame.id === OTHER) + expect(other).toHaveLength(1) + expect(bridge.frames().some((frame) => frame.type === 'end' && frame.id === OTHER)).toBe(false) + }) + + it('posts nothing for a stream the page unsubscribed while its backlog was full', () => { + const bridge = subscribed([ID]) + for (let index = 0; index < OVER_FRAME_WINDOW; index += 1) { + bridge.client.streams[0].emit({ type: 'data', streamId: 1, chunk: SMALL_CHUNK }) + } + bridge.host.receive(clientFrame({ type: 'cancel', id: ID, target: 'subscription' })) + const after = bridge.frames().length + // Whatever the desktop keeps sending, and whatever the page acks, is now nobody's. + bridge.client.streams[0].emit({ type: 'data', streamId: 1, chunk: SMALL_CHUNK }) + bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: 1 })) + expect(bridge.frames()).toHaveLength(after) + expect(bridge.timers.armed()).toBe(false) + }) + + it('posts nothing after an end, however much was still held', () => { + const bridge = subscribed([ID]) + for (let index = 0; index < OVER_FRAME_WINDOW; index += 1) { + bridge.client.streams[0].emit({ type: 'data', streamId: 1, chunk: SMALL_CHUNK }) + } + bridge.timers.fire() + const ends = bridge.frames().filter((frame) => frame.type === 'end') + expect(ends).toHaveLength(1) + const after = bridge.frames().length + bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: 1 })) + bridge.client.streams[0].emit({ type: 'data', streamId: 1, chunk: 'more' }) + expect(bridge.frames()).toHaveLength(after) + }) + + it('breaks a merge run on a payload that is not output, and keeps the order', () => { + // A resize or a metadata frame is state the reader applies in place; concatenating across one + // would deliver bytes it should have applied after. The run stops at it and resumes behind it. + const bridge = subscribed([ID]) + for (let index = 0; index < OVER_FRAME_WINDOW; index += 1) { + bridge.client.streams[0].emit({ type: 'data', streamId: 1, chunk: SMALL_CHUNK }) + } + bridge.client.streams[0].emit({ type: 'resized', streamId: 1, cols: 80, rows: 24 }) + bridge.client.streams[0].emit({ type: 'data', streamId: 1, chunk: 'after-the-resize' }) + drain(bridge, ID) + const kinds = bridge + .frames() + .filter((frame) => frame.type === 'event' && frame.id === ID) + .map((frame) => + frame.type === 'event' && + 'payload' in frame && + frame.payload !== null && + typeof frame.payload === 'object' && + 'type' in frame.payload + ? frame.payload.type + : null + ) + const resizeAt = kinds.indexOf('resized') + expect(resizeAt).toBeGreaterThan(0) + // The resize is its own frame, and the chunk behind it is behind it. + expect(kinds.slice(resizeAt + 1)).toContain('data') + expect(bridge.frames().some((frame) => frame.type === 'end')).toBe(false) + }) +}) + +/** + * The boundary the desktop's snapshot budget is sized against, checked on the side that enforces it. + * + * The page asks the host for a snapshot no larger than this, and the host trims against it by + * building the payload it will publish. Here is the other half of that contract: a payload that + * serializes to exactly the budget crosses, and one byte more does not. Without this the budget is + * a number two files agree on and nothing tests. + */ +describe('a snapshot payload at the budget the page asks for', () => { + function scrollbackPayload(payloadBytes: number): unknown { + const skeleton = JSON.stringify({ type: 'scrollback', streamId: 1, serialized: '' }).length + return { type: 'scrollback', streamId: 1, serialized: 'x'.repeat(payloadBytes - skeleton) } + } + + function post(payloadBytes: number): ReturnType { + const bridge = harness({ ready: true, terminalTimers: manualTimers() }) + bridge.host.receive( + clientFrame({ type: 'subscribe', id: ID, method: 'terminal.subscribe', params: {} }) + ) + const payload = scrollbackPayload(payloadBytes) + expect(JSON.stringify(payload)).toHaveLength(payloadBytes) + bridge.client.streams[0].emit(payload) + return bridge + } + + it('is delivered, and the frame it makes is inside the cap', () => { + const bridge = post(mobileTerminalSnapshotByteBudget() ?? 0) + const events = bridge.frames().filter((frame) => frame.type === 'event' && frame.id === ID) + expect(events).toHaveLength(1) + expect(bridge.frames().some((frame) => frame.type === 'end')).toBe(false) + expect(bridge.posted[bridge.posted.length - 1].length).toBeLessThanOrEqual( + BRIDGE_MAX_MESSAGE_BYTES + ) + }) + + /** + * The budget is a bound, and a bound with slack in it is doing its job. + * + * It is computed at the widest every envelope field can be written — a full-length id and `seq` + * at the largest integer it can hold — so a real first frame, whose `seq` is 1, has room to + * spare. Asserted as a direction rather than as a number: what must never happen is the budget + * leaving too little, and the amount it leaves over is the seq counter's width. + */ + it('leaves the frame inside the cap with room, rather than exactly at it', () => { + const bridge = post(mobileTerminalSnapshotByteBudget() ?? 0) + const frame = bridge.posted[bridge.posted.length - 1] + expect(frame.length).toBeLessThanOrEqual(BRIDGE_MAX_MESSAGE_BYTES) + expect(BRIDGE_MAX_MESSAGE_BYTES - frame.length).toBeLessThan(64) + }) + + it('ends the stream on a payload the cap cannot hold, which is why the host trims', () => { + // C0.3 stands: an event the page's own reader would refuse leaves a hole its reader cannot + // see. The budget exists so this arm is never reached by a snapshot the host chose to send. + const bridge = post(BRIDGE_MAX_MESSAGE_BYTES) + expect(bridge.frames().filter((frame) => frame.type === 'end')).toEqual([ + { v: 1, type: 'end', id: ID, reason: 'overflow' } + ]) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge-terminal-output-backlog.ts b/mobile/src/mobile-web-shell/bridge-terminal-output-backlog.ts new file mode 100644 index 00000000000..473c990564c --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge-terminal-output-backlog.ts @@ -0,0 +1,325 @@ +import { terminalStreamJsonByteLength } from '../../../src/shared/terminal-stream-json-byte-length' +import { BRIDGE_MAX_MESSAGE_BYTES, utf8ByteLength } from './bridge/bridge-caps' +import { bridgeEventEnvelopeBytes } from './bridge/bridge-event-envelope-bytes' + +/** + * What the shell does with terminal output the page has not caught up with. + * + * Every other stream has a backpressure window and ends when the page falls behind it, which is + * right for a stream whose reader can survive a gap. A terminal's cannot: a missing chunk is + * invisible in a transcript, so the choice is between holding the bytes and killing the pane, and + * the window as it stands kills it under an ordinary `cat`. Measured on this lane's fixtures the + * host produces 70.3 MiB/s of JSON while real xterm applies 2.2 MiB/s, so a 4 MiB window closes in + * 62 ms — before the first frame is painted, not after a page has gone wrong. + * + * So output is held, merged, and delivered as the page acks, and the stream ends only on the two + * things that are not slowness: a page that has stopped answering at all, and a backlog past what + * this process can hold. + */ + +/** + * How long the page may ack nothing before the stream is called dead. + * + * Derived from the drain, not chosen: the window the page works against is 4 MiB and real xterm + * applies 2.2 MiB/s, so clearing a full one takes about 1.9 s. This is an order of magnitude above + * that, which is the difference between a page that is slow — a big paste, a backgrounded tab, a + * GC pause — and one that is not running. Below that margin the rule would end streams that were + * about to recover, which is the failure it exists to stop. + */ +export const TERMINAL_STREAM_ACK_SILENCE_MS = 20_000 + +/** + * The most held output the shell will carry for one terminal. + * + * Bounded by the process rather than by the protocol: this is a phone holding an xterm, a WebView + * and the app beside it, and an unbounded backlog is the native queue growth the window was added + * to stop. At the measured 2.2 MiB/s drain this is about 15 s of catching up, which lands inside + * the silence bound above — so a page that is merely slow is limited by its own reading, and a + * page that is gone is ended by the clock rather than by how fast its terminal happened to print. + */ +export const TERMINAL_STREAM_MAX_PENDING_BYTES = 32 * 1024 * 1024 + +/** Why a held stream ended. Both reach the page as `overflow`; this is what the log says. */ +export type TerminalBacklogEnd = 'ack-silence' | 'pending-ceiling' + +/** Only this method's streams are held; every other one keeps the byte window exactly. */ +export const TERMINAL_STREAM_METHOD = 'terminal.subscribe' + +export function holdsTerminalOutput(method: string): boolean { + return method === TERMINAL_STREAM_METHOD +} + +/** The timer this backlog arms, injected so a test drives the clock rather than waiting on it. */ +export type TerminalBacklogTimers = { + set: (handler: () => void, ms: number) => unknown + clear: (handle: unknown) => void +} + +const REAL_TIMERS: TerminalBacklogTimers = { + set: (handler, ms) => setTimeout(handler, ms), + clear: (handle) => { + if (typeof handle === 'number' || typeof handle === 'object') { + // `clearTimeout` accepts both shapes; Node's handle is an object and a browser's a number. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handle came from this module's own `set`, which returns exactly what `clearTimeout` takes. + clearTimeout(handle as Parameters[0]) + } + } +} + +type TerminalOutputPayload = { type: 'data'; streamId: number; chunk: string } + +/** + * An output payload, read rather than asserted. + * + * Only this shape merges. Everything else on a terminal stream — the scrollback snapshot, a resize, + * a metadata frame, the subscribe acknowledgement — carries state the reader applies in order and + * cannot be concatenated with anything, so it is held whole and keeps its place in the queue. + */ +function readTerminalOutput(payload: unknown): TerminalOutputPayload | null { + if (payload === null || typeof payload !== 'object') { + return null + } + const record: Record = { ...payload } + return record.type === 'data' && + typeof record.streamId === 'number' && + typeof record.chunk === 'string' + ? { type: 'data', streamId: record.streamId, chunk: record.chunk } + : null +} + +/** What an output payload costs as JSON, which is what a frame is measured in. */ +function outputPayloadBytes(streamId: number, chunk: string): number { + return ( + JSON.stringify({ type: 'data', streamId, chunk: '' }).length + + terminalStreamJsonByteLength(chunk) - + // The skeleton already counted the empty string's two quotes, which the measure counts again. + 2 + ) +} + +type Held = + | { kind: 'output'; streamId: number; chunk: string; bytes: number } + | { kind: 'other'; payload: unknown; bytes: number } + +/** + * One terminal stream's held output, in order, with the two ways it can stop being held. + * + * Drop-free by construction: nothing here removes an entry that was not handed to the caller, and + * merging concatenates rather than chooses. The only exit that loses bytes is ending the stream, + * which the page is told about and can resubscribe from. + * + * One subscription's, which is why the merge run below compares no stream ids: every `data` + * payload reaching a backlog carries that subscription's single stream id, and a change that + * multiplexed two streams onto one record would merge their output into one payload under the + * first one's id. + */ +export class BridgeTerminalOutputBacklog { + private readonly queue: Held[] = [] + private pending = 0 + private peak = 0 + private merged = 0 + private frames = 0 + private silence: unknown = null + + constructor( + private readonly options: { + /** Called once, when the page has answered nothing for the silence bound. */ + onAckSilence: () => void + timers?: TerminalBacklogTimers + } + ) {} + + get pendingBytes(): number { + return this.pending + } + + /** The high-water mark, which is what says whether a stream was ever close to the ceiling. */ + get peakPendingBytes(): number { + return this.peak + } + + /** Frames the page never had to read because their bytes went out inside another one. */ + get coalescedFrames(): number { + return this.merged + } + + /** Frames this backlog handed back, which with the above is the ratio a device proof reads. */ + get deliveredFrames(): number { + return this.frames + } + + get held(): boolean { + return this.queue.length > 0 + } + + /** + * Hold a payload the page cannot be sent right now. False means the ceiling was reached. + * + * The payload that broke the ceiling is held anyway: the caller ends the stream on a false, and + * an entry dropped on the way out would make this the one place the drop-free rule is untrue. + */ + hold(payload: unknown): boolean { + const output = readTerminalOutput(payload) + if (output === null) { + // Serialized in full, because a payload this module does not model has no cheaper size. + const bytes = utf8ByteLength(JSON.stringify(payload) ?? 'null') + this.queue.push({ kind: 'other', payload, bytes }) + this.add(bytes) + } else { + const bytes = outputPayloadBytes(output.streamId, output.chunk) + this.queue.push({ kind: 'output', streamId: output.streamId, chunk: output.chunk, bytes }) + this.add(bytes) + } + this.syncSilence() + return this.pending <= TERMINAL_STREAM_MAX_PENDING_BYTES + } + + /** + * The next payload to send, merged as far as `allowedBytes` allows, or null for "not yet". + * + * `allowedBytes` is what one payload may occupy, which the caller narrows to the smaller of the + * window's room and one frame; this used to narrow it to the frame a second time, which no input + * could reach because the only caller had already done it. + * + * Null when the head does not fit is a wait, not a refusal: the caller comes back on the next ack + * with a wider window. A head that cannot fit even an empty window is handed over regardless — + * there is no later ack that would make room, and the ledger's own cap check is what decides + * whether a single payload that large ends the stream. + */ + next(allowedBytes: number, windowEmpty: boolean): unknown | null { + const head = this.queue[0] + if (head === undefined) { + return null + } + if (head.bytes > allowedBytes && !windowEmpty) { + return null + } + this.queue.shift() + this.take(head.bytes) + this.frames += 1 + if (head.kind === 'other') { + this.syncSilence() + return head.payload + } + let chunk = head.chunk + // Consecutive output only: anything else in between is state the reader applies in order, and + // merging across it would deliver bytes out of order. Nothing compares stream ids here, because + // a backlog belongs to one subscription and every `data` payload on it carries that + // subscription's single stream id; the comparison that used to be here could not fail. + while (this.queue.length > 0) { + const nextHeld = this.queue[0] + if (nextHeld.kind !== 'output') { + break + } + if (outputPayloadBytes(head.streamId, chunk + nextHeld.chunk) > allowedBytes) { + break + } + this.queue.shift() + this.take(nextHeld.bytes) + chunk += nextHeld.chunk + this.merged += 1 + } + this.syncSilence() + return { type: 'data', streamId: head.streamId, chunk } + } + + /** The page answered, so the silence clock starts again from here. */ + noteAck(): void { + this.clearSilence() + this.syncSilence() + } + + dispose(): void { + this.clearSilence() + this.queue.length = 0 + this.pending = 0 + } + + private add(bytes: number): void { + this.pending += bytes + this.peak = Math.max(this.peak, this.pending) + } + + private take(bytes: number): void { + this.pending = Math.max(0, this.pending - bytes) + } + + /** + * Armed exactly while something is pending, checked after every change to the queue. + * + * The invariant is "armed implies waiting on the page", and the first round broke it in one + * direction only: an ack re-armed the clock and the drain that followed emptied the queue without + * clearing it, so a terminal that had delivered everything and gone quiet died on `overflow` + * twenty seconds later. A rule that only arms is a rule that only ever kills more. + */ + private syncSilence(): void { + if (this.queue.length === 0) { + this.clearSilence() + return + } + this.armSilence() + } + + private armSilence(): void { + if (this.silence !== null) { + return + } + const timers = this.options.timers ?? REAL_TIMERS + this.silence = timers.set(() => { + this.silence = null + this.options.onAckSilence() + }, TERMINAL_STREAM_ACK_SILENCE_MS) + } + + private clearSilence(): void { + if (this.silence === null) { + return + } + const timers = this.options.timers ?? REAL_TIMERS + timers.clear(this.silence) + this.silence = null + } +} + +/** + * The escaped payload bytes one event frame can carry on a stream with this id. + * + * Derived from the cap and the envelope the shell really writes, rather than written down: a merge + * past this produces a frame the page's own reader refuses, which is the hole that holding the + * bytes exists to avoid. `seq` at its widest and the real id, because both are in every frame. + */ +export function terminalStreamMaxPayloadBytes(id: string): number { + return BRIDGE_MAX_MESSAGE_BYTES - bridgeEventEnvelopeBytes(id) +} + +/** + * Send as much held output as the page has made room for, merged as far as one frame allows. + * + * Bounded by the window rather than by a count: each pass re-reads it, so a page that acked one + * frame gets one frame back and a page that acked a megabyte gets a megabyte. An empty window is + * what lets the head through regardless of its size — there is no later ack that would make more + * room than none owed, and the caller's own cap check is what decides whether a single payload that + * large ends the stream under C0.3. + * + * Written against the ledger's four questions rather than inside it: what the shell does with held + * terminal output is this module's rule, and what a frame costs and whether it retired the stream + * is the ledger's. + */ +export function drainTerminalBacklog(deps: { + backlog: BridgeTerminalOutputBacklog + maxPayloadBytes: number + windowHasRoom: () => boolean + /** What the window has left for a payload right now, read again on every pass. */ + availableWindowBytes: () => number + /** True when the page owes nothing, which is what lets an oversized head through. */ + windowEmpty: () => boolean + send: (payload: unknown) => 'sent' | 'retired' +}): void { + while (deps.backlog.held && deps.windowHasRoom()) { + const available = Math.min(deps.maxPayloadBytes, Math.max(0, deps.availableWindowBytes())) + const payload = deps.backlog.next(available, deps.windowEmpty()) + if (payload === null || deps.send(payload) === 'retired') { + return + } + } +} diff --git a/mobile/src/mobile-web-shell/bridge/bridge-event-envelope-bytes.ts b/mobile/src/mobile-web-shell/bridge/bridge-event-envelope-bytes.ts new file mode 100644 index 00000000000..19f24cf1fba --- /dev/null +++ b/mobile/src/mobile-web-shell/bridge/bridge-event-envelope-bytes.ts @@ -0,0 +1,47 @@ +import { BRIDGE_ID_PATTERN, BRIDGE_PROTOCOL_VERSION } from './bridge-envelope' + +/** + * What an `event` frame costs around its payload, in one place because two budgets read it. + * + * Split out of `bridge-envelope.ts` rather than added to it: that file is the protocol's schemas + * and it is at its line cap, and this is a derivation over them rather than one of them. + */ + +/** + * The widest bridge id, read off the pattern that admits it rather than counted by eye. + * + * The pattern is a fixed-length class, so its own quantifier is the length: a change to it moves + * this number instead of leaving a budget that was right for the id the protocol used to carry. + */ +export function bridgeIdChars(): number { + const quantifier = /\{(\d+)\}\$$/.exec(BRIDGE_ID_PATTERN.source) + // A pattern that stopped being fixed-length is not a bound anything can derive, and guessing one + // is how a budget silently stops covering the frame it was written for. + if (quantifier === null) { + throw new Error('the bridge id pattern is no longer a fixed length') + } + return Number(quantifier[1]) +} + +/** + * What an `event` frame costs around its payload, at the widest every field can be written. + * + * One statement of the frame's shape, because there are two readers of it and they must not + * disagree: the page sizes the snapshot it asks the desktop for against this, and the shell sizes + * the terminal output it merges against it. Two skeletons would be two bounds, and the one that + * drifted would be discovered as a stream that ended. + * + * `id` at its full width and `seq` at the largest integer it can hold, because both appear in every + * frame and neither is known when a budget is computed. `payload: 0` leaves the key, its colon and + * the comma in the count and nothing else, so what is left over is exactly what the payload's own + * serialization may occupy. + */ +export function bridgeEventEnvelopeBytes(id = 'a'.repeat(bridgeIdChars())): number { + return JSON.stringify({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'event', + id, + seq: Number.MAX_SAFE_INTEGER, + payload: 0 + }).length +} diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index 82ceec90a1f..ef0f7192edd 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -83,7 +83,12 @@ const HEAD_CALLBACK_IDENTITY_SHA256 = // byteLength }` cast: the preview reader checks the content and salvages the flag, so `readMarkdownTab` // reads `fallback.value` directly. The dictation-mode refresh is main's own body again — it forwards // whatever mode the reply carried, so an absent one leaves the mic as inert as main left it. -const HEAD_CALLBACK_BODY_SHA256 = 'a5cad68712a53a2d5fb5514ecd391adb5bc7621d3542ac895ec65a383ac1810f' +// Refreshed on the merge of C7.2 and C7.3, which moved this pin from both sides: the terminal +// subscribe now carries the snapshot byte budget its transport imposes, nothing on a phone and the +// frame cap inside the shell's page, and the Markdown copy action gained the failure branch that +// answers a refused write. Re-recorded against the merged tree, since neither side's hash covers +// the other's body. The hook and string counts are C7.2's and stand. +const HEAD_CALLBACK_BODY_SHA256 = '5845c3b85217a3af9d3d2bfafe564a2b29a1b2c6776b5c2c9ec5afbf365a5157' // Refreshed for the startup effect: both `worktree.activate` sends became `worktreeActivate`, and // the sleeping-agent check reads that operation's verdict instead of the reply envelope. Refreshed // again when the reporter took the reply and interpreted it itself, retiring the hand-built diff --git a/mobile/src/session/terminal-snapshot-byte-budget.ts b/mobile/src/session/terminal-snapshot-byte-budget.ts new file mode 100644 index 00000000000..2a1de66f815 --- /dev/null +++ b/mobile/src/session/terminal-snapshot-byte-budget.ts @@ -0,0 +1,14 @@ +/** + * The bytes a terminal snapshot may occupy on the way to this client, or nothing when it has no + * ceiling of its own. + * + * Native: the socket delivers a stream frame as its own message with no per-message cap above it, + * so the desktop's own 512 KiB budget is the only one and the subscribe carries no field. Sending + * one would shrink a phone's scrollback for a limit that does not exist here. + * + * The `.web.ts` sibling is where this earns its name: inside the shell every event is one bridge + * frame under a hard byte cap, and an ANSI snapshot escapes into JSON at well over 1.3x. + */ +export function mobileTerminalSnapshotByteBudget(): number | undefined { + return undefined +} diff --git a/mobile/src/session/terminal-snapshot-byte-budget.web.test.ts b/mobile/src/session/terminal-snapshot-byte-budget.web.test.ts new file mode 100644 index 00000000000..b62ac04feb4 --- /dev/null +++ b/mobile/src/session/terminal-snapshot-byte-budget.web.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { BRIDGE_MAX_MESSAGE_BYTES } from '../mobile-web-shell/bridge/bridge-caps' +import { BRIDGE_PROTOCOL_VERSION } from '../mobile-web-shell/bridge/bridge-envelope' +import { mobileTerminalSnapshotByteBudget as nativeBudget } from './terminal-snapshot-byte-budget' +import { bridgeEventEnvelopeBytes } from '../mobile-web-shell/bridge/bridge-event-envelope-bytes' +import { mobileTerminalSnapshotByteBudget } from './terminal-snapshot-byte-budget.web' + +/** An id of the length the protocol's own pattern admits, which is what the bound is written for. */ +const WIDEST_ID = 'a'.repeat(22) + +describe('the snapshot budget a phone sends', () => { + it('is nothing at all, because the socket has no per-message cap', () => { + expect(nativeBudget()).toBeUndefined() + }) +}) + +describe('the snapshot budget the page sends', () => { + it('is the frame cap less what the event costs around the payload', () => { + expect(mobileTerminalSnapshotByteBudget()).toBe( + BRIDGE_MAX_MESSAGE_BYTES - bridgeEventEnvelopeBytes() + ) + }) + + /** + * The bound, checked against events of the shape the shell really posts. + * + * A budget derived from a skeleton is only a bound if a real frame never costs more than the + * envelope plus what its payload serializes to. Checked across the payload shapes this stream + * actually carries — a snapshot, a live output chunk, a bare acknowledgement — because the + * envelope is fixed and the payload is not, and a bound that held only for one of them is not a + * bound. + */ + it.each([ + ['a scrollback snapshot', { type: 'scrollback', streamId: 7, serialized: '\u001b[0mhello' }], + ['a live output chunk', { type: 'data', streamId: 7, chunk: 'x'.repeat(4096) }], + ['a bare acknowledgement', { type: 'subscribed', streamId: 7 }], + ['an empty object', {}] + ])('never costs more than the envelope plus its payload: %s', (_label, payload) => { + const frame = JSON.stringify({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'event', + id: WIDEST_ID, + seq: Number.MAX_SAFE_INTEGER, + payload + }) + expect(frame.length).toBeLessThanOrEqual( + bridgeEventEnvelopeBytes() + JSON.stringify(payload).length + ) + }) + + it('leaves a real snapshot event inside the cap when the payload spends the budget', () => { + // The whole contract in one assertion: a payload that serializes to exactly the budget produces + // a frame of at most the cap. The payload is built and measured rather than assembled from a + // guess at its overhead, which is the same rule the desktop applies on its side. + const budget = mobileTerminalSnapshotByteBudget() ?? 0 + const skeleton = JSON.stringify({ type: 'scrollback', streamId: 7, serialized: '' }).length + const payload = { type: 'scrollback', streamId: 7, serialized: 'x'.repeat(budget - skeleton) } + expect(JSON.stringify(payload).length).toBe(budget) + const frame = JSON.stringify({ + v: BRIDGE_PROTOCOL_VERSION, + type: 'event', + id: WIDEST_ID, + seq: Number.MAX_SAFE_INTEGER, + payload + }) + expect(frame.length).toBeLessThanOrEqual(BRIDGE_MAX_MESSAGE_BYTES) + }) + + it('moves with the cap rather than beside it', () => { + // A cap that moves and a budget that does not is a terminal that dies on a page it could have + // streamed, which is what a literal here would have produced. + expect(mobileTerminalSnapshotByteBudget()).toBeLessThan(BRIDGE_MAX_MESSAGE_BYTES) + expect(mobileTerminalSnapshotByteBudget()).toBeGreaterThan(BRIDGE_MAX_MESSAGE_BYTES - 1024) + }) +}) + +/** + * The number the desktop is handed, pinned so the host's own cases can name it. + * + * The host cannot import this — it is a different program with a different tsconfig — so its + * budget cases restate the value with a pointer here. Pinned rather than derived on both sides so + * a drift is a red line in one file rather than a stream that ends on a device. + */ +describe('the number the page sends', () => { + it('is 655,273 bytes: the 640 KiB cap less an 87-byte event envelope', () => { + expect(bridgeEventEnvelopeBytes()).toBe(87) + expect(mobileTerminalSnapshotByteBudget()).toBe(655_273) + }) +}) diff --git a/mobile/src/session/terminal-snapshot-byte-budget.web.ts b/mobile/src/session/terminal-snapshot-byte-budget.web.ts new file mode 100644 index 00000000000..c79f9b00550 --- /dev/null +++ b/mobile/src/session/terminal-snapshot-byte-budget.web.ts @@ -0,0 +1,25 @@ +import { BRIDGE_MAX_MESSAGE_BYTES } from '../mobile-web-shell/bridge/bridge-caps' +import { bridgeEventEnvelopeBytes } from '../mobile-web-shell/bridge/bridge-event-envelope-bytes' + +/** + * Web sibling: one bridge frame, less what the frame costs around it. + * + * The shell measures the serialized event against `BRIDGE_MAX_MESSAGE_BYTES` and ends the stream + * with `overflow` when it does not fit, which for a terminal means the pane dies before its first + * live byte with no recovery that would not reproduce it. Measured here: the 512 KiB raw budget + * hands back a 465,766-byte colour-dense 80-column snapshot that serializes to 669,268 bytes, + * 102.1% of the cap, because every ESC byte becomes six. + * + * Computed from the cap rather than written down beside it: a cap that moves and a budget that does + * not is a terminal that dies on a page it could have streamed. The envelope comes from the + * protocol module, which is the one place the frame's shape is stated, so this and the shell's own + * merge budget cannot drift apart. + * + * Everything inside `payload` is the desktop's to count, and it counts it by building the payload + * it will publish rather than by summing the fields it remembers — which is what let a snapshot + * accepted at exactly this budget arrive 169 bytes over the cap, on a frame carrying an + * 8-character request id; a 24-character one is 247 over. + */ +export function mobileTerminalSnapshotByteBudget(): number | undefined { + return BRIDGE_MAX_MESSAGE_BYTES - bridgeEventEnvelopeBytes() +} diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.ts b/mobile/src/session/use-mobile-session-terminal-subscription.ts index 333d472a30e..e92fb12488e 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react' import { isTerminalOscLinkRanges } from '../../../src/shared/terminal-osc-link-ranges' import * as nativeChatTerminalStream from './mobile-native-chat-terminal-stream' import { subscribeMobileTerminalSafely } from './mobile-terminal-stream-subscribe' +import { mobileTerminalSnapshotByteBudget } from './terminal-snapshot-byte-budget' import { readTerminalViewportDims, runTerminalViewportFitPass @@ -10,6 +11,9 @@ import { updateTerminalCwdFromStreamEvent } from './mobile-session-route-helpers import type { MobileDisplayMode } from './mobile-session-route-types' import type { MobileSessionTerminalSubscriptionFoundationModel } from './use-mobile-session-terminal-subscription-foundation' +/** Derived from constants, so it is read once rather than on every subscribe. */ +const snapshotByteBudget = mobileTerminalSnapshotByteBudget() + export function useMobileSessionTerminalSubscription( scope: MobileSessionTerminalSubscriptionFoundationModel ) { @@ -98,7 +102,10 @@ export function useMobileSessionTerminalSubscription( covered, viewportRef.current ), - capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered) + capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered), + // Undefined on a phone, where no per-message cap exists; omitted rather than sent as + // undefined so an older host sees the params it has always seen. + ...(snapshotByteBudget === undefined ? {} : { snapshotByteBudget }) }, (result) => { if (subscribeSeqRef.current.get(handle) !== seq) { diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json index d48b1914ef1..da746904f32 100644 --- a/mobile/web-entry/web-overrides.json +++ b/mobile/web-entry/web-overrides.json @@ -85,6 +85,10 @@ "file": "src/browser/browser-screencast-request.web.ts", "reason": "Not an RN Web API gap but a transport one that exists only in the page: a screencast frame crosses the bridge as one message under BRIDGE_MAX_MESSAGE_BYTES, and a phone's mobile view at the native device scale factor produces a worst-case JPEG larger than that. This file budgets the mobile view's area against the cap, the envelope it measures rather than names, and one worst-case bytes-per-pixel constant. Web view mode is untouched, because a letterboxed desktop viewport is not an area the page can predict." }, + { + "file": "src/session/terminal-snapshot-byte-budget.web.ts", + "reason": "Not an RN Web API gap but a transport one that exists only in the page. A terminal's first frame is its scrollback snapshot, which the desktop trims to 512 KiB of raw terminal text while the bridge measures the serialized event against BRIDGE_MAX_MESSAGE_BYTES. An ANSI snapshot is mostly ESC bytes and JSON spends six on each, so a colour-dense 80-column screen comes back at 669,268 bytes against a 655,360-byte cap and the stream ends with overflow before a live byte is painted. This file derives the budget the page sends in terminal.subscribe from the cap less the event envelope; the native sibling sends nothing, because a socket frame has no ceiling above it and a budget there would shrink a phone's scrollback for a limit that does not exist." + }, { "file": "src/session/mobile-native-chat-input-styles.web.ts", "reason": "The chat's composer and its question field render one point above the app's body size, which is 15 and under the floor below which iOS zooms the page on focus. keyboard-occlusion.web.ts reads a scale other than 1 as 'no keyboard' and answers 0, and on this screen that lift is the terminal's only feedback that its hidden input has focus, so one focus of the composer would cost the rest of the session. This file puts both fields on TEXT_INPUT_FONT_SIZE; the native sibling keeps 15." diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts index 73a359d73af..da5761dffcf 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts @@ -3,6 +3,7 @@ import { encodeTerminalStreamJson } from '../../../../../shared/terminal-stream-protocol' import { + mobileSnapshotByteBudget, sendMobileResizeRestream, sendSnapshotFrames, serializeStableMobileRendererSnapshot @@ -27,7 +28,15 @@ export function activateLegacyBinarySubscription( return } state.outputBatcher?.flush() - const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) + // One object for the budget and the frame it approves: the budget named + // `pending-output-overflow` while the send below named `renderer-mount-ready`, which is + // four bytes the approving number never counted. + const recoveryFrame = { kind: 'resized', reason: 'renderer-mount-ready' } as const + const recovery = await serializeStableMobileRendererSnapshot( + runtime, + ptyId, + mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, recoveryFrame) + ) if (state.closed) { return } @@ -41,11 +50,10 @@ export function activateLegacyBinarySubscription( runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) // Why: shipped mobile clients apply resized snapshots in place, so a blank xterm recovers without resubscribe. const recoveryStats = sendSnapshotFrames(state.sendFrame, { - kind: 'resized', + ...recoveryFrame, cols: recovery.cols, rows: recovery.rows, displayMode: state.displayMode, - reason: 'renderer-mount-ready', source: recovery.source, truncated: false, truncatedByByteBudget: recovery.truncatedByByteBudget, @@ -96,7 +104,12 @@ export function activateLegacyBinarySubscription( ptyId, state.sendFrame, event, - () => !state.closed && state.resizeGeneration === eventGeneration + () => !state.closed && state.resizeGeneration === eventGeneration, + mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, { + kind: 'resized', + displayMode: event.displayMode, + reason: event.reason + }) ) .then((restreamed) => { if (state.closed || state.resizeGeneration !== eventGeneration) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts index 72c8939cb6c..7b9d45d144c 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -1,4 +1,5 @@ import { + mobileSnapshotByteBudget, sendSnapshotFrames, serializeBudgetedMobileSnapshot, serializeStableMobileRendererSnapshot @@ -45,7 +46,17 @@ export async function publishLegacyBinaryInitialSnapshot( } let read = await runtime.readTerminal(params.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + // One object for the budget and the frame it approves. Written out twice, the two drifted: the + // budget measured a `scrollback` and the publication sent a `resized` with a `reason` beside it. + // `displayMode` is not in here because the flow re-reads it below, after the snapshot is + // serialized; the budget takes it at its widest instead. + const scrollbackFrame = { kind: 'scrollback' } as const + let serialized = await serializeBudgetedMobileSnapshot( + runtime, + ptyId, + isMobile, + mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, scrollbackFrame) + ) if (state.closed) { return } @@ -95,7 +106,13 @@ export async function publishLegacyBinaryInitialSnapshot( } if (rendererReady) { read = await runtime.readTerminal(params.terminal) - const stableRendererSnapshot = await serializeStableMobileRendererSnapshot(runtime, ptyId) + const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( + runtime, + ptyId, + // The same frame, because this snapshot is published by the scrollback send below rather + // than by one of its own: the `resized` it used to name is a frame nothing here sends. + mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, scrollbackFrame) + ) if (state.closed) { return } @@ -123,7 +140,15 @@ export async function publishLegacyBinaryInitialSnapshot( state.pendingOutputBytes = 0 state.pendingOutputOverflowed = false read = await runtime.readTerminal(params.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + serialized = await serializeBudgetedMobileSnapshot( + runtime, + ptyId, + isMobile, + mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, { + kind: 'scrollback', + displayMode: state.displayMode + }) + ) if (state.closed) { return } @@ -151,7 +176,7 @@ export async function publishLegacyBinaryInitialSnapshot( seq: layoutSeq }) const snapshotStats = sendSnapshotFrames(state.sendFrame, { - kind: 'scrollback', + ...scrollbackFrame, // Why: prefer the subscriber's viewport over the 80x24 stopgap when the PTY has // no size yet — the mismatch made mobile burn its resubscribe budget (STA-3337). cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, @@ -178,11 +203,19 @@ export async function publishLegacyBinaryInitialSnapshot( // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. state.lastResizeCols = serialized?.cols ?? size?.cols let recoveryAttempts = 0 + // The recovery's own frame, for the same reason: this one really is a `resized`, and it is the + // budget and the publication that have to agree on that, not a reader comparing two literals. + const recoveryFrame = { kind: 'resized', reason: 'pending-output-overflow' } as const // Why: if the bounded pre-subscribe tail overflowed, only a fresh model snapshot covers the dropped middle without replay gaps. while (state.pendingOutputOverflowed && recoveryAttempts < 2) { state.pendingOutputOverflowed = false recoveryAttempts += 1 - const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + const recovery = await serializeBudgetedMobileSnapshot( + runtime, + ptyId, + isMobile, + mobileSnapshotByteBudget(params.snapshotByteBudget, state.streamId, recoveryFrame) + ) if (state.closed) { return } @@ -195,11 +228,10 @@ export async function publishLegacyBinaryInitialSnapshot( } // Why: clients drop a repeat scrollback snapshot but apply 'resized' inline; omit seq so output-byte seqs don't pollute the layout-seq filter. const recoveryStats = sendSnapshotFrames(state.sendFrame, { - kind: 'resized', + ...recoveryFrame, cols: recovery.cols, rows: recovery.rows, displayMode: state.displayMode, - reason: 'pending-output-overflow', source: recovery.source, truncated: false, truncatedByByteBudget: recovery.truncatedByByteBudget, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts new file mode 100644 index 00000000000..f54498777d8 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-json-byte-budget.test.ts @@ -0,0 +1,450 @@ +import { describe, expect, it, vi } from 'vitest' +import { MOBILE_SNAPSHOT_BYTE_BUDGET } from '../../../scrollback-limits' +import { terminalSnapshotPayloadJsonBytes } from './terminal-snapshot-payload' +import { + serializeBudgetedMobileSnapshot, + serializeStableMobileRendererSnapshot, + type MobileSnapshotByteBudget +} from './terminal-snapshot-publication' +import type { OrcaRuntimeService } from '../../../orca-runtime' +import type { SerializedSnapshot } from './terminal-stream-types' + +/** + * The first frame of a page terminal, which today ends the stream before a byte is painted. + * + * The desktop trims the mobile snapshot to 512 KiB of raw terminal text. The page bridge measures + * the serialized event against 640 KiB, and an ANSI snapshot is mostly ESC bytes, each of which + * `JSON.stringify` spends six bytes on. A colour-dense 80-column screen crosses 1.43x, so a + * snapshot the desktop calls budgeted arrives 7% over the cap and `deliver` answers + * `cancel(id, 'overflow')` — a terminal dead on arrival with no recovery that does not reproduce it. + */ + +const COLUMNS = 80 + +/** One SGR colour change per cell, which is the worst case a real screen reaches. */ +function colourDenseRow(row: number): string { + let line = '' + for (let column = 0; column < COLUMNS; column += 1) { + line += `\u001b[38;5;${(row * COLUMNS + column) % 256}m#` + } + return `${line}\u001b[0m\r\n` +} + +function colourDenseScreen(rows: number): string { + let screen = '' + for (let row = 0; row < rows; row += 1) { + screen += colourDenseRow(row) + } + return screen +} + +/** + * A runtime whose scrollback is colour-dense to the row, so trimming rows really trims bytes. + * + * Rows rather than a fixed string: the serializer walks [1000, 500, 250, 100, 25, 0] and a stub + * that answered the same payload every time would prove the loop terminates and nothing else. + */ +function denseRuntime(): Pick { + return { + serializeTerminalBuffer: vi.fn( + async (_ptyId: string, options?: { scrollbackRows?: number }) => ({ + data: colourDenseScreen(Math.max(options?.scrollbackRows ?? 0, 24)), + cols: COLUMNS, + rows: 24, + // A long path, because it is one of the fields the subscriber cannot bound from its own side. + cwd: '/srv/checkouts/a-repository/packages/a-workspace/deeply/nested/leaf', + source: 'headless' as const, + oscLinks: [] + }) + ) + } +} + +/** + * The budget the page really sends, and the stream it is published on. + * + * Restated rather than imported: the page is a different program with a different tsconfig, and + * this host must not know what a bridge is — the budget is a parameter, and a client with another + * transport has another one. The number is pinned on the page's side in + * `mobile/src/session/terminal-snapshot-byte-budget.web.test.ts`, so a drift is a red line there + * rather than a stream that ends on a device. + */ +const PAGE_BUDGET = 655_273 +const STREAM_ID = 7 + +/** What the caller will publish with, which the budget needs because it builds the payload. */ +const PUBLICATION = { kind: 'scrollback', displayMode: 'auto' } as const + +function budget(bytes: number): MobileSnapshotByteBudget { + return { bytes, streamId: STREAM_ID, frame: { ...PUBLICATION } } +} + +/** Narrowed rather than asserted: a fixture that serialized nothing is a broken case, not a null. */ +function required(value: T | null): T { + if (value === null) { + throw new Error('the fixture serialized nothing') + } + return value +} + +/** The payload as the host will publish it, measured the way the host measures it. */ +function publishedPayloadBytes(serialized: NonNullable): number { + return terminalSnapshotPayloadJsonBytes( + { + ...PUBLICATION, + cols: serialized.cols, + rows: serialized.rows, + seq: serialized.seq, + cwd: serialized.cwd, + source: serialized.source, + oscLinks: serialized.oscLinks, + truncated: false, + truncatedByByteBudget: serialized.truncatedByByteBudget, + data: serialized.data + }, + STREAM_ID + ) +} + +/** The `cwd` a real subscription carries, long enough that the metadata is not rounding error. */ +const CWD = '/srv/checkouts/a-repository/packages/a-workspace/deeply/nested/leaf' + +/** + * A runtime whose screen is sized so round one's measure lands on exactly the budget. + * + * Round one summed the escaped text and four fields. Plain ASCII escapes to its own length plus the + * two quotes, so the screen below makes that sum exactly `PAGE_BUDGET` — accepted, and at the first + * candidate, so `truncatedByByteBudget` is false and nothing says it was trimmed. What the host + * then publishes is that text plus `kind`, `cols`, `rows`, `requestId`, `displayMode`, `reason`, + * `seq`, both truncation flags, the `type` and `streamId` the client adds, the `serialized` key and + * the object's own braces. That is the frame the reviewer measured at 655,529 against a + * 655,360-byte cap. + */ +function exactlyAtRoundOnesBudgetRuntime(): Pick { + const metaBytes = Buffer.byteLength( + JSON.stringify({ + cwd: CWD, + oscLinks: [], + pendingEscapeTailAnsi: undefined, + source: 'headless' + }), + 'utf8' + ) + const fullLength = PAGE_BUDGET - 2 - metaBytes + return { + // Shrinks with the row count, so the trim below has something to trim; at the first candidate + // it is exactly the screen round one accepted. + serializeTerminalBuffer: vi.fn( + async (_ptyId: string, options?: { scrollbackRows?: number }) => ({ + data: 'x'.repeat( + Math.floor((fullLength * Math.min(options?.scrollbackRows ?? 0, 1000)) / 1000) + ), + cols: COLUMNS, + rows: 24, + seq: 4_294_967_295, + cwd: CWD, + source: 'headless' as const, + oscLinks: [] + }) + ) + } +} + +/** + * A runtime no candidate can trim under the raw rule, so the zero-row screen is over it too. + * + * The same size at every candidate on purpose: what these cases separate is what the loop does + * when trimming has run out, and a fixture that shrinks would never reach that state. + */ +function alwaysOversizeRuntime(): Pick { + return { + serializeTerminalBuffer: vi.fn(async () => ({ + data: 'x'.repeat(MOBILE_SNAPSHOT_BYTE_BUDGET + 1024), + cols: COLUMNS, + rows: 24, + cwd: CWD, + source: 'headless' as const, + oscLinks: [] + })) + } +} + +describe('the mobile snapshot the page receives', () => { + it('reproduces the defect: the raw budget lets a screen past the frame cap', async () => { + const serialized = await serializeBudgetedMobileSnapshot(denseRuntime(), 'pty-1', true) + expect(serialized).not.toBeNull() + const data = serialized?.data ?? '' + // Under the budget the desktop applies, which is measured on the text. + expect(Buffer.byteLength(data, 'utf8')).toBeLessThanOrEqual(MOBILE_SNAPSHOT_BYTE_BUDGET) + // And over the cap the bridge applies, which is measured on the whole published payload. + expect(publishedPayloadBytes(required(serialized))).toBeGreaterThan(PAGE_BUDGET) + }) + + /** + * The case the first round's measure could not see. + * + * It summed the text and four fields, so a snapshot it accepted at exactly the budget published + * 169 bytes over a 655,360-byte cap and the stream ended with `overflow` before a byte was + * painted. Measured here against the payload the host really builds, which is the only measure + * that cannot be wrong by a field. + */ + it('accepts nothing whose published payload is over the budget', async () => { + const serialized = await serializeBudgetedMobileSnapshot( + denseRuntime(), + 'pty-1', + true, + budget(PAGE_BUDGET) + ) + expect(serialized).not.toBeNull() + expect(publishedPayloadBytes(required(serialized))).toBeLessThanOrEqual(PAGE_BUDGET) + }) + + /** + * The metadata is counted, not the text alone, and a long `requestId` is part of it. + * + * The reviewer's reproduction used an 8-character request id and a 24-character one, 169 and 247 + * bytes over. A budget that ignored the publication fields answers the same for both; one that + * builds the payload cannot. + */ + it('trims further when the publication carries more metadata', async () => { + const runtime = denseRuntime() + const [plain, withRequestId] = await Promise.all([ + serializeBudgetedMobileSnapshot(runtime, 'pty-1', true, budget(PAGE_BUDGET)), + serializeBudgetedMobileSnapshot(runtime, 'pty-1', true, { + bytes: PAGE_BUDGET, + streamId: STREAM_ID, + frame: { ...PUBLICATION, reason: 'a-reason-of-some-length', requestId: 999_999_999 } + }) + ]) + expect(publishedPayloadBytes(required(plain))).toBeLessThanOrEqual(PAGE_BUDGET) + expect(publishedPayloadBytes(required(withRequestId))).toBeLessThanOrEqual(PAGE_BUDGET) + }) + + it('says it trimmed, so the screen can tell a short scrollback from a whole one', async () => { + const serialized = await serializeBudgetedMobileSnapshot( + denseRuntime(), + 'pty-1', + true, + budget(PAGE_BUDGET) + ) + expect(serialized?.truncatedByByteBudget).toBe(true) + }) + + it('leaves a subscriber that named no budget on the raw byte rule', async () => { + // The compatibility half. An older page, and every socket client, sends no budget and is served + // exactly what it was served before: the payload size is not its transport's problem. + const runtime = denseRuntime() + const [withoutBudget, withBudget] = await Promise.all([ + serializeBudgetedMobileSnapshot(runtime, 'pty-1', true), + serializeBudgetedMobileSnapshot(runtime, 'pty-1', true, budget(PAGE_BUDGET)) + ]) + expect(withoutBudget?.scrollbackRows).toBeGreaterThan(withBudget?.scrollbackRows ?? 0) + }) + + /** + * The zero-row candidate a budget still cannot fit, which is where the loop used to give up. + * + * Zero scrollback is not a small screen: a wide colour-dense viewport still carries its 24 live + * rows, and the loop published that candidate whatever it measured. A capped subscriber then got + * one frame over its cap, ended the stream on `overflow` and painted nothing — the one outcome + * worse than a blank terminal, because live output would have repainted a blank one in a + * keystroke and there is no recovery from a stream that never opened. + * + * Ruling 15: publish it with the text emptied and the trim flagged. The stream opens, the page + * knows the screen it holds is not the screen the host had, and the next byte of output fixes it. + */ + it('empties the text of a zero-row candidate it cannot fit, rather than posting it over', async () => { + const serialized = await serializeBudgetedMobileSnapshot( + denseRuntime(), + 'pty-1', + true, + budget(4096) + ) + expect(serialized?.scrollbackRows).toBe(0) + expect(serialized?.data).toBe('') + expect(serialized?.truncatedByByteBudget).toBe(true) + expect(publishedPayloadBytes(required(serialized))).toBeLessThanOrEqual(4096) + }) + + /** + * The floor, which is the metadata the frame must carry however little the subscriber allows. + * + * A budget under it is a subscriber this host cannot serve, and there is nothing further to give + * up: the text is already gone. Recorded so the behaviour is a decision rather than something + * read off a stack trace, and so "never an over-budget frame" is read with the one exception it + * has rather than as a promise the host cannot keep. + */ + it('cannot go below the metadata the frame carries, and keeps the text empty there', async () => { + const serialized = await serializeBudgetedMobileSnapshot( + denseRuntime(), + 'pty-1', + true, + budget(16) + ) + expect(serialized?.scrollbackRows).toBe(0) + expect(serialized?.data).toBe('') + expect(serialized?.truncatedByByteBudget).toBe(true) + expect(publishedPayloadBytes(required(serialized))).toBeGreaterThan(16) + }) + + it('still sends an unbudgeted subscriber the screen it has always been sent', async () => { + // The other half of ruling 15, and the compatibility one: the raw rule keeps its fallback, so + // an older page and every socket client get the oversize screen rather than an empty frame. + const serialized = await serializeBudgetedMobileSnapshot(alwaysOversizeRuntime(), 'pty-1', true) + expect(serialized?.data.length).toBeGreaterThan(MOBILE_SNAPSHOT_BYTE_BUDGET) + expect(serialized?.truncatedByByteBudget).toBe(true) + }) +}) + +describe('a snapshot that round one accepted at exactly the budget', () => { + /** + * The blocker, as a number rather than as a claim. + * + * This is the frame the host would post. It is over the cap by the fields a summed measure never + * counted, and every one of them is in the payload the client assembles. + */ + it('publishes a payload over the budget when nothing trims it', async () => { + const runtime = exactlyAtRoundOnesBudgetRuntime() + const untrimmed = required( + await runtime.serializeTerminalBuffer('pty-1', { scrollbackRows: 1000 }) + ) + const published = terminalSnapshotPayloadJsonBytes( + { + ...PUBLICATION, + cols: untrimmed.cols, + rows: untrimmed.rows, + seq: untrimmed.seq, + cwd: untrimmed.cwd, + source: untrimmed.source, + oscLinks: untrimmed.oscLinks, + truncated: false, + truncatedByByteBudget: false, + data: untrimmed.data + }, + STREAM_ID + ) + expect(published).toBeGreaterThan(PAGE_BUDGET) + }) + + it('is trimmed by the measure that builds the payload, and says so', async () => { + const serialized = await serializeBudgetedMobileSnapshot( + exactlyAtRoundOnesBudgetRuntime(), + 'pty-1', + true, + budget(PAGE_BUDGET) + ) + expect(serialized).not.toBeNull() + expect(publishedPayloadBytes(required(serialized))).toBeLessThanOrEqual(PAGE_BUDGET) + expect(serialized?.truncatedByByteBudget).toBe(true) + }) +}) + +/** + * The other loop, which serves the renderer snapshot and retries when output moved under it. + * + * Its own case because it is a second copy of the same walk toward zero scrollback, reached by a + * different caller, and ruling 15 is a property of the walk rather than of either caller. A stable + * sequence on purpose: what is under test is the last candidate, not the retry. + */ +function stableRendererRuntime(): Pick< + OrcaRuntimeService, + 'getPtyOutputSequence' | 'serializeRendererTerminalBuffer' +> { + return { + getPtyOutputSequence: vi.fn(() => 11), + serializeRendererTerminalBuffer: vi.fn( + async (_ptyId: string, options?: { scrollbackRows?: number }) => ({ + data: colourDenseScreen(Math.max(options?.scrollbackRows ?? 0, 24)), + cols: COLUMNS, + rows: 24, + cwd: CWD, + source: 'renderer' as const, + oscLinks: [] + }) + ) + } +} + +describe('the renderer snapshot the page receives', () => { + it('empties the text of a zero-row candidate it cannot fit', async () => { + const serialized = await serializeStableMobileRendererSnapshot( + stableRendererRuntime(), + 'pty-1', + budget(4096) + ) + expect(serialized?.scrollbackRows).toBe(0) + expect(serialized?.data).toBe('') + expect(serialized?.truncatedByByteBudget).toBe(true) + expect(publishedPayloadBytes(required(serialized))).toBeLessThanOrEqual(4096) + }) + + it('leaves an unbudgeted subscriber its screen', async () => { + const serialized = await serializeStableMobileRendererSnapshot(stableRendererRuntime(), 'pty-1') + expect(serialized?.data.length).toBeGreaterThan(0) + }) +}) + +/** + * The fields the caller decides, which the budget must not read more narrowly than the publication. + * + * Every one of these was measured at one value and published at another. `kind` and `reason` drifted + * because the two objects were written out twice, five lines apart; `displayMode` drifts because + * the subscribe flow re-reads it from the runtime after the snapshot is serialized and before the + * frame is sent, so the budget cannot know it at all. Under-measuring any of them is the same + * defect as the summed field list: the frame goes out larger than the number that approved it. + */ +describe('the publication fields the budget has to assume', () => { + /** The widest a payload built from these options can be, as the host measures it. */ + function measured(frame: MobileSnapshotByteBudget['frame'], data: string): number { + return terminalSnapshotPayloadJsonBytes( + { + ...frame, + requestId: Number.MAX_SAFE_INTEGER, + seq: Number.MAX_SAFE_INTEGER, + truncated: false, + truncatedByByteBudget: false, + cols: COLUMNS, + rows: 24, + cwd: CWD, + source: 'headless', + oscLinks: [], + data + }, + STREAM_ID + ) + } + + /** A screen of a fixed size at every candidate, so the budget below is the whole of the margin. */ + function fixedScreenRuntime(data: string): Pick { + return { + serializeTerminalBuffer: vi.fn(async () => ({ + data, + cols: COLUMNS, + rows: 24, + cwd: CWD, + source: 'headless' as const, + oscLinks: [] + })) + } + } + + it('covers a publication that carries the wider display mode', async () => { + // `getMobileDisplayMode` answers `'auto' | 'desktop'`, and the subscribe flow re-reads it + // between serializing the snapshot and sending the frame, so the budget cannot know which it + // will be. Budgeted at exactly the `'auto'` measure, the `'desktop'` frame is three bytes + // longer than the number that approved it, and no trimming is left to absorb them: this is the + // margin, not a fixture with room in it. + const data = 'x'.repeat(4096) + const serialized = await serializeBudgetedMobileSnapshot( + fixedScreenRuntime(data), + 'pty-1', + true, + { + bytes: measured({ kind: 'scrollback', displayMode: 'auto' }, data), + streamId: STREAM_ID, + frame: { kind: 'scrollback', displayMode: 'auto' } + } + ) + expect(required(serialized).data).toBe('') + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts new file mode 100644 index 00000000000..8f361bf95f2 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-payload.ts @@ -0,0 +1,85 @@ +import type { SnapshotFrameOptions } from './terminal-stream-types' + +/** + * The shape of a snapshot on the wire, and what it costs a client that reads it as one payload. + * + * Split from the publication so the budget and the sender read one description of the frame. They + * used not to: the budget summed the fields somebody remembered and shipped a payload 169 bytes + * over the 655,360-byte cap while calling it budgeted, on a frame carrying an 8-character request + * id. The overshoot grows with that id: a 24-character one is 247 bytes over. + */ + +/** + * The metadata a SnapshotStart frame carries, built in one place. + * + * Extracted so the budget below measures the object this really sends rather than a list of the + * fields somebody remembered. A sum over a remembered list is what shipped those 169 bytes: it + * counted the text and four fields and forgot + * `kind`, `cols`, `rows`, `requestId`, `displayMode`, `reason`, `seq`, both truncation flags and + * the `serialized` key itself. A field added here is now paid for by both readers at once. + */ +export function buildSnapshotFrameMeta(options: SnapshotFrameOptions): Record { + return { + kind: options.kind, + cols: options.cols, + rows: options.rows, + requestId: options.requestId, + displayMode: options.displayMode, + reason: options.reason, + unavailable: options.unavailable, + seq: options.seq, + cwd: options.cwd, + source: options.source, + oscLinks: options.oscLinks, + pendingEscapeTailAnsi: options.pendingEscapeTailAnsi, + // Why conditional and additive: old clients ignore the unknown field, + // and a new client must read absence as unknown rather than zero, so + // no opcode or capability negotiation is involved (Rule 1 of + // docs/reference/remote-wire-compatibility.md). + // Why `seq` is required: the flags are only proven at this frame's own + // seq, so without a replay boundary the client cannot order them. + ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined + ? { kittyKeyboardFlags: options.kittyKeyboardFlags } + : {}), + ...(typeof options.seq === 'number' && options.terminalOwner + ? { terminalOwner: options.terminalOwner } + : {}), + // The terminalOwner conjunct is load-bearing, not redundant: no consumer + // re-checks it, and an un-gated alternateScreen would flip the renderer's + // mouse-reset selection on every alt-screen reattach of a live TUI. + ...(typeof options.seq === 'number' && + options.terminalOwner && + options.alternateScreen !== undefined + ? { alternateScreen: options.alternateScreen } + : {}), + truncated: options.truncated === true, + truncatedByByteBudget: options.truncatedByByteBudget === true + } +} + +/** + * What a bridged client's scrollback event costs, as the client assembles it. + * + * The client joins the chunks and spreads the metadata into one object with `type`, `streamId` and + * `serialized` beside it, and its transport measures the serialized result. So this builds that + * object and stringifies it: nothing is summed, nothing is estimated, and a field that joins the + * metadata is counted here the moment it is sent. + * + * A copy of the snapshot per call, which the trimming loop pays up to six times on a subscribe. + * That is the price of the only measure that cannot be wrong by a field, on a path that runs once + * per terminal attach. + */ +export function terminalSnapshotPayloadJsonBytes( + options: SnapshotFrameOptions, + streamId: number +): number { + return Buffer.byteLength( + JSON.stringify({ + ...buildSnapshotFrameMeta(options), + type: options.kind, + streamId, + serialized: options.data + }), + 'utf8' + ) +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts index b935bda7a6f..4a855a7e4c2 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts @@ -12,6 +12,10 @@ import { iterateTerminalStreamTextPayloads, requestedSnapshotScrollbackCandidates } from './terminal-stream-replay' +import { + buildSnapshotFrameMeta, + terminalSnapshotPayloadJsonBytes +} from './terminal-snapshot-payload' import type { SerializedSnapshot, SnapshotFrameOptions } from './terminal-stream-types' const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 @@ -67,42 +71,7 @@ export function sendSnapshotFrames( if ( sendFrame( TerminalStreamOpcode.SnapshotStart, - encodeTerminalStreamJson({ - kind: options.kind, - cols: options.cols, - rows: options.rows, - requestId: options.requestId, - displayMode: options.displayMode, - reason: options.reason, - unavailable: options.unavailable, - seq: options.seq, - cwd: options.cwd, - source: options.source, - oscLinks: options.oscLinks, - pendingEscapeTailAnsi: options.pendingEscapeTailAnsi, - // Why conditional and additive: old clients ignore the unknown field, - // and a new client must read absence as unknown rather than zero, so - // no opcode or capability negotiation is involved (Rule 1 of - // docs/reference/remote-wire-compatibility.md). - // Why `seq` is required: the flags are only proven at this frame's own - // seq, so without a replay boundary the client cannot order them. - ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined - ? { kittyKeyboardFlags: options.kittyKeyboardFlags } - : {}), - ...(typeof options.seq === 'number' && options.terminalOwner - ? { terminalOwner: options.terminalOwner } - : {}), - // The terminalOwner conjunct is load-bearing, not redundant: no consumer - // re-checks it, and an un-gated alternateScreen would flip the renderer's - // mouse-reset selection on every alt-screen reattach of a live TUI. - ...(typeof options.seq === 'number' && - options.terminalOwner && - options.alternateScreen !== undefined - ? { alternateScreen: options.alternateScreen } - : {}), - truncated: options.truncated === true, - truncatedByByteBudget: options.truncatedByByteBudget === true - }) + encodeTerminalStreamJson(buildSnapshotFrameMeta(options)) ) === false ) { return { bytes: 0, chunks: 0, published: false } @@ -120,10 +89,159 @@ export function sendSnapshotFrames( return { bytes, chunks, published } } +/** The publication fields a snapshot itself decides, which the budget reads off it. */ +type SnapshotVariableMeta = Pick< + NonNullable, + | 'cwd' + | 'oscLinks' + | 'pendingEscapeTailAnsi' + | 'source' + | 'seq' + | 'kittyKeyboardFlags' + | 'alternateScreen' + | 'terminalOwner' +> + +/** Narrowed to the one method this reads, so a caller can hand it a buffer source and nothing else. */ +type TerminalBufferSource = Pick + +/** + * What a subscriber with a frame cap told this host it can carry, and what it will publish with. + * + * The publication fields travel with the budget because the payload is measured by building it, + * and it cannot be built from the snapshot alone: `displayMode`, `reason` and `requestId` are the + * caller's, and a measure that left them out is exactly the sum that shipped a frame over the cap. + */ +export type MobileSnapshotByteBudget = { + /** The bytes one payload may occupy on this subscriber's transport. */ + bytes: number + /** The stream this will be published on, which the client writes into the payload. */ + streamId: number + /** The publication's own fields, as the caller will pass them to `sendSnapshotFrames`. */ + frame: Omit< + SnapshotFrameOptions, + | 'data' + | 'cols' + | 'rows' + | 'cwd' + | 'source' + | 'oscLinks' + | 'pendingEscapeTailAnsi' + | 'kittyKeyboardFlags' + | 'alternateScreen' + | 'terminalOwner' + | 'truncatedByByteBudget' + > +} + +/** `JSON.stringify` writes `false` in five bytes and `true` in four, so `false` is the bound. */ +const WIDEST_BOOLEAN = false + +type MobileDisplayMode = ReturnType + +/** Every mode the runtime can answer; the type below is what keeps this list complete. */ +const DISPLAY_MODES = ['auto', 'desktop'] as const satisfies readonly MobileDisplayMode[] + +/** Empty only while every mode is listed above, which is what makes the constant compile. */ +type UnlistedDisplayMode = Exclude + +/** + * The longest mode rather than the one the caller handed over. + * + * The subscribe flow re-reads the mode from the runtime between serializing the snapshot and + * sending the frame, so no caller can tell the budget which one the publication will carry. Taken + * at its widest for the same reason `seq` and `requestId` are: a mode measured at `auto` and + * published as `desktop` is three bytes the approving number never counted. Resolving to `never` + * when a mode is added is the point — a new one has to be weighed here, not discovered on a phone. + */ +const WIDEST_DISPLAY_MODE: [UnlistedDisplayMode] extends [never] ? string : never = + DISPLAY_MODES.reduce((widest, mode) => (mode.length > widest.length ? mode : widest)) + +/** + * The publication this snapshot would produce, at its widest where the answer is not yet known. + * + * A bound rather than a prediction, and the direction matters: `truncated` is decided after this + * runs and `seq` and `requestId` may be, so each is taken at the widest `JSON.stringify` can write + * rather than left out. Leaving one out is what under-measures, because an absent field costs + * nothing here and its real value costs bytes at publish — and forcing `seq` to a number also opens + * the three conditional fields it gates, which are counted for the same reason. + */ +function budgetedPublication( + data: string, + serialized: SnapshotVariableMeta & { cols: number; rows: number }, + budget: MobileSnapshotByteBudget +): SnapshotFrameOptions { + return { + ...budget.frame, + displayMode: WIDEST_DISPLAY_MODE, + requestId: budget.frame.requestId ?? Number.MAX_SAFE_INTEGER, + seq: budget.frame.seq ?? serialized.seq ?? Number.MAX_SAFE_INTEGER, + truncated: WIDEST_BOOLEAN, + truncatedByByteBudget: WIDEST_BOOLEAN, + cols: serialized.cols, + rows: serialized.rows, + cwd: serialized.cwd, + source: serialized.source, + oscLinks: serialized.oscLinks, + pendingEscapeTailAnsi: serialized.pendingEscapeTailAnsi, + kittyKeyboardFlags: serialized.kittyKeyboardFlags, + alternateScreen: serialized.alternateScreen, + terminalOwner: serialized.terminalOwner, + data + } +} + +/** + * Whether this snapshot is over whichever budget the subscriber is owed. + * + * Two budgets, not one scaled: a subscriber with a frame cap sends one and is measured on the + * payload it will receive, built rather than summed, and everyone else keeps the raw-text budget + * this host has always applied. Reading the payload size for a socket subscriber would shrink a + * screen that was never at risk, and reading the raw size for a bridged one is the defect this + * parameter exists for. + */ +function overMobileSnapshotBudget( + data: string, + serialized: SnapshotVariableMeta & { cols: number; rows: number }, + budget: MobileSnapshotByteBudget | undefined +): boolean { + return budget === undefined + ? terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET) + : terminalSnapshotPayloadJsonBytes( + budgetedPublication(data, serialized, budget), + budget.streamId + ) > budget.bytes +} + +/** + * The candidate a trimming loop publishes once it has nothing left to trim. + * + * Zero scrollback still carries the live rows, so the last candidate can be over a small cap. A + * budgeted subscriber gets that frame with its text emptied rather than one byte over (ruling 15): + * an open stream repaints on the next output, an `overflow` close does not reopen. A subscriber on + * the raw rule keeps the screen it has always been sent. Below the metadata alone there is nothing + * further to give up, and the frame goes out empty and still over. + */ +function publishedCandidate( + serialized: T, + data: string, + rows: number, + overByteBudget: boolean, + budget: MobileSnapshotByteBudget | undefined +) { + return { + ...serialized, + data: overByteBudget && budget !== undefined ? '' : data, + scrollbackRows: rows, + truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget + } +} + export async function serializeBudgetedMobileSnapshot( - runtime: OrcaRuntimeService, + runtime: TerminalBufferSource, ptyId: string, - isMobile: boolean + isMobile: boolean, + snapshotByteBudget?: MobileSnapshotByteBudget ): Promise { if (isTerminalSnapshotForcedUnavailable()) { return null @@ -146,22 +264,24 @@ export async function serializeBudgetedMobileSnapshot( return null } const data = (serialized.scrollbackAnsi ?? '') + serialized.data - const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET) + const overByteBudget = overMobileSnapshotBudget(data, serialized, snapshotByteBudget) if (!overByteBudget || rows === 0) { - return { - ...serialized, - data, - scrollbackRows: rows, - truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget - } + return publishedCandidate(serialized, data, rows, overByteBudget, snapshotByteBudget) } } return null } +/** Narrowed to what the retry loop reads, so a stable-snapshot case needs no whole runtime. */ +type RendererBufferSource = Pick< + OrcaRuntimeService, + 'getPtyOutputSequence' | 'serializeRendererTerminalBuffer' +> + export async function serializeStableMobileRendererSnapshot( - runtime: OrcaRuntimeService, - ptyId: string + runtime: RendererBufferSource, + ptyId: string, + snapshotByteBudget?: MobileSnapshotByteBudget ): Promise { const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] let candidateIndex = 0 @@ -180,16 +300,15 @@ export async function serializeStableMobileRendererSnapshot( if (!serialized) { return null } - const overByteBudget = terminalStreamByteLengthExceeds( - serialized.data, - MOBILE_SNAPSHOT_BYTE_BUDGET - ) + const overByteBudget = overMobileSnapshotBudget(serialized.data, serialized, snapshotByteBudget) if (!overByteBudget || rows === 0) { - return { - ...serialized, - scrollbackRows: rows, - truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget - } + return publishedCandidate( + serialized, + serialized.data, + rows, + overByteBudget, + snapshotByteBudget + ) } candidateIndex += 1 } @@ -202,13 +321,14 @@ export async function sendMobileResizeRestream( ptyId: string, sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void, event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }, - shouldSend?: () => boolean + shouldSend?: () => boolean, + snapshotByteBudget?: MobileSnapshotByteBudget ): Promise { // Why: only a true geometry reflow rewraps scrollback; a dimensionless mode-change would re-send the whole buffer for nothing. if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) { return false } - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true, snapshotByteBudget) if (!serialized) { return false } @@ -231,3 +351,24 @@ export async function sendMobileResizeRestream( }) return true } + +/** + * The budget a subscription hands the serializer, or nothing when the subscriber named none. + * + * Built at each publication site rather than once per subscription, because the fields it carries + * are that publication's: an initial scrollback and a resize re-stream write different `kind`s and + * `reason`s, and a budget measured against the wrong one is the sum this replaced. + * + * The rule the callers follow, because writing the fields out twice is how they drifted the first + * time: the `frame` handed here is the same object the publication spreads, never a second literal + * that agrees with it today. Anything the caller cannot know yet stays out of it and is taken at + * its widest by the measure — `seq`, `requestId`, both truncation flags, and `displayMode`, which + * the subscribe flow re-reads after the snapshot is serialized. + */ +export function mobileSnapshotByteBudget( + bytes: number | undefined, + streamId: number, + frame: MobileSnapshotByteBudget['frame'] +): MobileSnapshotByteBudget | undefined { + return bytes === undefined ? undefined : { bytes, streamId, frame } +} diff --git a/src/shared/rpc-contract/terminal-stream-params.ts b/src/shared/rpc-contract/terminal-stream-params.ts index 88506f63b47..27d0bd286fc 100644 --- a/src/shared/rpc-contract/terminal-stream-params.ts +++ b/src/shared/rpc-contract/terminal-stream-params.ts @@ -34,7 +34,21 @@ export const TerminalSubscribe = TerminalHandle.extend({ mobileInputLeaseOnly: z.literal(1).optional(), writeUnavailable: z.literal(1).optional() }) - .optional() + .optional(), + /** + * The bytes a mobile snapshot may occupy once it is JSON, when the subscriber has a frame cap. + * + * Additive and optional, so no negotiation is involved: a host that predates it ignores the field + * and trims on the raw byte budget it always did, and a subscriber that never sends one is served + * exactly as before (Rule 1 of docs/reference/remote-wire-compatibility.md). The page sends it + * because the shell measures the frame rather than the text, and an ANSI snapshot escapes every + * ESC byte into six — a 512 KiB budgeted screen serializes past the 640 KiB bridge cap and ends + * the stream before a live byte lands. + * + * Never inferred from `client.type`: a mobile subscriber on the socket has no frame cap at all, + * and one that sends this has whatever cap its own transport imposes. + */ + snapshotByteBudget: z.number().int().positive().optional() }) export const TerminalMultiplex = z.object({}) diff --git a/src/shared/terminal-stream-json-byte-length.test.ts b/src/shared/terminal-stream-json-byte-length.test.ts new file mode 100644 index 00000000000..1b894aea9d5 --- /dev/null +++ b/src/shared/terminal-stream-json-byte-length.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { terminalStreamJsonByteLength } from './terminal-stream-json-byte-length' + +/** + * The scan, checked against the serializer it is standing in for. + * + * `JSON.stringify` is the oracle rather than a table of expected numbers: what this module is for + * is not spending a copy of a half-megabyte snapshot to learn its size, and the only thing that + * makes that safe is agreeing with the serializer on every input class it will meet. + */ + +const CASES: [string, string][] = [ + ['empty', ''], + ['plain ascii', 'hello world'], + ['a quote and a backslash', 'a "quoted" c:\\path\\to'], + ['the five short escapes', '\b\t\n\f\r'], + [ + 'every other control character', + String.fromCharCode(...Array.from({ length: 32 }, (_, i) => i)) + ], + ['delete and high ascii', '\u007f\u0080\u07ff'], + ['two-byte and three-byte scalars', 'é ü ± 日本語 …'], + ['a surrogate pair', '🙂 emoji 👍🏽'], + ['a lone high surrogate', 'a\ud800b'], + ['a lone low surrogate', 'a\udc00b'], + ['a high surrogate at the end', 'trailing\ud83d'], + ['an ANSI colour run', '\u001b[38;5;196mred\u001b[0m\r\n'], + ['a full SGR screen line', '\u001b[01;31m\u001b[Kmatch\u001b[m\u001b[K'.repeat(40)] +] + +describe('the JSON size of a terminal payload', () => { + it.each(CASES)('agrees with JSON.stringify: %s', (_label, value) => { + expect(terminalStreamJsonByteLength(value)).toBe( + Buffer.byteLength(JSON.stringify(value), 'utf8') + ) + }) + + it('agrees on a screen built from every class at once', () => { + const mixed = CASES.map(([, value]) => value).join('\u001b[0m') + expect(terminalStreamJsonByteLength(mixed)).toBe( + Buffer.byteLength(JSON.stringify(mixed), 'utf8') + ) + }) + + it('costs an ANSI snapshot far more than its text, which is the whole reason it exists', () => { + // The defect in one assertion: the desktop budgets the left number and the bridge caps the + // right one, and a rule that measured the text would pass a frame that cannot be delivered. + const screen = '\u001b[38;5;196m#'.repeat(20_000) + expect(terminalStreamJsonByteLength(screen)).toBeGreaterThan( + Buffer.byteLength(screen, 'utf8') * 1.4 + ) + }) +}) diff --git a/src/shared/terminal-stream-json-byte-length.ts b/src/shared/terminal-stream-json-byte-length.ts new file mode 100644 index 00000000000..9e3cd229178 --- /dev/null +++ b/src/shared/terminal-stream-json-byte-length.ts @@ -0,0 +1,64 @@ +/** + * What a terminal snapshot costs once it is a JSON string, which is the only size that matters to + * a client reading it through the page bridge. + * + * The raw budget the desktop has always applied measures the text. The bridge measures the frame, + * and the two are not close: every ESC byte in an ANSI snapshot is a control character, so + * `JSON.stringify` spends six bytes on the one byte the text spent, and a colour-dense screen + * crosses 1.4x. A 512 KiB budgeted snapshot serializes past 640 KiB and the stream that carries it + * ends before its first live byte. + * + * Scanned rather than serialized, because its caller asks per output chunk on a hot stream and a + * `JSON.stringify` per chunk would copy every byte the terminal prints. The desktop's snapshot + * budget does serialize, on purpose: it runs once per attach and must not be wrong by a field. + */ + +/** `\b \t \n \f \r` have two-character escapes; every other control character costs `\uXXXX`. */ +const SHORT_ESCAPED_CONTROLS = new Set([0x08, 0x09, 0x0a, 0x0c, 0x0d]) + +const TWO_CHARACTER_ESCAPE_BYTES = 2 +const UNICODE_ESCAPE_BYTES = 6 +const QUOTE_BYTES = 2 + +const HIGH_SURROGATE_START = 0xd800 +const HIGH_SURROGATE_END = 0xdbff +const LOW_SURROGATE_START = 0xdc00 +const LOW_SURROGATE_END = 0xdfff + +/** + * The UTF-8 bytes `JSON.stringify(data)` produces, quotes included. + * + * Matches the serializer's own rules rather than approximating them: the two-character escapes, + * `\uXXXX` for every other control, and — since ES2019's well-formed JSON.stringify — `\uXXXX` for + * a lone surrogate, which a snapshot cut mid-character can carry. A surrogate pair is one scalar + * and four UTF-8 bytes across its two code units. + */ +export function terminalStreamJsonByteLength(data: string): number { + let bytes = QUOTE_BYTES + for (let index = 0; index < data.length; index += 1) { + const unit = data.charCodeAt(index) + if (unit === 0x22 || unit === 0x5c) { + bytes += TWO_CHARACTER_ESCAPE_BYTES + } else if (unit < 0x20) { + bytes += SHORT_ESCAPED_CONTROLS.has(unit) ? TWO_CHARACTER_ESCAPE_BYTES : UNICODE_ESCAPE_BYTES + } else if (unit < 0x80) { + bytes += 1 + } else if (unit < 0x800) { + bytes += 2 + } else if (unit >= HIGH_SURROGATE_START && unit <= HIGH_SURROGATE_END) { + const next = index + 1 < data.length ? data.charCodeAt(index + 1) : 0 + if (next >= LOW_SURROGATE_START && next <= LOW_SURROGATE_END) { + // One scalar over two units: four UTF-8 bytes, and the low half is not read again. + bytes += 4 + index += 1 + } else { + bytes += UNICODE_ESCAPE_BYTES + } + } else if (unit >= LOW_SURROGATE_START && unit <= LOW_SURROGATE_END) { + bytes += UNICODE_ESCAPE_BYTES + } else { + bytes += 3 + } + } + return bytes +}