Merge master into the 1.41.0 release branch (faster session close, README GIFs)

This commit is contained in:
Codeman maintainer committed 2026-10-10 05:02:29 +02:00
commit 209a539489
17 files changed
+492 -66

No files matched your search

+9 -3
View File
@@ -106,6 +106,7 @@ import {
getClaudeBinaryPath,
spawnPtyWithHelperRepair,
resolveLocalShell,
waitForProcessesExit,
} from './utils/index.js';
import {
MAX_TERMINAL_BUFFER_SIZE,
@@ -179,7 +180,10 @@ const WIRE_ACTIVITY_SETTLE_MS = 15_000;
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
/** Graceful shutdown delay when stopping session (100ms) */
/**
* Longest the PTY process gets to exit on SIGTERM before SIGKILL when stopping a
* session. A deadline, not a sleep: stop() moves on as soon as it has exited.
*/
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
@@ -4801,8 +4805,10 @@ export class Session extends EventEmitter {
console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err);
}
// Give it a moment to terminate gracefully
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS));
// Give it a moment to terminate gracefully. For a tmux-backed session this
// is the attach client, gone within a few ms of SIGTERM, and this used to be
// a fixed 100ms sleep on every close.
if (pid) await waitForProcessesExit([pid], { timeoutMs: GRACEFUL_SHUTDOWN_DELAY_MS });
// Force kill with SIGKILL if still alive
try {
+28 -6
View File
@@ -761,13 +761,35 @@ export class SubagentWatcher extends EventEmitter {
* by workingDir alone would kill subagents belonging to OTHER sessions.
*/
async killSubagentsForSession(workingDir: string, sessionId?: string): Promise<void> {
const subagents = this.getSubagentsForSession(workingDir);
for (const agent of subagents) {
if (agent.status === 'active' || agent.status === 'idle') {
// Only kill subagents belonging to this specific session
if (sessionId && agent.sessionId !== sessionId) continue;
await this.killSubagent(agent.agentId);
const targets = this.getSubagentsForSession(workingDir).filter(
// Only kill subagents belonging to this specific session
(agent) => (agent.status === 'active' || agent.status === 'idle') && (!sessionId || agent.sessionId === sessionId)
);
if (targets.length === 0) return;
// ONE process scan for the lot. This used to call killSubagent() per agent, and
// each call ran its own `pgrep -f claude` plus a /proc read per match (~85ms on a
// box with ~100 matching processes), so closing a session right after a workflow
// paid that once per recently active subagent. The match rules are
// findSubagentProcess()'s: getClaudePids() skips CODEMAN_MUX=1 processes too.
const pidMap = await this.getClaudePids();
const signalled = new Set<number>();
for (const agent of targets) {
// The liveness checker may have completed it while the scan ran.
if (agent.status !== 'active' && agent.status !== 'idle') continue;
for (const [pid, procInfo] of pidMap) {
if (signalled.has(pid)) continue;
if (procInfo.environ.includes(agent.sessionId) || procInfo.cmdline.includes(agent.sessionId)) {
signalled.add(pid);
try {
process.kill(pid, 'SIGTERM');
} catch {
// Process may have already exited
}
break; // one process per agent, as killSubagent() does
}
}
this.markSubagentAsCompleted(agent);
}
}
+53 -24
View File
@@ -94,7 +94,13 @@ import {
type DockerMount,
type DockerSeedCopy,
} from './docker-hosts.js';
import { wrapWithNice, SAFE_PATH_PATTERN, resolveLocalShell, loginShellArgs } from './utils/index.js';
import {
wrapWithNice,
SAFE_PATH_PATTERN,
resolveLocalShell,
loginShellArgs,
waitForProcessesExit,
} from './utils/index.js';
import type {
TerminalMultiplexer,
MuxSession,
@@ -144,12 +150,18 @@ const TMUX_CREATION_WAIT_MS = 100;
const GET_PID_MAX_RETRIES = 5;
const GET_PID_RETRY_MS = 200;
/** Delay after tmux kill command (200ms) */
/**
* How long killSession gives a pane's children to exit on SIGTERM before it
* re-scans and SIGKILLs. A deadline, not a sleep (see utils/process-exit-wait.ts).
*/
const TMUX_KILL_WAIT_MS = 200;
/** Delay for graceful shutdown (100ms) */
/** How long the pane's process group gets to exit on SIGTERM before SIGKILL. Also a deadline. */
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** How long killSession waits for every process it signalled to be gone before it warns. */
const KILL_VERIFY_TIMEOUT_MS = 2000;
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
@@ -2350,6 +2362,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* {@link getPanePid} without blocking the event loop, for the kill path: a close
* must not stall every other session's I/O while tmux answers.
*/
private async getPanePidAsync(muxName: string): Promise<number | null> {
if (IS_TEST_MODE) return 99999;
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in getPanePidAsync:', muxName);
return null;
}
try {
const { stdout } = await execAsync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
const pid = parseInt(stdout.trim(), 10);
return Number.isNaN(pid) ? null : pid;
} catch {
return null;
}
}
/**
* Check if a tmux session exists.
*/
@@ -2604,7 +2638,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
});
}
// Check if a process is still alive
// Check if a process is still alive. Signal decisions only: an unreaped zombie
// counts here, so its process group still gets the SIGKILL below. The WAITS use
// waitForProcessesExit(), which counts a zombie as exited.
private isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -2614,20 +2650,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Verify all PIDs are dead, with retry
// Verify all PIDs are dead, returning as soon as they are
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise<boolean> {
const startTime = Date.now();
const checkInterval = 100;
while (Date.now() - startTime < maxWaitMs) {
const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
if (aliveCount === 0) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, checkInterval));
}
const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
const stillAlive = await waitForProcessesExit(pids, { timeoutMs: maxWaitMs });
if (stillAlive.length > 0) {
console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
}
@@ -2685,7 +2710,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (isValidMuxName(session.muxName)) {
try {
// Local socket only — detaches the remote session by killing the local ssh pane.
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
@@ -2702,7 +2727,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Get current PID (may have changed)
const currentPid = this.getPanePid(session.muxName) || session.pid;
const currentPid = (await this.getPanePidAsync(session.muxName)) || session.pid;
console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
@@ -2724,7 +2749,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
// Most children are gone within a few ms; the re-scan below still runs, to
// catch anything spawned since the first one.
await waitForProcessesExit(childPids, { timeoutMs: TMUX_KILL_WAIT_MS });
childPids = await this.getChildPidsFresh(currentPid);
for (const childPid of childPids) {
@@ -2742,7 +2769,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (this.isProcessAlive(currentPid)) {
try {
process.kill(-currentPid, 'SIGTERM');
await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
await waitForProcessesExit([currentPid], { timeoutMs: GRACEFUL_SHUTDOWN_WAIT_MS });
if (this.isProcessAlive(currentPid)) {
process.kill(-currentPid, 'SIGKILL');
}
@@ -2751,10 +2778,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell)
// Strategy 3: Kill tmux session by name (guard the name before it reaches the shell).
// Async: tmux takes tens of ms to tear a session down, and execSync held the
// whole server for that long on every close.
if (isValidMuxName(session.muxName)) {
try {
execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
@@ -2798,7 +2827,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Verify all processes are dead
const allDead = await this.verifyProcessesDead(allPids, 2000);
const allDead = await this.verifyProcessesDead(allPids, KILL_VERIFY_TIMEOUT_MS);
if (!allDead) {
console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
}
+1
View File
@@ -12,6 +12,7 @@ export { Debouncer, KeyedDebouncer } from './debouncer.js';
export { startEventLoopMonitor } from './event-loop-monitor.js';
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
export { StaleExpirationMap } from './stale-expiration-map.js';
export { isProcessRunning, waitForProcessesExit, PROCESS_EXIT_POLL_MS } from './process-exit-wait.js';
export {
ANSI_ESCAPE_PATTERN_FULL,
ANSI_ESCAPE_PATTERN_SIMPLE,
+76
View File
@@ -0,0 +1,76 @@
/**
* @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;
}
+72 -20
View File
@@ -2294,6 +2294,8 @@ class CodemanApp {
}
_onSessionCreated(data) {
// A session this tab is closing stays closed until the server answers.
if (this._closingSessions?.has(data.id)) return;
this.sessions.set(data.id, data);
// Add new session to end of tab order
if (!this.sessionOrder.includes(data.id)) {
@@ -2318,6 +2320,9 @@ class CodemanApp {
_onSessionUpdated(data) {
const session = data.session || data;
// A session this tab is closing stays closed until the server answers
// (closeSession() puts it back if the delete is refused).
if (this._closingSessions?.has(session.id)) return;
const oldSession = this.sessions.get(session.id);
const claudeSessionIdJustSet = session.claudeSessionId && (!oldSession || !oldSession.claudeSessionId);
this.sessions.set(session.id, session);
@@ -6115,9 +6120,19 @@ class CodemanApp {
// Session Tabs
// ═══════════════════════════════════════════════════════════════
renderSessionTabs() {
renderSessionTabs({ immediate = false } = {}) {
// Don't re-render while user is typing in the inline rename input
if (this._inlineRenameActive) return;
if (immediate) {
// For a change the user just made and is watching for (closing a tab). The
// debounce restarts on every session update, so with busy sessions around a
// debounced pass can lag well past its 100ms. A pass still pending would only
// repeat this one, so it is dropped.
clearTimeout(this._debounceTimers.sessionTabs);
this._debounceTimers.sessionTabs = null;
this._renderSessionTabsImmediate();
return;
}
this._debouncedCall('sessionTabs', this._renderSessionTabsImmediate);
}
@@ -9824,27 +9839,32 @@ class CodemanApp {
}
async closeSession(sessionId, killMux = true) {
// ⚠️ Captured BEFORE the await, and the delete is announced to
// _onSessionDeleted through _closingSessions. The `session_deleted` SSE
// broadcast for THIS delete routinely lands while the request is still in
// flight, and that handler nulls activeSessionId and shows the welcome
// screen. Re-reading the field after the await therefore made the fallback
// below a coin flip: closing the tab you were on either moved you to the
// next session or dumped you on the home screen, depending on which path
// won the race (both outcomes measured on one build, 2026-08-17).
// Already on its way out (a repeated click, the mux panel racing the tab).
if (this._closingSessions.has(sessionId)) return;
// The tab goes FIRST and the server is asked after. The kill takes the server
// a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat
// there that long after "Kill" read as a click that did nothing. A refused
// delete puts the row back below.
//
// ⚠️ Everything is read BEFORE the first await, and the delete is announced
// to _onSessionDeleted through _closingSessions: the `session_deleted` SSE
// broadcast for THIS delete arrives while the request is still in flight, and
// that handler must leave the follow-up selection to this method (both
// outcomes of that race were measured on one build, 2026-08-17).
const session = this.sessions.get(sessionId);
const orderIndex = this.sessionOrder.indexOf(sessionId);
const wasActive = this.activeSessionId === sessionId;
// Tile grid open: the fallback is the NEIGHBOURING TILE, never the first
// sessionOrder entry (often not tiled, which would collapse the grid).
// Captured here for the same reason as wasActive: the SSE delete can remove
// the tile while the request is still in flight.
const grid = this._tileGrid;
const tileNeighborId = grid?.has(sessionId) ? window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId) : null;
this._closingSessions.add(sessionId);
let res = null;
try {
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
this._cleanupSessionData(sessionId);
// The last tile leaving closes the grid (no reselect): the pick below runs.
if (grid?.has(sessionId)) this.removeTile(sessionId, { refocus: false });
// The same teardown the SSE event runs (split pane, tile, detached window,
// WebSocket, per-session state), only now instead of when the server is
// done. It is idempotent, so the real event finds nothing left to do.
this._onSessionDeleted({ id: sessionId });
if (wasActive && grid?.open) {
// `auto`: the app chose this tile because the previous one went away.
@@ -9869,18 +9889,50 @@ class CodemanApp {
}
}
this.renderSessionTabs();
this.renderSessionTabs({ immediate: true });
res = await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
} catch (err) {
// `res` stays null: handled below like a delete that got no answer.
console.warn('[closeSession] close failed:', err);
} finally {
this._closingSessions.delete(sessionId);
}
// 404: already gone (closed from another tab or device), which is what was asked.
let gone = !!res && (res.ok || res.status === 404);
if (!gone) {
// Refused, or no answer. Ask rather than guess: a delete can land on the
// server and still lose its reply, and its session_deleted event has then
// already been spent while the request was in flight.
const check = await this._api(`/api/sessions/${sessionId}`);
gone = check?.status === 404;
}
if (gone) {
// An SSE resync (handleInit) that landed mid-request rebuilds the list from
// the server, which still had the session then.
if (this.sessions.has(sessionId)) this._onSessionDeleted({ id: sessionId });
if (killMux) {
this.showToast('Session closed and tmux killed', 'success');
} else {
this.showToast('Tab hidden, tmux still running', 'info');
}
} catch (err) {
this.showToast('Failed to close session', 'error');
} finally {
this._closingSessions.delete(sessionId);
return;
}
// Still on the server (or the server is unreachable, in which case the resync
// on reconnect has the last word): its row comes back where it was.
if (session && !this.sessions.has(sessionId)) {
this.sessions.set(sessionId, session);
if (!this.sessionOrder.includes(sessionId)) {
const at = orderIndex === -1 ? this.sessionOrder.length : Math.min(orderIndex, this.sessionOrder.length);
this.sessionOrder.splice(at, 0, sessionId);
this.saveSessionOrder();
}
this.renderSessionTabs();
}
this.showToast('Failed to close session', 'error');
}
// Request confirmation before closing a session