feat(extract): add --memory-limit-mb so a budget overrun aborts cleanly instead of an OOM kill (#3011) - #3076
Conversation
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.
Formal verification. 1 change(s) tested, no difference found (not proven).
Graphify review — findings
Adds an opt-in memory budget for extract and update via --memory-limit-mb / GRAPHIFY_MEMORY_LIMIT_MB, which caps the CLI process and its extraction workers with setrlimit so a runaway run aborts with exit status 3 and no partial graph.json instead of being OOM-killed. _arm_memory_budget resolves the flag (which wins over the env var and is written back so spawn workers and nested rebuilds inherit it), rejects a malformed value with exit 2, and warns once and continues where the platform can't enforce a limit. MemoryError now propagates through _safe_extract and pool workers rather than being logged as a skipped file, ensuring the graph is never silently published missing whatever came after the budget was hit.
Worth a look
- Memory budget flag races through process-global environment —
graphify/cli.py:613· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Process pool abort still waits for running workers after budget hit —
graphify/extract.py:5578· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- ThreadPool substitution runs the process initializer in the pytest process —
tests/test_memory_budget.py:227· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 1885 functions depend on the 421 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 494 callers, 42 callees - new:
_rebuild_code()— 98 callers, 50 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
dispatch_command()— 2 callers, 127 callees - new:
extract_js()— 80 callers, 3 callees - new:
_get_extractor()— 26 callers, 6 callees - new:
run_pipeline()— 8 callers, 13 callees - new:
collect_files()— 17 callers, 6 callees - …and 27 more — each is listed as a finding
Verification — 1885 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 1838 function(s) in the blast radius were not formally verified this run
Formal verification
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_extract\_parallel.
The verifier did not have enough to check \_extract\_parallel, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_safe\_extract.
The verifier did not have enough to check \_safe\_extract, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `extractor` is annotated `Callable` — outside the synthesizable primitive/collection set
No difference found (not proven): No behavior difference found in \_apply\_resource\_limits (not a proof).
The verifier ran both versions of \_apply\_resource\_limits on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
· 2 grounded finding(s) anchored inline below; 33 more finding(s) on lines outside this diff (see the check run).
| apply_memory_budget() | ||
|
|
||
|
|
||
| def _extract_single_file(args: tuple) -> tuple[int, dict]: |
There was a problem hiding this comment.
_extract_single_file()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return ru / 1024.0 if sys.platform != "darwin" else ru / (1024.0 * 1024.0) | ||
|
|
||
|
|
||
| def budget_error(exc: BaseException, *, phase: str) -> MemoryBudgetExceeded: |
There was a problem hiding this comment.
budget_error()
6 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
|
@abhay-codes07 thanks for taking this fix forward. do you know what else is needed to push it ? |
…ad of an OOM kill (Graphify-Labs#3011) `graphify extract` inside a memory-limited container could grow past the cgroup allowance and be OOM-killed: --max-workers bounds the AST pool, but the later JS/TS resolution passes retain source buffers and syntax trees for the whole corpus, and GRAPHIFY_REBUILD_MEMORY_LIMIT_MB only ever applied to hook/watch rebuilds. The kill left no graphify-specific failure, no stable exit status, and whatever had been written behind. `--memory-limit-mb N` / `GRAPHIFY_MEMORY_LIMIT_MB=N` on `extract`, `update` and the bare `graphify <path>` form: * caps the CLI process with setrlimit (RLIMIT_AS; RLIMIT_DATA on macOS) and, through a pool initializer, every extraction worker - workers start fresh under `spawn`, so they read the cap from the environment; * lets MemoryError propagate where the pipeline used to demote it: _safe_extract recorded it as a skipped file, and the pool's per-future handler warned and retried the file in-process, which would hit the same wall - either way a run could finish and publish a graph silently missing whatever came after; * reports the configured limit, the phase, and the observed peak, exits with status 3 (distinct from 1 = extraction failed, 2 = bad arguments) and writes no graph.json - the previous graph is left untouched; * refuses a malformed value (exit 2) rather than silently running with no budget, and on Windows says the budget cannot be enforced and continues, rather than pretending. The enforcement lives in a dependency-free graphify.memory_budget; watch._apply_resource_limits now delegates to it and also honours the general variable, with the hook-specific one keeping precedence.
…oesn't wait Review follow-up: the MemoryError handler called shutdown(wait=False, cancel_futures=True), but raising out of the enclosing 'with ProcessPoolExecutor as pool' block triggers the executor's __exit__, which calls shutdown(wait=True) and blocks on the other running workers — each potentially churning the very memory the budget tripped on. Terminate the live workers before re-raising so the implicit exit-shutdown returns at once and the budget error surfaces promptly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
862e43f to
e5310de
Compare
|
Worked through all three (rebased onto 0.9.59): Pool abort waited for running workers (real, fixed). Correct — although the handler calls Flag races through process-global environment. ThreadPool substitution runs the initializer in the pytest process. Fair observation about the harness: the thread-pool substitution exercises the budget-application and abort-propagation logic without spawning real subprocesses (so it runs deterministically in CI), which is deliberate — the process-pool path itself is covered by the worker-init and BrokenProcessPool tests. Kept. |
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.
Formal verification. 1 change(s) tested, no difference found (not proven).
Graphify review — findings
Adds an opt-in memory budget for graphify extract and graphify update, configurable via --memory-limit-mb or GRAPHIFY_MEMORY_LIMIT_MB, that caps the CLI process and its extraction workers via setrlimit. When the cap is exceeded, the run aborts with exit status 3 and never publishes a partial graph.json (even with --allow-partial), since _safe_extract and the pool initializer re-raise MemoryError rather than treating it as a skipped file. On platforms without setrlimit (Windows) it warns once and continues unenforced, and a malformed env/flag value is refused with exit 2.
Worth a look
- Memory budget abort path masks the intended error after shutdown clears worker list —
graphify/extract.py:6258· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- MemoryError abort path can be masked by AttributeError after executor shutdown —
graphify/extract.py:6262· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- MemoryError abort path reads ProcessPoolExecutor._processes after shutdown clears it —
graphify/extract.py:6264· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Global environment mutation in extract leaks across concurrent/subsequent runs —
graphify/extract.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- apply_memory_budget violates its never-raises contract —
graphify/memory_budget.py:125· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 2181 functions depend on the 460 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 589 callers, 44 callees - new:
_rebuild_code()— 115 callers, 51 callees - new:
extract_js()— 85 callers, 4 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
dispatch_command()— 2 callers, 129 callees - new:
_get_extractor()— 26 callers, 6 callees - new:
run_pipeline()— 8 callers, 13 callees - new:
collect_files()— 17 callers, 6 callees - …and 33 more — each is listed as a finding
Verification — 2181 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 2119 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
274 of 274 test file(s) selected (100%) via static blast radius.
Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.
tests/test_affected_cli.py— impact, full-run-safetytests/test_affected_member_seed.py— full-run-safetytests/test_agents_platform.py— impact, full-run-safetytests/test_analyze.py— full-run-safetytests/test_anthropic_custom_endpoint.py— full-run-safetytests/test_antigravity_install.py— full-run-safetytests/test_apm_fallback_version.py— full-run-safetytests/test_architecture_doc.py— full-run-safetytests/test_astro_extraction.py— impact, full-run-safetytests/test_astro_import_ids.py— impact, full-run-safetytests/test_atomic_canvas_export.py— full-run-safetytests/test_atomic_version_stamp.py— full-run-safetytests/test_atomic_writes.py— full-run-safetytests/test_backend_env_isolation.py— full-run-safetytests/test_backend_extras.py— full-run-safetytests/test_benchmark.py— full-run-safetytests/test_benchmark_raw_graph.py— full-run-safetytests/test_build.py— impact, full-run-safetytests/test_build_merge_dedup_scope.py— full-run-safetytests/test_build_merge_hyperedges_and_prune.py— full-run-safetytests/test_build_merge_shrink_guard.py— full-run-safetytests/test_builtin_global_type_refs.py— impact, full-run-safetytests/test_cache.py— full-run-safetytests/test_callflow_html.py— full-run-safetytests/test_cargo_introspect.py— full-run-safetytests/test_carried_hyperedge_remap.py— full-run-safetytests/test_case_sensitive_resolution.py— impact, full-run-safetytests/test_charmap_encoding.py— full-run-safetytests/test_chunking.py— full-run-safetytests/test_cjs_module_extension.py— impact, full-run-safetytests/test_claude_cli_backend.py— full-run-safetytests/test_claude_md.py— full-run-safetytests/test_cli_broken_pipe.py— full-run-safetytests/test_cli_export.py— full-run-safetytests/test_cli_help.py— full-run-safetytests/test_cluster.py— full-run-safetytests/test_codebuddy.py— impact, full-run-safetytests/test_community_hub_labels.py— full-run-safetytests/test_community_labels_skill.py— full-run-safetytests/test_confidence.py— full-run-safetytests/test_corrupt_graph_json.py— full-run-safetytests/test_cpp_nested_and_cli.py— impact, full-run-safetytests/test_cpp_objc_cross_file_calls.py— impact, full-run-safetytests/test_cpp_preprocess.py— full-run-safetytests/test_cross_extension_reexport_self_cycle.py— impact, full-run-safetytests/test_cross_language_call_resolution.py— impact, full-run-safetytests/test_cross_repo_member_calls.py— impact, full-run-safetytests/test_cross_repo_shared_types.py— full-run-safetytests/test_csharp_call_site_generic_args.py— impact, full-run-safetytests/test_csharp_enum_members.py— impact, full-run-safety- … and 224 more
non-code file(s) changed (
README.md) → running the full suite for safety (a code graph can't see config/fixture/data deps)
changed code file(s) with no mapped test (
README.md) — a coverage gap or a missing link — running the full suite rather than only the selected tests
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Formal verification
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_extract\_parallel.
The verifier did not have enough to check \_extract\_parallel, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_safe\_extract.
The verifier did not have enough to check \_safe\_extract, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `extractor` is annotated `Callable` — outside the synthesizable primitive/collection set
No difference found (not proven): No behavior difference found in \_apply\_resource\_limits (not a proof).
The verifier ran both versions of \_apply\_resource\_limits on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
· 2 grounded finding(s) anchored inline below; 39 more finding(s) on lines outside this diff (see the check run).
| apply_memory_budget() | ||
|
|
||
|
|
||
| def _extract_single_file(args: tuple) -> tuple[int, dict]: |
There was a problem hiding this comment.
_extract_single_file()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return ru / 1024.0 if sys.platform != "darwin" else ru / (1024.0 * 1024.0) | ||
|
|
||
|
|
||
| def budget_error(exc: BaseException, *, phase: str) -> MemoryBudgetExceeded: |
There was a problem hiding this comment.
budget_error()
6 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Closes #3011.
The problem
graphify extractinside a memory-limited container can grow past the cgroup allowance and be OOM-killed.--max-workersbounds the AST pool, but the later JS/TS symbol-resolution passes retain source buffers and syntax trees for the whole corpus, andGRAPHIFY_REBUILD_MEMORY_LIMIT_MBonly ever applied to hook/watch rebuilds — never to theextractCLI. The kill leaves no graphify-specific failure, no stable exit status, and whatever had been written behind (the reporter measured 4–6.5 GiB peaks and OOM kills in an 8 GiB pod).The change
--memory-limit-mb N/GRAPHIFY_MEMORY_LIMIT_MB=Nonextract,update, and the baregraphify <path>form (which re-entersextract):setrlimit(RLIMIT_AS;RLIMIT_DATAon macOS, whose allocator ignoresRLIMIT_AS) to the CLI process and, through a poolinitializer, to every extraction worker. Workers start fresh underspawn, so they read the cap from the environment; the flag is written back there so flag and env behave identically. An existing lower hard limit is never raised.MemoryError. Two places used to swallow it:_safe_extractrecorded it as a skipped file, and the pool's per-future handler warned and retried the file in-process — which would hit the same wall in the parent. Either way a run could finish and publish a graph silently missing whatever came after. Now the pool cancels its queued work and the error propagates as a typedMemoryBudgetExceeded(aMemoryErrorsubclass, so existing handling still applies).graph.json— the previous graph is untouched.--allow-partialdoes not apply here: the operator asked to be stopped.setrlimit, the CLI says the budget cannot be enforced and continues; it does not pretend.The enforcement lives in a dependency-free
graphify/memory_budget.py.watch._apply_resource_limitsnow delegates to it and honours the general variable too, with the hook-specificGRAPHIFY_REBUILD_MEMORY_LIMIT_MBkeeping precedence on that path, so one setting covers every rebuild.RLIMIT_ASbounds virtual address space, which over-approximates the RSS a cgroup accounts — the README says to set the budget somewhat below the container limit. It is a per-process cap rather than an aggregate tree budget; that is what the platform offers portably without a dependency, and it is the same semantic the existing hook limit has always had.What it looks like
On Windows:
Tests
tests/test_memory_budget.py— 29 tests: value parsing and the refused env value; the typed error;setrlimitreally applied and really biting (a 2 GiB allocation under a 256 MB cap raisesMemoryError, run in a subprocess so the test process is never capped) and a lower existing hard limit preserved; the pool initializer and its construction;watch._apply_resource_limitsprecedence;_safe_extractstill swallowing ordinary failures but lettingMemoryErrorthrough; sequential extraction stopping rather than finishing partial; a worker hitting the budget aborting the pool with the typed error instead of a "worker failed" warning; the CLI exiting 3 with nograph.jsonfor both flag forms and the env var; bad values exiting 2; the unenforceable-platform warning; the ordinary #2445 failure path unchanged; andupdatetaking the flag, exiting 3, and still rejecting unknown options.With the wiring reverted and only the module kept, 12 of them fail; with it, 27 pass and the 2
setrlimittests skip on Windows (they run on the Linux CI; I also ran the enforcement path under WSL: cap 256 MB →MemoryErroron the 2 GiB allocation). The full suite matches thev8baseline.README: an env-table row and a command-reference line.