-
-
Notifications
You must be signed in to change notification settings - Fork 11.6k
Refuse to spawn an extraction pool that would spawn its own (#1637) #3620
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ayushcodes10
wants to merge
24
commits into
Graphify-Labs:v8
Choose a base branch
from
ayushcodes10:fix-1637-windows-spawn-guard
base: v8
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+866
−1
Open
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
b5c78e7
Refuse to spawn an extraction pool that would spawn its own
ayushcodes10 f658d3e
Add regression tests for the runaway pool spawn fix
ayushcodes10 b53ab1c
Add changelog entry for issue 1637
ayushcodes10 a325bd5
Detect the real main guard statement, not a bare substring
ayushcodes10 b86d873
Add regression tests for the real main guard detection
ayushcodes10 ff6f0ec
Update changelog entry for issue 1637
ayushcodes10 897e27e
Detect the main guard by parsing, not a regex over the text
ayushcodes10 16cb9d8
Add regression tests for the parser based guard detection
ayushcodes10 b92aee2
Update changelog entry for issue 1637 review finding
ayushcodes10 adab6b4
Only check the module top level for a real main guard
ayushcodes10 c86d00d
Add a regression test for the module scope only guard check
ayushcodes10 55c9157
Update changelog entry for issue 1637 again
ayushcodes10 9269912
Check that the actual call site sits inside the guard
ayushcodes10 c087f93
Add regression tests for the call site vs guard check
ayushcodes10 c72f07a
Update changelog entry for issue 1637 once more
ayushcodes10 da6454c
Print a diagnostic when declining a pool inside a worker process
ayushcodes10 196ba9a
Assert the worker process decline prints its diagnostic
ayushcodes10 1d2b3d0
Update changelog entry for issue 1637 for the diagnostic
ayushcodes10 12b45d5
Check the actual start method, not a hardcoded platform name
ayushcodes10 b163e0b
Add regression tests for the start method aware guard check
ayushcodes10 ade7731
Update changelog entry for issue 1637 for the platform gate
ayushcodes10 a7e3d1c
Recognize a main check narrowed by an and as a real guard
ayushcodes10 1177e48
Add regression tests for the compound and guard recognition
ayushcodes10 e104274
Update changelog entry for issue 1637 for the and guard
ayushcodes10 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,6 +6370,137 @@ def _extract_single_file(args: tuple) -> tuple[int, dict]: | |
| return idx, result | ||
|
|
||
|
|
||
| def _is_main_guard_test(test: ast.expr) -> bool: | ||
| """Whether an ``if`` statement's test is ``__name__ == "__main__"``, in | ||
| 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): | ||
| 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: | ||
| """#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 | ||
| 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. | ||
|
|
||
| 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. | ||
|
|
||
| Only the module's direct top-level statements are checked, not every | ||
| 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 | ||
| ``<module>`` 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: | ||
| return False | ||
| try: | ||
| main_src = Path(main_file).read_text(encoding="utf-8", errors="ignore") | ||
| except OSError: | ||
| return False | ||
| 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 | ||
| 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 == "<module>": | ||
| 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 _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( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
20 callers depend on it (afferent coupling). Grounded coupling-delta finding (deterministic), not an LLM guess. |
||
| uncached_work: list[tuple[int, Path]], | ||
| per_file: list[dict | None], | ||
|
|
@@ -6380,11 +6512,36 @@ 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. | ||
| """ | ||
| 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: | ||
| 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 _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 " | ||
| "(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 | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_extract_parallel()18 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.