124 lines
6.3 KiB
TypeScript
124 lines
6.3 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { test } from "bun:test";
|
|
|
|
import { rootPath } from "./config";
|
|
|
|
const webObserveSource = readFileSync(rootPath("scripts/src/gc-remote-web-observe.py"), "utf8");
|
|
const runnerSource = readFileSync(rootPath("scripts/src/gc-remote-runner.py"), "utf8");
|
|
|
|
function runPythonFixture(body: string): Record<string, unknown> {
|
|
const fixture = [
|
|
"import json, os, re, signal, time",
|
|
"PROVIDER_ID = 'NC01'",
|
|
"def safe_int(value, fallback=0):",
|
|
" try: return int(value)",
|
|
" except (TypeError, ValueError): return fallback",
|
|
"def config_list(config, key, fallback):",
|
|
" value = config.get(key, fallback)",
|
|
" return value if isinstance(value, list) else fallback",
|
|
"def config_int(config, key, fallback, minimum=None, maximum=None):",
|
|
" value = config.get(key, fallback)",
|
|
" return value if isinstance(value, int) and not isinstance(value, bool) else fallback",
|
|
"def config_float(config, key, fallback, minimum=None, maximum=None): return float(config.get(key, fallback))",
|
|
"def fmt_bytes(value): return str(value)",
|
|
"def redact_command_preview(value): return str(value)[:200]",
|
|
"def observe_run_record(path, stale_hours): return {}",
|
|
webObserveSource,
|
|
body,
|
|
].join("\n");
|
|
const result = spawnSync("python3", ["-"], { input: fixture, encoding: "utf8" });
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
return JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
|
}
|
|
|
|
test("memory-pressure candidate fixtures protect active observers and classify only closed stale/orphan trees", () => {
|
|
const result = runPythonFixture(String.raw`
|
|
MEMORY_CONFIG = {
|
|
"observeStateRoots": ["/tmp/observe"], "minimumReadableObserveRoots": 1,
|
|
"observeRunDepth": 6, "observeScanLimit": 100, "staleRunMaxAgeHours": 1,
|
|
"processScanLimit": 100, "processStaleMinAgeSeconds": 3600,
|
|
"maxMemoryReclaimCandidates": 20, "terminationGraceSeconds": 1,
|
|
"terminationPollMilliseconds": 50, "observerRootPatterns": ["observer-runner.mjs"],
|
|
"browserRootPatterns": ["observer-runner.mjs", "cliDaemon.mjs"],
|
|
"browserProcessPatterns": ["chromium", "/usr/bin/chromium"]
|
|
}
|
|
def row(pid, ppid, sid, start, age, command, rss=1024):
|
|
return {"pid": pid, "ppid": ppid, "sid": sid, "startTicks": start, "ageSeconds": age, "rssBytes": rss, "commandLine": command, "commandPreview": command}
|
|
processes = {
|
|
100: row(100, 1, 100, 1000, 7200, "node observer-runner.mjs"),
|
|
101: row(101, 100, 100, 1001, 7200, "/usr/bin/chromium --type=browser"),
|
|
200: row(200, 1, 200, 2000, 7200, "node observer-runner.mjs"),
|
|
300: row(300, 1, 300, 3000, 7200, "node cliDaemon.mjs"),
|
|
301: row(301, 300, 300, 3001, 7200, "/usr/bin/chromium --type=browser"),
|
|
}
|
|
read_proc_process_table = lambda: processes
|
|
observer_run_records = lambda: [
|
|
{"pid": 100, "pidAlive": True, "staleLiveSignal": False},
|
|
{"pid": 200, "pidAlive": True, "staleLiveSignal": True},
|
|
]
|
|
candidates = collect_memory_reclaim_candidates()
|
|
print(json.dumps({"kinds": sorted(item["kind"] for item in candidates), "roots": sorted(item["rootPid"] for item in candidates), "diagnostics": memory_reclaim_scan_diagnostics()}))
|
|
`);
|
|
assert.deepEqual(result.kinds, ["browser-orphan-process-tree", "stale-web-observer-process-tree"]);
|
|
assert.deepEqual(result.roots, [200, 300]);
|
|
assert.equal((result.diagnostics as Record<string, unknown>).blocked, false);
|
|
});
|
|
|
|
test("memory-pressure process scans fail closed on empty or truncated process tables", () => {
|
|
const result = runPythonFixture(String.raw`
|
|
MEMORY_CONFIG = {
|
|
"observeStateRoots": ["/tmp/observe"], "minimumReadableObserveRoots": 1,
|
|
"observeRunDepth": 6, "observeScanLimit": 100, "staleRunMaxAgeHours": 1,
|
|
"processScanLimit": 100, "processStaleMinAgeSeconds": 3600,
|
|
"maxMemoryReclaimCandidates": 20, "terminationGraceSeconds": 1,
|
|
"terminationPollMilliseconds": 50, "observerRootPatterns": ["observer-runner.mjs"],
|
|
"browserRootPatterns": ["cliDaemon.mjs"], "browserProcessPatterns": ["chromium"]
|
|
}
|
|
observer_run_records = lambda: []
|
|
read_proc_process_table = lambda: {}
|
|
empty = collect_memory_reclaim_candidates()
|
|
empty_diag = memory_reclaim_scan_diagnostics()
|
|
def truncated_scan():
|
|
global _PROCESS_SCAN_TRUNCATED
|
|
_PROCESS_SCAN_TRUNCATED = True
|
|
return {1: {"pid": 1, "ppid": 0, "sid": 1, "startTicks": 1, "ageSeconds": 7200, "rssBytes": 1, "commandLine": "node cliDaemon.mjs", "commandPreview": "node cliDaemon.mjs"}}
|
|
read_proc_process_table = truncated_scan
|
|
truncated = collect_memory_reclaim_candidates()
|
|
truncated_diag = memory_reclaim_scan_diagnostics()
|
|
print(json.dumps({"emptyCount": len(empty), "emptyBlocked": empty_diag["blocked"], "emptyError": empty_diag["processScanError"], "truncatedCount": len(truncated), "truncatedBlocked": truncated_diag["blocked"], "truncatedFlag": truncated_diag["processScanTruncated"]}))
|
|
`);
|
|
assert.equal(result.emptyCount, 0);
|
|
assert.equal(result.emptyBlocked, true);
|
|
assert.equal(result.emptyError, "proc-process-table-empty");
|
|
assert.equal(result.truncatedCount, 0);
|
|
assert.equal(result.truncatedBlocked, true);
|
|
assert.equal(result.truncatedFlag, true);
|
|
});
|
|
|
|
test("post-TERM identity matching tolerates reparenting but never PID startTicks reuse", () => {
|
|
const result = runPythonFixture(String.raw`
|
|
identity = {"pid": 41, "ppid": 10, "sid": 10, "startTicks": 1234}
|
|
reparented = {41: {"pid": 41, "ppid": 1, "sid": 41, "startTicks": 1234}}
|
|
reused = {41: {"pid": 41, "ppid": 10, "sid": 10, "startTicks": 9999}}
|
|
print(json.dumps({
|
|
"preTermReparented": matching_live_identity(identity, reparented) is not None,
|
|
"postTermReparented": matching_surviving_identity(identity, reparented) is not None,
|
|
"postTermReused": matching_surviving_identity(identity, reused) is not None,
|
|
}))
|
|
`);
|
|
assert.equal(result.preTermReparented, false);
|
|
assert.equal(result.postTermReparented, true);
|
|
assert.equal(result.postTermReused, false);
|
|
});
|
|
|
|
test("memory-pressure async runner uses a parent-child barrier and locked worker state", () => {
|
|
assert.match(runnerSource, /os\.pipe\(\)/u);
|
|
assert.match(runnerSource, /os\.read\(read_descriptor/u);
|
|
assert.match(runnerSource, /workerStartTicks/u);
|
|
assert.match(runnerSource, /write_memory_job_state\(/u);
|
|
assert.match(runnerSource, /workerAlive/u);
|
|
assert.match(runnerSource, /worker-identity-unavailable|worker-exited-without-terminal-state/u);
|
|
});
|