fix(terminal): paint paired remote terminals on first subscribe (#8954)

* fix(terminal): paint paired remote terminals on first subscribe

* test(terminal): cover desktop legacy subscriber PTY wait

The widened terminal.subscribe gate (isMobile -> params.client) had no
regression test: reverting it to the mobile-only gate left the suite
green. Add a desktop legacy-subscriber case asserting the mount request
and late-PTY wait fire before the scrollback-only fallback.

* fix(terminal): cancel abandoned multiplex PTY waits

Desktop subscribers can close while the new late-PTY wait is pending. Register provisional slot cancellation and abort all pending waits on unsubscribe, connection abort, or multiplex teardown so a late PTY cannot resurrect a ghost output stream.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
Rod Boev 2026-07-17 20:08:53 -04:00 committed by GitHub
parent 5907816457
commit 72a5c6f199
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 471 additions and 51 deletions

View File

@ -1580,6 +1580,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
let closed = false
let cursor = 0
const streams = new Map<number, TerminalMultiplexStream>()
const pendingPtyWaitControllers = new Map<number, Set<AbortController>>()
let ackTotalInFlightBytes = 0
let resolveMultiplex = (): void => {}
const multiplexClosed = new Promise<void>((resolve) => {
@ -1799,11 +1800,28 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
emit({ type: 'end', streamId })
}
}
const cancelPendingPtyWaits = (streamId: number): void => {
const controllers = pendingPtyWaitControllers.get(streamId)
if (!controllers) {
return
}
pendingPtyWaitControllers.delete(streamId)
for (const controller of controllers) {
controller.abort()
}
}
const cancelAllPendingPtyWaits = (): void => {
for (const streamId of Array.from(pendingPtyWaitControllers.keys())) {
cancelPendingPtyWaits(streamId)
}
}
const closeMultiplex = (): void => {
if (closed) {
return
}
closed = true
signal?.removeEventListener('abort', cancelAllPendingPtyWaits)
cancelAllPendingPtyWaits()
const remoteDesktopKeysByPty = new Map<string, string[]>()
for (const streamId of Array.from(streams.keys())) {
const stream = streams.get(streamId)
@ -1830,6 +1848,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
return
}
if (frame.opcode === TerminalStreamOpcode.Unsubscribe) {
cancelPendingPtyWaits(stream.streamId)
detachStream(stream.streamId, false)
return
}
@ -2081,15 +2100,47 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
emit({ type: 'end', streamId: request.streamId })
return
}
if (!leaf?.ptyId && isMobile) {
if (!leaf?.ptyId && request.client) {
// Why: a never-mounted tab has no graph leaf to await; mounting the
// exact tab lets its PTY attach without activating the worktree.
runtime.requestRendererTerminalTabMount(request.terminal)
const waitController = new AbortController()
const pendingControllers = pendingPtyWaitControllers.get(request.streamId) ?? new Set()
pendingControllers.add(waitController)
pendingPtyWaitControllers.set(request.streamId, pendingControllers)
if (signal?.aborted) {
waitController.abort()
}
// Why: the live slot handler does not exist until the PTY attaches;
// retain cancellation ownership while the pane is still pending.
const unregisterPendingHandler = registerBinaryStreamHandler(
request.streamId,
(frame) => {
if (frame.opcode === TerminalStreamOpcode.Unsubscribe) {
cancelPendingPtyWaits(request.streamId)
detachStream(request.streamId, false)
}
}
)
try {
const ptyId = await runtime.waitForLeafPtyId(request.terminal, 10_000, signal)
const ptyId = await runtime.waitForLeafPtyId(
request.terminal,
10_000,
waitController.signal
)
leaf = { ptyId }
} catch {
if (closed || signal?.aborted) {
if (closed || signal?.aborted || waitController.signal.aborted) {
return
}
// Fall through to the explicit no_connected_pty error below.
} finally {
const currentControllers = pendingPtyWaitControllers.get(request.streamId)
currentControllers?.delete(waitController)
if (currentControllers?.size === 0) {
pendingPtyWaitControllers.delete(request.streamId)
}
unregisterPendingHandler()
}
}
if (!leaf?.ptyId) {
@ -2213,30 +2264,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
return
}
if (!isMobile) {
stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
const mode =
event.mode === 'mobile-fit'
? event.mode
: (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey)
.mode ?? 'desktop-fit')
emit({
type: 'fit-override-changed',
streamId: request.streamId,
mode,
cols: event.cols,
rows: event.rows
})
})
stream.unsubscribeDriver = runtime.subscribeToDriverChanges(ptyId, (driver) => {
emit({
type: 'driver-changed',
streamId: request.streamId,
driver
})
})
}
let read = await runtime.readTerminal(request.terminal)
let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
if (closed || streams.get(request.streamId) !== stream) {
@ -2264,25 +2291,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const layoutSeq = runtime.getLayout(ptyId)?.seq
const snapshotFrameSeq = serialized?.seq ?? layoutSeq
const snapshotOutputSeq = serialized?.seq
if (!isMobile) {
const fitOverride = runtime.getTerminalFitOverride(ptyId)
const desktopHold = runtime.getRemoteDesktopFitHold?.(
ptyId,
stream.remoteDesktopSubscriptionKey
) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 }
emit({
type: 'fit-override-changed',
streamId: request.streamId,
mode: fitOverride?.mode ?? desktopHold.mode,
cols: fitOverride?.cols ?? desktopHold.cols,
rows: fitOverride?.rows ?? desktopHold.rows
})
emit({
type: 'driver-changed',
streamId: request.streamId,
driver: runtime.getDriver(ptyId)
})
}
emit({
type: 'subscribed',
streamId: request.streamId,
@ -2327,6 +2335,46 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
stream.pendingOutputBytes = 0
stream.pendingOutputOverflowed = false
stream.outputBatcher.flush()
if (!isMobile) {
stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => {
const mode =
event.mode === 'mobile-fit'
? event.mode
: (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey)
.mode ?? 'desktop-fit')
emit({
type: 'fit-override-changed',
streamId: request.streamId,
mode,
cols: event.cols,
rows: event.rows
})
})
stream.unsubscribeDriver = runtime.subscribeToDriverChanges(ptyId, (driver) => {
emit({
type: 'driver-changed',
streamId: request.streamId,
driver
})
})
const fitOverride = runtime.getTerminalFitOverride(ptyId)
const desktopHold = runtime.getRemoteDesktopFitHold?.(
ptyId,
stream.remoteDesktopSubscriptionKey
) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 }
emit({
type: 'fit-override-changed',
streamId: request.streamId,
mode: fitOverride?.mode ?? desktopHold.mode,
cols: fitOverride?.cols ?? desktopHold.cols,
rows: fitOverride?.rows ?? desktopHold.rows
})
emit({
type: 'driver-changed',
streamId: request.streamId,
driver: runtime.getDriver(ptyId)
})
}
stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => {
stream.outputBatcher.flush()
const resizeGeneration = stream.resizeGeneration + 1
@ -2430,6 +2478,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
})
signal?.addEventListener('abort', cancelAllPendingPtyWaits, { once: true })
runtime.registerSubscriptionCleanup(
`terminal-multiplex:${connectionId}`,
closeMultiplex,
@ -2464,10 +2514,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
}
// Why: the left pane's PTY spawns asynchronously after the tab is created.
// Mobile clients that subscribe before the PTY is ready would get a bare
// Clients that subscribe before the PTY is ready would get a bare
// scrollback+end with no live stream or phone-fit. Wait for the PTY so
// the subscribe can proceed normally.
if (!leaf?.ptyId && isMobile) {
if (!leaf?.ptyId && params.client) {
// Why: a never-mounted tab has no graph leaf to await; mounting the
// exact tab lets its PTY attach without activating the worktree.
rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal)

View File

@ -26,6 +26,7 @@ function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRuntimeSe
// only stub the legacy resolveLeafForHandle still bind; tests that need a
// null/stale leaf override this explicitly.
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
requestRendererTerminalTabMount: vi.fn().mockReturnValue(true),
updateRemoteDesktopViewer: vi.fn().mockResolvedValue(true),
unregisterRemoteDesktopViewer: vi.fn().mockResolvedValue(true),
unregisterRemoteDesktopViewers: vi.fn().mockResolvedValue(true),
@ -40,6 +41,92 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
function startDesktopMultiplexSubscribe(
overrides: Partial<OrcaRuntimeService> = {},
trace?: string[]
) {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => void>()
const runtime = stubRuntime({
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snapshot', cols: 120, rows: 40 }),
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()),
getTerminalFitOverride: vi.fn().mockReturnValue(null),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
cleanups.get(id)?.()
}),
...overrides,
waitForTerminal:
overrides.waitForTerminal ?? vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => {
messages.push(msg)
const type = JSON.parse(msg).result?.type
if (type) {
trace?.push(type)
}
},
{
connectionId: 'conn-desktop-first-paint',
sendBinary: (bytes) => {
binaryFrames.push(bytes)
const opcode = decodeTerminalStreamFrame(bytes)?.opcode
if (
opcode === TerminalStreamOpcode.SnapshotStart ||
opcode === TerminalStreamOpcode.SnapshotChunk ||
opcode === TerminalStreamOpcode.SnapshotEnd
) {
trace?.push('snapshot')
}
},
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
return { messages, binaryFrames, handlers, cleanups, runtime, dispatchPromise }
}
function sendDesktopMultiplexSubscribe(
handlers: Map<number, (frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void>
) {
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: 1,
payload: encodeTerminalStreamJson({
streamId: 7,
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' },
capabilities: { ackOutput: 1, desktopViewportClaims: 1 },
viewport: { cols: 120, rows: 40 }
})
})
)!
)
}
describe('terminal multiplex RPC', () => {
it('multiplexes terminal streams and routes desktop resize to the source PTY', async () => {
vi.useFakeTimers()
@ -2676,11 +2763,15 @@ describe('terminal multiplex RPC', () => {
)
await vi.waitFor(() => expect(runtime.waitForLeafPtyId).toHaveBeenCalled())
expect(runtime.waitForLeafPtyId).toHaveBeenCalledWith('terminal-1', 10_000, controller.signal)
const pendingWaitSignal = vi.mocked(runtime.waitForLeafPtyId).mock.calls[0]?.[2]
expect(runtime.waitForLeafPtyId).toHaveBeenCalledWith(
'terminal-1',
10_000,
expect.any(AbortSignal)
)
controller.abort()
await Promise.resolve()
await Promise.resolve()
await vi.waitFor(() => expect(pendingWaitSignal?.aborted).toBe(true))
expect(runtime.readTerminal).not.toHaveBeenCalled()
expect(
@ -2694,6 +2785,285 @@ describe('terminal multiplex RPC', () => {
await dispatchPromise
})
it("waits for a desktop multiplex subscriber's PTY before retiring the terminal", async () => {
let resolvePty: (ptyId: string) => void = () => {}
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }),
requestRendererTerminalTabMount: vi.fn(),
waitForLeafPtyId: vi.fn(
() =>
new Promise<string>((resolve) => {
resolvePty = resolve
})
)
})
const harness = startDesktopMultiplexSubscribe(runtime)
await vi.waitFor(() =>
expect(harness.messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
sendDesktopMultiplexSubscribe(harness.handlers)
await vi.waitFor(() => expect(runtime.waitForLeafPtyId).toHaveBeenCalled())
expect(runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1')
expect(
harness.binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Error)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
).toEqual([])
resolvePty('pty-1')
await vi.waitFor(() =>
expect(
harness.binaryFrames.map((frame) => decodeTerminalStreamFrame(frame)?.opcode)
).toContain(TerminalStreamOpcode.SnapshotChunk)
)
expect(
harness.binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Error)
.map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : ''))
).toEqual([])
harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.()
await harness.dispatchPromise
})
it('cancels a pending desktop PTY wait when its multiplex slot unsubscribes', async () => {
let resolvePty: (ptyId: string) => void = () => {}
let waitSignal: AbortSignal | undefined
const readTerminal = vi.fn().mockResolvedValue({ tail: [], truncated: false })
const subscribeToTerminalData = vi.fn().mockReturnValue(vi.fn())
const registerRemoteTerminalViewSubscriber = vi.fn().mockReturnValue(vi.fn())
const waitForLeafPtyId = vi.fn(
(_handle: string, _timeoutMs?: number, signal?: AbortSignal) =>
new Promise<string>((resolve, reject) => {
resolvePty = resolve
waitSignal = signal
signal?.addEventListener('abort', () => reject(new Error('request_aborted')), {
once: true
})
})
)
const harness = startDesktopMultiplexSubscribe({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }),
waitForLeafPtyId,
readTerminal,
subscribeToTerminalData,
registerRemoteTerminalViewSubscriber
})
await vi.waitFor(() =>
expect(harness.messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
sendDesktopMultiplexSubscribe(harness.handlers)
await vi.waitFor(() => expect(waitForLeafPtyId).toHaveBeenCalled())
harness.handlers.get(7)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Unsubscribe,
streamId: 7,
seq: 2,
payload: new Uint8Array()
})
)!
)
resolvePty('pty-1')
await vi.waitFor(() =>
expect(waitSignal?.aborted || readTerminal.mock.calls.length > 0).toBe(true)
)
// Why: a closed pane must not become a hidden live-output consumer when its late PTY appears.
expect(waitSignal?.aborted).toBe(true)
expect(readTerminal).not.toHaveBeenCalled()
expect(subscribeToTerminalData).not.toHaveBeenCalled()
expect(registerRemoteTerminalViewSubscriber).not.toHaveBeenCalled()
expect(harness.handlers.has(7)).toBe(false)
harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.()
await harness.dispatchPromise
})
it("still reports no_connected_pty when a desktop multiplex subscriber's PTY never appears", async () => {
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }),
waitForLeafPtyId: vi.fn().mockRejectedValue(new Error('timeout'))
})
const harness = startDesktopMultiplexSubscribe(runtime)
await vi.waitFor(() =>
expect(harness.messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
sendDesktopMultiplexSubscribe(harness.handlers)
await vi.waitFor(() =>
expect(
harness.binaryFrames.map((frame) => decodeTerminalStreamFrame(frame)?.opcode)
).toContain(TerminalStreamOpcode.Error)
)
const errorFrame = harness.binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.find((frame) => frame?.opcode === TerminalStreamOpcode.Error)
expect(errorFrame && decodeTerminalStreamText(errorFrame.payload)).toBe('no_connected_pty')
harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.()
await harness.dispatchPromise
})
it('emits initial desktop fit events after the first multiplex snapshot', async () => {
const trace: string[] = []
let fitListener: ((event: { mode: string; cols: number; rows: number }) => void) | undefined
let driverListener: ((driver: unknown) => void) | undefined
const harness = startDesktopMultiplexSubscribe(
{
readTerminal: vi.fn(async () => {
fitListener?.({ mode: 'desktop-fit', cols: 100, rows: 30 })
driverListener?.({ kind: 'transition-during-snapshot' })
return { tail: [], truncated: false } as unknown as Awaited<
ReturnType<OrcaRuntimeService['readTerminal']>
>
}),
subscribeToFitOverrideChanges: vi.fn((_ptyId, listener) => {
fitListener = listener
return vi.fn()
}),
subscribeToDriverChanges: vi.fn((_ptyId, listener) => {
driverListener = listener
return vi.fn()
})
},
trace
)
await vi.waitFor(() =>
expect(harness.messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
sendDesktopMultiplexSubscribe(harness.handlers)
await vi.waitFor(() => expect(trace).toContain('driver-changed'))
expect(trace.lastIndexOf('snapshot')).toBeLessThan(trace.indexOf('fit-override-changed'))
expect(trace.lastIndexOf('snapshot')).toBeLessThan(trace.indexOf('driver-changed'))
harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.()
await harness.dispatchPromise
})
it('does not wait when a desktop multiplex subscriber already has a PTY', async () => {
const runtime = stubRuntime({
requestRendererTerminalTabMount: vi.fn(),
waitForLeafPtyId: vi.fn()
})
const harness = startDesktopMultiplexSubscribe(runtime)
await vi.waitFor(() =>
expect(harness.messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
sendDesktopMultiplexSubscribe(harness.handlers)
await vi.waitFor(() =>
expect(harness.messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(
true
)
)
expect(runtime.waitForLeafPtyId).not.toHaveBeenCalled()
expect(runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled()
harness.cleanups.get('terminal-multiplex:conn-desktop-first-paint')?.()
await harness.dispatchPromise
})
it('preserves clientless multiplex subscriptions without a PTY wait', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
let cleanup: () => void = () => {}
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }),
requestRendererTerminalTabMount: vi.fn(),
waitForLeafPtyId: vi.fn(),
registerSubscriptionCleanup: vi.fn((_id: string, callback: () => void) => {
cleanup = callback
})
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.multiplex', {}),
(msg) => messages.push(msg),
{
connectionId: 'conn-clientless-multiplex',
sendBinary: (bytes) => {
binaryFrames.push(bytes)
},
registerBinaryStreamHandler: (streamId, handler) => {
handlers.set(streamId, handler)
return () => handlers.delete(streamId)
}
}
)
await vi.waitFor(() =>
expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true)
)
handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId: 0,
seq: 1,
payload: encodeTerminalStreamJson({ streamId: 7, terminal: 'terminal-1' })
})
)!
)
await vi.waitFor(() =>
expect(binaryFrames.map((frame) => decodeTerminalStreamFrame(frame)?.opcode)).toContain(
TerminalStreamOpcode.Error
)
)
expect(runtime.waitForLeafPtyId).not.toHaveBeenCalled()
expect(runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled()
const errorFrame = binaryFrames
.map((frame) => decodeTerminalStreamFrame(frame))
.find((frame) => frame?.opcode === TerminalStreamOpcode.Error)
expect(errorFrame && decodeTerminalStreamText(errorFrame.payload)).toBe('no_connected_pty')
cleanup()
await dispatchPromise
})
it('preserves clientless legacy subscriptions without a PTY wait or mount', async () => {
const messages: string[] = []
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }),
waitForLeafPtyId: vi.fn(),
requestRendererTerminalTabMount: vi.fn(),
readTerminal: vi.fn().mockResolvedValue({ tail: ['scrollback'], truncated: false })
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', { terminal: 'terminal-1' }),
(msg) => messages.push(msg),
{ connectionId: 'conn-clientless-legacy' }
)
await dispatchPromise
expect(runtime.waitForLeafPtyId).not.toHaveBeenCalled()
expect(runtime.requestRendererTerminalTabMount).not.toHaveBeenCalled()
expect(messages.map((msg) => JSON.parse(msg).result?.type)).toEqual(['subscribed', 'end'])
})
it('waits for a desktop legacy subscriber PTY before the scrollback-only fallback', async () => {
const messages: string[] = []
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }),
waitForLeafPtyId: vi.fn().mockRejectedValue(new Error('timeout')),
requestRendererTerminalTabMount: vi.fn().mockReturnValue(true),
readTerminal: vi.fn().mockResolvedValue({ tail: ['scrollback'], truncated: false })
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: 'desktop-1', type: 'desktop' }
}),
(msg) => messages.push(msg),
{ connectionId: 'conn-desktop-legacy' }
)
await dispatchPromise
// Widened gate: a desktop client must mount + await its late PTY, not skip
// straight to the bare scrollback path the way it did under the mobile-only gate.
expect(runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1')
expect(runtime.waitForLeafPtyId).toHaveBeenCalledWith('terminal-1', 10_000, undefined)
expect(messages.map((msg) => JSON.parse(msg).result?.type)).toEqual(['subscribed', 'end'])
})
it('keeps view-subscriber releases balanced when a same-streamId subscribe overwrites a blocked one', async () => {
const messages: string[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []