Skip to content

Commit 0e76440

Browse files
committed
fix: kill pane processes before destroying tmux sessions to prevent zombies
opencode (Go binary) ignores SIGHUP from tmux, so kill-session/kill-window alone leaves orphaned processes consuming CPU. Now explicitly SIGTERM+SIGKILL pane processes before destroying sessions, windows, and tagged windows.
1 parent ea0d1a2 commit 0e76440

1 file changed

Lines changed: 66 additions & 5 deletions

File tree

src/lib/tmux.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ export async function killTaggedWindows(jobId: string): Promise<number> {
154154
const [target, taggedId] = line.split(' ', 2);
155155
if (taggedId === jobId && target) {
156156
try {
157+
const pids = await getPaneProcesses(target);
158+
await killPaneProcesses(pids);
157159
const killProc = spawn(["tmux", "kill-window", "-t", target], { stderr: "pipe" });
158160
await killProc.exited;
159161
killed++;
@@ -199,14 +201,66 @@ export async function windowExists(
199201
}
200202

201203
/**
202-
* Kill a tmux session
204+
* Get PIDs of all panes within a tmux target (session or window).
205+
* Returns an array of PIDs. Best-effort — returns empty on failure.
206+
*/
207+
async function getPaneProcesses(target: string): Promise<number[]> {
208+
try {
209+
const proc = spawn(
210+
["tmux", "list-panes", "-t", target, "-F", "#{pane_pid}"],
211+
{ stderr: "pipe" },
212+
);
213+
const output = await new Response(proc.stdout).text();
214+
const exitCode = await proc.exited;
215+
if (exitCode !== 0) return [];
216+
217+
return output
218+
.trim()
219+
.split('\n')
220+
.map((line) => parseInt(line.trim(), 10))
221+
.filter((pid) => !isNaN(pid) && pid > 0);
222+
} catch {
223+
return [];
224+
}
225+
}
226+
227+
/**
228+
* Kill processes running inside tmux panes before destroying them.
229+
* Sends SIGTERM first, waits briefly, then SIGKILL for survivors.
230+
* This prevents orphaned processes (e.g., opencode ignoring SIGHUP from tmux).
231+
*/
232+
async function killPaneProcesses(pids: number[]): Promise<void> {
233+
if (pids.length === 0) return;
234+
235+
for (const pid of pids) {
236+
try { process.kill(pid, 'SIGTERM'); } catch {}
237+
}
238+
239+
await new Promise((resolve) => setTimeout(resolve, 500));
240+
241+
for (const pid of pids) {
242+
try { process.kill(pid, 'SIGKILL'); } catch {}
243+
}
244+
}
245+
246+
/**
247+
* Kill a tmux session.
248+
* Kills pane processes first to prevent orphans — opencode ignores SIGHUP
249+
* from tmux, so kill-session alone leaves zombie processes consuming CPU.
203250
*/
204251
export async function killSession(name: string): Promise<void> {
252+
const pids = await getPaneProcesses(name);
253+
const killedProcesses = pids.length > 0;
254+
await killPaneProcesses(pids);
255+
205256
try {
206257
const proc = spawn(["tmux", "kill-session", "-t", name], { stderr: "pipe" });
207258
const exitCode = await proc.exited;
208259
if (exitCode !== 0) {
209-
throw new Error(`tmux kill-session failed with exit code ${exitCode}`);
260+
// Tolerate failure only if we killed pane processes (session may have auto-closed)
261+
if (!killedProcesses) {
262+
throw new Error(`tmux kill-session failed with exit code ${exitCode}`);
263+
}
210264
}
211265
} catch (error) {
212266
throw new Error(
@@ -216,18 +270,25 @@ export async function killSession(name: string): Promise<void> {
216270
}
217271

218272
/**
219-
* Kill a window in a tmux session
273+
* Kill a window in a tmux session.
274+
* First kills pane processes to prevent orphans (see killSession).
220275
*/
221276
export async function killWindow(
222277
session: string,
223278
window: string
224279
): Promise<void> {
280+
const target = `${session}:${window}`;
281+
const pids = await getPaneProcesses(target);
282+
const killedProcesses = pids.length > 0;
283+
await killPaneProcesses(pids);
284+
225285
try {
226-
const target = `${session}:${window}`;
227286
const proc = spawn(["tmux", "kill-window", "-t", target], { stderr: "pipe" });
228287
const exitCode = await proc.exited;
229288
if (exitCode !== 0) {
230-
throw new Error(`tmux kill-window failed with exit code ${exitCode}`);
289+
if (!killedProcesses) {
290+
throw new Error(`tmux kill-window failed with exit code ${exitCode}`);
291+
}
231292
}
232293
} catch (error) {
233294
throw new Error(

0 commit comments

Comments
 (0)