feat(split): Pane B reconnects after a drop instead of staying dead

A Codeman restart (every deploy) or a network blip used to leave Pane B
dead, with a marker asking the user to close and reopen the split.
TerminalTile now reconnects:

- A transient close reconnects on the primary pane's backoff ladder
  (CodemanWsReconnect) plus jitter; the attempt count resets only on a
  successful open. The redelivery sweep's forced close (1005) counts as
  transient.
- On reopen the closed state is cleared before the buffer refresh that
  closes the output gap, so no stale marker lands under a healthy pane.
- 4003/4004/4009/4010 stop the pane for good and report once through a
  new onExit(code) callback; the marker says why.
- Sockets are replaced race-free: the old one is detached before a new
  one opens, and every handler ignores events from a socket that is no
  longer current. destroy() cancels a pending reconnect.
- reconnectNow() lets an owner skip the backoff.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 09:54:57 +02:00
parent 5e3dbf2057
commit 0ec17633ba
3 changed files with 342 additions and 47 deletions
+10 -6
View File
@@ -176,7 +176,8 @@ function deferred<T>() {
return { promise, resolve };
}
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('Pane B disconnected');
// Every marker variant (reconnecting, session ended, refused, taken over) starts the same way.
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('[disconnected');
/** Lets every microtask the vm-side promise chain queued run. */
const settle = () => new Promise((r) => setTimeout(r, 0));
@@ -722,8 +723,11 @@ describe('TerminalTile scroll-to-top history pull', () => {
expect(connect).toContain('this._installWheelListener();');
expect(connect).toContain('this._onLiveClear();');
expect(connect).not.toContain('this.terminal.clear();');
// The tests below drive the close through _onSocketClosed() directly.
expect(connect).toContain('this.ws.onclose = () => this._onSocketClosed();');
// The tests below drive the close through _onSocketClosed() directly; the
// socket's own handler forwards the close event (and its code) there, and
// only for the current socket (_openSocket).
expect(connect).toContain('this._onSocketClosed(event);');
expect(connect).toMatch(/ws\.onclose = \(event\) => \{\s*if \(ws !== this\.ws\) return;/);
});
it('a close with no pull running writes the marker straight away', () => {
@@ -747,9 +751,9 @@ describe('TerminalTile scroll-to-top history pull', () => {
void pane._pullHistory();
await settle();
const marker = expect.stringContaining('Pane B disconnected');
const marker = expect.stringContaining('[disconnected');
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/));
expect(writes.at(-1)).toEqual(expect.stringMatching(/\[disconnected/));
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
});
@@ -1004,7 +1008,7 @@ describe('TerminalTile scroll-to-top history pull', () => {
await settle();
for (const call of pane.terminal.write.mock.calls) {
expect(call[0]).toEqual(expect.not.stringMatching(/Pane B disconnected/));
expect(call[0]).toEqual(expect.not.stringMatching(/\[disconnected/));
}
});