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 <noreply@anthropic.com>

* fix(ssh): the newest bridged attach keeps its viewer even when an older one answers last

Co-authored-by: Claude <noreply@anthropic.com>

* fix(ssh): an older bridged connect still paints its screen when a newer attach keeps the viewer

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-19 16:31:28 -07:00
committed by GitHub
co-authored by Claude
parent 3f9e4ecc1b
commit fbd0a29eed
2 changed files with 63 additions and 2 deletions
@@ -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)
@@ -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 }
}