diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index e4f2dd5e..488cfd53 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -405,6 +405,7 @@ export function resumeTerminal( } if (exitInfo && !exitInfo.alive) { entry.terminal.write(`\r\n[Process exited with code ${exitInfo.exitCode ?? -1}]\r\n`); + entry.exited = true; } const savedTitle = exitInfo?.title?.trim(); if (savedTitle && savedTitle !== UNNAMED_PANEL_TITLE) { diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 47ff02c6..914597eb 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -123,6 +123,7 @@ import { toggleSessionTodo, } from './terminal-registry'; import { pasteFilePaths } from './clipboard'; +import { registry } from './terminal-store'; interface MockTerminalInstance { writes: string[]; @@ -344,6 +345,16 @@ describe('terminal-registry alert behavior', () => { expect(isUntouched('restore-legacy')).toBe(false); }); + it('marks restored exited sessions as exited in the registry', () => { + const entry = resumeTerminal('resume-exited', null, { + alive: false, + exitCode: 42, + }) as TestTerminalEntry; + + expect(entry.terminal.writes).toContain('\r\n[Process exited with code 42]\r\n'); + expect(registry.get('resume-exited')?.exited).toBe(true); + }); + it('Story 1: quick response never becomes busy', () => { const id = 'story-1'; createSession( diff --git a/lib/src/lib/terminal-store.ts b/lib/src/lib/terminal-store.ts index 5e771e3c..fda769cc 100644 --- a/lib/src/lib/terminal-store.ts +++ b/lib/src/lib/terminal-store.ts @@ -24,9 +24,10 @@ export interface TerminalEntry { isReplaying: boolean; untouched: boolean; /** - * The PTY process has exited (onPtyExit fired) but the pane lingers in the - * registry showing "[Process exited…]". The directory reports this surface as - * `alive: false` so the phone's picker stops offering it as attachable. + * The PTY process has exited (onPtyExit fired or resume restored it as + * exited) but the pane lingers in the registry showing "[Process exited…]". + * The directory reports this surface as `alive: false` so the phone's picker + * stops offering it as attachable. */ exited?: boolean; } diff --git a/lib/src/remote/client/pocket-client.test.ts b/lib/src/remote/client/pocket-client.test.ts index 6b929556..442c16dc 100644 --- a/lib/src/remote/client/pocket-client.test.ts +++ b/lib/src/remote/client/pocket-client.test.ts @@ -123,6 +123,7 @@ function recordingWebAuthn(): { class FakeSocket implements PocketSocket { readyState = 0; + closeEmits = true; readonly sent: Array> = []; readonly #handlers = new Map void>>(); @@ -138,13 +139,13 @@ class FakeSocket implements PocketSocket { close(): void { this.readyState = 3; - this.#emit('close', { code: 1000 }); + if (this.closeEmits) this.emitClose(1000); } /** Simulate the server/network dropping the connection (no client `close()`). */ drop(): void { this.readyState = 3; - this.#emit('close', { code: 1006 }); + this.emitClose(1006); } fireOpen(): void { @@ -157,6 +158,10 @@ class FakeSocket implements PocketSocket { this.#emit('message', { data: JSON.stringify(frame) }); } + emitClose(code = 1000): void { + this.#emit('close', { code }); + } + #emit(type: string, ev: unknown): void { for (const handler of this.#handlers.get(type) ?? []) handler(ev); } @@ -454,6 +459,45 @@ describe('socket lifecycle', () => { expect(hostGone).toBe(0); expect(harness.client.socketOpen).toBe(false); }); + + it('ignores host-gone and close events from a stale socket after reconnecting', async () => { + const sockets = [new FakeSocket(), new FakeSocket()]; + const harness = makeClient( + { ...AUTH_ROUTES }, + { createWebSocket: () => sockets.shift() ?? new FakeSocket() }, + ); + const first = sockets[0]!; + const second = sockets[1]!; + + await harness.client.setup('pw', 'My Phone'); + await harness.client.signin(); + + const firstOpen = harness.client.openSocket(); + first.fireOpen(); + await firstOpen; + await connectEstablished({ ...harness, socket: first }); + + first.closeEmits = false; + harness.client.close(); + + const secondOpen = harness.client.openSocket(); + second.fireOpen(); + await secondOpen; + await connectEstablished({ ...harness, socket: second }); + + let hostGone = 0; + harness.client.setOnHostGone(() => hostGone++); + + first.server({ t: 'host-gone' }); + expect(hostGone).toBe(0); + expect(harness.client.connectedHostId).toBe('h1'); + expect(harness.client.socketOpen).toBe(true); + + first.emitClose(); + expect(hostGone).toBe(0); + expect(harness.client.connectedHostId).toBe('h1'); + expect(harness.client.socketOpen).toBe(true); + }); }); describe('remote-api correlation', () => { diff --git a/lib/src/remote/client/pocket-client.ts b/lib/src/remote/client/pocket-client.ts index bbfc6b48..2104a8c3 100644 --- a/lib/src/remote/client/pocket-client.ts +++ b/lib/src/remote/client/pocket-client.ts @@ -228,10 +228,20 @@ export class PocketClient { const url = `${this.#wsBase}${WS_ROUTES.client}?${WS_TOKEN_PARAM}=${encodeURIComponent(token)}`; const ws = this.#createWebSocket(url); this.#ws = ws; - ws.addEventListener('message', (ev) => this.#onFrame((ev as { data?: unknown }).data)); - ws.addEventListener('close', () => this.#onClose()); + const isCurrent = () => this.#ws === ws; + ws.addEventListener('message', (ev) => { + if (!isCurrent()) return; + this.#onFrame((ev as { data?: unknown }).data); + }); + ws.addEventListener('close', () => this.#onClose(ws)); return new Promise((resolve, reject) => { - ws.addEventListener('open', () => resolve()); + ws.addEventListener('open', () => { + if (!isCurrent()) { + reject(new Error('relay socket superseded')); + return; + } + resolve(); + }); ws.addEventListener('error', () => reject(new Error('relay socket error'))); ws.addEventListener('close', () => reject(new Error('relay socket closed before open'))); }); @@ -496,7 +506,8 @@ export class PocketClient { } } - #onClose(): void { + #onClose(ws: PocketSocket): void { + if (this.#ws !== ws) return; // `close()` tears down and nulls #ws before the event fires, so a non-null // #ws here means the socket died on us (server restart, network drop) rather // than an intentional close. An unexpected drop of an established session is diff --git a/lib/src/remote/pocket-app/PocketWall.tsx b/lib/src/remote/pocket-app/PocketWall.tsx index bac609a2..a5438ecf 100644 --- a/lib/src/remote/pocket-app/PocketWall.tsx +++ b/lib/src/remote/pocket-app/PocketWall.tsx @@ -35,7 +35,12 @@ import { import { getTerminalInstance, refitSession } from '../../lib/terminal-registry'; import { doPaste } from '../../lib/clipboard'; import type { RemotePtyAdapter } from '../client/remote-adapter'; -import { activatePane, directorySessionItems, directoryWallSessions } from './wall-model'; +import { + activatePane, + attachableDirectoryEntries, + directorySessionItems, + directoryWallSessions, +} from './wall-model'; /** Same default theme the website playground restores, unless the user picked one. */ const POCKET_THEME_ID = 'vscode.theme-kimbie-dark.kimbie-dark'; @@ -61,6 +66,7 @@ export function PocketWall({ adapter }: { adapter: RemotePtyAdapter }): React.Re const [activePaneId, setActivePaneId] = useState(null); const [touchMode, setTouchMode] = useState('gestures'); const [keyboardMode, setKeyboardMode] = useState('type'); + const attachableEntries = useMemo(() => attachableDirectoryEntries(entries), [entries]); // Track the live directory. Re-read on subscribe in case a snapshot landed // between the initial render and this effect. @@ -71,9 +77,9 @@ export function PocketWall({ adapter }: { adapter: RemotePtyAdapter }): React.Re // Default to (and stay on a valid) pane as the directory changes. useEffect(() => { - if (activePaneId && entries.some((entry) => entry.surfaceId === activePaneId)) return; - setActivePaneId(entries[0]?.surfaceId ?? null); - }, [entries, activePaneId]); + if (activePaneId && attachableEntries.some((entry) => entry.surfaceId === activePaneId)) return; + setActivePaneId(attachableEntries[0]?.surfaceId ?? null); + }, [attachableEntries, activePaneId]); // One attachment at a time: on every active-pane change, attach it with the // pane's current xterm dims (if it exists yet), then refit through the now- @@ -85,10 +91,10 @@ export function PocketWall({ adapter }: { adapter: RemotePtyAdapter }): React.Re void activatePane(adapter, activePaneId, dims, refitSession); }, [adapter, activePaneId]); - const wallSessions = useMemo(() => directoryWallSessions(entries), [entries]); + const wallSessions = useMemo(() => directoryWallSessions(attachableEntries), [attachableEntries]); const sessionItems = useMemo( - () => directorySessionItems(entries, activePaneId), - [entries, activePaneId], + () => directorySessionItems(attachableEntries, activePaneId), + [attachableEntries, activePaneId], ); const mouseStates = useSyncExternalStore( diff --git a/lib/src/remote/pocket-app/wall-model.test.ts b/lib/src/remote/pocket-app/wall-model.test.ts index 5256dffe..ce852e0c 100644 --- a/lib/src/remote/pocket-app/wall-model.test.ts +++ b/lib/src/remote/pocket-app/wall-model.test.ts @@ -9,6 +9,7 @@ import type { DirectoryEntry } from 'server-lib-common'; import { activatePane, + attachableDirectoryEntries, directorySessionItems, directoryWallSessions, type PaneActivator, @@ -40,6 +41,28 @@ describe('directoryWallSessions', () => { it('falls back to a default title when the Host sends an empty one', () => { expect(directoryWallSessions([entry('s1', { title: '' })])).toEqual([{ id: 's1', title: 'Terminal' }]); }); + + it('skips exited entries because they are not attachable', () => { + expect( + directoryWallSessions([ + entry('dead-first', { alive: false }), + entry('live-second', { title: 'ssh' }), + ]), + ).toEqual([{ id: 'live-second', title: 'ssh' }]); + }); +}); + +describe('attachableDirectoryEntries', () => { + it('keeps only alive entries in Host order', () => { + expect( + attachableDirectoryEntries([ + entry('dead-a', { alive: false }), + entry('live-b'), + entry('dead-c', { alive: false }), + entry('live-d'), + ]).map((item) => item.surfaceId), + ).toEqual(['live-b', 'live-d']); + }); }); describe('directorySessionItems', () => { @@ -74,6 +97,16 @@ describe('directorySessionItems', () => { const [item] = directorySessionItems([entry('s1', { activity: 'running', cwd: '/tmp' })], null); expect(item.secondary).toBe('/tmp'); }); + + it('does not expose dead entries as selectable sessions', () => { + const items = directorySessionItems( + [entry('s1', { alive: false }), entry('s2', { title: 'alive' })], + 's1', + ); + expect(items).toEqual([ + { id: 's2', title: 'alive', secondary: null, active: false, status: undefined, todo: false }, + ]); + }); }); describe('activatePane', () => { diff --git a/lib/src/remote/pocket-app/wall-model.ts b/lib/src/remote/pocket-app/wall-model.ts index 3a5ca65f..dd83eead 100644 --- a/lib/src/remote/pocket-app/wall-model.ts +++ b/lib/src/remote/pocket-app/wall-model.ts @@ -10,6 +10,10 @@ * in `MobileTerminalUi` shows. On the remote side, badge state is * Host-authoritative and rides the directory (not local terminal parsing), * so this replaces `useMobileWallSessionItems` rather than layering on it. + * + * The Host may keep exited panes in the directory with `alive:false`. Those are + * historical entries, not attach targets, so the Pocket wall filters them out + * before rendering selectable sessions or defaulting the active pane. */ import type { DirectoryEntry } from 'server-lib-common'; @@ -24,9 +28,16 @@ function paneTitle(entry: DirectoryEntry): string { return entry.title || DEFAULT_TITLE; } +export function attachableDirectoryEntries(entries: DirectoryEntry[]): DirectoryEntry[] { + return entries.filter((entry) => entry.alive); +} + /** The `{id,title}` sessions `MobileWall` mounts, in Host order. */ export function directoryWallSessions(entries: DirectoryEntry[]): MobileWallSession[] { - return entries.map((entry) => ({ id: entry.surfaceId, title: paneTitle(entry) })); + return attachableDirectoryEntries(entries).map((entry) => ({ + id: entry.surfaceId, + title: paneTitle(entry), + })); } /** @@ -39,7 +50,7 @@ export function directorySessionItems( entries: DirectoryEntry[], activeSurfaceId: string | null, ): MobileTerminalSessionItem[] { - return entries.map((entry) => ({ + return attachableDirectoryEntries(entries).map((entry) => ({ id: entry.surfaceId, title: paneTitle(entry), secondary: secondaryLine(entry),