diff --git a/scripts/src/gc-remote-memory-pressure.test.ts b/scripts/src/gc-remote-memory-pressure.test.ts index c3f895b6..d5be3636 100644 --- a/scripts/src/gc-remote-memory-pressure.test.ts +++ b/scripts/src/gc-remote-memory-pressure.test.ts @@ -38,7 +38,7 @@ function runPythonFixture(body: string): Record { function runRunnerPythonFixture(functionNames: string[], body: string): Record { const source = Buffer.from(runnerSource, "utf8").toString("base64"); const fixture = [ - "import ast, base64, json", + "import ast, base64, json, os, re, time", `source = base64.b64decode(${JSON.stringify(source)}).decode("utf-8")`, `wanted = set(${JSON.stringify(functionNames)})`, "tree = ast.parse(source)", @@ -471,6 +471,100 @@ print(json.dumps({"compact": compact, "full": full, "executed": executed})) assert.equal(compact.selection.configId, "root-file-cache"); }); +test("memory-pressure plan and run treat an exact target with zero candidates as a successful no-op", () => { + const result = runRunnerPythonFixture([ + "summarize_memory_candidates", + "apply_memory_target_summary", + "memory_pressure_selection", + "memory_pressure_option_suffix", + "plan_payload", + "start_memory_pressure_job", + ], String.raw` +PROVIDER_ID = "NC01" +OPTIONS = {"memoryPressureOnly": True, "memoryReclaimConfigId": "root-file-cache"} +target = 3221225472 +MEMORY_CONFIG = {"cgroupReclaim": {"targetMemAvailableBytes": target}} +def safe_int(value, fallback=0): + try: return int(value) + except (TypeError, ValueError): return fallback +def fmt_bytes(value): return str(value) +def now_iso(): return "2026-07-21T00:00:01Z" +def status_command(job_id): return "gc remote NC01 status --job-id %s" % job_id +snapshot = {"ok": True, "memory": {"availableBytes": target}, "swap": {"freeBytes": 1}} +snapshot_count = 0 +def collect_memory_snapshot(): + global snapshot_count + snapshot_count += 1 + return snapshot +def memory_reclaim_scan_diagnostics(): return {"ok": True, "blocked": False} +def job_paths(job_id): return {"state": "/tmp/fixture-state"} +written = [] +def write_json_atomic(path, payload): written.append(payload) +def unexpected_fork(): raise RuntimeError("zero-candidate target-met run must not fork") +os.fork = unexpected_fork +plan = plan_payload("2026-07-21T00:00:00Z", {}, [], [], []) +run = start_memory_pressure_job("2026-07-21T00:00:00Z", []) +print(json.dumps({"plan": plan, "run": run, "written": written, "snapshotCount": snapshot_count})) +`); + const plan = result.plan as Record; + const run = result.run as Record; + assert.equal(plan.summary.targetMet, true); + assert.equal(plan.summary.targetGapBytes, 0); + assert.equal(plan.summary.outcome, "memory-target-met"); + assert.equal(plan.summary.candidateCount, 0); + assert.equal(run.ok, true); + assert.equal(run.status, "succeeded"); + assert.equal(run.outcome, "memory-target-met"); + assert.equal(run.summary.targetMet, true); + assert.equal(run.summary.targetGapBytes, 0); + assert.equal(run.summary.outcome, "memory-target-met"); + assert.equal(run.mutation, false); + assert.deepEqual(run.results, []); + assert.equal(result.snapshotCount, 2); + assert.deepEqual(result.written, [run]); + + const config = Bun.YAML.parse(readFileSync(rootPath("config/unidesk-cli.yaml"), "utf8")) as Record; + const projected = projectRemoteGcResult(plan, config.gc.remote.targets.NC01, { + action: "plan", + memoryPressureOnly: true, + full: false, + }) as Record; + assert.equal(projected.runEligibility.allowed, true); + assert.equal(projected.runEligibility.allCandidatesEvaluated, true); + assert.deepEqual(projected.runEligibility.reasons, []); +}); + +test("memory-pressure zero-candidate run remains blocked below the target", () => { + const result = runRunnerPythonFixture([ + "summarize_memory_candidates", + "apply_memory_target_summary", + "memory_pressure_selection", + "start_memory_pressure_job", + ], String.raw` +PROVIDER_ID = "NC01" +OPTIONS = {"memoryPressureOnly": True, "memoryReclaimConfigId": "root-file-cache"} +target = 3221225472 +MEMORY_CONFIG = {"cgroupReclaim": {"targetMemAvailableBytes": target}} +def safe_int(value, fallback=0): + try: return int(value) + except (TypeError, ValueError): return fallback +def fmt_bytes(value): return str(value) +def now_iso(): return "2026-07-21T00:00:01Z" +def status_command(job_id): return "gc remote NC01 status --job-id %s" % job_id +def collect_memory_snapshot(): return {"ok": True, "memory": {"availableBytes": target - 1}} +def memory_reclaim_scan_diagnostics(): return {"ok": True, "blocked": False} +def job_paths(job_id): return {"state": "/tmp/fixture-state"} +def write_json_atomic(path, payload): pass +run = start_memory_pressure_job("2026-07-21T00:00:00Z", []) +print(json.dumps(run)) +`); + assert.equal(result.ok, true); + assert.equal(result.status, "blocked"); + assert.equal(result.outcome, "no-memory-reclaim-candidates"); + assert.equal((result.summary as Record).targetMet, false); + assert.equal((result.summary as Record).targetGapBytes, 1); +}); + test("default memory-pressure plan projects an actionable YAML-bounded preview below stdout limit", () => { const processCandidate = (index: number): Record => ({ id: `browser-orphan-process-tree:${1000 + index}:12345`, diff --git a/scripts/src/gc-remote-runner.py b/scripts/src/gc-remote-runner.py index 38fb795e..6d29c9c4 100644 --- a/scripts/src/gc-remote-runner.py +++ b/scripts/src/gc-remote-runner.py @@ -1506,6 +1506,21 @@ def summarize_memory_candidates(candidates, returned): } +def apply_memory_target_summary(summary, memory_snapshot): + target_available = safe_int((MEMORY_CONFIG.get("cgroupReclaim") or {}).get("targetMemAvailableBytes")) + snapshot_memory = memory_snapshot.get("memory") if isinstance(memory_snapshot, dict) else None + available = safe_int(snapshot_memory.get("availableBytes")) if isinstance(snapshot_memory, dict) else 0 + target_met = target_available > 0 and available >= target_available + summary.update({ + "targetMemAvailableBytes": target_available, + "targetMet": target_met, + "targetGapBytes": max(0, target_available - available) if target_available > 0 else None, + }) + if target_met: + summary["outcome"] = "memory-target-met" + return summary + + def memory_pressure_selection(): config_id = OPTIONS.get("memoryReclaimConfigId") if not isinstance(config_id, str) or not config_id: @@ -1721,7 +1736,8 @@ def returned_results(results): def plan_payload(observed_at, preflight, protected, candidates, visible): if bool(OPTIONS.get("memoryPressureOnly")): scan = memory_reclaim_scan_diagnostics() - summary = summarize_memory_candidates(candidates, visible) + memory_before = collect_memory_snapshot() + summary = apply_memory_target_summary(summarize_memory_candidates(candidates, visible), memory_before) if scan.get("blocked"): summary["outcome"] = "memory-reclaim-scan-blocked" return { @@ -1733,7 +1749,7 @@ def plan_payload(observed_at, preflight, protected, candidates, visible): "mutation": False, "observedAt": observed_at, "options": OPTIONS, - "memoryBefore": collect_memory_snapshot(), + "memoryBefore": memory_before, "summary": summary, "selection": memory_pressure_selection(), "candidateScan": scan, @@ -2683,7 +2699,8 @@ def start_memory_pressure_job(observed_at, candidates): os.getpid(), ) paths = job_paths(job_id) - summary = summarize_memory_candidates(candidates, candidates) + memory_before = collect_memory_snapshot() + summary = apply_memory_target_summary(summarize_memory_candidates(candidates, candidates), memory_before) scan = memory_reclaim_scan_diagnostics() base = { "ok": scan.get("ok") is True, @@ -2698,6 +2715,7 @@ def start_memory_pressure_job(observed_at, candidates): "observedAt": observed_at, "startedAt": observed_at, "summary": summary, + "memoryBefore": memory_before, "selection": memory_pressure_selection(), "candidateScan": scan, } @@ -2713,6 +2731,19 @@ def start_memory_pressure_job(observed_at, candidates): write_json_atomic(paths["state"], terminal) return terminal if not candidates: + if summary.get("targetMet") is True: + terminal = dict(base) + terminal.update({ + "ok": True, + "status": "succeeded", + "outcome": "memory-target-met", + "mutation": False, + "finishedAt": now_iso(), + "memoryAfter": memory_before, + "results": [], + }) + write_json_atomic(paths["state"], terminal) + return terminal terminal = dict(base) terminal.update({ "status": "blocked", diff --git a/scripts/src/gc-remote.ts b/scripts/src/gc-remote.ts index 16909b7a..ef1acb87 100644 --- a/scripts/src/gc-remote.ts +++ b/scripts/src/gc-remote.ts @@ -707,11 +707,12 @@ export function projectRemoteGcResult( const memoryPressure = recordOrEmpty(remoteTarget.memoryPressure); const previewLimit = positiveInteger(memoryPressure.planPreviewLimit); const totalCandidateCount = positiveInteger(summary.candidateCount) ?? 0; - const allCandidatesEvaluated = totalCandidateCount > 0 && totalCandidateCount === candidates.length; + const targetMet = summary.targetMet === true; + const allCandidatesEvaluated = totalCandidateCount === candidates.length; const reasons: string[] = []; if (payload.ok !== true) reasons.push("plan-not-ok"); if (scan.ok !== true || scan.blocked === true) reasons.push("candidate-scan-blocked"); - if (totalCandidateCount === 0) reasons.push("no-candidates"); + if (totalCandidateCount === 0 && !targetMet) reasons.push("no-candidates"); if (!allCandidatesEvaluated) reasons.push("candidate-page-incomplete"); if (!candidates.every(memoryPressureCandidateAllowed)) reasons.push("unsupported-or-incomplete-candidate"); if (previewLimit === null) reasons.push("yaml-plan-preview-limit-invalid");