Files
orca/src/shared/terminal-stream-protocol.ts
T
6e3ebf534c fix(terminal): arbitrate active desktop viewport ownership (#8252)
* fix(terminal): let remote desktop viewers own the shared PTY width

A remote (relay/shared-control) desktop viewer resizes the host source PTY to
its own width, but was never registered as a width owner. So the host's own fit
cascade (window resize, split drag, tab reveal, "+"-new-tab re-render) freely
resized the viewed PTY back to the host-local width with no signal to the
viewer. The viewer kept its narrower grid while the host streamed wider
alt-screen frames -> cell-layout garble ("porridge") until a manual resize.

The mobile "presence lock" already solves this shape for phones by suppressing
the host's pty:resize while the phone drives. This does the same for remote
desktop viewers WITHOUT joining the mobile driver state machine (a viewer needs
only resize suppression, not input lock / phone-fit / driver banners), and it
routes every PTY geometry change through the existing enqueueLayout/applyLayout
serialization path rather than resizing the PTY ad hoc.

Design:
- Registry `remoteDesktopViewers: Map<ptyId, Map<subscriptionKey, viewport>>`,
  keyed per SUBSCRIPTION so duplicate streams of one client cannot release each
  other. isPtyResizeDrivenRemotely() = mobile driver OR any viewer present;
  pty:resize (host fit cascade) bails on it. INPUT is never locked (shared
  control: host and viewers can both type), unlike mobile.
- New internal layout target { kind: 'remote-desktop' } in applyLayout: clears
  terminalFitOverrides like 'desktop', resizes only when dims changed, emits no
  fit-override notifications, and does not call onExternalPtyResize (so a
  viewer's width never pollutes desktop restore state). enqueueLayout remains
  the sole serialized writer of PTY dimensions.
- Smallest client wins: applyRemoteDesktopLayout() sizes the PTY to the SMALLEST
  attached viewer, so viewers with different screen sizes never overflow the
  narrowest grid.
- No snapshot/replay race: a viewport that arrives while the initial scrollback
  snapshot is being serialized is buffered and applied as ONE serialized resize
  before serialization, so a mid-repaint alt screen is never baked into the
  snapshot.
- Reclaim on detach: when the last viewer leaves, applyRemoteDesktopLayout
  resizes the PTY back to the host's own width via enqueueLayout({kind:'desktop'}),
  so the host reflows to full width without a manual window jiggle. The host
  width is captured at the pty:resize suppression point (the host's own resize
  attempts) — a source a remote viewport never pollutes.
- Mobile coexistence: a phone outranks a viewer; when the phone leaves, the
  mobile-release paths call applyRemoteDesktopLayout so a surviving viewer keeps
  the PTY, else the host reclaims.

Verified live (two local desktop instances): correct render on connect, no
garble on viewer-window resize (synchronous), and host reclaims full width on
disconnect. Unit tests cover the registry lifecycle, smallest-client-wins,
reclaim-via-layout, and mobile coexistence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>

* fix(terminal): address remote-desktop PTY width review findings

Resolves four CodeRabbit findings on the remote-desktop viewer width
ownership change, plus a round-trip race surfaced while fixing the leak:

- Leak: the one-shot terminal.updateViewport RPC has no disconnect hook,
  so it must never create a width floor (nothing releases it, pinning the
  host at a stale width). Add refreshRemoteDesktopViewer, which only
  refreshes floors the client already owns via its stream subscription
  (matched by clientId), mirroring the mobile updateMobileViewport
  no-op-without-subscription invariant. The one-shot handler now refreshes
  instead of registering.
- Round-trip replay: with the one-shot fallback now refresh-only, a resize
  landing during the subscribe round-trip (connected, stream not yet
  current) was dropped. The transport now replays the latest viewport over
  the stream once it becomes current.
- Reclaim: keep the host reclaim target unless the reclaim resize actually
  landed (result.ok), so a failed reclaim can retry against true host
  geometry instead of a stale remote width.
- Snapshot drain: drain pendingRemoteDesktopViewport in the
  sendRequestedSnapshot finally block; a viewer resize parked during a
  SnapshotRequest buffering window was otherwise dropped until the next
  resize.
- Key scope: scope the multiplex width-floor key by connectionId so two
  connections reusing the same client-local streamId can't overwrite or
  release each other's floor.

Tests: refresh-never-creates-floor, reclaim-target-retained-on-failed-resize,
SnapshotRequest parked-resize drain, connection-scoped key assertions, and a
round-trip replay race test (verified to fail without the flush).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(terminal): arbitrate active desktop viewport ownership

Co-authored-by: Orca <help@stably.ai>

* test(terminal): expect viewport claim capability

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): preserve input across ownership teardown

Co-authored-by: Orca <help@stably.ai>

* test(terminal): keep subscribe buffer coverage within lint limit

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: shady <shady2k@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-11 03:02:29 -07:00

108 lines
3.3 KiB
TypeScript

const TERMINAL_STREAM_KIND = 0x74
const TERMINAL_STREAM_VERSION = 1
const HEADER_BYTES = 16
export enum TerminalStreamOpcode {
Output = 1,
SnapshotStart = 2,
SnapshotChunk = 3,
SnapshotEnd = 4,
Resized = 5,
Error = 6,
Input = 7,
Resize = 8,
Subscribe = 9,
Unsubscribe = 10,
SnapshotRequest = 11,
Metadata = 12,
// Why 13: Metadata=12 shipped to mobile clients in v1.4.120; Ack (branch-only
// remote-multiplex flow control) renumbers to stay wire-compatible.
Ack = 13,
// Why 14: Ack already occupies 13 on current clients; older runtimes ignore
// this opcode and still receive the compatibility Resize frame behind it.
ClaimViewport = 14
}
export type TerminalStreamFrame = {
opcode: TerminalStreamOpcode
streamId: number
seq: number
payload: Uint8Array
}
export function encodeTerminalStreamFrame(frame: TerminalStreamFrame): Uint8Array {
const out = new Uint8Array(HEADER_BYTES + frame.payload.length)
const view = new DataView(out.buffer, out.byteOffset, out.byteLength)
view.setUint8(0, TERMINAL_STREAM_KIND)
view.setUint8(1, TERMINAL_STREAM_VERSION)
view.setUint8(2, frame.opcode)
view.setUint8(3, 0)
view.setUint32(4, frame.streamId, true)
const seq = Math.max(0, Math.floor(frame.seq))
view.setUint32(8, Math.floor(seq / 0x100000000), true)
view.setUint32(12, seq >>> 0, true)
out.set(frame.payload, HEADER_BYTES)
return out
}
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
if (bytes.length < HEADER_BYTES) {
return null
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
if (view.getUint8(0) !== TERMINAL_STREAM_KIND || view.getUint8(1) !== TERMINAL_STREAM_VERSION) {
return null
}
const opcode = view.getUint8(2)
if (!isTerminalStreamOpcode(opcode)) {
return null
}
const high = view.getUint32(8, true)
const low = view.getUint32(12, true)
return {
opcode,
streamId: view.getUint32(4, true),
seq: high * 0x100000000 + low,
payload: bytes.slice(HEADER_BYTES)
}
}
export function encodeTerminalStreamJson(value: unknown): Uint8Array {
return new TextEncoder().encode(JSON.stringify(value))
}
export function decodeTerminalStreamJson<T>(payload: Uint8Array): T | null {
try {
return JSON.parse(new TextDecoder().decode(payload)) as T
} catch {
return null
}
}
export function encodeTerminalStreamText(value: string): Uint8Array {
return new TextEncoder().encode(value)
}
export function decodeTerminalStreamText(payload: Uint8Array): string {
return new TextDecoder().decode(payload)
}
function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
return (
value === TerminalStreamOpcode.Output ||
value === TerminalStreamOpcode.SnapshotStart ||
value === TerminalStreamOpcode.SnapshotChunk ||
value === TerminalStreamOpcode.SnapshotEnd ||
value === TerminalStreamOpcode.Resized ||
value === TerminalStreamOpcode.Error ||
value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Resize ||
value === TerminalStreamOpcode.Subscribe ||
value === TerminalStreamOpcode.Unsubscribe ||
value === TerminalStreamOpcode.SnapshotRequest ||
value === TerminalStreamOpcode.Metadata ||
value === TerminalStreamOpcode.Ack ||
value === TerminalStreamOpcode.ClaimViewport
)
}