From b5c78e7ec4e77ae0207da3ef1f8671d61a42a86c Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 01:57:17 +0530 Subject: [PATCH 01/24] Refuse to spawn an extraction pool that would spawn its own A guard less caller (no if __name__ == "__main__": block) makes every Windows spawned worker re execute the top level module on import. If that module calls extract() again at module scope, the worker opens its own pool, whose own guard less children do the same, faster than any per future BrokenProcessPool exception can surface and stop it. Two checks now run before the pool is ever opened: refuse unconditionally when already inside a multiprocessing child (a legitimate call only ever happens in the main process), and on Windows, skip the pool when the caller's own __main__ module has no guard, rather than only catching the failure after the fact. Co-Authored-By: Claude Sonnet 5 --- graphify/extract.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/graphify/extract.py b/graphify/extract.py index d54b841d9..aa0d2b3b3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6369,6 +6369,27 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: return idx, result +def _caller_main_lacks_guard() -> bool: + """#1637: on Windows (spawn start method), a caller script with no + ``if __name__ == "__main__":`` guard makes every worker re-execute the + top-level module on import — including, if it calls ``extract()`` at + module scope, spawning its OWN pool. Each of those child pools spawns + more children the same way, faster than any per-future exception can + surface and stop it: a fork bomb, not a slow failure. Read the caller's + own source (best-effort; a read failure means "can't tell", not "missing") + so the pool is never opened in the first place, rather than caught after + the fact via BrokenProcessPool once the damage is already spawning. + """ + main_file = getattr(sys.modules.get("__main__"), "__file__", None) + if not main_file: + return False + try: + main_src = Path(main_file).read_text(encoding="utf-8", errors="ignore") + except OSError: + return False + return "__main__" not in main_src + + def _extract_parallel( uncached_work: list[tuple[int, Path]], per_file: list[dict | None], @@ -6385,6 +6406,25 @@ def _extract_parallel( BrokenProcessPool); the caller should fall back to sequential extraction. """ import concurrent.futures + import multiprocessing + + # #1637: a legitimate call to extract() only ever happens in the main + # process. If we are somehow already running inside a spawned worker + # (the guard-less-caller re-execution case above), opening ANOTHER pool + # here is exactly the recursive step that turns a single missing guard + # into an unbounded process explosion. Refuse unconditionally, before + # even a spawn-capable platform check, since this is never correct. + if multiprocessing.parent_process() is not None: + return False + + if sys.platform == "win32" and _caller_main_lacks_guard(): + print( + " warning: calling script lacks an `if __name__ == \"__main__\":` " + "guard; extracting sequentially to avoid runaway process spawning " + "(pass parallel=False to extract() to silence this check)", + file=sys.stderr, flush=True, + ) + return False if max_workers is None: # Honour GRAPHIFY_MAX_WORKERS env override; otherwise scale to the From f658d3e50bf3d5fa3face4999d2feb509c61493d Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 01:57:22 +0530 Subject: [PATCH 02/24] Add regression tests for the runaway pool spawn fix Covers both new guards in _extract_parallel: refusing to open a pool from inside a spawned worker, and pre emptively declining the pool on Windows for a caller whose main module lacks the guard, while confirming a properly guarded caller still takes the pool path. Co-Authored-By: Claude Sonnet 5 --- tests/test_extract.py | 116 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index d7a263b8e..54a794771 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2307,6 +2307,122 @@ def submit(self, *a, **kw): assert spawned["count"] == 1, "multi-worker runs must still use the pool" +def test_extract_parallel_declines_pool_inside_a_spawned_worker(tmp_path, monkeypatch): + """#1637: a guard-less Windows caller makes every spawned worker re-execute + the top-level module. If that module calls extract() again at module + scope, the worker would open its OWN pool, whose own guard-less children + do the same — unbounded process growth, not a single recoverable + failure. _extract_parallel must refuse to open a pool at all whenever it + is already running inside a multiprocessing child, regardless of + platform, since a legitimate call only ever happens in the main process. + """ + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed inside a worker") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: object()) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "must decline and hand the work back for sequential extraction" + assert spawned["count"] == 0, "no pool may be spawned from inside a worker process" + + +def test_extract_parallel_declines_pool_on_windows_when_caller_lacks_guard( + tmp_path, monkeypatch +): + """#1637: on Windows, pre-empt the pool entirely when the caller script has + no `if __name__ == "__main__":` guard, instead of discovering the failure + only after BrokenProcessPool -- by then the pool has already started + respawning dying workers faster than the exception can stop it. + """ + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text("from graphify.extract import extract\nextract([])\n", encoding="utf-8") + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed for a guard-less caller") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "must decline and hand the work back for sequential extraction" + assert spawned["count"] == 0, "no pool may be spawned for a guard-less Windows caller" + + +def test_extract_parallel_still_spawns_pool_on_windows_when_caller_has_guard( + tmp_path, monkeypatch +): + """Guard the #1637 fix: a caller that DOES have the guard must still take + the pool path on Windows, so legitimate scripts keep their parallelism.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guarded = tmp_path / "runner.py" + guarded.write_text( + "from graphify.extract import extract\n" + "def main():\n" + " extract([])\n" + 'if __name__ == "__main__":\n' + " main()\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guarded) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") + + spawned = {"count": 0} + + class FakePool: + def __init__(self, *a, **kw): + spawned["count"] += 1 + def __enter__(self): + return self + def __exit__(self, *a): + return False + def submit(self, *a, **kw): + raise concurrent.futures.process.BrokenProcessPool("stop here") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert spawned["count"] == 1, "a guarded caller must still use the pool on Windows" + + def test_extract_falls_back_when_worker_future_breaks_pool( tmp_path, monkeypatch, capsys ): From b53ab1c7bb141e942b7367239556bea5f4c2e21e Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 01:57:45 +0530 Subject: [PATCH 03/24] Add changelog entry for issue 1637 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0daf515..56c1d2a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) +- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard (#1637, thanks @ray8875). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). From a325bd5454bc87ec3a3accce3d3a2625e2b2bb3a Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 20:55:22 +0530 Subject: [PATCH 04/24] Detect the real main guard statement, not a bare substring A review on this PR flagged that the guard less caller check used plain substring containment: any mention of __main__ anywhere in the caller's source, including a comment, docstring, or unrelated string literal, made the check report a guard that was not actually there, defeating the fork bomb protection this function exists to provide. Now matches the actual if statement (either operand order), so only a real guard counts. Co-Authored-By: Claude Sonnet 5 --- graphify/extract.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/graphify/extract.py b/graphify/extract.py index aa0d2b3b3..334aff205 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6369,6 +6369,13 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: return idx, result +_MAIN_GUARD_RE = re.compile( + r'^[ \t]*if\s+(?:__name__\s*==\s*[\'"]__main__[\'"]' + r'|[\'"]__main__[\'"]\s*==\s*__name__)\s*:', + re.MULTILINE, +) + + def _caller_main_lacks_guard() -> bool: """#1637: on Windows (spawn start method), a caller script with no ``if __name__ == "__main__":`` guard makes every worker re-execute the @@ -6379,6 +6386,10 @@ def _caller_main_lacks_guard() -> bool: own source (best-effort; a read failure means "can't tell", not "missing") so the pool is never opened in the first place, rather than caught after the fact via BrokenProcessPool once the damage is already spawning. + + Looks for the actual guard statement, not a bare substring match — a + docstring, comment, or unrelated string literal mentioning ``__main__`` + must not be read as a guard that isn't really there. """ main_file = getattr(sys.modules.get("__main__"), "__file__", None) if not main_file: @@ -6387,7 +6398,7 @@ def _caller_main_lacks_guard() -> bool: main_src = Path(main_file).read_text(encoding="utf-8", errors="ignore") except OSError: return False - return "__main__" not in main_src + return _MAIN_GUARD_RE.search(main_src) is None def _extract_parallel( From b86d873d9f0a5e51e8337f2592e15aced889060a Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 20:55:25 +0530 Subject: [PATCH 05/24] Add regression tests for the real main guard detection Covers the false positive a plain reviewer found (an unrelated mention of __main__ in a comment or docstring must not be read as a guard) and the reversed operand order, which is valid Python and must still be recognized as a real guard. Co-Authored-By: Claude Sonnet 5 --- tests/test_extract.py | 92 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index 54a794771..307753f2b 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2375,6 +2375,51 @@ def fake_pool(*a, **kw): assert spawned["count"] == 0, "no pool may be spawned for a guard-less Windows caller" +def test_extract_parallel_declines_pool_when_main_only_appears_in_a_comment( + tmp_path, monkeypatch +): + """A caller with no real guard, whose source merely mentions __main__ in + a comment or docstring, must still be treated as guard-less. A bare + substring check on the source text ("__main__" in main_src) would read + that unrelated mention as a guard that is not actually there, and open + a pool for a caller that has none -- exactly the fork bomb condition + this check exists to prevent.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text( + '"""Runs as __main__ in CI; see __main__ in the deploy docs."""\n' + "# note: __main__ is not actually guarded here\n" + "from graphify.extract import extract\n" + "extract([])\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed for a guard-less caller") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "an unrelated __main__ mention must not be read as a real guard" + assert spawned["count"] == 0, "no pool may be spawned for a guard-less Windows caller" + + def test_extract_parallel_still_spawns_pool_on_windows_when_caller_has_guard( tmp_path, monkeypatch ): @@ -2423,6 +2468,53 @@ def submit(self, *a, **kw): assert spawned["count"] == 1, "a guarded caller must still use the pool on Windows" +def test_extract_parallel_spawns_pool_for_reversed_guard_order(tmp_path, monkeypatch): + """The guard detection must also accept the less common, still valid + `if "__main__" == __name__:` operand order, not just the conventional + `if __name__ == "__main__":` spelling.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guarded = tmp_path / "runner.py" + guarded.write_text( + "from graphify.extract import extract\n" + "def main():\n" + " extract([])\n" + 'if "__main__" == __name__:\n' + " main()\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guarded) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") + + spawned = {"count": 0} + + class FakePool: + def __init__(self, *a, **kw): + spawned["count"] += 1 + def __enter__(self): + return self + def __exit__(self, *a): + return False + def submit(self, *a, **kw): + raise concurrent.futures.process.BrokenProcessPool("stop here") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert spawned["count"] == 1, "the reversed operand order is still a real guard" + + def test_extract_falls_back_when_worker_future_breaks_pool( tmp_path, monkeypatch, capsys ): From ff6f0ec5e5abb885cf817c851da1341a7975f61e Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 20:55:27 +0530 Subject: [PATCH 06/24] Update changelog entry for issue 1637 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c1d2a5a..d33af6f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) -- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard (#1637, thanks @ray8875). +- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check itself now looks for the actual guard statement instead of a bare substring match, so a docstring, comment, or unrelated string literal that merely mentions `__main__` is no longer read as a guard that is not really there (#1637, thanks @ray8875). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). From 897e27e63eee082165ed2b8943f3d7766b99592f Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 21:40:09 +0530 Subject: [PATCH 07/24] Detect the main guard by parsing, not a regex over the text A formal review round on this PR found three real gaps in the regex this replaces: a guard shaped line sitting inside a triple quoted string or a docstring example was still read as a real guard, the exact false positive class this whole check exists to close, just needing more specific bait text to trigger. And a valid but less common parenthesized comparison was wrongly rejected as no guard at all, a regression from the plain substring check this branch started from. Now parses the caller's source with ast and looks for a real if statement whose test compares __name__ to the string "__main__" in either order. The parser never sees string or comment contents as code at all, and parens are transparent to it, so both gaps close at once. A source that fails to parse is treated the same as an unreadable file, matching the existing best effort philosophy here. Co-Authored-By: Claude Sonnet 5 --- graphify/extract.py | 47 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 334aff205..1e35a3cc5 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1,6 +1,7 @@ """Deterministic structural extraction from source code using tree-sitter. Outputs nodes+edges dicts.""" from __future__ import annotations +import ast import hashlib import importlib import json @@ -6369,11 +6370,26 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: return idx, result -_MAIN_GUARD_RE = re.compile( - r'^[ \t]*if\s+(?:__name__\s*==\s*[\'"]__main__[\'"]' - r'|[\'"]__main__[\'"]\s*==\s*__name__)\s*:', - re.MULTILINE, -) +def _is_main_guard_test(test: ast.expr) -> bool: + """Whether an ``if`` statement's test is ``__name__ == "__main__"``, in + either operand order. Parens around the comparison are transparent to + the AST, and this never looks inside a string, comment, or docstring — + only a real comparison expression in executable code satisfies it.""" + if not isinstance(test, ast.Compare): + return False + if len(test.ops) != 1 or not isinstance(test.ops[0], ast.Eq): + return False + left, right = test.left, test.comparators[0] + + def _is_dunder_name(node: ast.expr) -> bool: + return isinstance(node, ast.Name) and node.id == "__name__" + + def _is_main_string(node: ast.expr) -> bool: + return isinstance(node, ast.Constant) and node.value == "__main__" + + return (_is_dunder_name(left) and _is_main_string(right)) or ( + _is_main_string(left) and _is_dunder_name(right) + ) def _caller_main_lacks_guard() -> bool: @@ -6387,9 +6403,13 @@ def _caller_main_lacks_guard() -> bool: so the pool is never opened in the first place, rather than caught after the fact via BrokenProcessPool once the damage is already spawning. - Looks for the actual guard statement, not a bare substring match — a - docstring, comment, or unrelated string literal mentioning ``__main__`` - must not be read as a guard that isn't really there. + Parses the source and looks for a real ``if`` statement with this test, + rather than a regex over the text — a regex line match still treats a + guard-shaped line sitting inside a triple-quoted string or a docstring + example as a real guard (it is not executable code), and still rejects + a valid but less common form like a parenthesized comparison. The AST + does not see string contents as code at all, and is indifferent to + formatting, so both gaps close at once. """ main_file = getattr(sys.modules.get("__main__"), "__file__", None) if not main_file: @@ -6398,7 +6418,16 @@ def _caller_main_lacks_guard() -> bool: main_src = Path(main_file).read_text(encoding="utf-8", errors="ignore") except OSError: return False - return _MAIN_GUARD_RE.search(main_src) is None + try: + tree = ast.parse(main_src) + except SyntaxError: + # Can't tell whether a guard is present -- treated the same as an + # unreadable file above, not escalated into "assume it's missing". + return False + return not any( + isinstance(node, ast.If) and _is_main_guard_test(node.test) + for node in ast.walk(tree) + ) def _extract_parallel( From 16cb9d855df58fb2b68e2cb3c1cff2db98836854 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 21:40:12 +0530 Subject: [PATCH 08/24] Add regression tests for the parser based guard detection Covers the three review reported gaps directly: guard text inside a triple quoted string, guard text inside a docstring example, and a parenthesized comparison, plus an unparseable caller falling back to the existing best effort treatment instead of being escalated. Co-Authored-By: Claude Sonnet 5 --- tests/test_extract.py | 183 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index 307753f2b..2829134f5 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2515,6 +2515,189 @@ def submit(self, *a, **kw): assert spawned["count"] == 1, "the reversed operand order is still a real guard" +def test_extract_parallel_declines_pool_when_guard_text_is_inside_a_string(tmp_path, monkeypatch): + """Review finding on the regex based detector this replaces: a guard + shaped line sitting inside a triple-quoted string is not executable + code and must not be read as a real guard. The caller here has no + actual if statement at all.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text( + '"""\n' + "Example usage:\n" + 'if __name__ == "__main__":\n' + " main()\n" + '"""\n' + "from graphify.extract import extract\n" + "extract([])\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed for a guard-less caller") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "guard text inside a string is not a real guard" + assert spawned["count"] == 0 + + +def test_extract_parallel_declines_pool_when_guard_text_is_in_a_docstring_example( + tmp_path, monkeypatch +): + """Same class of finding, the other shape reported: a guard shaped line + inside a function's own docstring, documenting how to call it, must + not be read as the module actually having a guard.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text( + "from graphify.extract import extract\n" + "\n" + "def run_from_cli():\n" + ' """Entry point.\n' + "\n" + " Typical usage:\n" + ' if __name__ == "__main__":\n' + " run_from_cli()\n" + ' """\n' + " extract([])\n" + "\n" + "run_from_cli()\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed for a guard-less caller") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "guard text inside a docstring example is not a real guard" + assert spawned["count"] == 0 + + +def test_extract_parallel_spawns_pool_for_a_parenthesized_guard(tmp_path, monkeypatch): + """Review finding: a parenthesized comparison, `if (__name__ == + "__main__"):`, is valid Python and a real guard, but was rejected by + the regex based detector this replaces since it required `if` to be + followed immediately by the comparison with no parens in between.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guarded = tmp_path / "runner.py" + guarded.write_text( + "from graphify.extract import extract\n" + "def main():\n" + " extract([])\n" + 'if (__name__ == "__main__"):\n' + " main()\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guarded) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") + + spawned = {"count": 0} + + class FakePool: + def __init__(self, *a, **kw): + spawned["count"] += 1 + def __enter__(self): + return self + def __exit__(self, *a): + return False + def submit(self, *a, **kw): + raise concurrent.futures.process.BrokenProcessPool("stop here") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert spawned["count"] == 1, "a parenthesized comparison is still a real guard" + + +def test_extract_parallel_spawns_pool_when_caller_source_fails_to_parse(tmp_path, monkeypatch): + """An unparseable caller (a syntax error, or a non-Python source read as + text) means the guard's presence genuinely can't be determined -- this + is treated the same as an unreadable file, not escalated into "assume + it's missing", matching this function's existing best-effort philosophy.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + broken = tmp_path / "runner.py" + broken.write_text("def broken(:\n", encoding="utf-8") + + class FakeMain: + __file__ = str(broken) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") + + spawned = {"count": 0} + + class FakePool: + def __init__(self, *a, **kw): + spawned["count"] += 1 + def __enter__(self): + return self + def __exit__(self, *a): + return False + def submit(self, *a, **kw): + raise concurrent.futures.process.BrokenProcessPool("stop here") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert spawned["count"] == 1, "an unparseable caller must not be treated as guard-less" + + def test_extract_falls_back_when_worker_future_breaks_pool( tmp_path, monkeypatch, capsys ): From b92aee27cdca8ef72d6b5d50a1ad39586c3da791 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 21:40:21 +0530 Subject: [PATCH 09/24] Update changelog entry for issue 1637 review finding Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d33af6f7a..feba50861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) -- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check itself now looks for the actual guard statement instead of a bare substring match, so a docstring, comment, or unrelated string literal that merely mentions `__main__` is no longer read as a guard that is not really there (#1637, thanks @ray8875). +- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all (#1637, thanks @ray8875). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). From adab6b40b781e850b49ec1e822f521dd32687e94 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 18 Sep 2026 14:13:01 +0530 Subject: [PATCH 10/24] Only check the module top level for a real main guard A fresh review round found that ast.walk() finds a guard anywhere in the tree, including one nested inside an unrelated function, class, or dead branch. Such a guard never actually runs at import time and protects nothing, so a caller whose real module scope code is fully unguarded could still be treated as safe, letting the exact fork bomb scenario this check exists to prevent happen anyway. The guard idiom only has its intended effect as a bare top level statement, so only the module's direct top level statements are checked now, not every node anywhere in the source. Co-Authored-By: Claude Sonnet 5 --- graphify/extract.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/graphify/extract.py b/graphify/extract.py index 1e35a3cc5..62e1b83f9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6410,6 +6410,13 @@ def _caller_main_lacks_guard() -> bool: a valid but less common form like a parenthesized comparison. The AST does not see string contents as code at all, and is indifferent to formatting, so both gaps close at once. + + Only the module's direct top-level statements are checked, not every + node anywhere in the tree: ``ast.walk`` also finds a guard nested inside + an unrelated function, class, or dead branch, which never executes at + import time and so provides no actual protection at all. The idiom + itself only has its intended effect as a bare top-level statement, so + that is the only place a real guard can be. """ main_file = getattr(sys.modules.get("__main__"), "__file__", None) if not main_file: @@ -6426,7 +6433,7 @@ def _caller_main_lacks_guard() -> bool: return False return not any( isinstance(node, ast.If) and _is_main_guard_test(node.test) - for node in ast.walk(tree) + for node in tree.body ) From c86d00d7fc87712b8dd7221fe00af60afaf1ed06 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 18 Sep 2026 14:13:01 +0530 Subject: [PATCH 11/24] Add a regression test for the module scope only guard check Covers a guard nested inside an unrelated function, alongside a genuinely unguarded module scope extract() call, the exact combination that let ast.walk() report a guard where there was none. Co-Authored-By: Claude Sonnet 5 --- tests/test_extract.py | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index 2829134f5..306c828c3 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2657,6 +2657,51 @@ def submit(self, *a, **kw): assert spawned["count"] == 1, "a parenthesized comparison is still a real guard" +def test_extract_parallel_declines_pool_for_a_guard_nested_in_an_unrelated_function( + tmp_path, monkeypatch +): + """Review finding: ast.walk() finds a guard anywhere in the tree, including + one nested inside an unrelated function that never runs at import time and + so provides no actual protection at all -- the module-scope extract() call + right below it is genuinely unguarded. Only a real, top-level guard should + count.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text( + "from graphify.extract import extract\n" + "def unrelated_helper():\n" + ' if __name__ == "__main__":\n' + " pass\n" + "extract([])\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed for a guard-less caller") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "a guard nested inside an unrelated function must not count as real" + assert spawned["count"] == 0, "no pool may be spawned for a genuinely guard-less caller" + + def test_extract_parallel_spawns_pool_when_caller_source_fails_to_parse(tmp_path, monkeypatch): """An unparseable caller (a syntax error, or a non-Python source read as text) means the guard's presence genuinely can't be determined -- this From 55c9157aa85ce24f751847bdc2ce6c26be6b9784 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 18 Sep 2026 14:13:01 +0530 Subject: [PATCH 12/24] Update changelog entry for issue 1637 again Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index feba50861..674bafa7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) -- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all (#1637, thanks @ray8875). +- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all. It now checks only the module's direct top level statements, since a guard the parser found nested inside an unrelated function, class, or dead branch never actually runs at import time and protects nothing — checking anywhere in the tree could still report a fully unguarded module as safe (#1637, thanks @ray8875). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). From 92699125dcb86b0989bf21674171581f9f24fe21 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 19 Sep 2026 23:10:43 +0530 Subject: [PATCH 13/24] Check that the actual call site sits inside the guard A fresh review round found that checking for a guard anywhere in the module's top level statements is not enough: a module can have a real top level guard and a separate, genuinely unguarded top level statement that calls extract() outside it, and the check would still report it as safe since some guard exists somewhere. The specific statement that led to this call now has to sit inside one. Found by walking the call stack for the outermost frame belonging to the module's own top level code, whose current line is wherever the chain of calls that reached extract() started, and checking that line against the guards' ranges directly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/extract.py | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 62e1b83f9..2579172db 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6412,11 +6412,23 @@ def _caller_main_lacks_guard() -> bool: formatting, so both gaps close at once. Only the module's direct top-level statements are checked, not every - node anywhere in the tree: ``ast.walk`` also finds a guard nested inside - an unrelated function, class, or dead branch, which never executes at - import time and so provides no actual protection at all. The idiom - itself only has its intended effect as a bare top-level statement, so - that is the only place a real guard can be. + node anywhere in the tree: a guard found anywhere in the tree also + matches one nested inside an unrelated function, class, or dead branch, + which never executes at import time and so provides no actual + protection at all. The idiom itself only has its intended effect as a + bare top-level statement, so that is the only place a real guard can + be. + + A module can have a real top-level guard AND a genuinely unguarded + top-level statement that calls ``extract()`` outside it, so finding + *some* guard anywhere in the module is not enough either -- the + specific top-level statement that led to this call must itself be + inside one. That statement is found by walking the call stack for the + outermost frame belonging to this module's own top-level code (its + ```` code object): its current line is wherever the chain of + calls that reached ``extract()`` started, and is checked against the + guards' line ranges directly, without needing to trace the call graph + through any intervening function. """ main_file = getattr(sys.modules.get("__main__"), "__file__", None) if not main_file: @@ -6431,10 +6443,24 @@ def _caller_main_lacks_guard() -> bool: # Can't tell whether a guard is present -- treated the same as an # unreadable file above, not escalated into "assume it's missing". return False - return not any( - isinstance(node, ast.If) and _is_main_guard_test(node.test) + guard_ranges = [ + (node.lineno, node.end_lineno) for node in tree.body - ) + if isinstance(node, ast.If) and _is_main_guard_test(node.test) + ] + if not guard_ranges: + return True + frame = sys._getframe() + while frame is not None: + code = frame.f_code + if code.co_filename == main_file and code.co_name == "": + line = frame.f_lineno + return not any(start <= line <= end for start, end in guard_ranges) + frame = frame.f_back + # Could not find the module's own top-level frame in the call stack + # (should not normally happen) -- can't tell where the call originated, + # treated the same as the unreadable/unparseable cases above. + return False def _extract_parallel( From c087f936f857bca2d83ad13ba3a096e682d2e981 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 19 Sep 2026 23:10:49 +0530 Subject: [PATCH 14/24] Add regression tests for the call site vs guard check Both actually execute the caller script as a top level module rather than only pointing sys.modules at a file that never runs, since the new check walks the real call stack and a monkeypatched file alone does not exercise it. Covers a call site genuinely outside a real top level guard, and the companion case of one genuinely inside it via an intervening function call. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_extract.py | 78 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index 306c828c3..da79de734 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2702,6 +2702,84 @@ def fake_pool(*a, **kw): assert spawned["count"] == 0, "no pool may be spawned for a genuinely guard-less caller" +def _exec_as_main_and_call_extract_parallel(tmp_path, monkeypatch, script_src, script_path): + """Actually execute script_src as a top-level module (not just point + sys.modules["__main__"] at a file that is never run), so the call stack + genuinely contains a real frame at script_path when checkpoint() + reaches _extract_parallel. A monkeypatched __file__ alone can't exercise + the call-site-vs-guard check below, since that check walks the real call + stack, not just module metadata. Returns the spawn count.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + script_path.write_text(script_src, encoding="utf-8") + + class FakeMain: + __file__ = str(script_path) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + class FakePool: + def __init__(self, *a, **kw): + spawned["count"] += 1 + def __enter__(self): + return self + def __exit__(self, *a): + return False + def submit(self, *a, **kw): + raise concurrent.futures.process.BrokenProcessPool("stop here") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + def checkpoint(): + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + + g = {"__name__": "__main__", "__file__": str(script_path), "checkpoint": checkpoint} + exec(compile(script_src, str(script_path), "exec"), g) + return spawned["count"] + + +def test_extract_parallel_declines_pool_when_the_call_site_is_outside_the_guard( + tmp_path, monkeypatch +): + """Review finding: a module can have a real top-level guard AND a + separate, genuinely unguarded top-level statement that calls extract() + (via _extract_parallel here) outside it. Finding *some* guard anywhere + in the module is not enough -- the specific call site that led to this + call must itself be inside one, or the exact fork bomb this check + exists to prevent still happens through the unguarded statement.""" + spawned_count = _exec_as_main_and_call_extract_parallel( + tmp_path, monkeypatch, + 'if __name__ == "__main__":\n pass\ncheckpoint()\n', + tmp_path / "mixed_runner.py", + ) + assert spawned_count == 0, "no pool may be spawned when the call site itself is unguarded" + + +def test_extract_parallel_spawns_pool_when_the_call_site_is_inside_the_guard( + tmp_path, monkeypatch +): + """Companion to the finding above, under real execution rather than the + monkeypatched-metadata-only setup the other tests use: a call site that + genuinely does sit inside the guard (via an intervening function call, + the idiomatic shape) must still take the pool path.""" + spawned_count = _exec_as_main_and_call_extract_parallel( + tmp_path, monkeypatch, + 'def main():\n checkpoint()\nif __name__ == "__main__":\n main()\n', + tmp_path / "guarded_runner.py", + ) + assert spawned_count == 1, "a call site genuinely inside the guard must take the pool path" + + def test_extract_parallel_spawns_pool_when_caller_source_fails_to_parse(tmp_path, monkeypatch): """An unparseable caller (a syntax error, or a non-Python source read as text) means the guard's presence genuinely can't be determined -- this From c72f07a6fb25c7244c78454e4e124bd372604424 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 19 Sep 2026 23:11:05 +0530 Subject: [PATCH 15/24] Update changelog entry for issue 1637 once more Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 674bafa7d..50c8961e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) -- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all. It now checks only the module's direct top level statements, since a guard the parser found nested inside an unrelated function, class, or dead branch never actually runs at import time and protects nothing — checking anywhere in the tree could still report a fully unguarded module as safe (#1637, thanks @ray8875). +- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all. It now checks only the module's direct top level statements, since a guard the parser found nested inside an unrelated function, class, or dead branch never actually runs at import time and protects nothing — checking anywhere in the tree could still report a fully unguarded module as safe. It now also verifies the actual call site that led to this call sits inside a guard, not just that some guard exists anywhere in the module — a genuinely unguarded top level statement calling extract() outside an unrelated guard block used to be misreported as safe too (#1637, thanks @ray8875). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). From da6454ce3eb8b452fdd26e1eeb6257846db40228 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 19 Sep 2026 23:24:46 +0530 Subject: [PATCH 16/24] Print a diagnostic when declining a pool inside a worker process A review finding pointed out this branch returned silently, unlike the sibling guard less caller branch just below it which prints a warning to stderr. Both decline reasons should be visible the same way rather than one being a quiet fallback. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/extract.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/graphify/extract.py b/graphify/extract.py index 2579172db..b835befda 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6488,6 +6488,12 @@ def _extract_parallel( # into an unbounded process explosion. Refuse unconditionally, before # even a spawn-capable platform check, since this is never correct. if multiprocessing.parent_process() is not None: + print( + " warning: extract() was called from inside a worker process; " + "extracting sequentially instead of opening a nested pool " + "(pass parallel=False to extract() to silence this check)", + file=sys.stderr, flush=True, + ) return False if sys.platform == "win32" and _caller_main_lacks_guard(): From 196ba9afef395b9eefe28ddb410a77aceb31d377 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 19 Sep 2026 23:24:52 +0530 Subject: [PATCH 17/24] Assert the worker process decline prints its diagnostic Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_extract.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_extract.py b/tests/test_extract.py index da79de734..06180edba 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2307,7 +2307,7 @@ def submit(self, *a, **kw): assert spawned["count"] == 1, "multi-worker runs must still use the pool" -def test_extract_parallel_declines_pool_inside_a_spawned_worker(tmp_path, monkeypatch): +def test_extract_parallel_declines_pool_inside_a_spawned_worker(tmp_path, monkeypatch, capsys): """#1637: a guard-less Windows caller makes every spawned worker re-execute the top-level module. If that module calls extract() again at module scope, the worker would open its OWN pool, whose own guard-less children @@ -2315,6 +2315,10 @@ def test_extract_parallel_declines_pool_inside_a_spawned_worker(tmp_path, monkey failure. _extract_parallel must refuse to open a pool at all whenever it is already running inside a multiprocessing child, regardless of platform, since a legitimate call only ever happens in the main process. + + A review finding pointed out this branch returned silently, unlike the + sibling "caller lacks a guard" branch just below it which prints a + diagnostic -- decline reasons should both be visible the same way. """ import concurrent.futures import multiprocessing @@ -2335,6 +2339,9 @@ def fake_pool(*a, **kw): ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) assert ok is False, "must decline and hand the work back for sequential extraction" assert spawned["count"] == 0, "no pool may be spawned from inside a worker process" + assert "worker process" in capsys.readouterr().err, ( + "declining here must be visible, matching the sibling guard-less branch" + ) def test_extract_parallel_declines_pool_on_windows_when_caller_lacks_guard( From 1d2b3d07336ea6268202addb394c4cf45e0e8009 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 19 Sep 2026 23:25:07 +0530 Subject: [PATCH 18/24] Update changelog entry for issue 1637 for the diagnostic Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c8961e5..6a5bb0a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) -- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all. It now checks only the module's direct top level statements, since a guard the parser found nested inside an unrelated function, class, or dead branch never actually runs at import time and protects nothing — checking anywhere in the tree could still report a fully unguarded module as safe. It now also verifies the actual call site that led to this call sits inside a guard, not just that some guard exists anywhere in the module — a genuinely unguarded top level statement calling extract() outside an unrelated guard block used to be misreported as safe too (#1637, thanks @ray8875). +- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all. It now checks only the module's direct top level statements, since a guard the parser found nested inside an unrelated function, class, or dead branch never actually runs at import time and protects nothing — checking anywhere in the tree could still report a fully unguarded module as safe. It now also verifies the actual call site that led to this call sits inside a guard, not just that some guard exists anywhere in the module — a genuinely unguarded top level statement calling extract() outside an unrelated guard block used to be misreported as safe too. Declining a pool because extract() was called from inside a worker process now prints a diagnostic too, matching the sibling guard less branch instead of falling back silently (#1637, thanks @ray8875). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). From 12b45d5a56c9d9684ea050a6886aeea9ef7c4405 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sun, 20 Sep 2026 02:37:22 +0530 Subject: [PATCH 19/24] Check the actual start method, not a hardcoded platform name A review finding pointed out that gating the guard check on sys.platform == win32 missed macOS, which has defaulted to the spawn start method since Python 3.8. The exact same fork bomb this check exists to prevent, a guard less caller under spawn re executing its own top level extract() call in every worker, is fully reproducible there too, not just on the one platform actually checked for. The gate now asks multiprocessing directly whether opening a pool here would use spawn, since that is the only start method where a missing guard matters at all. Reads the current setting without fixing it as a side effect, since get_start_method's own default behavior permanently locks in the platform default the first time it is called, which would wrongly pre empt a caller that has not yet made its own set_start_method call. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/extract.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index b835befda..78c26c574 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6393,7 +6393,8 @@ def _is_main_string(node: ast.expr) -> bool: def _caller_main_lacks_guard() -> bool: - """#1637: on Windows (spawn start method), a caller script with no + """#1637: under the spawn start method (the only one on Windows, and the + default on macOS since Python 3.8), a caller script with no ``if __name__ == "__main__":`` guard makes every worker re-execute the top-level module on import — including, if it calls ``extract()`` at module scope, spawning its OWN pool. Each of those child pools spawns @@ -6463,6 +6464,32 @@ def _caller_main_lacks_guard() -> bool: return False +def _pool_will_use_spawn() -> bool: + """Whether opening a ProcessPoolExecutor here would use the ``spawn`` + start method (#1637 follow up): the guard-less-caller fork bomb only + happens under ``spawn``, which re-imports/re-executes the ``__main__`` + module in every child. ``fork`` and ``forkserver`` never re-run + top-level code, so this check only matters when spawn is actually in + play. Checking the literal platform name (``win32`` only) missed macOS, + which has defaulted to spawn since Python 3.8 -- the same fork bomb is + fully reproducible there, not just on Windows. + + Uses ``allow_none=True`` to read the CURRENT setting without fixing it + as a side effect: ``get_start_method()``'s default behavior permanently + locks in the platform default the first time it is called, which would + wrongly pre-empt a caller that has not yet made its own + ``set_start_method()`` call. + """ + import multiprocessing + + method = multiprocessing.get_start_method(allow_none=True) + if method is None: + # Not yet fixed: peek at the platform default without fixing it. + # get_all_start_methods() always lists it first. + method = multiprocessing.get_all_start_methods()[0] + return method == "spawn" + + def _extract_parallel( uncached_work: list[tuple[int, Path]], per_file: list[dict | None], @@ -6474,7 +6501,7 @@ def _extract_parallel( """Extract uncached files in parallel using ProcessPoolExecutor. Returns True if the pool ran to completion. Returns False if the pool - failed in a recoverable way (typically Windows-spawn without an + failed in a recoverable way (typically the spawn start method without an ``if __name__ == "__main__"`` guard in the calling script, which causes BrokenProcessPool); the caller should fall back to sequential extraction. """ @@ -6496,7 +6523,7 @@ def _extract_parallel( ) return False - if sys.platform == "win32" and _caller_main_lacks_guard(): + if _pool_will_use_spawn() and _caller_main_lacks_guard(): print( " warning: calling script lacks an `if __name__ == \"__main__\":` " "guard; extracting sequentially to avoid runaway process spawning " From b163e0b7a2bc1087ab27c5ccf3c0c6b3df3c42c1 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sun, 20 Sep 2026 02:37:55 +0530 Subject: [PATCH 20/24] Add regression tests for the start method aware guard check The existing Windows tests now also mock get_start_method to spawn, since sys.platform alone no longer drives the check. Two new tests cover the actual finding: macOS (spawn, sys.platform darwin) must still decline for a guard less caller, and fork (Linux's default) must still take the pool path even without a guard, since fork never runs the caller's top level code again in the first place. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_extract.py | 94 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index 06180edba..db55639ff 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2363,6 +2363,7 @@ class FakeMain: __file__ = str(guardless) monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") monkeypatch.setitem(sys.modules, "__main__", FakeMain()) monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) @@ -2382,6 +2383,90 @@ def fake_pool(*a, **kw): assert spawned["count"] == 0, "no pool may be spawned for a guard-less Windows caller" +def test_extract_parallel_declines_pool_on_macos_when_caller_lacks_guard( + tmp_path, monkeypatch +): + """Review finding: checking sys.platform == "win32" missed macOS, which + has defaulted to the spawn start method since Python 3.8 -- the exact + same fork bomb this check exists to prevent is fully reproducible there, + not just on Windows. The check is now based on the actual multiprocessing + start method, not a hardcoded platform name.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text("from graphify.extract import extract\nextract([])\n", encoding="utf-8") + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed for a guard-less caller") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "must decline and hand the work back for sequential extraction" + assert spawned["count"] == 0, "no pool may be spawned for a guard-less macOS spawn caller" + + +def test_extract_parallel_spawns_pool_when_start_method_is_fork_even_without_a_guard( + tmp_path, monkeypatch +): + """Under fork (Linux's default), a missing guard is harmless -- fork + never re-imports/re-executes the __main__ module in the child, so there + is no re-execution to guard against. The check must not fire and force + an unnecessary sequential fallback just because the caller happens to + lack a guard it was never going to need.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text("from graphify.extract import extract\nextract([])\n", encoding="utf-8") + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "fork") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") + + spawned = {"count": 0} + + class FakePool: + def __init__(self, *a, **kw): + spawned["count"] += 1 + def __enter__(self): + return self + def __exit__(self, *a): + return False + def submit(self, *a, **kw): + raise concurrent.futures.process.BrokenProcessPool("stop here") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert spawned["count"] == 1, "fork needs no guard, so the pool path must still be taken" + + def test_extract_parallel_declines_pool_when_main_only_appears_in_a_comment( tmp_path, monkeypatch ): @@ -2408,6 +2493,7 @@ class FakeMain: __file__ = str(guardless) monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") monkeypatch.setitem(sys.modules, "__main__", FakeMain()) monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) @@ -2450,6 +2536,7 @@ class FakeMain: __file__ = str(guarded) monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") monkeypatch.setitem(sys.modules, "__main__", FakeMain()) monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") @@ -2497,6 +2584,7 @@ class FakeMain: __file__ = str(guarded) monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") monkeypatch.setitem(sys.modules, "__main__", FakeMain()) monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") @@ -2547,6 +2635,7 @@ class FakeMain: __file__ = str(guardless) monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") monkeypatch.setitem(sys.modules, "__main__", FakeMain()) monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) @@ -2597,6 +2686,7 @@ class FakeMain: __file__ = str(guardless) monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") monkeypatch.setitem(sys.modules, "__main__", FakeMain()) monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) @@ -2639,6 +2729,7 @@ class FakeMain: __file__ = str(guarded) monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") monkeypatch.setitem(sys.modules, "__main__", FakeMain()) monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") @@ -2690,6 +2781,7 @@ class FakeMain: __file__ = str(guardless) monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") monkeypatch.setitem(sys.modules, "__main__", FakeMain()) monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) @@ -2726,6 +2818,7 @@ class FakeMain: __file__ = str(script_path) monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) spawned = {"count": 0} @@ -2803,6 +2896,7 @@ class FakeMain: __file__ = str(broken) monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") monkeypatch.setitem(sys.modules, "__main__", FakeMain()) monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") From ade7731aaf31c234ddeadd41b185557326411faf Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sun, 20 Sep 2026 02:38:32 +0530 Subject: [PATCH 21/24] Update changelog entry for issue 1637 for the platform gate Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a5bb0a77..c1d702db6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) -- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all. It now checks only the module's direct top level statements, since a guard the parser found nested inside an unrelated function, class, or dead branch never actually runs at import time and protects nothing — checking anywhere in the tree could still report a fully unguarded module as safe. It now also verifies the actual call site that led to this call sits inside a guard, not just that some guard exists anywhere in the module — a genuinely unguarded top level statement calling extract() outside an unrelated guard block used to be misreported as safe too. Declining a pool because extract() was called from inside a worker process now prints a diagnostic too, matching the sibling guard less branch instead of falling back silently (#1637, thanks @ray8875). +- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all. It now checks only the module's direct top level statements, since a guard the parser found nested inside an unrelated function, class, or dead branch never actually runs at import time and protects nothing — checking anywhere in the tree could still report a fully unguarded module as safe. It now also verifies the actual call site that led to this call sits inside a guard, not just that some guard exists anywhere in the module — a genuinely unguarded top level statement calling extract() outside an unrelated guard block used to be misreported as safe too. Declining a pool because extract() was called from inside a worker process now prints a diagnostic too, matching the sibling guard less branch instead of falling back silently. The Windows check itself was also too narrow: it gated on the literal platform name, but macOS has defaulted to the spawn start method since Python 3.8, so the same fork bomb is fully reproducible there too. It now asks multiprocessing directly whether opening a pool would use spawn, the only start method where a missing guard matters, rather than hardcoding a platform name (#1637, thanks @ray8875). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). From a7e3d1cc7892cf7987ea1d54af9beaf4e05dfcde Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sun, 20 Sep 2026 02:42:50 +0530 Subject: [PATCH 22/24] Recognize a main check narrowed by an and as a real guard A review finding pointed out that a compound condition like if __name__ == "__main__" and verbose: was treated as no guard at all, since only a bare comparison was accepted. Every operand of an and must be true for the body to run, so recognizing any one of them as the real check is still correct, and this only causes an unnecessary sequential fallback rather than anything unsafe, but it is a real gap worth closing. An or is deliberately never recognized this way, since the body can then run even when the name genuinely is not main if the other side is true. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/extract.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 78c26c574..edfbc1c90 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6372,9 +6372,20 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: def _is_main_guard_test(test: ast.expr) -> bool: """Whether an ``if`` statement's test is ``__name__ == "__main__"``, in - either operand order. Parens around the comparison are transparent to - the AST, and this never looks inside a string, comment, or docstring — - only a real comparison expression in executable code satisfies it.""" + either operand order, optionally narrowed by an ``and`` (e.g. + ``__name__ == "__main__" and verbose``). Parens around the comparison + are transparent to the AST, and this never looks inside a string, + comment, or docstring — only a real comparison expression in executable + code satisfies it. + + Only ``and`` is recursed through: every operand of an ``and`` must be + true for the body to run, so recognizing any one of them as the real + guard is still correct. An ``or`` is NOT safe to recognize this way — + the body can run even when ``__name__`` isn't ``"__main__"`` if the + other side is true — so a disjunction is never treated as a guard. + """ + if isinstance(test, ast.BoolOp) and isinstance(test.op, ast.And): + return any(_is_main_guard_test(value) for value in test.values) if not isinstance(test, ast.Compare): return False if len(test.ops) != 1 or not isinstance(test.ops[0], ast.Eq): From 1177e48ea2af90f21dacf37c9d87ec5620f4c378 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sun, 20 Sep 2026 02:42:57 +0530 Subject: [PATCH 23/24] Add regression tests for the compound and guard recognition Covers the finding itself (a __main__ check narrowed by and still takes the pool path) and the companion boundary case that must keep declining (the same check narrowed by or is not a real guard, since the body can run without __name__ genuinely being main). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_extract.py | 92 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index db55639ff..eb600bec3 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2755,6 +2755,98 @@ def submit(self, *a, **kw): assert spawned["count"] == 1, "a parenthesized comparison is still a real guard" +def test_extract_parallel_spawns_pool_for_a_compound_and_guard(tmp_path, monkeypatch): + """Review finding: `if __name__ == "__main__" and verbose:` is a valid, + real guard -- every operand of `and` must be true for the body to run, + so it only executes when __name__ genuinely is "__main__" -- but was + rejected by a check that only accepted a bare comparison.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guarded = tmp_path / "runner.py" + guarded.write_text( + "from graphify.extract import extract\n" + "def main():\n" + " extract([])\n" + 'if __name__ == "__main__" and True:\n' + " main()\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guarded) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + monkeypatch.setenv("GRAPHIFY_MAX_WORKERS", "4") + + spawned = {"count": 0} + + class FakePool: + def __init__(self, *a, **kw): + spawned["count"] += 1 + def __enter__(self): + return self + def __exit__(self, *a): + return False + def submit(self, *a, **kw): + raise concurrent.futures.process.BrokenProcessPool("stop here") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", FakePool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert spawned["count"] == 1, "a __main__ check narrowed by `and` is still a real guard" + + +def test_extract_parallel_declines_pool_for_an_or_disjunction_with_main(tmp_path, monkeypatch): + """Companion to the finding above: `if __name__ == "__main__" or verbose:` + is NOT a real guard -- the body can run even when __name__ isn't + "__main__" if the other side of the `or` is true -- so this must still + decline, unlike the `and` case.""" + import concurrent.futures + import multiprocessing + from graphify import extract as extract_mod + + guardless = tmp_path / "runner.py" + guardless.write_text( + "from graphify.extract import extract\n" + "def main():\n" + " extract([])\n" + 'if __name__ == "__main__" or True:\n' + " main()\n", + encoding="utf-8", + ) + + class FakeMain: + __file__ = str(guardless) + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(multiprocessing, "get_start_method", lambda allow_none=False: "spawn") + monkeypatch.setitem(sys.modules, "__main__", FakeMain()) + monkeypatch.setattr(multiprocessing, "parent_process", lambda: None) + + spawned = {"count": 0} + + def fake_pool(*a, **kw): + spawned["count"] += 1 + raise AssertionError("ProcessPoolExecutor must not be constructed for a fake guard") + + monkeypatch.setattr(concurrent.futures, "ProcessPoolExecutor", fake_pool) + + uncached = [(i, FIXTURES / "sample.py") for i in range(25)] + per_file: list = [None] * len(uncached) + + ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, None, len(uncached)) + assert ok is False, "an `or` disjunction with __main__ is not a real guard" + assert spawned["count"] == 0, "no pool may be spawned when the guard is not really one" + + def test_extract_parallel_declines_pool_for_a_guard_nested_in_an_unrelated_function( tmp_path, monkeypatch ): From e1042748960ef7d95cbcdd67d1daa561b7a8dc89 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sun, 20 Sep 2026 02:43:16 +0530 Subject: [PATCH 24/24] Update changelog entry for issue 1637 for the and guard Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d702db6..6bd55d207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) -- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all. It now checks only the module's direct top level statements, since a guard the parser found nested inside an unrelated function, class, or dead branch never actually runs at import time and protects nothing — checking anywhere in the tree could still report a fully unguarded module as safe. It now also verifies the actual call site that led to this call sits inside a guard, not just that some guard exists anywhere in the module — a genuinely unguarded top level statement calling extract() outside an unrelated guard block used to be misreported as safe too. Declining a pool because extract() was called from inside a worker process now prints a diagnostic too, matching the sibling guard less branch instead of falling back silently. The Windows check itself was also too narrow: it gated on the literal platform name, but macOS has defaulted to the spawn start method since Python 3.8, so the same fork bomb is fully reproducible there too. It now asks multiprocessing directly whether opening a pool would use spawn, the only start method where a missing guard matters, rather than hardcoding a platform name (#1637, thanks @ray8875). +- Fix: `extract()`'s parallel path no longer opens a `ProcessPoolExecutor` that can spawn its own. On Windows, a caller script with no `if __name__ == "__main__":` guard made every worker re execute the top level module on import — if that module called `extract()` again at module scope, the worker opened its own pool, whose own guard less children did the same, faster than a per future `BrokenProcessPool` exception could surface and stop it, growing unbounded rather than failing over to sequential extraction. Two checks now run before the pool is opened: unconditionally refuse when already inside a multiprocessing child, and on Windows, decline pre emptively when the caller's own `__main__` module lacks the guard. The guard check now parses the caller's source and looks for a real `if` statement comparing `__name__` to `"__main__"`, instead of matching the text — a regex still misread a guard shaped line sitting inside a triple quoted string or a docstring example as a real guard, and rejected a valid parenthesized comparison as no guard at all. It now checks only the module's direct top level statements, since a guard the parser found nested inside an unrelated function, class, or dead branch never actually runs at import time and protects nothing — checking anywhere in the tree could still report a fully unguarded module as safe. It now also verifies the actual call site that led to this call sits inside a guard, not just that some guard exists anywhere in the module — a genuinely unguarded top level statement calling extract() outside an unrelated guard block used to be misreported as safe too. Declining a pool because extract() was called from inside a worker process now prints a diagnostic too, matching the sibling guard less branch instead of falling back silently. The Windows check itself was also too narrow: it gated on the literal platform name, but macOS has defaulted to the spawn start method since Python 3.8, so the same fork bomb is fully reproducible there too. It now asks multiprocessing directly whether opening a pool would use spawn, the only start method where a missing guard matters, rather than hardcoding a platform name. A guard narrowed by an `and` (`if __name__ == "__main__" and verbose:`) is now recognized too, since every operand of an `and` must be true for the body to run, so it still only executes when the name genuinely is `"__main__"`; an `or` is deliberately never recognized this way (#1637, thanks @ray8875). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov).