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.
This commit is contained in:
parent
c0b9b4ec5d
commit
d06ea7f265
1 changed files with 53 additions and 2 deletions
|
|
@ -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<RuntimeProcessTableRow[]> | null = null;
|
||||
|
||||
constructor(
|
||||
wslService = new TmuxWslService(),
|
||||
|
|
@ -192,6 +218,31 @@ export class TmuxPlatformCommandExecutor {
|
|||
}
|
||||
|
||||
async listRuntimeProcesses(): Promise<RuntimeProcessTableRow[]> {
|
||||
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<RuntimeProcessTableRow[]> {
|
||||
const result =
|
||||
process.platform === 'win32'
|
||||
? await this.#wslService.execInPreferredDistro([
|
||||
|
|
|
|||
Loading…
Reference in a new issue