This is a bit more complex, I had issue making it smaller.
What do you think?
#!/usr/bin/env -S uv run --python 3.14t --script
# /// script
# requires-python = "==3.14.6"
# dependencies = [
# "flask==3.1.3",
# "werkzeug==3.1.8",
# "playwright==1.60.0",
# "greenlet==3.5.3",
# "pytest==9.1.1",
# ]
# ///
"""Minimal reproducer: greenlet 3.5.3 SIGSEGVs under free-threaded CPython 3.14t.
Playwright's *sync* API bridges sync->async via greenlet. On no-GIL CPython,
driving it against a threaded Flask server that does per-request cyclic-object
churn crashes the interpreter:
Fatal Python error: Segmentation fault (fault PC: _Py_HandlePending)
or intermittently `ValueError: <ContextVar 'flask.app_ctx'> was created in a
different Context` -- the same root cause.
ROOT CAUSE: the free-threaded stop-the-world GC fires while a Playwright greenlet
thread is mid-stack-switch; greenlet's saved C-stack/tstate bounds are inconsistent
at that instant, so the STW collector faults walking the parked thread. It is a
probabilistic race between the GC rate (driven by `_heavy_object_churn`) and the
greenlet-switch rate (driven by the sync API). Kill either rate and it stops.
It is NOT a no-GIL data race in our code: it also crashes with PYTHON_GIL=1, so the
breakage is in the cp314t-ABI greenlet, not the runtime GIL state. The fix is to
run Playwright e2e on a non-freethreaded cpython-3.14.
HOW TO RUN (gated off by default since it crashes the interpreter):
uv run --python 3.14t --script test_greenlet_ft_repro.py
(or `./test_greenlet_ft_repro.py` once chmod +x -- the shebang bakes in
`--python 3.14t --script`). First time, install Firefox once:
`uvx --from playwright==1.60.0 playwright install firefox`. Expected: SIGSEGV
(exit 139). REPRO_ITERS overrides the cycle count.
"""
import itertools
import os
import socket
import sys
import threading
from collections.abc import Generator
import pytest
from flask import Flask, Response
from playwright.sync_api import Page, sync_playwright
from werkzeug.serving import make_server
_ITERS = int(os.environ.get("REPRO_ITERS", "150"))
_STATIC_HTML = (
'<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">'
"<title>repro</title></head><body><h1>static</h1></body></html>"
)
_CHURN_ROUNDS = 10
_CHURN_SIZE = 500
def _heavy_object_churn() -> None:
"""Allocate + discard a cyclic object graph so gen-2 STW GC keeps firing."""
for _ in range(_CHURN_ROUNDS):
nodes: list[dict[str, object]] = [
{"i": i, "p": [i, str(i), (i, i, i)], "next": None} for i in range(_CHURN_SIZE)
]
for a, b in itertools.pairwise(nodes):
a["next"] = b
nodes[-1]["next"] = nodes[0] # close the cycle
del nodes
def _make_repro_app() -> Flask:
app = Flask(__name__)
@app.get("/e/<int:project_id>/<int:video_id>/")
def page(project_id: int, video_id: int) -> Response:
_heavy_object_churn()
return Response(_STATIC_HTML, mimetype="text/html")
return app
@pytest.fixture
def repro_server() -> Generator[str, None, None]:
"""Run `_make_repro_app` on an in-process threaded werkzeug server."""
app = _make_repro_app()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
host, port = sock.getsockname()
sock.close() # make_server re-binds with SO_REUSEADDR; no EADDRINUSE on the freed port
server = make_server(host, port, app, threaded=True)
thread = threading.Thread(target=server.serve_forever, name="repro-live-server", daemon=True)
thread.start()
try:
yield f"http://{host}:{port}"
finally:
server.shutdown()
thread.join(timeout=5)
server.server_close()
@pytest.fixture
def page2() -> Generator[Page, None, None]:
"""Launch Firefox via Playwright's sync (greenlet) API -- the bridge under test."""
with sync_playwright() as p:
browser = p.firefox.launch()
context = browser.new_context()
page = context.new_page()
page.set_default_navigation_timeout(20_000)
page.set_default_timeout(15_000)
try:
yield page
finally:
context.close()
browser.close()
def test_greenlet_ft_crash(repro_server: str, page2: Page) -> None:
url = f"{repro_server}/e/1/1/"
for i in range(_ITERS):
page2.goto(url, wait_until="load")
# Poll the DOM to drive Playwright's sync greenlet bridge; no playback.
for _ in range(8):
page2.evaluate("() => document.title")
page2.wait_for_timeout(25)
page2.goto("about:blank")
print(f"iter {i} ok", flush=True)
if __name__ == "__main__":
# Drive the test via pytest.main so the fixtures/marks stay a normal test module.
# --noconftest + -c os.devnull keep the run self-contained (no project conftest/addopts).
sys.exit(pytest.main([__file__, "--noconftest", "-c", os.devnull, "-p", "no:randomly", "-x", "-s"]))
❯ uv run --python 3.14.6t --script test_greenlet_ft_repro.py
Installed 16 packages in 9ms
==================================================================================================================================================================== test session starts ====================================================================================================================================================================
platform linux -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
rootdir: /dev
configfile: null
collected 1 item
../../../../../dev iter 0 ok
iter 1 ok
iter 2 ok
iter 3 ok
Fatal Python error: Segmentation fault
<Cannot show all threads while the GIL is disabled>
Stack (most recent call first):
File "/home/guru/Desktop/test_greenlet_ft_repro.py", line 68 in _heavy_object_churn
File "/home/guru/Desktop/test_greenlet_ft_repro.py", line 81 in page
File "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/lib/python3.14t/site-packages/flask/app.py", line 902 in dispatch_request
File "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/lib/python3.14t/site-packages/flask/app.py", line 917 in full_dispatch_request
File "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/lib/python3.14t/site-packages/flask/app.py", line 1511 in wsgi_app
File "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/lib/python3.14t/site-packages/flask/app.py", line 1536 in __call__
File "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/lib/python3.14t/site-packages/werkzeug/serving.py", line 332 in execute
File "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/lib/python3.14t/site-packages/werkzeug/serving.py", line 371 in run_wsgi
File "/home/guru/.local/share/uv/python/cpython-3.14.6+freethreaded-linux-x86_64-gnu/lib/python3.14t/http/server.py", line 484 in handle_one_request
File "/home/guru/.local/share/uv/python/cpython-3.14.6+freethreaded-linux-x86_64-gnu/lib/python3.14t/http/server.py", line 496 in handle
File "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/lib/python3.14t/site-packages/werkzeug/serving.py", line 399 in handle
File "/home/guru/.local/share/uv/python/cpython-3.14.6+freethreaded-linux-x86_64-gnu/lib/python3.14t/socketserver.py", line 766 in __init__
File "__init__", line ??? in __init__
<invalid frame>
Current thread's C stack trace (most recent call first):
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python", at _Py_DumpStack+0x30 [0x4a7ae0]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x533a9b]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x53393f]
Binary file "/lib/x86_64-linux-gnu/libc.so.6", at +0x42520 [0x7b3a31842520]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1cdf0bf]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python", at _Py_HandlePending+0xd5 [0x1b456a1]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1cdb8cf]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1a151f2]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1af9c6a]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1af9b9b]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1a09930]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1a151f2]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1a149d0]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1a11914]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1a151f2]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1a14a71]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1b5e688]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1a64658]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1a151f2]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1a14a71]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1ae14ed]
Binary file "/home/guru/.cache/uv/environments-v2/test-greenlet-ft-repro-343027ea349605ed/bin/python" [0x1ae1472]
Binary file "/lib/x86_64-linux-gnu/libc.so.6", at +0x94ac3 [0x7b3a31894ac3]
Binary file "/lib/x86_64-linux-gnu/libc.so.6", at +0x1268d0 [0x7b3a319268d0]
Extension modules: markupsafe._speedups, greenlet._greenlet (total: 2)
This is a bit more complex, I had issue making it smaller.
What do you think?