Files
Codeman/test/process-exit-wait.test.ts
T
Codeman maintainer 89177651a6 perf(sessions): closing a session no longer waits on the server
Closing a tab took ~0.55-0.7s on an idle machine, more with child
processes or recently active subagents. Most of it was waiting:

- The web UI kept the tab until DELETE returned, then removed it on the
  100ms tab-render debounce, which every session update restarts.
  closeSession() is now optimistic: the tab, tile and split go and the
  next session is selected before the request is sent, rendered at
  once. A refused delete (checked with a GET, since a delete can land
  and lose its reply) puts the row back at its old index with the
  error toast. This also fixes a latent bug: _apiDelete never throws,
  so an HTTP error used to report "Session closed" while the session
  kept running. SSE upserts skip ids that are being closed.
- The kill path slept fixed intervals (100ms PTY grace, 200ms for the
  pane's children, 100ms for the process group) and verified in 100ms
  steps. waitForProcessesExit() (utils/process-exit-wait.ts) keeps
  every deadline but returns once the processes are gone, counting a
  zombie as exited. Signal decisions keep kill(pid, 0).
- tmux kill-session and the pane-pid lookup ran via execSync, freezing
  the server for ~70ms per close. Now async.
- killSubagentsForSession() ran a full `pgrep -f claude` scan per
  active/idle subagent (~85ms each with ~100 matching processes). It
  now scans once for all of them.

Measured on an isolated instance: click to tab gone 540-690ms -> 58-95ms;
DELETE of a claude session ~450ms -> ~200-260ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 04:20:46 +02:00

82 lines
3.2 KiB
TypeScript

/**
* @fileoverview The kill path's waits end when the processes do, not on a timer.
*
* Closing a session used to sleep a fixed 100 + 200 + 100 ms across its signals
* and then verify in 100 ms steps, so a session whose processes were gone in a
* few ms still took ~0.45 s to close. `waitForProcessesExit()` keeps each of
* those deadlines but returns as soon as nothing is left running, and
* `isProcessRunning()` counts a zombie as exited (it holds nothing but its pid
* until its reaper gets to it, and `kill(pid, 0)` cannot tell it apart).
*
* Port: N/A.
*/
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { isProcessRunning, waitForProcessesExit } from '../src/utils/process-exit-wait.js';
describe('waitForProcessesExit', () => {
it('returns as soon as every pid has exited, long before the deadline', async () => {
let polls = 0;
const isRunning = (): boolean => ++polls < 4;
const started = Date.now();
const survivors = await waitForProcessesExit([101, 102], { timeoutMs: 5000, pollMs: 5, isRunning });
expect(survivors).toEqual([]);
expect(Date.now() - started).toBeLessThan(1000);
});
it('returns the survivors at the deadline instead of throwing', async () => {
const started = Date.now();
const survivors = await waitForProcessesExit([7, 8], {
timeoutMs: 60,
pollMs: 10,
isRunning: (pid) => pid === 8,
});
expect(survivors).toEqual([8]);
expect(Date.now() - started).toBeGreaterThanOrEqual(50);
});
it('does not wait at all when nothing is running', async () => {
const started = Date.now();
expect(await waitForProcessesExit([], { timeoutMs: 5000 })).toEqual([]);
expect(await waitForProcessesExit([9], { timeoutMs: 5000, isRunning: () => false })).toEqual([]);
expect(Date.now() - started).toBeLessThan(100);
});
});
describe('isProcessRunning', () => {
it('is true for a live process and false for a pid that does not exist', () => {
expect(isProcessRunning(process.pid)).toBe(true);
// Above the default pid_max on Linux and macOS alike.
expect(isProcessRunning(4_194_304 + 12_345)).toBe(false);
});
it.skipIf(process.platform !== 'linux')('counts a zombie as exited, which kill(pid, 0) cannot', async () => {
// `sleep 0` exits at once, and its parent then becomes `sleep 5`, which never
// reaps anything: the child stays a zombie until the parent itself goes.
const parent = spawn('sh', ['-c', 'sleep 0 & echo $!; exec sleep 5'], { stdio: ['ignore', 'pipe', 'ignore'] });
try {
const zombie = await new Promise<number>((resolve, reject) => {
parent.stdout.once('data', (chunk: Buffer) => resolve(parseInt(chunk.toString(), 10)));
parent.once('error', reject);
});
const state = (): string => {
const stat = readFileSync(`/proc/${zombie}/stat`, 'utf8');
return stat.charAt(stat.lastIndexOf(')') + 2);
};
for (let i = 0; i < 100 && state() !== 'Z'; i++) await new Promise((r) => setTimeout(r, 10));
expect(state()).toBe('Z');
expect(() => process.kill(zombie, 0)).not.toThrow();
expect(isProcessRunning(zombie)).toBe(false);
} finally {
parent.kill('SIGKILL');
}
});
});