From ea5312e940cc6165ee88a0d3fdb177f5ffd93d5f Mon Sep 17 00:00:00 2001 From: centdix Date: Sun, 22 Feb 2026 14:44:27 +0000 Subject: [PATCH] fix: prevent stale proc exit from deleting active terminal session Co-Authored-By: Claude Opus 4.6 --- dev-dashboard/backend/src/server.ts | 13 ++++++++-- dev-dashboard/backend/src/terminal.ts | 36 +++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/dev-dashboard/backend/src/server.ts b/dev-dashboard/backend/src/server.ts index 270e33112b..ef8e673c67 100644 --- a/dev-dashboard/backend/src/server.ts +++ b/dev-dashboard/backend/src/server.ts @@ -23,6 +23,10 @@ import { const PORT = parseInt(process.env.DASHBOARD_PORT || "5111"); +function ts(): string { + return new Date().toISOString().slice(11, 23); +} + /** Map branch name → worktree directory using git worktree list. */ function getWorktreePaths(): Map { const result = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe" }); @@ -108,8 +112,8 @@ Bun.serve({ }, websocket: { - open(_ws) { - // Wait for the client to send its actual dimensions before spawning + open(ws) { + console.log(`[ws:${ts()}] open worktree=${ws.data.worktree}`); }, async message(ws, message) { @@ -125,16 +129,19 @@ Bun.serve({ if (!ws.data.attached) { // First resize = client reporting actual dimensions. Spawn now. ws.data.attached = true; + console.log(`[ws:${ts()}] first resize (attaching) worktree=${worktree} cols=${msg.cols} rows=${msg.rows}`); try { await attach(worktree, msg.cols, msg.rows); const { onData, onExit } = makeCallbacks(ws); setCallbacks(worktree, onData, onExit); const scrollback = getScrollback(worktree); + console.log(`[ws:${ts()}] attached worktree=${worktree} scrollback=${scrollback.length} bytes`); if (scrollback) { ws.send(JSON.stringify({ type: "scrollback", data: scrollback })); } } catch (err: unknown) { const errMsg = err instanceof Error ? err.message : String(err); + console.log(`[ws:${ts()}] attach failed worktree=${worktree}: ${errMsg}`); ws.send(JSON.stringify({ type: "error", message: errMsg })); ws.close(); } @@ -149,8 +156,10 @@ Bun.serve({ }, async close(ws) { + console.log(`[ws:${ts()}] close worktree=${ws.data.worktree} attached=${ws.data.attached}`); clearCallbacks(ws.data.worktree); await detach(ws.data.worktree); + console.log(`[ws:${ts()}] close complete worktree=${ws.data.worktree}`); }, }, }); diff --git a/dev-dashboard/backend/src/terminal.ts b/dev-dashboard/backend/src/terminal.ts index 202cca8c96..1efaa4467b 100644 --- a/dev-dashboard/backend/src/terminal.ts +++ b/dev-dashboard/backend/src/terminal.ts @@ -14,6 +14,10 @@ const MAX_SCROLLBACK = 5000; const sessions = new Map(); let sessionCounter = 0; +function ts(): string { + return new Date().toISOString().slice(11, 23); +} + function groupedName(): string { return `${SESSION_PREFIX}${++sessionCounter}`; } @@ -49,13 +53,17 @@ export async function attach( cols: number, rows: number ): Promise { + console.log(`[term:${ts()}] attach(${worktreeName}) cols=${cols} rows=${rows} existing=${sessions.has(worktreeName)}`); if (sessions.has(worktreeName)) { + console.log(`[term:${ts()}] attach(${worktreeName}) detaching existing session first`); await detach(worktreeName); + console.log(`[term:${ts()}] attach(${worktreeName}) detach complete`); } const tmuxSession = await getTmuxSession(); const gName = groupedName(); const windowTarget = `wm-${worktreeName}`; + console.log(`[term:${ts()}] attach(${worktreeName}) tmuxSession=${tmuxSession} gName=${gName} window=${windowTarget}`); // Kill stale session with same name if it exists (leftover from previous server run) killTmuxSession(gName); @@ -88,6 +96,7 @@ export async function attach( }); session.proc = proc; + console.log(`[term:${ts()}] attach(${worktreeName}) spawned pid=${proc.pid}`); // Read stdout → push to scrollback + callback (async () => { @@ -109,8 +118,14 @@ export async function attach( })(); proc.exited.then((exitCode) => { - session.onExit?.(exitCode); - sessions.delete(worktreeName); + console.log(`[term:${ts()}] proc exited(${worktreeName}) pid=${proc.pid} code=${exitCode}`); + // Only clean up if this session is still the active one (not replaced by a new attach) + if (sessions.get(worktreeName) === session) { + session.onExit?.(exitCode); + sessions.delete(worktreeName); + } else { + console.log(`[term:${ts()}] proc exited(${worktreeName}) stale session, skipping cleanup`); + } killTmuxSession(gName); }); @@ -119,19 +134,30 @@ export async function attach( export async function detach(worktreeName: string): Promise { const session = sessions.get(worktreeName); - if (!session) return; + if (!session) { + console.log(`[term:${ts()}] detach(${worktreeName}) no session found`); + return; + } + console.log(`[term:${ts()}] detach(${worktreeName}) killing pid=${session.proc.pid} tmux=${session.groupedSessionName}`); session.proc.kill(); sessions.delete(worktreeName); killTmuxSession(session.groupedSessionName); + console.log(`[term:${ts()}] detach(${worktreeName}) done`); } export function write(worktreeName: string, data: string): void { const session = sessions.get(worktreeName); - if (session && session.proc.stdin) { - (session.proc.stdin as FileSink).write(new TextEncoder().encode(data)); + if (!session) { + console.log(`[term:${ts()}] write(${worktreeName}) NO SESSION - input dropped (${data.length} bytes)`); + return; } + if (!session.proc.stdin) { + console.log(`[term:${ts()}] write(${worktreeName}) NO STDIN - input dropped (${data.length} bytes)`); + return; + } + (session.proc.stdin as FileSink).write(new TextEncoder().encode(data)); } export function resize(worktreeName: string, cols: number, rows: number): void {