mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 14:22:14 +03:00
* fix(usage): bound completed request retention Completed request previews used V8 sliced strings that kept multi-megabyte request backing stores alive. Detach and byte-bound cached details, and add a credential-free profiler with cleanup and physical-retention assertions. * fix(usage): give the JON-562 memory-profile canary realistic timeouts The 100k-token worker step alone takes ~230s (tsx/esm boot of the full route/handler module graph plus the real request lifecycle), well past the driver's hardcoded 180s spawnSync timeout — the resulting SIGKILL surfaces as `worker.status === null`, indistinguishable from a real crash. Bump the worker timeout to 300s and the test's own outer/inner timeouts to match the measured ~230-330s real runtime. Also make git-branch provenance detached-HEAD safe: `git branch --show-current` is empty on a detached HEAD (the normal state for a CI PR checkout, and for this fix worktree itself), which made the canary throw "git branch is empty" deterministically outside a regular branch checkout. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
997 lines
35 KiB
TypeScript
997 lines
35 KiB
TypeScript
/**
|
|
* JON-562: bounded memory profile for the real Claude `/v1/messages` request boundary.
|
|
*
|
|
* The default driver runs each context size in a fresh child process. The worker uses a
|
|
* synthetic request and a local fetch stub, so it exercises admission, parsing, translation,
|
|
* request logging and SSE cleanup without credentials or provider calls. Raw payloads are never
|
|
* written. Artifacts are private (umask 077) and contain only measurements plus V8 profiles of
|
|
* the synthetic process.
|
|
*
|
|
* Usage:
|
|
* node --import tsx/esm scripts/perf/messages-route-memory-profile.ts \
|
|
* --output-dir /tmp/omniroute-JON-562
|
|
*
|
|
* Defaults: 100k, 300k and 600k token-equivalents; six sequential requests per fresh process;
|
|
* concurrency=1; stream=true; cancellation=none. Context size is the only changing factor.
|
|
*/
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import inspector from "node:inspector";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import v8 from "node:v8";
|
|
|
|
export const BYTES_PER_TOKEN_EQUIVALENT = 4;
|
|
export const DEFAULT_TOKEN_EQUIVALENTS = [100_000, 300_000, 600_000] as const;
|
|
const PROVENANCE_FILES = [
|
|
"src/lib/usage/completedRequestDetails.ts",
|
|
"src/lib/usage/usageHistory.ts",
|
|
"tests/unit/active-request-stream-chunks-lifecycle.test.ts",
|
|
"scripts/perf/messages-route-memory-profile.ts",
|
|
"tests/unit/messages-route-memory-profile.test.ts",
|
|
] as const;
|
|
|
|
type ClaudePayload = {
|
|
model: string;
|
|
max_tokens: number;
|
|
stream: boolean;
|
|
messages: Array<{ role: "user"; content: string }>;
|
|
};
|
|
|
|
export type MemoryRow = {
|
|
phase: "baseline" | "after_route" | "after_drain" | "settled" | "final";
|
|
elapsedMs: number;
|
|
requestIndex?: number;
|
|
heapUsedBytes: number;
|
|
heapTotalBytes: number;
|
|
rssBytes: number;
|
|
externalBytes: number;
|
|
arrayBuffersBytes: number;
|
|
admission: {
|
|
activeHeavy: number;
|
|
activeHealthyHeadroom: number;
|
|
inflightBytes: number;
|
|
queuedBytes: number;
|
|
waiting: number;
|
|
};
|
|
};
|
|
|
|
type GrowthSummary = {
|
|
baselineHeapUsedBytes: number;
|
|
finalSettledHeapUsedBytes: number;
|
|
settledGrowthBytes: number;
|
|
settledSlopeBytesPerRequest: number;
|
|
growthToWireRatio: number;
|
|
};
|
|
|
|
function markerFor(tokenEquivalent: number): string {
|
|
return ["JON", "562", tokenEquivalent, "CONTEXT"].join("-");
|
|
}
|
|
|
|
/**
|
|
* Build the credential-free environment inherited by profiler children.
|
|
* @param source - Environment to copy allowlisted runtime fields from.
|
|
* @returns A new environment containing only allowlisted fields and fixed test settings.
|
|
*/
|
|
export function buildWorkerEnv(
|
|
source: NodeJS.ProcessEnv | Record<string, string | undefined> = process.env
|
|
): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = {};
|
|
for (const key of ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "TZ"] as const) {
|
|
const value = source[key];
|
|
if (value) env[key] = value;
|
|
}
|
|
env.NODE_ENV = "test";
|
|
env.APP_LOG_LEVEL = "error";
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Build an ASCII-only Claude body with an exact, repeatable serialized size.
|
|
* @param tokenEquivalent - Context size expressed at four serialized bytes per token.
|
|
* @returns The synthetic body, its marker and exact target wire size.
|
|
* @throws {RangeError} If `tokenEquivalent` is invalid or too small for the fixed envelope.
|
|
* @throws {Error} If serialization does not match the calculated target size.
|
|
*/
|
|
export function buildClaudeContextPayload(tokenEquivalent: number): {
|
|
body: ClaudePayload;
|
|
marker: string;
|
|
targetWireBytes: number;
|
|
} {
|
|
if (!Number.isSafeInteger(tokenEquivalent) || tokenEquivalent < 64) {
|
|
throw new RangeError("tokenEquivalent must be an integer >= 64");
|
|
}
|
|
const targetWireBytes = tokenEquivalent * BYTES_PER_TOKEN_EQUIVALENT;
|
|
const marker = markerFor(tokenEquivalent);
|
|
const body: ClaudePayload = {
|
|
model: "openai/gpt-4.1",
|
|
max_tokens: 8,
|
|
stream: true,
|
|
messages: [{ role: "user", content: "" }],
|
|
};
|
|
const fixedBytes = Buffer.byteLength(JSON.stringify(body), "utf8");
|
|
const contentBytes = targetWireBytes - fixedBytes;
|
|
if (contentBytes < marker.length) {
|
|
throw new RangeError("tokenEquivalent is too small for the fixed request envelope");
|
|
}
|
|
body.messages[0].content = marker + "x".repeat(contentBytes - marker.length);
|
|
const actualBytes = Buffer.byteLength(JSON.stringify(body), "utf8");
|
|
if (actualBytes !== targetWireBytes) {
|
|
throw new Error(`payload calibration failed: wanted ${targetWireBytes}, got ${actualBytes}`);
|
|
}
|
|
return { body, marker, targetWireBytes };
|
|
}
|
|
|
|
/**
|
|
* Calculate post-GC growth from baseline and settled samples only.
|
|
* @param rows - Ordered memory samples from one isolated workload.
|
|
* @param wireBytes - Exact serialized request size used to calculate the growth ratio.
|
|
* @returns Baseline, final, slope and wire-ratio measurements.
|
|
* @throws {Error} If the samples contain no baseline or settled row.
|
|
*/
|
|
export function summarizeSettledGrowth(
|
|
rows: Array<Pick<MemoryRow, "phase" | "heapUsedBytes" | "requestIndex">>,
|
|
wireBytes: number
|
|
): GrowthSummary {
|
|
const baseline = rows.find((row) => row.phase === "baseline");
|
|
const settled = rows.filter((row) => row.phase === "settled");
|
|
if (!baseline || settled.length === 0) {
|
|
throw new Error("baseline and settled samples are required");
|
|
}
|
|
const final = settled[settled.length - 1];
|
|
const settledGrowthBytes = final.heapUsedBytes - baseline.heapUsedBytes;
|
|
const settledSlopeBytesPerRequest =
|
|
settled.length < 2
|
|
? settledGrowthBytes
|
|
: (final.heapUsedBytes - settled[0].heapUsedBytes) / (settled.length - 1);
|
|
return {
|
|
baselineHeapUsedBytes: baseline.heapUsedBytes,
|
|
finalSettledHeapUsedBytes: final.heapUsedBytes,
|
|
settledGrowthBytes,
|
|
settledSlopeBytesPerRequest,
|
|
growthToWireRatio: settledGrowthBytes / wireBytes,
|
|
};
|
|
}
|
|
|
|
function argValue(flag: string): string | undefined {
|
|
const index = process.argv.indexOf(flag);
|
|
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
}
|
|
|
|
function positiveIntArg(flag: string, fallback: number): number {
|
|
const raw = argValue(flag);
|
|
if (raw === undefined) return fallback;
|
|
const value = Number(raw);
|
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
throw new RangeError(`${flag} must be a positive integer`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function privateDirectory(directory: string): void {
|
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
fs.chmodSync(directory, 0o700);
|
|
}
|
|
|
|
function writePrivateJson(file: string, value: unknown): void {
|
|
fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
|
|
fs.chmodSync(file, 0o600);
|
|
}
|
|
|
|
function appendPrivateJsonLine(file: string, value: unknown): void {
|
|
fs.appendFileSync(file, JSON.stringify(value) + "\n", { mode: 0o600 });
|
|
}
|
|
|
|
/**
|
|
* Run work while guaranteeing removal of its raw heap-snapshot path.
|
|
* @param snapshotFile - Raw snapshot path owned by the operation.
|
|
* @param work - Worker/analyzer operation to run before cleanup.
|
|
* @returns The fulfilled result from `work`.
|
|
* @throws The original work or cleanup error.
|
|
*/
|
|
export async function withRawSnapshotCleanup<T>(
|
|
snapshotFile: string,
|
|
work: () => Promise<T>
|
|
): Promise<T> {
|
|
try {
|
|
return await work();
|
|
} finally {
|
|
fs.rmSync(snapshotFile, { force: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a worker snapshot unless a successful driver handoff owns it.
|
|
* @param snapshotFile - Raw worker snapshot path.
|
|
* @param state - Whether the worker completed and the driver accepted ownership.
|
|
* @returns Nothing.
|
|
*/
|
|
export function cleanupWorkerSnapshot(
|
|
snapshotFile: string,
|
|
state: { workerComplete: boolean; snapshotHandoff: boolean }
|
|
): void {
|
|
if (!state.workerComplete || !state.snapshotHandoff) {
|
|
fs.rmSync(snapshotFile, { force: true });
|
|
}
|
|
}
|
|
|
|
function forceGc(): void {
|
|
if (typeof globalThis.gc !== "function") {
|
|
throw new Error("JON-562 worker requires node --expose-gc");
|
|
}
|
|
for (let index = 0; index < 4; index += 1) globalThis.gc();
|
|
}
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function inspectorPost<T>(
|
|
session: inspector.Session,
|
|
method: string,
|
|
params: Record<string, unknown> = {}
|
|
): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
session.post(method, params, (error, result) => {
|
|
if (error) reject(error);
|
|
else resolve(result as T);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function startAllocationSampling(): Promise<{
|
|
stop: () => Promise<Record<string, unknown>>;
|
|
disconnect: () => void;
|
|
}> {
|
|
const session = new inspector.Session();
|
|
session.connect();
|
|
await inspectorPost(session, "HeapProfiler.enable");
|
|
await inspectorPost(session, "HeapProfiler.startSampling", {
|
|
samplingInterval: 32 * 1024,
|
|
includeObjectsCollectedByMajorGC: true,
|
|
includeObjectsCollectedByMinorGC: true,
|
|
});
|
|
return {
|
|
stop: async () => {
|
|
const result = await inspectorPost<{ profile: Record<string, unknown> }>(
|
|
session,
|
|
"HeapProfiler.stopSampling"
|
|
);
|
|
return result.profile;
|
|
},
|
|
disconnect: () => session.disconnect(),
|
|
};
|
|
}
|
|
|
|
function openAiSseResponse(): Response {
|
|
const encoder = new TextEncoder();
|
|
const frames = [
|
|
`data: ${JSON.stringify({
|
|
id: "chatcmpl_JON562",
|
|
object: "chat.completion.chunk",
|
|
created: 1,
|
|
model: "gpt-4.1",
|
|
choices: [{ index: 0, delta: { role: "assistant", content: "ok" } }],
|
|
})}\n\n`,
|
|
`data: ${JSON.stringify({
|
|
id: "chatcmpl_JON562",
|
|
object: "chat.completion.chunk",
|
|
created: 1,
|
|
model: "gpt-4.1",
|
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
})}\n\n`,
|
|
"data: [DONE]\n\n",
|
|
];
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
for (const frame of frames) controller.enqueue(encoder.encode(frame));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "content-type": "text/event-stream" } }
|
|
);
|
|
}
|
|
|
|
async function runWorker(): Promise<void> {
|
|
process.umask(0o077);
|
|
const tokenEquivalent = positiveIntArg("--tokens", 100_000);
|
|
const iterations = positiveIntArg("--iterations", 6);
|
|
const outputDir = path.resolve(argValue("--output-dir") ?? "");
|
|
if (!argValue("--output-dir")) throw new Error("--output-dir is required in worker mode");
|
|
privateDirectory(outputDir);
|
|
|
|
const memoryFile = path.join(outputDir, "memory.jsonl");
|
|
fs.writeFileSync(memoryFile, "", { mode: 0o600 });
|
|
const startedAt = performance.now();
|
|
const rows: MemoryRow[] = [];
|
|
let providerCalls = 0;
|
|
let harness: Awaited<
|
|
ReturnType<
|
|
typeof import("../../tests/integration/_chatPipelineHarness.ts").createChatPipelineHarness
|
|
>
|
|
> | null = null;
|
|
let sampler: Awaited<ReturnType<typeof startAllocationSampling>> | null = null;
|
|
let heapSnapshotFile: string | null = null;
|
|
let workerComplete = false;
|
|
const snapshotHandoff = process.argv.includes("--snapshot-handoff");
|
|
|
|
try {
|
|
const { createChatPipelineHarness } =
|
|
await import("../../tests/integration/_chatPipelineHarness.ts");
|
|
harness = await createChatPipelineHarness(`JON-562-${tokenEquivalent}`);
|
|
const messagesRoute = await import("../../src/app/api/v1/messages/route.ts");
|
|
const { perConnectionAdmissionController } =
|
|
await import("../../src/shared/middleware/chatBodyAdmission.ts");
|
|
const { reloadResourcePressureRuntime } =
|
|
await import("../../open-sse/utils/resourcePressure.ts");
|
|
|
|
reloadResourcePressureRuntime({
|
|
heapThresholdMb: null,
|
|
immediateHeapUsedMb: () => 1,
|
|
sample: async () => ({
|
|
observedAtMs: Date.now(),
|
|
v8: { heapUsedBytes: 1, heapLimitBytes: Number.MAX_SAFE_INTEGER },
|
|
process: {
|
|
rssBytes: 1,
|
|
externalBytes: 0,
|
|
arrayBuffersBytes: 0,
|
|
availableBytes: null,
|
|
constrainedBytes: null,
|
|
},
|
|
cgroup: {
|
|
currentBytes: null,
|
|
maxBytes: null,
|
|
highBytes: null,
|
|
fileBytes: null,
|
|
events: null,
|
|
},
|
|
psi: null,
|
|
}),
|
|
});
|
|
harness.BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
|
await harness.resetStorage();
|
|
await harness.seedConnection("openai", {
|
|
name: "JON-562-local-stub",
|
|
apiKey: "synthetic-not-a-credential",
|
|
});
|
|
globalThis.fetch = async () => {
|
|
providerCalls += 1;
|
|
return openAiSseResponse();
|
|
};
|
|
|
|
const sample = (phase: MemoryRow["phase"], requestIndex?: number): void => {
|
|
const usage = process.memoryUsage();
|
|
const admission = perConnectionAdmissionController.snapshot();
|
|
const row: MemoryRow = {
|
|
phase,
|
|
elapsedMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
requestIndex,
|
|
heapUsedBytes: usage.heapUsed,
|
|
heapTotalBytes: usage.heapTotal,
|
|
rssBytes: usage.rss,
|
|
externalBytes: usage.external,
|
|
arrayBuffersBytes: usage.arrayBuffers,
|
|
admission: {
|
|
activeHeavy: admission.activeHeavy,
|
|
activeHealthyHeadroom: admission.activeHealthyHeadroom,
|
|
inflightBytes: admission.inflightBytes,
|
|
queuedBytes: admission.queuedBytes,
|
|
waiting: admission.waiting,
|
|
},
|
|
};
|
|
rows.push(row);
|
|
appendPrivateJsonLine(memoryFile, row);
|
|
};
|
|
|
|
const runRequest = async (
|
|
tokens: number,
|
|
requestIndex: number,
|
|
measured: boolean
|
|
): Promise<number> => {
|
|
const { body, targetWireBytes } = buildClaudeContextPayload(tokens);
|
|
const serialized = JSON.stringify(body);
|
|
const request = new Request("http://omniroute.invalid/v1/messages", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"content-length": String(targetWireBytes),
|
|
accept: "text/event-stream",
|
|
},
|
|
body: serialized,
|
|
});
|
|
const response = await messagesRoute.POST(request, {});
|
|
if (measured) sample("after_route", requestIndex);
|
|
const responseBody = await response.arrayBuffer();
|
|
if (measured) sample("after_drain", requestIndex);
|
|
if (response.status !== 200) {
|
|
throw new Error(
|
|
`route returned ${response.status} (${responseBody.byteLength} response bytes)`
|
|
);
|
|
}
|
|
return responseBody.byteLength;
|
|
};
|
|
|
|
await runRequest(Math.min(tokenEquivalent, 2_048), 0, false);
|
|
await delay(50);
|
|
forceGc();
|
|
|
|
// Allocation sampling is deliberately completed before the retention time series. The
|
|
// inspector profiler retains its own sampled stack records; leaving it enabled would make
|
|
// post-GC heap growth look linear even when the request payload itself was collectible.
|
|
sampler = await startAllocationSampling();
|
|
await runRequest(tokenEquivalent, 0, false);
|
|
const allocationProfile = await sampler.stop();
|
|
const allocationFile = path.join(outputDir, "allocation.heapprofile");
|
|
writePrivateJson(allocationFile, allocationProfile);
|
|
sampler.disconnect();
|
|
sampler = null;
|
|
|
|
await delay(100);
|
|
forceGc();
|
|
sample("baseline");
|
|
|
|
const responseBytes: number[] = [];
|
|
for (let requestIndex = 1; requestIndex <= iterations; requestIndex += 1) {
|
|
responseBytes.push(await runRequest(tokenEquivalent, requestIndex, true));
|
|
await delay(50);
|
|
forceGc();
|
|
sample("settled", requestIndex);
|
|
}
|
|
|
|
await delay(250);
|
|
forceGc();
|
|
sample("final");
|
|
|
|
if (!process.argv.includes("--no-snapshot")) {
|
|
forceGc();
|
|
heapSnapshotFile = path.join(outputDir, "post-gc.heapsnapshot");
|
|
v8.writeHeapSnapshot(heapSnapshotFile);
|
|
fs.chmodSync(heapSnapshotFile, 0o600);
|
|
}
|
|
|
|
const { targetWireBytes } = buildClaudeContextPayload(tokenEquivalent);
|
|
const growth = summarizeSettledGrowth(rows, targetWireBytes);
|
|
const settledRows = rows.filter((row) => row.phase === "settled");
|
|
const released = settledRows.every(
|
|
(row) =>
|
|
row.admission.activeHeavy === 0 &&
|
|
row.admission.activeHealthyHeadroom === 0 &&
|
|
row.admission.inflightBytes === 0 &&
|
|
row.admission.waiting === 0
|
|
);
|
|
if (!released) throw new Error("admission state remained live after an SSE response drained");
|
|
|
|
const manifest = {
|
|
ticket: "JON-562",
|
|
status: "complete",
|
|
route: "/v1/messages",
|
|
tokenEquivalent,
|
|
bytesPerTokenEquivalent: BYTES_PER_TOKEN_EQUIVALENT,
|
|
exactWireBytes: targetWireBytes,
|
|
iterations,
|
|
providerCalls,
|
|
conditions: {
|
|
concurrency: 1,
|
|
cancellation: "none",
|
|
stream: true,
|
|
messageCount: 1,
|
|
toolCount: 0,
|
|
provider: "local-fetch-stub",
|
|
networkCalls: 0,
|
|
},
|
|
runtime: {
|
|
node: process.version,
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
heapSizeLimitBytes: v8.getHeapStatistics().heap_size_limit,
|
|
},
|
|
responseBytes,
|
|
allocationSampling: {
|
|
intervalBytes: 32 * 1024,
|
|
file: path.basename(allocationFile),
|
|
requestCount: 1,
|
|
measuredWindow: "one post-warmup request before the retention time series",
|
|
},
|
|
retentionSeries: { allocationSamplingEnabled: false },
|
|
heapSnapshot: heapSnapshotFile ? path.basename(heapSnapshotFile) : null,
|
|
admissionReleasedAfterEveryRequest: released,
|
|
growth,
|
|
};
|
|
writePrivateJson(path.join(outputDir, "manifest.json"), manifest);
|
|
workerComplete = true;
|
|
process.stdout.write(JSON.stringify({ outputDir, status: "complete", growth }) + "\n");
|
|
} finally {
|
|
try {
|
|
if (sampler) {
|
|
await sampler.stop().catch(() => undefined);
|
|
sampler.disconnect();
|
|
}
|
|
await harness?.cleanup();
|
|
} finally {
|
|
if (heapSnapshotFile) {
|
|
cleanupWorkerSnapshot(heapSnapshotFile, { workerComplete, snapshotHandoff });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export type HeapSnapshot = {
|
|
snapshot: {
|
|
meta: {
|
|
node_fields: string[];
|
|
node_types: Array<string[] | string>;
|
|
edge_fields: string[];
|
|
edge_types: Array<string[] | string>;
|
|
};
|
|
};
|
|
nodes: number[];
|
|
edges: number[];
|
|
strings: string[];
|
|
};
|
|
|
|
function safeLabel(value: string, marker: string): string {
|
|
if (value.includes(marker) || value.length > 120) return "<SYNTHETIC_CONTEXT_REDACTED>";
|
|
if (path.isAbsolute(value)) return `<ABSOLUTE_PATH_REDACTED>/${path.basename(value)}`;
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Classify marker-bearing V8 string graphs by their physical backing size.
|
|
* @param parsed - Parsed V8 heap snapshot.
|
|
* @param marker - Synthetic context marker to locate.
|
|
* @param minimumSelfSizeBytes - Minimum backing-graph size treated as retained context.
|
|
* @returns Candidate counts, physical sizes, verdict and large component roots.
|
|
*/
|
|
export function classifyContextBackingCandidates(
|
|
parsed: HeapSnapshot,
|
|
marker: string,
|
|
minimumSelfSizeBytes: number
|
|
): {
|
|
matchingPreviewNodes: number;
|
|
maxSelfSizeBytes: number;
|
|
maxBackingRetainedSizeBytes: number;
|
|
retainedContextSelfSizeBytes: number;
|
|
largeBackingRetained: boolean;
|
|
largeNodeIndexes: number[];
|
|
} {
|
|
const nodeFields = parsed.snapshot.meta.node_fields;
|
|
const nodeWidth = nodeFields.length;
|
|
const nodeTypeIndex = nodeFields.indexOf("type");
|
|
const nodeNameIndex = nodeFields.indexOf("name");
|
|
const nodeSelfSizeIndex = nodeFields.indexOf("self_size");
|
|
const edgeCountIndex = nodeFields.indexOf("edge_count");
|
|
const edgeFields = parsed.snapshot.meta.edge_fields;
|
|
const edgeWidth = edgeFields.length;
|
|
const edgeTypeIndex = edgeFields.indexOf("type");
|
|
const edgeTargetIndex = edgeFields.indexOf("to_node");
|
|
const nodeTypes = parsed.snapshot.meta.node_types[nodeTypeIndex] as string[];
|
|
const edgeTypes = parsed.snapshot.meta.edge_types[edgeTypeIndex] as string[];
|
|
const nodeCount = parsed.nodes.length / nodeWidth;
|
|
const matching: Array<{ nodeIndex: number; selfSizeBytes: number }> = [];
|
|
const stringParents: Array<number[] | undefined> = new Array(nodeCount);
|
|
const stringChildren: Array<number[] | undefined> = new Array(nodeCount);
|
|
const isStringNode = (nodeIndex: number): boolean => {
|
|
const type = nodeTypes[parsed.nodes[nodeIndex * nodeWidth + nodeTypeIndex]];
|
|
return type === "string" || type === "concatenated string" || type === "sliced string";
|
|
};
|
|
|
|
let edgeOffset = 0;
|
|
for (let from = 0; from < nodeCount; from += 1) {
|
|
const edgeCount = parsed.nodes[from * nodeWidth + edgeCountIndex];
|
|
for (let local = 0; local < edgeCount; local += 1) {
|
|
const type = edgeTypes[parsed.edges[edgeOffset + edgeTypeIndex]];
|
|
const to = parsed.edges[edgeOffset + edgeTargetIndex] / nodeWidth;
|
|
if (type === "internal" && isStringNode(from) && isStringNode(to)) {
|
|
(stringChildren[from] ??= []).push(to);
|
|
(stringParents[to] ??= []).push(from);
|
|
}
|
|
edgeOffset += edgeWidth;
|
|
}
|
|
}
|
|
|
|
for (let nodeIndex = 0; nodeIndex < nodeCount; nodeIndex += 1) {
|
|
const offset = nodeIndex * nodeWidth;
|
|
const type = nodeTypes[parsed.nodes[offset + nodeTypeIndex]];
|
|
if (type !== "string" && type !== "concatenated string" && type !== "sliced string") continue;
|
|
const name = parsed.strings[parsed.nodes[offset + nodeNameIndex]] ?? "";
|
|
if (name.includes(marker)) {
|
|
matching.push({ nodeIndex, selfSizeBytes: parsed.nodes[offset + nodeSelfSizeIndex] });
|
|
}
|
|
}
|
|
|
|
const componentRoots = new Set<number>();
|
|
for (const entry of matching) {
|
|
const queue = [entry.nodeIndex];
|
|
const seen = new Set<number>();
|
|
while (queue.length > 0) {
|
|
const node = queue.pop() as number;
|
|
if (seen.has(node)) continue;
|
|
seen.add(node);
|
|
const parents = stringParents[node] ?? [];
|
|
if (parents.length === 0) componentRoots.add(node);
|
|
else queue.push(...parents);
|
|
}
|
|
}
|
|
|
|
const components = [...componentRoots].map((root) => {
|
|
const nodes = new Set<number>();
|
|
const queue = [root];
|
|
let retainedSizeBytes = 0;
|
|
while (queue.length > 0) {
|
|
const node = queue.pop() as number;
|
|
if (nodes.has(node)) continue;
|
|
nodes.add(node);
|
|
retainedSizeBytes += parsed.nodes[node * nodeWidth + nodeSelfSizeIndex];
|
|
queue.push(...(stringChildren[node] ?? []));
|
|
}
|
|
return { root, nodes, retainedSizeBytes };
|
|
});
|
|
const large = components.filter(
|
|
(component) => component.retainedSizeBytes >= minimumSelfSizeBytes
|
|
);
|
|
const retainedNodes = new Set<number>();
|
|
for (const component of large) {
|
|
for (const node of component.nodes) retainedNodes.add(node);
|
|
}
|
|
return {
|
|
matchingPreviewNodes: matching.length,
|
|
maxSelfSizeBytes: matching.reduce(
|
|
(maximum, entry) => Math.max(maximum, entry.selfSizeBytes),
|
|
0
|
|
),
|
|
maxBackingRetainedSizeBytes: components.reduce(
|
|
(maximum, component) => Math.max(maximum, component.retainedSizeBytes),
|
|
0
|
|
),
|
|
retainedContextSelfSizeBytes: [...retainedNodes].reduce(
|
|
(total, node) => total + parsed.nodes[node * nodeWidth + nodeSelfSizeIndex],
|
|
0
|
|
),
|
|
largeBackingRetained: large.length > 0,
|
|
largeNodeIndexes: large.map((component) => component.root),
|
|
};
|
|
}
|
|
|
|
function analyzeSnapshot(snapshotFile: string, marker: string, minimumSelfSizeBytes: number) {
|
|
const parsed = JSON.parse(fs.readFileSync(snapshotFile, "utf8")) as HeapSnapshot;
|
|
const meta = parsed.snapshot.meta;
|
|
const nodeFields = meta.node_fields;
|
|
const edgeFields = meta.edge_fields;
|
|
const nodeWidth = nodeFields.length;
|
|
const edgeWidth = edgeFields.length;
|
|
const nodeTypeIndex = nodeFields.indexOf("type");
|
|
const nodeNameIndex = nodeFields.indexOf("name");
|
|
const edgeCountIndex = nodeFields.indexOf("edge_count");
|
|
const edgeTypeIndex = edgeFields.indexOf("type");
|
|
const edgeNameIndex = edgeFields.indexOf("name_or_index");
|
|
const edgeTargetIndex = edgeFields.indexOf("to_node");
|
|
const nodeTypes = meta.node_types[nodeTypeIndex] as string[];
|
|
const edgeTypes = meta.edge_types[edgeTypeIndex] as string[];
|
|
const nodeCount = parsed.nodes.length / nodeWidth;
|
|
const classification = classifyContextBackingCandidates(parsed, marker, minimumSelfSizeBytes);
|
|
const candidateNodes = new Set(classification.largeNodeIndexes);
|
|
|
|
const parents: Array<Array<{ from: number; edgeType: string; edgeName: string }> | undefined> =
|
|
new Array(nodeCount);
|
|
let edgeOffset = 0;
|
|
for (let from = 0; from < nodeCount; from += 1) {
|
|
const nodeOffset = from * nodeWidth;
|
|
const edgeCount = parsed.nodes[nodeOffset + edgeCountIndex];
|
|
for (let local = 0; local < edgeCount; local += 1) {
|
|
const type = edgeTypes[parsed.edges[edgeOffset + edgeTypeIndex]];
|
|
const rawName = parsed.edges[edgeOffset + edgeNameIndex];
|
|
const to = parsed.edges[edgeOffset + edgeTargetIndex] / nodeWidth;
|
|
if (type !== "weak") {
|
|
const edgeName =
|
|
type === "element" || type === "hidden" ? String(rawName) : parsed.strings[rawName];
|
|
(parents[to] ??= []).push({ from, edgeType: type, edgeName: edgeName ?? "" });
|
|
}
|
|
edgeOffset += edgeWidth;
|
|
}
|
|
}
|
|
|
|
const describeNode = (index: number) => {
|
|
const offset = index * nodeWidth;
|
|
return {
|
|
type: nodeTypes[parsed.nodes[offset + nodeTypeIndex]],
|
|
name: safeLabel(parsed.strings[parsed.nodes[offset + nodeNameIndex]] ?? "", marker),
|
|
};
|
|
};
|
|
|
|
const paths: unknown[] = [];
|
|
for (const target of [...candidateNodes].slice(0, 5)) {
|
|
const queue: Array<{ node: number; path: Array<Record<string, unknown>> }> = [
|
|
{ node: target, path: [{ node: describeNode(target) }] },
|
|
];
|
|
const seen = new Set([target]);
|
|
let found: Array<Record<string, unknown>> | null = null;
|
|
while (queue.length > 0 && !found) {
|
|
const current = queue.shift() as { node: number; path: Array<Record<string, unknown>> };
|
|
if (current.node === 0 || current.path.length >= 32) {
|
|
found = current.path;
|
|
break;
|
|
}
|
|
for (const parent of parents[current.node] ?? []) {
|
|
if (seen.has(parent.from)) continue;
|
|
seen.add(parent.from);
|
|
const nextPath = [
|
|
...current.path,
|
|
{
|
|
retainedBy: describeNode(parent.from),
|
|
edgeType: parent.edgeType,
|
|
edgeName: safeLabel(parent.edgeName, marker),
|
|
},
|
|
];
|
|
if (parent.from === 0) {
|
|
found = nextPath;
|
|
break;
|
|
}
|
|
queue.push({ node: parent.from, path: nextPath });
|
|
}
|
|
}
|
|
paths.push(found ?? [{ node: describeNode(target) }, { finding: "no root within 32 edges" }]);
|
|
}
|
|
|
|
return {
|
|
matchingContextPreviewNodes: classification.matchingPreviewNodes,
|
|
maxContextNodeSelfSizeBytes: classification.maxSelfSizeBytes,
|
|
maxContextBackingRetainedSizeBytes: classification.maxBackingRetainedSizeBytes,
|
|
retainedContextSelfSizeBytes: classification.retainedContextSelfSizeBytes,
|
|
minimumLargeBackingSelfSizeBytes: minimumSelfSizeBytes,
|
|
largeBackingRetained: classification.largeBackingRetained,
|
|
finding:
|
|
candidateNodes.size === 0
|
|
? "Only detached context previews remained; no context-sized backing string crossed the V8 self_size threshold."
|
|
: "A context-sized backing string remained after forced GC; redacted root paths follow.",
|
|
paths,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Enforce the physical large-backing retention gate.
|
|
* @param result - Analyzer verdict to enforce.
|
|
* @returns Nothing.
|
|
* @throws {Error} If a context-sized backing string survived forced garbage collection.
|
|
*/
|
|
export function assertNoLargeBacking(result: { largeBackingRetained: boolean }): void {
|
|
if (result.largeBackingRetained) {
|
|
throw new Error("context-sized backing string remained after forced GC");
|
|
}
|
|
}
|
|
|
|
async function runAnalyzer(): Promise<void> {
|
|
process.umask(0o077);
|
|
const snapshotArg = argValue("--snapshot");
|
|
if (!snapshotArg) throw new Error("analyzer requires --snapshot");
|
|
const snapshotFile = path.resolve(snapshotArg);
|
|
const outputArg = argValue("--output-dir");
|
|
const marker = argValue("--marker") ?? "";
|
|
try {
|
|
if (!outputArg || !marker) {
|
|
throw new Error("analyzer requires --output-dir and --marker");
|
|
}
|
|
const minimumSelfSizeBytes = positiveIntArg("--minimum-self-size-bytes", 1_024);
|
|
const outputDir = path.resolve(outputArg);
|
|
const result = analyzeSnapshot(snapshotFile, marker, minimumSelfSizeBytes);
|
|
const snapshotStat = fs.statSync(snapshotFile);
|
|
const snapshotSha256 = await sha256File(snapshotFile);
|
|
const redactedResult = {
|
|
...result,
|
|
snapshot: {
|
|
state: "deleted-after-local-analysis",
|
|
byteSize: snapshotStat.size,
|
|
sha256: snapshotSha256,
|
|
},
|
|
};
|
|
writePrivateJson(path.join(outputDir, "retainers.redacted.json"), redactedResult);
|
|
assertNoLargeBacking(result);
|
|
process.stdout.write(JSON.stringify(redactedResult) + "\n");
|
|
} finally {
|
|
fs.rmSync(snapshotFile, { force: true });
|
|
}
|
|
}
|
|
|
|
function harnessHash(): string {
|
|
return createHash("sha256")
|
|
.update(fs.readFileSync(new URL(import.meta.url)))
|
|
.digest("hex");
|
|
}
|
|
|
|
function gitOutput(args: string[]): string {
|
|
const result = spawnSync("git", args, {
|
|
encoding: "utf8",
|
|
env: buildWorkerEnv(process.env),
|
|
});
|
|
if (result.status !== 0) {
|
|
throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim()}`);
|
|
}
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
function sha256(value: string | Buffer): string {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function sourceProvenance() {
|
|
const testedCommit = gitOutput(["rev-parse", "HEAD"]);
|
|
if (!/^[0-9a-f]{40}$/.test(testedCommit)) {
|
|
throw new Error(`invalid git HEAD: ${testedCommit || "empty"}`);
|
|
}
|
|
// `git branch --show-current` is empty on a detached HEAD, which is the normal checkout
|
|
// state for a CI PR run and for this fix worktree's own detached `git worktree add` — fall
|
|
// back to a descriptive marker instead of treating that as an error.
|
|
const branch = gitOutput(["branch", "--show-current"]) || `detached@${testedCommit.slice(0, 12)}`;
|
|
const sourceFiles = Object.fromEntries(
|
|
PROVENANCE_FILES.map((file) => {
|
|
if (!fs.existsSync(file)) throw new Error(`provenance file missing: ${file}`);
|
|
return [file, sha256(fs.readFileSync(file))];
|
|
})
|
|
);
|
|
const trackedDiff = gitOutput(["diff", "--binary", "HEAD", "--", ...PROVENANCE_FILES]);
|
|
return {
|
|
testedCommit,
|
|
branch,
|
|
statusPorcelain: gitOutput(["status", "--porcelain=v1", "--untracked-files=all"])
|
|
.split("\n")
|
|
.filter(Boolean),
|
|
trackedDiffSha256: sha256(trackedDiff),
|
|
sourceSetSha256: sha256(JSON.stringify(sourceFiles)),
|
|
sourceFiles,
|
|
};
|
|
}
|
|
|
|
function sha256File(file: string): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const hash = createHash("sha256");
|
|
const input = fs.createReadStream(file);
|
|
input.on("error", reject);
|
|
input.on("data", (chunk) => hash.update(chunk));
|
|
input.on("end", () => resolve(hash.digest("hex")));
|
|
});
|
|
}
|
|
|
|
async function runDriver(): Promise<void> {
|
|
process.umask(0o077);
|
|
const outputDir = path.resolve(
|
|
argValue("--output-dir") ??
|
|
path.join(os.tmpdir(), `omniroute-JON-562-${new Date().toISOString().replace(/[:.]/g, "-")}`)
|
|
);
|
|
const iterations = positiveIntArg("--iterations", 6);
|
|
const tokenCases = (argValue("--tokens") ?? DEFAULT_TOKEN_EQUIVALENTS.join(","))
|
|
.split(",")
|
|
.map((raw) => Number(raw));
|
|
if (tokenCases.some((value) => !Number.isSafeInteger(value) || value < 64)) {
|
|
throw new RangeError("--tokens must be a comma-separated list of integers >= 64");
|
|
}
|
|
privateDirectory(outputDir);
|
|
|
|
const cases: unknown[] = [];
|
|
const scriptFile = fileURLToPath(import.meta.url);
|
|
for (const tokenEquivalent of tokenCases) {
|
|
const caseDir = path.join(outputDir, `context-${tokenEquivalent}`);
|
|
privateDirectory(caseDir);
|
|
const snapshotFile = path.join(caseDir, "post-gc.heapsnapshot");
|
|
await withRawSnapshotCleanup(snapshotFile, async () => {
|
|
const worker = spawnSync(
|
|
process.execPath,
|
|
[
|
|
"--expose-gc",
|
|
"--import",
|
|
"tsx/esm",
|
|
scriptFile,
|
|
"--worker",
|
|
"--snapshot-handoff",
|
|
"--tokens",
|
|
String(tokenEquivalent),
|
|
"--iterations",
|
|
String(iterations),
|
|
"--output-dir",
|
|
caseDir,
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
env: buildWorkerEnv(process.env),
|
|
// Measured ~230s wall for the 100k-token case alone (tsx/esm boot of the full
|
|
// route/handler module graph + real request lifecycle) on an idle box — 180s left
|
|
// no margin and made SIGKILL-on-timeout indistinguishable from a real worker crash
|
|
// (`worker.status` is `null`, which already fails the `!== 0` check below).
|
|
timeout: 300_000,
|
|
killSignal: "SIGKILL",
|
|
}
|
|
);
|
|
if (worker.status !== 0) {
|
|
throw new Error(`worker ${tokenEquivalent} failed:\n${worker.stdout}\n${worker.stderr}`);
|
|
}
|
|
|
|
const analyzer = spawnSync(
|
|
process.execPath,
|
|
[
|
|
"--max-old-space-size=4096",
|
|
"--import",
|
|
"tsx/esm",
|
|
scriptFile,
|
|
"--analyze-snapshot",
|
|
"--snapshot",
|
|
snapshotFile,
|
|
"--output-dir",
|
|
caseDir,
|
|
"--marker",
|
|
markerFor(tokenEquivalent),
|
|
"--minimum-self-size-bytes",
|
|
String(Math.floor((tokenEquivalent * BYTES_PER_TOKEN_EQUIVALENT) / 2)),
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
env: buildWorkerEnv(process.env),
|
|
timeout: 180_000,
|
|
killSignal: "SIGKILL",
|
|
}
|
|
);
|
|
if (analyzer.status !== 0) {
|
|
throw new Error(
|
|
`snapshot analyzer ${tokenEquivalent} failed:\n${analyzer.stdout}\n${analyzer.stderr}`
|
|
);
|
|
}
|
|
const retaining = JSON.parse(
|
|
fs.readFileSync(path.join(caseDir, "retainers.redacted.json"), "utf8")
|
|
);
|
|
assertNoLargeBacking(retaining);
|
|
const caseManifestFile = path.join(caseDir, "manifest.json");
|
|
const caseManifest = JSON.parse(fs.readFileSync(caseManifestFile, "utf8"));
|
|
caseManifest.heapSnapshot = retaining.snapshot;
|
|
writePrivateJson(caseManifestFile, caseManifest);
|
|
cases.push(caseManifest);
|
|
});
|
|
}
|
|
|
|
const provenance = sourceProvenance();
|
|
const workloadManifest = {
|
|
ticket: "JON-562",
|
|
status: "complete",
|
|
testedCommit: provenance.testedCommit,
|
|
provenance,
|
|
harnessSha256: harnessHash(),
|
|
baseBranch: "release/v3.8.51",
|
|
inheritedBaseRed: "diegosouzapw/OmniRoute#12732",
|
|
variedFactor: "serialized context bytes only",
|
|
fixedConditions: {
|
|
concurrency: 1,
|
|
cancellation: "none",
|
|
stream: true,
|
|
iterations,
|
|
messageCount: 1,
|
|
toolCount: 0,
|
|
provider: "local-fetch-stub",
|
|
externalProviderCalls: 0,
|
|
},
|
|
tokenEquivalentCases: tokenCases,
|
|
bytesPerTokenEquivalent: BYTES_PER_TOKEN_EQUIVALENT,
|
|
byteBudgetEvidence:
|
|
"Unit-level only: real-route responses in this checkpoint are small, so the 256-entry cap binds before the 16 MiB byte cap. The route matrix proves sliced backing detachment, not a 16 MiB runtime plateau.",
|
|
cases,
|
|
};
|
|
writePrivateJson(path.join(outputDir, "workload-manifest.json"), workloadManifest);
|
|
process.stdout.write(JSON.stringify({ outputDir, status: "complete" }) + "\n");
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
if (process.argv.includes("--worker")) return runWorker();
|
|
if (process.argv.includes("--analyze-snapshot")) return runAnalyzer();
|
|
return runDriver();
|
|
}
|
|
|
|
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : "";
|
|
if (invokedPath === import.meta.url) {
|
|
main().catch((error: unknown) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
process.stderr.write(`[JON-562] ${message}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|