Files
orca/src/main/ssh/ssh-git-response-stream-reader.test.ts
T
e233d6a641 Recover automatically instead of getting stuck on "Multiplexer disposed" when an SSH relay drops (#12216)
* fix(ssh): recover instead of wedging when the relay channel dies mid-connect

Three coupled defects made a dropped SSH relay look like a permanent bug:

1. SshRelaySession.establish()/reconnect() ran their last liveness gate before
   configureRelayGraceTime(), whose mux.notify() can dispose the mux
   synchronously (writer control-lane admission cap, or a throwing transport).
   The session then latched _state='ready' + _onReady (status bar "connected")
   while watchMuxForRelayLoss() silently no-op'd on the dead mux, so the bounded
   relay backoff in ipc/ssh.ts never ran and the fs/pty/git providers stayed
   registered against a dead multiplexer. Both sites now re-check
   mux.isDisposed() after the notify and take the existing failure path.

2. SshChannelMultiplexer.request()/notifyWithSettlement() always reported the
   permanent-shutdown string 'Multiplexer disposed' with no code, even when the
   recorded dispose reason was connection_lost. The reason is now recorded and a
   shared disposedError() factory serves dispose(), request(),
   notifyWithSettlement(), so a transient drop reports
   'SSH connection lost, reconnecting...' / CONNECTION_LOST. onDispose() on an
   already-disposed mux now fires the handler synchronously with that reason
   instead of returning a silent no-op (without retaining it).

3. TerminalErrorToast no longer renders a transient relay drop in the red
   "please file an issue" style. The marker is matched with includes() because
   the message reaches the toast IPC-wrapped.

ssh-git-response-stream-reader registers its onDispose subscriber after the
abort wiring, since an already-dead mux now fails synchronously there and the
cleanup must be able to drop the caller's abort listener.

Closes #11953

* fix(ssh): treat a mux killed during PTY reattach as relay loss

reconnect()'s post-reattach gate bare-returned when ownsAttempt() went false,
and reattachKnownPtys swallows every per-PTY error, so a control-lane failure
during a large reattach burst disposed the mux without ever reaching the catch:
providers stayed bound to the dead mux, no relay-loss watcher was installed, and
the session wedged in 'reconnecting' until restart. Take the failure path when
our own mux is the one that died so ssh.ts's bounded backoff retries.

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

* fix(ssh): recover when relay dies during setup instead of wedging

Introduce verifyRelayAttempt() to detect mux disposal at each setup phase
(consumer session, home resolution, provider registration, PTY reattach).
Routes mid-setup connection loss into relay-loss recovery instead of
hanging in reconnecting state.

* Extract SSH disposal error factory

Multiple sites were duplicating the disposal error creation logic with
specific message and code values. The renderer uses these to distinguish
temporary disconnects (show reconnection overlay) from permanent shutdown
(show error toast), so all producers must use the same factory to avoid
silent UI degradation.

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-08-05 00:13:41 -07:00

31 lines
1.3 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { requestGitStreamable } from './ssh-git-response-stream-reader'
import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer'
function createMockTransport(): MultiplexerTransport {
return {
write: () => {},
onData: () => {},
onClose: () => {}
}
}
describe('requestGitStreamable on an already-dead multiplexer', () => {
it('rejects as a transient relay loss and leaves no listener on the caller signal', async () => {
const mux = new SshChannelMultiplexer(createMockTransport())
mux.dispose('connection_lost')
const controller = new AbortController()
const addListener = vi.spyOn(controller.signal, 'addEventListener')
const removeListener = vi.spyOn(controller.signal, 'removeEventListener')
await expect(
requestGitStreamable(mux, 'git.status', { cwd: '/repo' }, { signal: controller.signal })
).rejects.toThrow('SSH connection lost, reconnecting...')
// #11953: a disposed mux fails synchronously inside onDispose, so the abort
// listener must already be registered when that cleanup runs — otherwise it
// outlives the request for the lifetime of the caller's signal.
expect(removeListener).toHaveBeenCalledTimes(addListener.mock.calls.length)
})
})