-
Notifications
You must be signed in to change notification settings - Fork 270
Fix free-threaded GC crash from inherited C-stack refs (issue #515) #517
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
Merged
jamadden
merged 6 commits into
python-greenlet:master
from
ddorian:issue515-c-stack-refs
Jul 22, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
14e24b2
Fix free-threaded GC crash from inherited C-stack refs (issue #515)
ddorian 980ee7a
Visit a suspended greenlet's C-stack refs during GC (issue #515)
ddorian 39bf70c
Hold the C-stack ref snapshot in a std::vector<OwnedObject>
ddorian 78932dc
Change notes are for end users, they don't need to go into technical …
jamadden 1ff35f4
Merge branch 'master' into issue515-c-stack-refs
jamadden 73a0a1a
Fix the suspended C-stack-ref GC test to catch its regression
ddorian 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
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 |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| """Regression test for GC of a suspended greenlet's C-stack refs (issue #515). | ||
|
|
||
| When a greenlet suspends while the interpreter is holding a ``_PyCStackRef`` | ||
| (for example, mid attribute resolution), that deferred reference sits on the | ||
| greenlet's C stack. The free-threaded collector only walks the running | ||
| thread's list in ``gc_visit_thread_stacks``, so unless greenlet visits the | ||
| suspended one from ``tp_traverse`` an object reachable only through it is freed | ||
| early and used after free once the greenlet resumes. | ||
|
|
||
| Reproducing that needs the pinned object to be deferred-refcounted (so its | ||
| refcount doesn't keep it alive) and reachable only through the C-stack ref. A | ||
| class is deferred-refcounted, and a metaclass ``__get__`` makes a class act as a | ||
| descriptor -- so ``obj.attr``, where ``attr`` is such a class, pins the class in | ||
| a ``_PyCStackRef`` across the (Python) ``__get__``. We switch away from inside | ||
| ``__get__``, drop every other reference to the class, and collect: on a | ||
| regressed free-threaded build the class is gone by the time we resume; the fix | ||
| keeps it alive because ``tp_traverse`` visited the snapshot taken at suspend. | ||
|
|
||
| Prints "C STACK REFS GC OK" on a fixed build (and on with-GIL builds, where | ||
| ordinary refcounting keeps the class alive); a regressed free-threaded build | ||
| reports the class was collected and exits non-zero. | ||
| """ | ||
| import gc | ||
| import sys | ||
| import weakref | ||
|
|
||
| import greenlet | ||
|
|
||
| parent = greenlet.getcurrent() | ||
| observed = {} | ||
|
|
||
|
|
||
| class Meta(type): | ||
| def __get__(cls, obj, objtype=None): | ||
| # type(cls) is Meta, which defines __get__, so ``cls`` is a descriptor; | ||
| # the getattr machinery pins it in a _PyCStackRef across this call. A | ||
| # class is deferred-refcounted on a free-threaded build, so only that | ||
| # (deferred) C-stack ref will be keeping it alive in a moment. | ||
| # | ||
| # Drop the locals the descriptor protocol gave us (the class is ``cls``): | ||
| # greenlet visits a suspended greenlet's frames, so a leftover frame ref | ||
| # would keep the class alive on its own and mask the bug. | ||
| del cls, obj, objtype | ||
| child.switch() | ||
| return 42 | ||
|
|
||
|
|
||
| pinned = Meta('pinned', (), {}) # a class => deferred-refcounted | ||
| holder = type('holder', (), {'attr': pinned}) # holder.attr invokes Meta.__get__ | ||
| box = [pinned] | ||
| del pinned | ||
|
|
||
|
|
||
| def child_work(): | ||
| # ``parent`` is suspended inside Meta.__get__, holding a C-stack ref to the | ||
| # class. Drop every *other* reference to it, then collect: now only greenlet | ||
| # visiting the suspended C-stack ref can keep the class alive. | ||
| ref = weakref.ref(box[0]) | ||
| box[0] = None | ||
| del holder.attr | ||
| for _ in range(5): | ||
| gc.collect() | ||
| observed['alive'] = ref() is not None | ||
| parent.switch() | ||
|
|
||
|
|
||
| child = greenlet.greenlet(child_work) | ||
| result = holder().attr # Meta.__get__ -> switch -> gc -> back | ||
| assert result == 42, result | ||
|
|
||
| alive = observed.get('alive') | ||
| print(f"py={sys.version.split()[0]} gil={getattr(sys, '_is_gil_enabled', lambda: True)()} " | ||
| f"greenlet={greenlet.__version__} alive={alive}", flush=True) | ||
| if not alive: | ||
| raise SystemExit("REGRESSED: a class reachable only through a suspended " | ||
| "greenlet's C-stack ref was collected early") | ||
| print("C STACK REFS GC OK", flush=True) | ||
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 |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| """Reproducer for the free-threaded GC crash in issue #515. | ||
|
|
||
| https://github.com/python-greenlet/greenlet/issues/515 | ||
|
|
||
| Before the fix, ``set_initial_state`` copied the parent thread state's | ||
| ``_PyCStackRef`` list head into each new greenlet. Those nodes sit on the | ||
| parent greenlet's C stack, so once the child starts running on its own stack | ||
| and overwrites that region their ``next`` pointers dangle. The free-threaded | ||
| collector walks ``c_stack_refs`` for every thread, so a GC on any thread ends | ||
| up chasing those dangling nodes (inside ``gc_collect_main`` -> | ||
| ``gc_visit_thread_stacks``) while such a child is active, and segfaults. | ||
|
|
||
| The fiddly part is getting a *non-empty* list to inherit at all. The | ||
| interpreter only holds a ``_PyCStackRef`` transiently, while it is resolving an | ||
| attribute -- ``_Py_LoadAttr_StackRefSteal`` pins ``self`` across ``obj.attr()`` | ||
| -- so the greenlet has to be primed from inside that window. Priming it from a | ||
| property getter does exactly that; it is also the same deep-attribute context | ||
| Playwright's sync bridge happens to start its fiber from. A churn thread then | ||
| keeps the collector busy while the dispatcher runs on its stale list. | ||
|
|
||
| A fixed build prints "ISSUE 515 OK"; a regressed one segfaults in about a | ||
| second. With-GIL builds are unaffected -- ordinary refcounting keeps the nodes | ||
| alive -- so there it simply prints the OK line. | ||
|
jamadden marked this conversation as resolved.
|
||
| """ | ||
| import gc | ||
| import os | ||
| import sys | ||
| import threading | ||
|
|
||
| import greenlet | ||
|
|
||
| # The crash lands within a second, so a few seconds leaves plenty of margin on | ||
| # a slow machine without dragging out a fixed build. | ||
| DURATION = float(os.environ.get("REPRO_SECONDS", "4")) | ||
| NWORKERS = int(os.environ.get("REPRO_THREADS", "6")) | ||
|
|
||
|
|
||
| def _busy(): | ||
| # Keep the dispatcher doing work (resolving str methods) so a collection on | ||
| # the churn thread catches it while it is the active greenlet. | ||
| return "abc".upper().lower().strip().title() | ||
|
|
||
|
|
||
| class Bridge: | ||
| def __init__(self, stop): | ||
| self.stop = stop | ||
| self.main = greenlet.getcurrent() | ||
| self.disp = greenlet.greenlet(self._loop) | ||
|
|
||
| def _loop(self): | ||
| while not self.stop[0]: | ||
| for _ in range(50): | ||
| _busy() | ||
| self.main.switch() | ||
|
|
||
| @property | ||
| def _prime(self): | ||
| # Reaching this getter means we are mid attribute-resolution, so a | ||
| # _PyCStackRef for ``self`` is live right now. Priming the dispatcher | ||
| # from here is what makes it inherit a non-empty c_stack_refs head. | ||
| self.disp.switch() | ||
| return None | ||
|
|
||
| def start(self): | ||
| # Reading the property (rather than calling a method) is deliberate: the | ||
| # getter runs *during* attribute resolution, so it primes the dispatcher | ||
| # while the _PyCStackRef for ``self`` is still held. We return the value | ||
| # only so this isn't a bare, pointless-looking expression statement. | ||
| return self._prime | ||
|
|
||
| def resume(self): | ||
| self.disp.switch() | ||
|
|
||
|
|
||
| def worker(stop): | ||
| bridge = Bridge(stop) | ||
| bridge.start() | ||
| # start() has returned, so the stack the inherited nodes point at is being | ||
| # reused -- the dispatcher's list is stale now. Keep it active to trip the GC. | ||
| while not stop[0]: | ||
| bridge.resume() | ||
|
|
||
|
|
||
| def churn(stop): | ||
| while not stop[0]: | ||
| # A cycle forces a real (stop-the-world) collection rather than just a | ||
| # refcount drop. | ||
| nodes = [{"i": i, "next": None} for i in range(500)] | ||
| for a, b in zip(nodes, nodes[1:]): | ||
| a["next"] = b | ||
| nodes[-1]["next"] = nodes[0] | ||
| del nodes | ||
| gc.collect() | ||
|
|
||
|
|
||
| def main(): | ||
| stop = [False] | ||
| threads = [threading.Thread(target=churn, args=(stop,), daemon=True)] | ||
| threads += [threading.Thread(target=worker, args=(stop,), daemon=True) | ||
| for _ in range(NWORKERS)] | ||
| for t in threads: | ||
| t.start() | ||
| threading.Event().wait(DURATION) | ||
| stop[0] = True | ||
| for t in threads: | ||
| t.join(timeout=5) | ||
| print("ISSUE 515 OK") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
| sys.exit(0) | ||
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
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.
Uh oh!
There was an error while loading. Please reload this page.