From d06ea7f265b517dd5552cf44c70289d63a7d7985 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 29 May 2026 23:46:22 +0300 Subject: [PATCH] perf: cache runtime process table to stop ps spawn storm listRuntimeProcesses spawned a full `ps -ax` on every call with no caching. It is invoked very frequently while a team runs: runtime liveness/telemetry snapshots are rebuilt whenever a team file changes (invalidateRuntimeSnapshotCaches fires from ~25 sites), so the main process ended up forking ps dozens of times per second, which is expensive from the large Electron main process and pegged its CPU. Add a 1s TTL cache plus in-flight coalescing on the single ps spawn point. Liveness/telemetry callers already tolerate ~2s staleness via their own snapshot caches and the OS process table changes negligibly within a second, so this caps ps to <=1/s without affecting liveness correctness. Measured posix_spawn dropped from ~146/s to ~11/s with a team running. --- .../runtime/TmuxPlatformCommandExecutor.ts | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) 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([