diff --git a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts index 739fea0b..c0d10bbf 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts @@ -32,6 +32,25 @@ export interface RuntimeProcessTableRow { rssBytes?: number; } +/** + * Short-lived cache window for the global process table. + * + * `listRuntimeProcesses` spawns a full `ps -ax`, which is expensive when forked + * from the large Electron main process. Runtime liveness/telemetry callers fire + * very frequently (every team file change invalidates their per-team snapshot + * caches), so without throttling here the main process spawns `ps` dozens of + * times per second while a team runs. Those callers already tolerate ~2s + * staleness via their own snapshot caches, and the OS process table changes + * negligibly within a second, so a 1s window collapses the spawn storm without + * affecting liveness correctness. + */ +const RUNTIME_PROCESS_TABLE_CACHE_TTL_MS = 1_000; + +interface RuntimeProcessTableCacheEntry { + rows: RuntimeProcessTableRow[]; + expiresAtMs: number; +} + export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] { const rows: RuntimeProcessTableRow[] = []; for (const line of output.split('\n')) { @@ -39,8 +58,13 @@ export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow if (enrichedMatch) { const pid = Number.parseInt(enrichedMatch[1], 10); const ppid = Number.parseInt(enrichedMatch[2], 10); - const cpuPercent = Number(enrichedMatch[3]); - const rssKb = Number(enrichedMatch[4]); + // `ps` formats the %cpu column with the locale decimal separator (e.g. "12,5" on + // de_DE/fr_FR locales, which the runtime inherits via process.env). Normalize the + // comma to a dot so Number() does not return NaN — otherwise the enriched parse would + // fail its isFinite guard and fall back to the basic parser, leaking the pcpu/rss + // columns into `command`. + const cpuPercent = Number(enrichedMatch[3]?.replace(',', '.')); + const rssKb = Number(enrichedMatch[4]?.replace(',', '.')); const command = enrichedMatch[5]?.trim() ?? ''; if ( Number.isFinite(pid) && @@ -80,6 +104,8 @@ export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow export class TmuxPlatformCommandExecutor { readonly #wslService: TmuxWslService; readonly #packageManagerResolver: TmuxPackageManagerResolver; + #runtimeProcessTableCache: RuntimeProcessTableCacheEntry | null = null; + #runtimeProcessTableInFlight: Promise | null = null; constructor( wslService = new TmuxWslService(), @@ -192,6 +218,31 @@ export class TmuxPlatformCommandExecutor { } async listRuntimeProcesses(): Promise { + const cached = this.#runtimeProcessTableCache; + if (cached && cached.expiresAtMs > Date.now()) { + return cached.rows; + } + if (this.#runtimeProcessTableInFlight) { + return this.#runtimeProcessTableInFlight; + } + const request = this.#readRuntimeProcessesUncached() + .then((rows) => { + this.#runtimeProcessTableCache = { + rows, + expiresAtMs: Date.now() + RUNTIME_PROCESS_TABLE_CACHE_TTL_MS, + }; + return rows; + }) + .finally(() => { + if (this.#runtimeProcessTableInFlight === request) { + this.#runtimeProcessTableInFlight = null; + } + }); + this.#runtimeProcessTableInFlight = request; + return request; + } + + async #readRuntimeProcessesUncached(): Promise { const result = process.platform === 'win32' ? await this.#wslService.execInPreferredDistro([