mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-11 09:49:41 +02:00
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>
77 lines
3.0 KiB
TypeScript
77 lines
3.0 KiB
TypeScript
/**
|
|
* @fileoverview Wait for processes to exit, and return as soon as they have.
|
|
*
|
|
* The session kill path used to sleep a FIXED interval after each signal (100 ms
|
|
* for the PTY client, 200 ms for the pane's children, 100 ms for the process
|
|
* group) and then verified in 100 ms steps. A process that was gone after 3 ms
|
|
* still cost the whole interval, so closing a tab spent most of its ~0.5 s in
|
|
* timers. This keeps every deadline the kill path had; it only stops waiting
|
|
* once there is nothing left to wait for.
|
|
*
|
|
* A zombie counts as exited. It holds nothing but its pid until the parent reaps
|
|
* it, and on the kill path that parent is the tmux server or the service
|
|
* manager, which is no reason to hold up a close. `kill(pid, 0)` cannot tell a
|
|
* zombie from a running process, so on Linux the state letter in
|
|
* `/proc/<pid>/stat` decides; without procfs `kill(pid, 0)` is the answer.
|
|
*
|
|
* @module utils/process-exit-wait
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
/** Poll step while waiting: an exit is noticed within about one frame. */
|
|
export const PROCESS_EXIT_POLL_MS = 10;
|
|
|
|
/**
|
|
* True while `pid` names a process that has not exited. A pid we may not signal
|
|
* reads as not running, which is what the kill path's own check always did:
|
|
* there is nothing it could do about such a process anyway.
|
|
*/
|
|
export function isProcessRunning(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (process.platform !== 'linux') return true;
|
|
let stat: string;
|
|
try {
|
|
stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
} catch (err) {
|
|
// Gone between the two reads. Any other failure: trust kill(pid, 0).
|
|
return (err as NodeJS.ErrnoException).code !== 'ENOENT';
|
|
}
|
|
// "<pid> (<comm>) <state> …": comm may hold spaces and parentheses itself,
|
|
// so the state is the field after the LAST ')'.
|
|
const close = stat.lastIndexOf(')');
|
|
const state = close === -1 ? '' : stat.charAt(close + 2);
|
|
return state !== 'Z' && state !== 'X';
|
|
}
|
|
|
|
export interface WaitForExitOptions {
|
|
/** Give up after this long; the survivors are returned, never thrown. */
|
|
timeoutMs: number;
|
|
/** Poll step, {@link PROCESS_EXIT_POLL_MS} by default. */
|
|
pollMs?: number;
|
|
/** Liveness probe, {@link isProcessRunning} by default (injectable for tests). */
|
|
isRunning?: (pid: number) => boolean;
|
|
}
|
|
|
|
/**
|
|
* Resolve once every pid in `pids` has exited, or at the deadline with the ones
|
|
* that have not. Never rejects.
|
|
*/
|
|
export async function waitForProcessesExit(pids: readonly number[], options: WaitForExitOptions): Promise<number[]> {
|
|
const isRunning = options.isRunning ?? isProcessRunning;
|
|
const pollMs = Math.max(1, options.pollMs ?? PROCESS_EXIT_POLL_MS);
|
|
const deadline = Date.now() + options.timeoutMs;
|
|
let running = pids.filter((pid) => isRunning(pid));
|
|
while (running.length > 0) {
|
|
const remaining = deadline - Date.now();
|
|
if (remaining <= 0) break;
|
|
await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remaining)));
|
|
running = running.filter((pid) => isRunning(pid));
|
|
}
|
|
return running;
|
|
}
|