fix: cache ps output across sessions to fix 51s dashboard load (#244)

* fix: cache ps output across sessions to fix 51s dashboard load

findClaudeProcess() was calling `ps -eo pid,tty,args` once per session.
On machines with ~1150 processes, each `ps` call takes 30-35s. With 18
sessions enriched in parallel, this caused /api/sessions to take 51+
seconds despite the 2s per-session timeout.

Add a module-level TTL cache (5s) with in-flight deduplication so `ps`
is called at most once regardless of session count. Also reduce timeouts
from 30s to 5s — stale process data isn't useful.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: guard ps cache callbacks against stale overwrites

The .then() and catch callbacks in getCachedProcessList() could clobber
a newer in-flight cache entry if the TTL expired and a new request
replaced psCache while the old one was still pending. Both now check
`psCache?.promise === promise` before writing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-03-01 20:41:03 +05:30 committed by GitHub
parent 3cb03f6222
commit 128b94a932
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 55 additions and 8 deletions

View File

@ -30,7 +30,7 @@ vi.mock("node:os", () => ({
homedir: mockHomedir,
}));
import { create, manifest, default as defaultExport } from "./index.js";
import { create, manifest, default as defaultExport, resetPsCache } from "./index.js";
// ---------------------------------------------------------------------------
// Test helpers
@ -108,6 +108,7 @@ function mockJsonlFiles(
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
resetPsCache();
mockHomedir.mockReturnValue("/mock/home");
});

View File

@ -386,6 +386,55 @@ function extractCost(lines: JsonlLine[]): CostEstimate | undefined {
// Process Detection
// =============================================================================
/**
* TTL cache for `ps -eo pid,tty,args` output. Without this, listing N sessions
* would spawn N concurrent `ps` processes, each taking 30+ seconds on machines
* with many processes. The cache ensures `ps` is called at most once per TTL
* window regardless of how many sessions are being enriched.
*/
let psCache: { output: string; timestamp: number; promise?: Promise<string> } | null = null;
const PS_CACHE_TTL_MS = 5_000;
/** Reset the ps cache. Exported for testing only. */
export function resetPsCache(): void {
psCache = null;
}
async function getCachedProcessList(): Promise<string> {
const now = Date.now();
if (psCache && now - psCache.timestamp < PS_CACHE_TTL_MS) {
// Cache hit — return resolved output or wait for in-flight request
if (psCache.promise) return psCache.promise;
return psCache.output;
}
// Cache miss or expired — start a single `ps` call and share the promise.
// Guard both callbacks so they only update psCache if it still belongs to
// this request — a newer request may have replaced it while we were waiting.
const promise = execFileAsync("ps", ["-eo", "pid,tty,args"], {
timeout: 5_000,
}).then(({ stdout }) => {
if (psCache?.promise === promise) {
psCache = { output: stdout, timestamp: Date.now() };
}
return stdout;
});
// Store the in-flight promise so concurrent callers share it
psCache = { output: "", timestamp: now, promise };
try {
return await promise;
} catch {
// On failure, clear cache so the next caller retries — but only if
// psCache still points to this request (avoid clobbering a newer entry)
if (psCache?.promise === promise) {
psCache = null;
}
return "";
}
}
/**
* Check if a process named "claude" is running in the given runtime handle's context.
* Uses ps to find processes by TTY (for tmux) or by PID.
@ -397,7 +446,7 @@ async function findClaudeProcess(handle: RuntimeHandle): Promise<number | null>
const { stdout: ttyOut } = await execFileAsync(
"tmux",
["list-panes", "-t", handle.id, "-F", "#{pane_tty}"],
{ timeout: 30_000 },
{ timeout: 5_000 },
);
// Iterate all pane TTYs (multi-pane sessions) — succeed on any match
const ttys = ttyOut
@ -407,12 +456,9 @@ async function findClaudeProcess(handle: RuntimeHandle): Promise<number | null>
.filter(Boolean);
if (ttys.length === 0) return null;
// Use `args` instead of `comm` so we can match the CLI name even when
// the process runs via a wrapper (e.g. node, python). `comm` would
// report "node" instead of "claude" in those cases.
const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], {
timeout: 30_000,
});
const psOut = await getCachedProcessList();
if (!psOut) return null;
const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, "")));
// Match "claude" as a word boundary — prevents false positives on
// names like "claude-code" or paths that merely contain the substring.