Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/src/lib/terminal-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions lib/src/lib/terminal-registry.alert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ import {
toggleSessionTodo,
} from './terminal-registry';
import { pasteFilePaths } from './clipboard';
import { registry } from './terminal-store';

interface MockTerminalInstance {
writes: string[];
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions lib/src/lib/terminal-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
48 changes: 46 additions & 2 deletions lib/src/remote/client/pocket-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ function recordingWebAuthn(): {

class FakeSocket implements PocketSocket {
readyState = 0;
closeEmits = true;
readonly sent: Array<Record<string, unknown>> = [];
readonly #handlers = new Map<string, Array<(ev: unknown) => void>>();

Expand All @@ -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 {
Expand All @@ -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);
}
Expand Down Expand Up @@ -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', () => {
Expand Down
19 changes: 15 additions & 4 deletions lib/src/remote/client/pocket-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')));
});
Expand Down Expand Up @@ -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
Expand Down
20 changes: 13 additions & 7 deletions lib/src/remote/pocket-app/PocketWall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -61,6 +66,7 @@ export function PocketWall({ adapter }: { adapter: RemotePtyAdapter }): React.Re
const [activePaneId, setActivePaneId] = useState<string | null>(null);
const [touchMode, setTouchMode] = useState<MobileTerminalTouchMode>('gestures');
const [keyboardMode, setKeyboardMode] = useState<MobileTerminalKeyboardMode>('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.
Expand All @@ -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-
Expand All @@ -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(
Expand Down
33 changes: 33 additions & 0 deletions lib/src/remote/pocket-app/wall-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { DirectoryEntry } from 'server-lib-common';

import {
activatePane,
attachableDirectoryEntries,
directorySessionItems,
directoryWallSessions,
type PaneActivator,
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
15 changes: 13 additions & 2 deletions lib/src/remote/pocket-app/wall-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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),
}));
}

/**
Expand All @@ -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),
Expand Down
Loading