Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Name>` 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).
Expand Down
40 changes: 40 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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
Expand Down
116 changes: 116 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down
Loading