mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 17:29: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>
82 lines
3.2 KiB
TypeScript
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');
|
|
}
|
|
});
|
|
});
|