fix(terminal): reconcile tabs with server sessions and keep shown terminals alive
The tab list lived only in per-tab sessionStorage, so a new browser tab, another device, or cleared storage showed an empty terminal sidebar while PTYs kept running server-side, and orphans leaked until the idle sweep. Add GET /api/terminal/sessions and adopt unknown server sessions into the local tab projection (additive only; a failed listing changes nothing). The idle sweep also reaped terminals in background tabs because only the active tab holds a WebSocket attachment. Add POST /api/terminal/touch and have open clients periodically refresh activity for every session their tabs reference.
This commit is contained in:
@@ -80,8 +80,19 @@ export interface ForceKillOptions {
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface TerminalServerSession {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
status: 'running' | 'exited';
|
||||
createdAt: number | null;
|
||||
}
|
||||
|
||||
export interface TerminalAPI {
|
||||
listShells?(): Promise<TerminalShellOption[]>;
|
||||
/** Server-side sessions for a working directory; absent on runtimes without a server terminal list. */
|
||||
listSessions?(cwd: string): Promise<TerminalServerSession[]>;
|
||||
/** Marks the sessions as active so the server's idle sweep does not reap terminals an open client still shows. */
|
||||
touchSessions?(sessionIds: string[]): Promise<void>;
|
||||
createSession(options: CreateTerminalOptions): Promise<TerminalSession>;
|
||||
connect(sessionId: string, handlers: TerminalHandlers): Subscription;
|
||||
sendInput(sessionId: string, input: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalSession, TerminalShellOption, TerminalStreamEvent } from './api/types';
|
||||
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalShellOption, TerminalStreamEvent } from './api/types';
|
||||
import { openRuntimeWebSocket } from './relay/runtime-socket';
|
||||
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
@@ -356,6 +356,32 @@ export async function createTerminalSession(options: CreateTerminalOptions): Pro
|
||||
if (!response.ok) throw await responseError(response, 'Failed to create terminal session');
|
||||
return response.json() as Promise<TerminalSession>;
|
||||
}
|
||||
export async function listTerminalSessions(cwd: string): Promise<TerminalServerSession[]> {
|
||||
const response = await runtimeFetch(`/api/terminal/sessions?cwd=${encodeURIComponent(cwd)}`);
|
||||
if (!response.ok) throw await responseError(response, 'Failed to list terminal sessions');
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
const rawSessions = payload && typeof payload === 'object' && 'sessions' in payload ? payload.sessions : null;
|
||||
if (!Array.isArray(rawSessions)) throw new Error('Failed to list terminal sessions');
|
||||
const parsed: TerminalServerSession[] = [];
|
||||
for (const entry of rawSessions as unknown[]) {
|
||||
if (typeof entry !== 'object' || entry === null) continue;
|
||||
// SAFETY: every field is verified below before the value is used.
|
||||
const candidate = entry as Partial<Record<keyof TerminalServerSession, unknown>>;
|
||||
if (typeof candidate.sessionId !== 'string' || typeof candidate.cwd !== 'string') continue;
|
||||
if (candidate.status !== 'running' && candidate.status !== 'exited') continue;
|
||||
parsed.push({
|
||||
sessionId: candidate.sessionId,
|
||||
cwd: candidate.cwd,
|
||||
status: candidate.status,
|
||||
createdAt: typeof candidate.createdAt === 'number' ? candidate.createdAt : null,
|
||||
});
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
export async function touchTerminalSessions(sessionIds: string[]): Promise<void> {
|
||||
if (sessionIds.length === 0) return;
|
||||
await command('/api/terminal/touch', 'POST', { sessionIds });
|
||||
}
|
||||
export async function listTerminalShells(): Promise<TerminalShellOption[]> {
|
||||
const response = await runtimeFetch('/api/terminal/shells');
|
||||
if (!response.ok) throw await responseError(response, 'Failed to list terminal shells');
|
||||
|
||||
Reference in New Issue
Block a user