From fbd0a29eed178d4ed0f4f526db49b4c7da67bf21 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:31:28 -0700 Subject: [PATCH] fix(ssh): two overlapping bridged-terminal attaches hand back the viewer they replace (#30856) * fix(ssh): two overlapping bridged attaches hand back the viewer they replace Co-authored-by: Claude * fix(ssh): the newest bridged attach keeps its viewer even when an older one answers last Co-authored-by: Claude * fix(ssh): an older bridged connect still paints its screen when a newer attach keeps the viewer Co-authored-by: Claude --------- Co-authored-by: Claude --- .../legacy-relay-bridge-transport.test.ts | 42 +++++++++++++++++++ .../legacy-relay-bridge-transport.ts | 23 +++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.test.ts b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.test.ts index c77121735b2..83c69205b4b 100644 --- a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.test.ts @@ -387,6 +387,48 @@ describe('a runtime pane bridged to an older relay’s terminal', () => { ).toEqual(['ssh:ssh-box@@pty-3']) }) + it('hands back every viewer when two attaches for the pane overlap', async () => { + let next = 0 + bridge.attach.mockImplementation(async () => { + next += 1 + return { status: 'attached', replay: '', acquisitionId: next } + }) + const transport = createLegacyRelayBridgeTransport(PTY, innerTransport()) + // A connect and an attach for the same pane, both answered before either settles. + const connecting = transport.connect({ url: '', sessionId: PTY, callbacks: callbacks() }) + transport.attach({ existingPtyId: PTY, callbacks: callbacks() }) + await connecting + await vi.waitFor(() => expect(bridge.attach).toHaveBeenCalledTimes(2)) + await Promise.resolve() + transport.detach?.() + expect(bridge.detach.mock.calls.map(([args]) => args.acquisitionId).sort()).toEqual([1, 2]) + }) + + it('keeps the newest attach’s viewer when an older attach answers last', async () => { + const answers: ((acquisitionId: number) => void)[] = [] + bridge.attach.mockImplementation( + () => + new Promise((resolve) => + answers.push((acquisitionId) => + resolve({ status: 'attached', replay: `SCREEN-${acquisitionId}`, acquisitionId }) + ) + ) + ) + const transport = createLegacyRelayBridgeTransport(PTY, innerTransport()) + const older = transport.connect({ url: '', sessionId: PTY, callbacks: callbacks() }) + transport.attach({ existingPtyId: PTY, callbacks: callbacks() }) + await vi.waitFor(() => expect(answers).toHaveLength(2)) + // The newer attach answers first, then the older one. + answers[1](2) + await vi.waitFor(() => expect(transport.isConnected()).toBe(true)) + answers[0](1) + // The connect still paints its screen: the attach that won delivers none of its own. + await expect(older).resolves.toMatchObject({ replay: 'SCREEN-1' }) + expect(bridge.detach.mock.calls.map(([args]) => args.acquisitionId)).toEqual([1]) + transport.detach?.() + expect(bridge.detach.mock.calls.map(([args]) => args.acquisitionId)).toEqual([1, 2]) + }) + it('hands any other connect straight to the managed server’s transport', async () => { const inner = innerTransport() const transport = createLegacyRelayBridgeTransport(PTY, inner) diff --git a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts index 47ec54ab9e0..aee0b048605 100644 --- a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts +++ b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts @@ -42,6 +42,23 @@ export function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransp viewer = null } + /** Issue order of this pane's attaches, and which one the held viewer came from. */ + const seqs = { issued: 0, viewer: 0 } + + // Overlapping attaches (connect, attach, reattach) each get a viewer; the newest issued keeps + // its own, whichever answers last, and every other one is handed back. + const adoptViewer = (seq: number, acquisitionId: number): void => { + if (seq < seqs.viewer) { + api?.detach({ ptyId, acquisitionId }) + return + } + if (viewer !== null && viewer !== acquisitionId) { + api?.detach({ ptyId, acquisitionId: viewer }) + } + viewer = acquisitionId + seqs.viewer = seq + } + const release = (): void => { epoch += 1 unsubscribe.forEach((dispose) => dispose()) @@ -85,6 +102,7 @@ export function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransp } reattaching = true const at = epoch + const seq = ++seqs.issued try { // The screen comes through onReplay in output order: applied from this reply instead, its // clear would erase output the relay printed after it. @@ -93,7 +111,7 @@ export function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransp return } if (result.status === 'attached') { - viewer = result.acquisitionId + adoptViewer(seq, result.acquisitionId) clearUnreachable() } else if (result.status === 'exited' || result.status === 'not-bridged') { clearUnreachable() @@ -168,6 +186,7 @@ export function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransp mode = 'bridge' subscribe() const at = epoch + const seq = ++seqs.issued const result = await api.attach({ ptyId }) if (!stillWanted(at, result)) { return undefined @@ -184,7 +203,7 @@ export function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransp showUnreachable() return { id: ptyId, isReattach: true, replay: '' } } - viewer = result.acquisitionId + adoptViewer(seq, result.acquisitionId) return { id: ptyId, isReattach: true, replay: result.replay } }