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;
|
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[] {
|
export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] {
|
||||||
const rows: RuntimeProcessTableRow[] = [];
|
const rows: RuntimeProcessTableRow[] = [];
|
||||||
for (const line of output.split('\n')) {
|
for (const line of output.split('\n')) {
|
||||||
|
|
@ -39,8 +58,13 @@ export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow
|
||||||
if (enrichedMatch) {
|
if (enrichedMatch) {
|
||||||
const pid = Number.parseInt(enrichedMatch[1], 10);
|
const pid = Number.parseInt(enrichedMatch[1], 10);
|
||||||
const ppid = Number.parseInt(enrichedMatch[2], 10);
|
const ppid = Number.parseInt(enrichedMatch[2], 10);
|
||||||
const cpuPercent = Number(enrichedMatch[3]);
|
// `ps` formats the %cpu column with the locale decimal separator (e.g. "12,5" on
|
||||||
const rssKb = Number(enrichedMatch[4]);
|
// 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() ?? '';
|
const command = enrichedMatch[5]?.trim() ?? '';
|
||||||
if (
|
if (
|
||||||
Number.isFinite(pid) &&
|
Number.isFinite(pid) &&
|
||||||
|
|
@ -80,6 +104,8 @@ export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow
|
||||||
export class TmuxPlatformCommandExecutor {
|
export class TmuxPlatformCommandExecutor {
|
||||||
readonly #wslService: TmuxWslService;
|
readonly #wslService: TmuxWslService;
|
||||||
readonly #packageManagerResolver: TmuxPackageManagerResolver;
|
readonly #packageManagerResolver: TmuxPackageManagerResolver;
|
||||||
|
#runtimeProcessTableCache: RuntimeProcessTableCacheEntry | null = null;
|
||||||
|
#runtimeProcessTableInFlight: Promise<RuntimeProcessTableRow[]> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
wslService = new TmuxWslService(),
|
wslService = new TmuxWslService(),
|
||||||
|
|
@ -192,6 +218,31 @@ export class TmuxPlatformCommandExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
async listRuntimeProcesses(): Promise<RuntimeProcessTableRow[]> {
|
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 =
|
const result =
|
||||||
process.platform === 'win32'
|
process.platform === 'win32'
|
||||||
? await this.#wslService.execInPreferredDistro([
|
? await this.#wslService.execInPreferredDistro([
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue