Files
hyperframes/packages/engine/src/services/chromeMemorySampler.ts
T
Vance IngallsandClaude Fable 5.1 a35168eddd fix(engine): sampleOnce() joins an in-flight sample instead of skipping it
With a boolean reentrancy flag, the Task 3 close sequence
(`stop(); await sampleOnce()`) returned immediately whenever an interval
tick was still awaiting CDP/ps, so getCapturePerfSummary read stats from up
to one interval earlier and onSample fired once more after the session had
closed. Reproduced with a 350 ms getPids on a 100 ms interval: close
resolved with samples=0 and the callback landed afterwards.

The guard now keeps the in-flight promise and returns it to any caller, so
an interval tick still never stacks a second round-trip (the existing
reentrancy test holds) while a close-time call resolves only after the
running tick has merged. Deviates from the plan's reference body, which
used `let inFlight = false`; the ChromeMemorySampler interface and all
names are unchanged, only the sampleOnce() contract is stronger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:52:02 -07:00

145 lines
5.1 KiB
TypeScript

import type { ProcessRssSample } from "../utils/processRss.js";
/**
* Chrome process memory observed during one capture session. Peaks are
* per-process-type maxima across samples; `rssLastMb` is the whole tree at
* the most recent successful sample. Surfaced on render_error /
* render_complete so the long-render "Target closed" class (spec §2.5) can be
* correlated with memory instead of guessed at.
*/
export interface ChromeMemoryStats {
browserRssPeakMb?: number;
rendererRssPeakMb?: number;
rssLastMb?: number;
gpuProcessSeenLastSample?: boolean;
samples: number;
}
export interface ChromePids {
browser?: number;
renderers: number[];
gpu: number[];
}
/**
* Timer seams, typed to the two-argument shape the sampler uses rather than
* `typeof setInterval`: with the DOM lib in scope that type also carries the
* `(handler, timeout) => number` overload, so a test could only satisfy it
* with a cast. The globals are assignable to these narrower shapes.
*/
type SetIntervalFn = (callback: () => void, ms: number) => NodeJS.Timeout;
type ClearIntervalFn = (timer: NodeJS.Timeout) => void;
export interface ChromeMemorySamplerDeps {
getPids: () => Promise<ChromePids>;
sampleRss: (pids: readonly number[]) => Promise<ProcessRssSample[]>;
intervalMs: number;
onSample?: (stats: ChromeMemoryStats) => void;
/** Test seam; defaults to the global setInterval. */
setIntervalFn?: SetIntervalFn;
/** Test seam; defaults to the global clearInterval. */
clearIntervalFn?: ClearIntervalFn;
}
export interface ChromeMemorySampler {
start(): void;
stop(): void;
stats(): ChromeMemoryStats;
/**
* One sample, awaited by the caller; used by tests and by close(). If a
* sample is already in flight this resolves when that sample finishes
* instead of starting another, so `stop(); await sampleOnce()` always sees
* the running tick's result and `onSample` cannot fire after it resolves.
*/
sampleOnce(): Promise<void>;
}
function maxDefined(a: number | undefined, b: number | undefined): number | undefined {
if (a === undefined) return b;
if (b === undefined) return a;
return Math.max(a, b);
}
/** Pure merge of one sample into the running stats. Empty `rss` → unchanged. */
export function mergeSample(
prev: ChromeMemoryStats,
pids: ChromePids,
rss: readonly ProcessRssSample[],
): ChromeMemoryStats {
if (rss.length === 0) return prev;
const byPid = new Map(rss.map((s) => [s.pid, s.rssMb]));
const browserMb = pids.browser === undefined ? undefined : byPid.get(pids.browser);
const rendererMbs = pids.renderers
.map((p) => byPid.get(p))
.filter((v): v is number => v !== undefined);
const gpuMbs = pids.gpu.map((p) => byPid.get(p)).filter((v): v is number => v !== undefined);
const rendererMax = rendererMbs.length > 0 ? Math.max(...rendererMbs) : undefined;
const total = rss.reduce((sum, s) => sum + s.rssMb, 0);
return {
browserRssPeakMb: maxDefined(prev.browserRssPeakMb, browserMb),
rendererRssPeakMb: maxDefined(prev.rendererRssPeakMb, rendererMax),
rssLastMb: total,
gpuProcessSeenLastSample: gpuMbs.length > 0,
samples: prev.samples + 1,
};
}
export function createChromeMemorySampler(deps: ChromeMemorySamplerDeps): ChromeMemorySampler {
const setIntervalFn: SetIntervalFn = deps.setIntervalFn ?? setInterval;
const clearIntervalFn: ClearIntervalFn = deps.clearIntervalFn ?? clearInterval;
let stats: ChromeMemoryStats = { samples: 0 };
let timer: NodeJS.Timeout | null = null;
let inFlight: Promise<void> | null = null;
const runSample = async (): Promise<void> => {
try {
const pids = await deps.getPids();
const all = [pids.browser, ...pids.renderers, ...pids.gpu].filter(
(p): p is number => typeof p === "number",
);
const rss = await deps.sampleRss(all);
const next = mergeSample(stats, pids, rss);
if (next !== stats) {
stats = next;
deps.onSample?.(stats);
}
} catch {
// Sampling is observability only; a CDP or ps failure must never reach
// the capture loop. The next tick retries.
}
};
const sampleOnce = (): Promise<void> => {
// Reentrancy guard: an interval tick that lands while a sample is still
// running joins it rather than stacking a second CDP/ps round-trip, and a
// close-time call waits for the running tick instead of skipping it.
// runSample never rejects, so the .finally reset always runs; it runs on
// a microtask, after the assignment below.
if (inFlight !== null) return inFlight;
inFlight = runSample().finally(() => {
inFlight = null;
});
return inFlight;
};
return {
start() {
if (timer !== null) return;
// Node's Timeout has unref(); the engine already relies on it
// (utils/processTracker.ts). Keeps the interval from holding the
// process open if a caller forgets stop().
timer = setIntervalFn(() => {
void sampleOnce();
}, deps.intervalMs);
timer.unref();
},
stop() {
if (timer === null) return;
clearIntervalFn(timer);
timer = null;
},
stats: () => stats,
sampleOnce,
};
}