Skip to content
Open
140 changes: 140 additions & 0 deletions scripts/memory-recall-soak-child.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* #820 memory-recall soak probe child.
*
* The parent owns the mock provider and clients. This child owns only the real
* OpenCodex proxy plus a loopback-only control listener for payload-free scalar
* metrics, which keeps process RSS attributable to the proxy rather than the
* load generator. This is an offline probe, not a CI test or production route.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { OcxConfig } from "../src/types";

function optionValue(name: string): string | null {
const index = Bun.argv.indexOf(name);
if (index === -1 || index + 1 >= Bun.argv.length) return null;
return Bun.argv[index + 1] ?? null;
}

const upstreamBaseUrl = optionValue("--upstream");
if (!upstreamBaseUrl) {
console.error("memory-recall-soak-child: --upstream is required");
process.exit(2);
}

let upstreamUrl: URL;
try {
upstreamUrl = new URL(upstreamBaseUrl);
} catch {
console.error("memory-recall-soak-child: --upstream must be a URL");
process.exit(2);
}
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]"]);
if (upstreamUrl.protocol !== "http:" && upstreamUrl.protocol !== "https:") {
console.error("memory-recall-soak-child: --upstream must be http or https");
process.exit(2);
}
if (!LOOPBACK_HOSTNAMES.has(upstreamUrl.hostname)) {
console.error("memory-recall-soak-child: upstream must be loopback");
process.exit(2);
}

const home = mkdtempSync(join(tmpdir(), "ocx-memory-recall-soak-"));
process.env.OPENCODEX_HOME = home;
function cleanupHome(): void {
try { rmSync(home, { recursive: true, force: true }); } catch { /* temp cleanup only */ }
}
process.on("exit", cleanupHome);

const [configModule, serverModule, memoryModule, lifecycleModule, relayModule, responseStateModule] = await Promise.all([
import("../src/config"),
import("../src/server"),
import("../src/lib/app-owned-memory"),
import("../src/server/lifecycle"),
import("../src/server/relay"),
import("../src/responses/state"),
]);

const config = {
port: 0,
defaultProvider: "mock",
providers: {
mock: {
adapter: "openai-chat",
baseUrl: `${upstreamUrl.toString().replace(/\/$/, "")}/v1`,
apiKey: "memory-soak-local-only",
allowPrivateNetwork: true,
},
},
} as OcxConfig;
configModule.saveConfig(config);
const proxy = serverModule.startServer(0);

function metrics() {
const usage = process.memoryUsage();
return {
atMs: Date.now(),
pid: process.pid,
platform: process.platform,
bunVersion: Bun.version,
bunRevision: Bun.revision,
uptimeSeconds: process.uptime(),
rss: usage.rss,
heapUsed: usage.heapUsed,
heapTotal: usage.heapTotal,
external: usage.external,
arrayBuffers: usage.arrayBuffers,
activeTurnCount: lifecycleModule.getActiveTurnCount(),
appOwnedBytes: memoryModule.appOwnedBytesSnapshot(),
inspectionCounters: relayModule.getInspectionCounters(),
responseState: responseStateModule.responseStateMetrics(),
};
}

let closing = false;
let control: ReturnType<typeof Bun.serve> | undefined;

async function closeAndExit(code: number): Promise<never> {
if (closing) {
await Bun.sleep(25);
process.exit(code);
}
closing = true;
try { await proxy.stop(true); } catch { /* best-effort probe teardown */ }
try { control?.stop(true); } catch { /* best-effort probe teardown */ }
cleanupHome();
process.exit(code);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

control = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/metrics" && req.method === "GET") {
return Response.json(metrics(), {
headers: { "cache-control": "no-store" },
});
}
if (url.pathname === "/shutdown" && req.method === "POST") {
setTimeout(() => { void closeAndExit(0); }, 0);
return Response.json({ ok: true });
}
return new Response("not found", { status: 404 });
},
});

console.log(JSON.stringify({
type: "ready",
proxyUrl: proxy.url.toString(),
controlUrl: control.url.toString(),
pid: process.pid,
platform: process.platform,
bunVersion: Bun.version,
bunRevision: Bun.revision,
}));

for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => { void closeAndExit(128); });
}
159 changes: 159 additions & 0 deletions scripts/memory-recall-soak-lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
export interface MemoryRecallSoakOptions {
seed: number;
sustainedSessions: number;
sustainedRounds: number;
sustainedWaves: number;
burstSessions: number;
burstRounds: number;
faultSessions: number;
slowConsumerPercent: number;
cancelPercent: number;
idleDeadlineMs: number;
sampleIntervalMs: number;
}

export const DEFAULT_MEMORY_RECALL_SOAK_OPTIONS: MemoryRecallSoakOptions = {
seed: 820_001,
sustainedSessions: 32,
sustainedRounds: 10,
sustainedWaves: 3,
burstSessions: 64,
burstRounds: 1,
faultSessions: 32,
slowConsumerPercent: 25,
cancelPercent: 25,
idleDeadlineMs: 30_000,
sampleIntervalMs: 100,
};

