import type { AbandonOrigin } from "./abandon-load.js"; import { type AttributedDiff } from "./attribution.js"; import { type ConfidenceReport } from "./confidence.js"; import type { HeapSample } from "./control-server.js"; import { type MeasurementEnvironment } from "./environment.js"; import { diffSnapshotFiles, type HeapDiff } from "./heap-diff.js"; import { type PrerenderManifest } from "./manifests.js"; import { extractModuleRegistry } from "./module-registry.js"; import { runRitual, type LoadOutcome, type PeakSample, type PhaseTiming, type SettleOutcome } from "./ritual.js"; import { readNextVersion, type MatchedSignature } from "./signatures.js"; import { type TrendResult, type TrendVerdict } from "./trend.js"; /** What one repetition of a route concluded, for disclosing the spread. */ export type RepetitionSummary = { verdict: TrendVerdict; /** Growth normalized by traffic, so repetitions stay comparable. */ growthPer1000Requests: number; }; export type RouteReport = { route: string; status: "skipped"; reason: string; } | { route: string; status: "failed"; reason: string; } /** * The measured process ran out of heap partway through. This is a verdict, * not a failure: the route did not fit in the limit the run gave it, and the * outcome decides regardless of the shape of the truncated curve — the same * rule the build path applies to a static-generation worker that dies. * * Kept apart from `measured` because there is no after-snapshot and no * complete trend, and dressing it as a full measurement would claim data * that does not exist. */ | { route: string; status: "died-of-heap"; requestPath: string; reason: string; /** Post-GC readings up to the death; may be as short as the baseline. */ memorySamples: HeapSample[]; peaks: PeakSample[]; cyclesCompleted: number; cyclesRequested: number; requestsPerCycle: number; } /** * The route was reachable, but the load could not have exercised the code * path it represents — so no verdict is emitted. Measuring an ISR route * without driving revalidation reports a flat curve about the static cache, * which reads as health and is not. */ | { route: string; status: "not-exercised"; reason: string; } | { route: string; status: "measured"; /** Concrete path requested (differs from `route` for dynamic templates). */ requestPath: string; samples: number[]; /** * Full post-GC memory samples. RSS matters as much as the heap: a * process can hold gigabytes of RSS with a flat JS heap (allocator * behaviour, external buffers), which is a different diagnosis and a * different fix than a heap leak. */ memorySamples: HeapSample[]; /** * Highest memory reached *during* each load cycle. Every other number * here is post-GC; this is the one a container limit is judged against. */ peaks: PeakSample[]; /** * One reading per cycle taken before any forced collection, and the * trend over them. What a production process holds between full GCs is * not what this tool's verdict measures — see `unreclaimed-retention`. * Empty when a reading was lost: a hole would make every later delta * span two cycles. */ unreclaimedSamples: HeapSample[]; unreclaimedTrend: TrendResult; /** Requests each cycle served — what the growth rates normalize by. */ requestsPerCycle: number; /** * One entry per repetition when `repeat` was greater than 1, in the * order they ran. Absent for a single measurement, so `run.json` keeps * its existing shape unless repetition was asked for. */ repetitions?: RepetitionSummary[]; /** * The early-disconnect regime, when the route asked for one. Recorded * because a curve measured with cuts landing mid-stream and one measured * with cuts landing before the response are different experiments, and * the counters alone do not say which was intended. */ abandon?: { afterMs: number; from: AbandonOrigin; }; /** * Distinct keys the load cycled through, when the route asked for a * bounded set. A verdict about a cache depends on how many keys it saw, * so the number belongs on the record with the rest of the regime. */ keyCardinality?: number; /** * Seconds of this route's ISR revalidation period. Absent on routes not * served from the ISR cache. Recorded because a curve measured against a * cache and one measured against a re-render are different experiments. */ revalidatedEverySeconds?: number; /** * Whether the load carried the build's own revalidation header. Set apart * from the period because the two answer different questions: the period * says the ISR cache is in play, this says which of Next's two paths * served the requests that produced the curve. */ revalidationDriven?: true; /** RSS growth per 1000 requests, computed like the heap figure. */ rssPer1000Requests: number; /** Wall-clock per phase — explains where a long run spent its time. */ timings: PhaseTiming[]; /** What each load phase actually did (sent, 2xx, abandoned…). */ loadOutcomes: LoadOutcome[]; /** Whether the heap held still before each sample. */ settleOutcomes: SettleOutcome[]; /** * Audit of the measurement against its own evidence. `trend` stays as * measured; when the evidence does not support it, `confidence` * carries the verdict that does — see `effectiveVerdict`. */ confidence: ConfidenceReport; trend: TrendResult; growthPer1000Requests: number; baselineSnapshot: string; afterSnapshot: string; /** Null when the verdict is stable and diffAll was not requested. */ diff: HeapDiff | null; /** * Why this route has no attribution, when the diff was attempted and * could not run. * * An absent diff and an unreadable snapshot both surface as `diff: null`, * and they are opposite findings: one says nothing grew enough to name, * the other says the evidence could not be read. A report that conflates * them lets a leak with no attribution pass for a leak with nothing to * attribute. */ attributionGap?: { /** * `snapshot-unreadable`: both snapshots exist, the diff refused them. * `snapshot-unavailable`: the final snapshot was never taken, so there * is nothing to diff. Either way the verdict below still stands. */ reason: "snapshot-unreadable" | "snapshot-unavailable"; detail: string; }; /** Null when there is no diff or no module registry. */ attribution: AttributedDiff | null; signatures: MatchedSignature[]; /** * Cycles used by the second pass, when the first came back * `inconclusive` and the run went back for more evidence. */ resolvedWithCycles?: number; }; export type MeasuredRoute = Extract; export type RunParameters = { warmupRequests: number; loadRequests: number; connections: number; cycles: number; idleMs: number; /** * Old-space cap of each measured process (MB). Part of the measurement * regime: a run near its ceiling is not the same experiment as one with * headroom, so it belongs on the record. */ maxOldSpaceMb: number; /** * Per-cycle growth gate the verdicts were judged against (bytes). Derived * from `loadRequests`; recorded because a verdict whose threshold is not * printed cannot be audited or reproduced. */ minGrowthPerCycle: number; }; export type RunReport = { appDir: string; startedAt: string; workDir: string; /** Carried so the report can suggest sample params the build already knows. */ prerender?: PrerenderManifest; /** * Whether a leak of known size was detected in this environment during this * session. * * A `stable` verdict means either "the app does not leak" or "the * measurement did not work", and a flat curve looks the same both ways. When * this says verified, the second reading is excluded; when it does not, the * report must not imply otherwise. Absent verification is the ordinary case, * not a failure. */ harness: { verified: false; } | { verified: true; growthPer1000Requests: number; }; environment: MeasurementEnvironment; parameters: RunParameters; routes: RouteReport[]; bundle: { htmlReport: string; issues: Array<{ route: string; file: string; }>; }; }; export type RunOptions = { appDir: string; /** Built bootstrap module for `--import` into measured processes. */ bootstrapPath: string; /** Parent output directory. Default: `/.next-leak`. */ outputDir?: string; warmupRequests?: number; loadRequests?: number; connections?: number; cycles?: number; idleMs?: number; /** Old-space cap for each measured process (MB). Default 512. */ maxOldSpaceMb?: number; /** * How long each measured process gets to start listening, in milliseconds. * Default: `DEFAULT_READY_TIMEOUT_MS`. */ readyTimeoutMs?: number; /** Also diff routes with a stable verdict. Default false: diffs are slow. */ diffAll?: boolean; /** Only measure routes matching these templates or prefixes. */ routeFilter?: string[]; /** * Measure a route again, with more cycles, when the first pass could not * call it. Default true: the run should answer the question it was asked. */ resolveInconclusive?: boolean; /** * How many times to measure each route, each from a fresh server. Default 1. * * Distinct from `cycles`, which watches a single process for longer. The * variance that makes a number unpublishable is between processes. */ repeat?: number; /** * Growth the self-check measured on its planted leak, when one ran before * this measurement. Its presence is what lets the report say a `stable` * verdict was produced by a harness known to work. */ harnessVerifiedAt?: number; /** Abort between phases; remaining routes are reported as interrupted. */ signal?: AbortSignal; onProgress?: (message: string) => void; }; export type RunEstimate = { /** * Costs no run avoids: process launch, GC, snapshots and the minimum * settle. Request-serving time is left out on purpose — throughput is the * one term that swings 10x between a hello-world route and a real one, so a * floor that guessed at it would not be a floor. */ fastSeconds: number; /** Full idle window every cycle, at the lowest throughput ever measured. */ slowSeconds: number; }; export declare function estimateRun(routeCount: number, parameters: RunParameters): RunEstimate; /** * A point estimate cannot be right across app weights: the adaptive idle ends * as soon as the heap holds still, which on a small app is almost at once. A * range says that out loud instead of quoting the worst case as the price. */ export declare function formatEstimate(estimate: RunEstimate): string; export declare function formatDuration(seconds: number): string; export type RunnerDeps = { ritual: typeof runRitual; diff: typeof diffSnapshotFiles; freePort: () => Promise; registry: typeof extractModuleRegistry; nextVersion: typeof readNextVersion; }; export declare function freePort(): Promise; export declare function routeSlug(route: string): string; /** * Combines repeated measurements of one route into the verdict it earned. * * Unanimity or nothing. Taking the most severe would let one noisy repetition * in five accuse a healthy route, and a false accusation is the expensive * error; taking the majority would discard exactly the disagreement the * repetitions were run to find. When they disagree the honest report is that * the measurement did not settle, which is what `inconclusive` means and what * already routes to re-measurement and away from issue drafts. */ export declare function aggregateRepetitions(passes: readonly RouteReport[]): RouteReport; /** * Full measurement run: validate the target, discover routes, run the ritual * per route in a fresh process, diff snapshots for non-stable verdicts, and * persist `run.json` plus raw snapshots under the work directory. */ export declare function runMeasurement(options: RunOptions, deps?: RunnerDeps): Promise;