fix: prevent stale proc exit from deleting active terminal session

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-02-22 14:44:27 +00:00
parent c62ba73ce4
commit ea5312e940
2 changed files with 42 additions and 7 deletions

View File

@@ -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<string, string> {
const result = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe" });
@@ -108,8 +112,8 @@ Bun.serve<WsData>({
},
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<WsData>({
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<WsData>({
},
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}`);
},
},
});

View File

@@ -14,6 +14,10 @@ const MAX_SCROLLBACK = 5000;
const sessions = new Map<string, TerminalSession>();
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<string> {
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<void> {
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 {