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:
@@ -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()`.
|
||||
|
||||
@@ -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' }); }
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<TerminalSession> {
|
||||
return createTerminalSession(options);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user