const QUICK_MEMORY_RECALL_SOAK_OPTIONS: MemoryRecallSoakOptions = {
...DEFAULT_MEMORY_RECALL_SOAK_OPTIONS,
sustainedSessions: 4,
sustainedRounds: 2,
sustainedWaves: 2,
burstSessions: 8,
faultSessions: 8,
idleDeadlineMs: 10_000,
sampleIntervalMs: 50,
};

const INTEGER_FLAGS: ReadonlyArray<{
flag: string;
key: keyof MemoryRecallSoakOptions;
min: number;
max: number;
}> = [
{ flag: "--seed", key: "seed", min: 0, max: 0xffff_ffff },
{ flag: "--sessions", key: "sustainedSessions", min: 1, max: 96 },
{ flag: "--rounds", key: "sustainedRounds", min: 1, max: 100 },
{ flag: "--waves", key: "sustainedWaves", min: 2, max: 20 },
{ flag: "--burst-sessions", key: "burstSessions", min: 1, max: 96 },
{ flag: "--burst-rounds", key: "burstRounds", min: 1, max: 20 },
{ flag: "--fault-sessions", key: "faultSessions", min: 0, max: 96 },
{ flag: "--slow-percent", key: "slowConsumerPercent", min: 0, max: 100 },
{ flag: "--cancel-percent", key: "cancelPercent", min: 0, max: 100 },
{ flag: "--idle-deadline-ms", key: "idleDeadlineMs", min: 1_000, max: 120_000 },
{ flag: "--sample-interval-ms", key: "sampleIntervalMs", min: 25, max: 5_000 },
];

export function memoryRecallSoakUsage(): string {
return [
"Usage: bun scripts/memory-recall-soak.ts [options]",
"",
"Offline #820 acceptance/profiling probe. It starts an isolated OpenCodex child",
"against a local mock provider and emits payload-free JSON metrics.",
"",
"Options:",
" --quick Small deterministic smoke profile",
" --seed N Deterministic workload seed",
" --sessions N Sustained independent sessions (default 32)",
" --rounds N Recall rounds per sustained session (default 10)",
" --waves N Identical sustained waves (default 3)",
" --burst-sessions N Independent burst sessions (default 64)",
" --burst-rounds N Recall rounds in the burst (default 1)",
" --fault-sessions N Fault/cancel probe sessions (default 32)",
" --slow-percent N Slow-consumer share, 0..100 (default 25)",
" --cancel-percent N Fault-wave cancellation share, 0..100 (default 25)",
" --idle-deadline-ms N Cleanup invariant deadline (default 30000)",
" --sample-interval-ms N Child-memory sample cadence (default 100)",
" --help Show this help",
].join("\n");
}

export function parseMemoryRecallSoakOptions(args: readonly string[]): MemoryRecallSoakOptions {
const options = args.includes("--quick")
? { ...QUICK_MEMORY_RECALL_SOAK_OPTIONS }
: { ...DEFAULT_MEMORY_RECALL_SOAK_OPTIONS };
const knownFlags = new Set<string>(["--quick", "--help", ...INTEGER_FLAGS.map(row => row.flag)]);

for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (!arg.startsWith("--")) throw new Error(`unexpected positional argument: ${arg}`);
if (!knownFlags.has(arg)) throw new Error(`unknown option: ${arg}`);
if (arg === "--quick" || arg === "--help") continue;

const spec = INTEGER_FLAGS.find(row => row.flag === arg);
if (!spec) continue;
const raw = args[index + 1];
if (raw === undefined || raw.startsWith("--")) throw new Error(`${arg} requires an integer value`);
const value = Number(raw);
if (!Number.isSafeInteger(value) || value < spec.min || value > spec.max) {
throw new Error(`${arg} must be an integer in ${spec.min}..${spec.max}`);
}
options[spec.key] = value;
index += 1;
}

return options;
}

export function mulberry32(seed: number): () => number {
let state = seed >>> 0;
return () => {
state |= 0;
state = (state + 0x6D2B79F5) | 0;
let value = Math.imul(state ^ (state >>> 15), 1 | state);
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
};
}

export function stableHash(text: string, seed = 0): number {
let hash = (0x811c9dc5 ^ (seed >>> 0)) >>> 0;
for (let index = 0; index < text.length; index++) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}

export function deterministicToolCount(sessionId: string, round: number, seed: number): number {
return 1 + (stableHash(`${sessionId}:${round}`, seed) % 8);
}

export function deterministicPercent(sessionId: string, salt: string, seed: number): number {
return stableHash(`${salt}:${sessionId}`, seed) % 100;
}

export function linearSlope(values: readonly number[]): number | null {
if (values.length < 2) return null;
const count = values.length;
const meanX = (count - 1) / 2;
const meanY = values.reduce((sum, value) => sum + value, 0) / count;
let numerator = 0;
let denominator = 0;
for (let index = 0; index < count; index++) {
const dx = index - meanX;
numerator += dx * (values[index] - meanY);
denominator += dx * dx;
}
return denominator === 0 ? null : numerator / denominator;
}

export function maxFinite(values: readonly number[]): number | null {
let maximum = Number.NEGATIVE_INFINITY;
for (const value of values) {
if (Number.isFinite(value) && value > maximum) maximum = value;
}
return maximum === Number.NEGATIVE_INFINITY ? null : maximum;
}
Loading
Loading