diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 1be167d6e..b0983d5e0 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1580,6 +1580,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let closed = false let cursor = 0 const streams = new Map() + const pendingPtyWaitControllers = new Map>() let ackTotalInFlightBytes = 0 let resolveMultiplex = (): void => {} const multiplexClosed = new Promise((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() 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) diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 99a77fb9a..f281d72e4 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -26,6 +26,7 @@ function stubRuntime(overrides: Partial = {}): 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 = {}, + trace?: string[] +) { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map 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(() => {})) + }) + 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>) => 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((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((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 + > + }), + 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[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => 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[] = []