From 2f27f0ec4b225557f94343317a32e63e6d8886a8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 14:28:45 +0300 Subject: [PATCH] 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. --- .../ui/src/components/views/TerminalView.tsx | 45 ++++++++++++++ packages/ui/src/lib/api/types.ts | 11 ++++ packages/ui/src/lib/terminalApi.ts | 28 ++++++++- .../ui/src/stores/useTerminalStore.test.ts | 41 +++++++++++++ packages/ui/src/stores/useTerminalStore.ts | 58 +++++++++++++++++++ .../web/server/lib/terminal/DOCUMENTATION.md | 2 + packages/web/server/lib/terminal/runtime.js | 30 +++++++++- .../web/server/lib/terminal/runtime.test.js | 26 +++++++++ packages/web/src/api/terminal.ts | 10 ++++ 9 files changed, 249 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 2fd4a6fb..3f465531 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -58,6 +58,7 @@ export const TerminalView: React.FC = ({ visible }) => { const setActiveTab = useTerminalStore((s) => s.setActiveTab); const closeTab = useTerminalStore((s) => s.closeTab); const setTabSessionId = useTerminalStore((s) => s.setTabSessionId); + const adoptServerSessions = useTerminalStore((s) => s.adoptServerSessions); const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle); const setConnecting = useTerminalStore((s) => s.setConnecting); const appendToBuffer = useTerminalStore((s) => s.appendToBuffer); @@ -176,6 +177,50 @@ export const TerminalView: React.FC = ({ visible }) => { directoryRef.current = effectiveDirectory; }, [effectiveDirectory]); + // The tab list is a per-client projection, so ask the server what actually + // exists for this directory and adopt sessions no local tab references + // (another device, a fresh browser tab, or a reload with cleared storage). + // A failed listing changes nothing: adoption is additive only. + React.useEffect(() => { + if (!terminalHydrated || !effectiveDirectory || !terminal.listSessions) { + return; + } + let cancelled = false; + const directory = effectiveDirectory; + void terminal.listSessions(directory) + .then((serverSessions) => { + if (cancelled || directoryRef.current !== directory) return; + adoptServerSessions(directory, serverSessions); + }) + .catch(() => { /* keep local tabs; the next mount or directory switch retries */ }); + return () => { + cancelled = true; + }; + }, [terminalHydrated, effectiveDirectory, terminal, adoptServerSessions]); + + // The server reaps terminals with no attached socket after an idle timeout, + // but only the active tab holds an attachment. While this client is open, + // periodically mark every session its tabs reference as active so + // background tabs (and other directories' terminals) are not reaped. + React.useEffect(() => { + if (!terminal.touchSessions) { + return; + } + const touch = () => { + if (typeof navigator !== 'undefined' && !navigator.onLine) return; + const ids: string[] = []; + for (const dirState of useTerminalStore.getState().sessions.values()) { + for (const tab of dirState.tabs) { + if (tab.terminalSessionId) ids.push(tab.terminalSessionId); + } + } + if (ids.length > 0) void terminal.touchSessions?.(ids).catch(() => {}); + }; + touch(); + const interval = setInterval(touch, 10 * 60 * 1000); + return () => clearInterval(interval); + }, [terminal]); + React.useEffect(() => { if (!showQuickKeys && activeModifier !== null) { setActiveModifier(null); diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index eaca77ed..d6eef89e 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -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; + /** Server-side sessions for a working directory; absent on runtimes without a server terminal list. */ + listSessions?(cwd: string): Promise; + /** Marks the sessions as active so the server's idle sweep does not reap terminals an open client still shows. */ + touchSessions?(sessionIds: string[]): Promise; createSession(options: CreateTerminalOptions): Promise; connect(sessionId: string, handlers: TerminalHandlers): Subscription; sendInput(sessionId: string, input: string): Promise; diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 72e25849..762c7bda 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -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; } +export async function listTerminalSessions(cwd: string): Promise { + 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>; + 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 { + if (sessionIds.length === 0) return; + await command('/api/terminal/touch', 'POST', { sessionIds }); +} export async function listTerminalShells(): Promise { const response = await runtimeFetch('/api/terminal/shells'); if (!response.ok) throw await responseError(response, 'Failed to list terminal shells'); diff --git a/packages/ui/src/stores/useTerminalStore.test.ts b/packages/ui/src/stores/useTerminalStore.test.ts index c60f0ffd..d6e2d8a2 100644 --- a/packages/ui/src/stores/useTerminalStore.test.ts +++ b/packages/ui/src/stores/useTerminalStore.test.ts @@ -12,6 +12,47 @@ const buffer = (tabId: string) => useTerminalStore.getState().getBuffer('/repo', describe('terminal state reconciliation', () => { afterEach(() => useTerminalStore.getState().clearAll()); + test('adopts unknown server sessions into the fresh placeholder tab', () => { + setup(); + useTerminalStore.getState().adoptServerSessions('/repo', [ + { sessionId: 'srv-1', status: 'running', createdAt: 100 }, + { sessionId: 'srv-2', status: 'exited', createdAt: null }, + ]); + const state = useTerminalStore.getState().getDirectoryState('/repo')!; + expect(state.tabs.map((tab) => tab.id)).toEqual(['srv-1', 'srv-2']); + expect(state.tabs[0].terminalSessionId).toBe('srv-1'); + expect(state.tabs[0].lifecycle).toBe('running'); + expect(state.tabs[1].lifecycle).toBe('exited'); + expect(state.activeTabId).toBe('srv-1'); + }); + + test('adoption is additive: existing tabs and referenced sessions survive', () => { + const tabId = setup(); + useTerminalStore.getState().appendToBuffer('/repo', tabId, 'output', 1); + useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-live'); + useTerminalStore.getState().adoptServerSessions('/repo', [ + { sessionId: 'srv-live', status: 'running', createdAt: 1 }, + { sessionId: 'srv-orphan', status: 'running', createdAt: 2 }, + ]); + const state = useTerminalStore.getState().getDirectoryState('/repo')!; + expect(state.tabs).toHaveLength(2); + expect(state.tabs[0].id).toBe(tabId); + expect(state.tabs[1].id).toBe('srv-orphan'); + expect(state.activeTabId).toBe(tabId); + }); + + test('re-adopting the same sessions changes nothing', () => { + setup(); + useTerminalStore.getState().adoptServerSessions('/repo', [ + { sessionId: 'srv-1', status: 'running', createdAt: 100 }, + ]); + const before = useTerminalStore.getState().sessions; + useTerminalStore.getState().adoptServerSessions('/repo', [ + { sessionId: 'srv-1', status: 'running', createdAt: 100 }, + ]); + expect(useTerminalStore.getState().sessions).toBe(before); + }); + test('applies snapshots atomically and deduplicates output by sequence', () => { const tabId = setup(); useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 4); diff --git a/packages/ui/src/stores/useTerminalStore.ts b/packages/ui/src/stores/useTerminalStore.ts index 485717ff..5771b072 100644 --- a/packages/ui/src/stores/useTerminalStore.ts +++ b/packages/ui/src/stores/useTerminalStore.ts @@ -71,6 +71,10 @@ interface TerminalStore { getBuffer: (directory: string, tabId: string) => TerminalBuffer; createTab: (directory: string) => string; + adoptServerSessions: ( + directory: string, + serverSessions: Array<{ sessionId: string; status: 'running' | 'exited'; createdAt: number | null }>, + ) => void; setActiveTab: (directory: string, tabId: string) => void; setTabLabel: (directory: string, tabId: string, label: string) => void; setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void; @@ -334,6 +338,60 @@ export const useTerminalStore = create()( return tabId; }, + /** + * The server owns which terminal sessions exist; the local tab list is + * only this client's projection. Adoption is strictly additive: server + * sessions no local tab references become tabs (id = session id, the + * create/attach contract), and nothing is ever removed here, so a + * failed or partial listing cannot destroy local tabs. + */ + adoptServerSessions: (directory, serverSessions) => { + const key = normalizeDirectory(directory); + if (!key || serverSessions.length === 0) return; + + set((state) => { + const existing = state.sessions.get(key); + const knownIds = new Set(); + for (const tab of existing?.tabs ?? []) { + knownIds.add(tab.id); + if (tab.terminalSessionId) knownIds.add(tab.terminalSessionId); + } + + const newcomers = serverSessions.filter((session) => !knownIds.has(session.sessionId)); + if (newcomers.length === 0) return state; + + const tabs = [...(existing?.tabs ?? [])]; + // A single untouched placeholder tab (fresh directory state) is + // replaced by the first adopted session instead of sitting next to it. + const placeholder = tabs.length === 1 + && tabs[0].terminalSessionId === null + && tabs[0].lifecycle === 'idle' + && !state.buffers.has(bufferKey(key, tabs[0].id)) + ? tabs[0] + : null; + if (placeholder) tabs.length = 0; + + for (const session of newcomers) { + const tab: TerminalTab = { + ...createEmptyTab(session.sessionId, placeholder && tabs.length === 0 ? placeholder.label : nextDefaultTabLabel(tabs)), + terminalSessionId: session.sessionId, + lifecycle: session.status, + createdAt: session.createdAt ?? Date.now(), + }; + tabs.push(tab); + } + + const previousActive = existing?.activeTabId ?? null; + const activeTabId = previousActive && tabs.some((tab) => tab.id === previousActive) + ? previousActive + : tabs[0]?.id ?? null; + + const newSessions = new Map(state.sessions); + newSessions.set(key, { tabs, activeTabId }); + return { sessions: newSessions }; + }); + }, + setActiveTab: (directory: string, tabId: string) => { const key = normalizeDirectory(directory); set((state) => { diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index 33fd9e54..f6271a71 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -19,6 +19,8 @@ HTTP remains the authenticated command plane for create, resize, appearance updates, restart, close, and force-kill. There is no SSE output or HTTP input compatibility path. +`GET /api/terminal/sessions` enumerates live sessions (optionally filtered by resolved `cwd`) so clients can adopt terminals their local tab projection does not know about — another device, a new browser tab, or cleared storage. `POST /api/terminal/touch` refreshes `lastActivity` for the listed session ids; open clients call it periodically so background tabs, which hold no WebSocket attachment, are not idle-reaped while a client still shows them. + ## PTY Lifecycle - IDs are client-provided or generated with `randomUUID()`. diff --git a/packages/web/server/lib/terminal/runtime.js b/packages/web/server/lib/terminal/runtime.js index 5e90ac93..d018b6fe 100644 --- a/packages/web/server/lib/terminal/runtime.js +++ b/packages/web/server/lib/terminal/runtime.js @@ -228,7 +228,7 @@ export function createTerminalRuntime({ } if (!existing && sessions.size + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached'); const creation = (async () => { - const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false }; + const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false, createdAt: Date.now() }; await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell }); sessions.set(id, session); return session; @@ -297,6 +297,34 @@ export function createTerminalRuntime({ res.status(500).json({ error: error?.message || 'Failed to list terminal shells' }); } }); + app.get('/api/terminal/sessions', (req, res) => { + const rawCwd = typeof req.query?.cwd === 'string' ? req.query.cwd.trim() : ''; + const cwdFilter = rawCwd ? path.resolve(rawCwd) : null; + const list = []; + for (const session of sessions.values()) { + if (cwdFilter && path.resolve(session.cwd) !== cwdFilter) continue; + list.push({ + sessionId: session.id, + cwd: session.cwd, + status: session.status, + createdAt: Number.isInteger(session.createdAt) ? session.createdAt : null, + }); + } + res.json({ sessions: list }); + }); + app.post('/api/terminal/touch', (req, res) => { + const rawIds = Array.isArray(req.body?.sessionIds) ? req.body.sessionIds : []; + const now = Date.now(); + let touched = 0; + for (const id of rawIds) { + if (typeof id !== 'string') continue; + const session = sessions.get(id); + if (!session) continue; + session.lastActivity = now; + touched += 1; + } + res.json({ touched }); + }); app.post('/api/terminal/create', async (req, res) => { try { const session = await createSession(req.body ?? {}); res.json({ sessionId: session.id, cols: session.cols, rows: session.rows, status: session.status }); } catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json({ error: error?.message || 'Failed to create terminal session' }); } diff --git a/packages/web/server/lib/terminal/runtime.test.js b/packages/web/server/lib/terminal/runtime.test.js index 8232be95..f0a972df 100644 --- a/packages/web/server/lib/terminal/runtime.test.js +++ b/packages/web/server/lib/terminal/runtime.test.js @@ -179,6 +179,32 @@ describe('terminal runtime', () => { } finally { await harness.runtime.shutdown(); } }); + it('lists sessions scoped to a working directory and refreshes activity via touch', async () => { + const harness = createHarness(); + try { + await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-a', cwd: '/repo' } }, createResponse()); + await harness.routes.post.get('/api/terminal/create')({ body: { sessionId: 'term-b', cwd: '/other' } }, createResponse()); + + const all = createResponse(); + harness.routes.get.get('/api/terminal/sessions')({ query: {} }, all); + expect(all.body.sessions.map((s) => s.sessionId).sort()).toEqual(['term-a', 'term-b']); + + const scoped = createResponse(); + harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, scoped); + expect(scoped.body.sessions).toEqual([ + { sessionId: 'term-a', cwd: '/repo', status: 'running', createdAt: expect.any(Number) }, + ]); + + const touch = createResponse(); + harness.routes.post.get('/api/terminal/touch')({ body: { sessionIds: ['term-a', 'missing', 42] } }, touch); + expect(touch.body).toEqual({ touched: 1 }); + + const malformed = createResponse(); + harness.routes.post.get('/api/terminal/touch')({ body: {} }, malformed); + expect(malformed.body).toEqual({ touched: 0 }); + } finally { await harness.runtime.shutdown(); } + }); + it('strips AppImage ARGV0 from PTY child environments', async () => { const previousArgv0 = process.env.ARGV0; process.env.ARGV0 = '/path/to/OpenChamber/OpenChamber-1.17.2-linux-x86_64.AppImage'; diff --git a/packages/web/src/api/terminal.ts b/packages/web/src/api/terminal.ts index 1c029ab2..c82f2446 100644 --- a/packages/web/src/api/terminal.ts +++ b/packages/web/src/api/terminal.ts @@ -8,6 +8,8 @@ import { restartTerminalSession, forceKillTerminal, listTerminalShells, + listTerminalSessions, + touchTerminalSessions, } from '@openchamber/ui/lib/terminalApi'; import type { TerminalAPI, @@ -23,6 +25,14 @@ export const createWebTerminalAPI = (): TerminalAPI => ({ return listTerminalShells(); }, + async listSessions(cwd: string) { + return listTerminalSessions(cwd); + }, + + async touchSessions(sessionIds: string[]) { + await touchTerminalSessions(sessionIds); + }, + async createSession(options: CreateTerminalOptions): Promise { return createTerminalSession(options); },