From 22e2dff937085266ac439edc1338c9ecbbaf4ff8 Mon Sep 17 00:00:00 2001 From: DABH Date: Thu, 10 Sep 2026 12:12:29 -0500 Subject: [PATCH 1/3] Bound full GC passes in tests to one test's allocations tests/contrib/langgraph/test_replay.py tripped the workflow deadlock detector on the 3.10 macOS runner while the first activation was in StateGraph.compile(); the interrupt landed in a weakref callback inside ast.parse, i.e. during a cyclic GC pass. That activation does 3-8 ms of work and imports nothing into the sandbox. The test process heap grows to ~1.7M objects and a gen2 pass over it (160-290 ms locally, several times that on a 3-core runner shared by three xdist workers) runs on whichever thread allocates, so first activations occasionally absorb it inside the 2 s budget. Freezing each test's survivors at teardown keeps later passes to one test's allocations: on tests/contrib (3.10, -n 3) the largest GC pause inside an activation dropped from 291 ms to 20 ms and wall time from 48 s to 33 s. The replay test also asserts nothing is imported into the sandbox after initial workflow load, matching the OpenAI Agents replay test. --- tests/conftest.py | 8 ++++++++ tests/contrib/langgraph/test_replay.py | 20 ++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3c02d8d18..f0bb548f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import asyncio +import gc import multiprocessing.context import os import sys @@ -58,6 +59,13 @@ def pytest_runtest_setup(item): # type: ignore[reportMissingParameterType] print() +@pytest.hookimpl(trylast=True) +def pytest_runtest_teardown() -> None: + # Freeze survivors so later full GC passes only cover one test's allocations + gc.collect() + gc.freeze() + + def pytest_addoption(parser): # type: ignore[reportMissingParameterType] parser.addoption( "-E", diff --git a/tests/contrib/langgraph/test_replay.py b/tests/contrib/langgraph/test_replay.py index f5d1a8e92..3c8880093 100644 --- a/tests/contrib/langgraph/test_replay.py +++ b/tests/contrib/langgraph/test_replay.py @@ -1,4 +1,5 @@ import sys +import warnings from datetime import timedelta from uuid import uuid4 @@ -52,10 +53,21 @@ async def test_replay(client: Client): ) await handle.result() - await Replayer( - workflows=[TwoNodesWorkflow], - plugins=[plugin], - ).replay_workflow(await handle.fetch_history()) + with warnings.catch_warnings(record=True) as recorder: + warnings.filterwarnings( + "always", message=r"Module .* was imported after initial workflow load" + ) + await Replayer( + workflows=[TwoNodesWorkflow], + plugins=[plugin], + ).replay_workflow(await handle.fetch_history()) + + # Sandbox imports during an activation count toward the deadlock timeout + assert not [ + str(w.message) + for w in recorder + if "was imported after initial workflow load" in str(w.message) + ] @pytest.mark.skipif( From 764aa2b612e99e8b9fa899182f544c19918d8966 Mon Sep 17 00:00:00 2001 From: DABH Date: Thu, 10 Sep 2026 14:28:49 -0500 Subject: [PATCH 2/3] Freeze the heap once after collection instead of after every test Freezing after every test made each test's surviving objects permanent: running tests/worker/test_workflow.py serially, RSS grew from 77 MB to 3.6 GB over 229 tests versus a 490 MB plateau without the hook, and on CI the 2-core ubuntu-arm runners died mid-run with "runner has received a shutdown signal" on every attempt. A single freeze after collection keeps the import-time heap out of every later full pass without accumulating anything: RSS plateaus at 390 MB and a full gc.collect() per test drops from 70 ms mean / 179 ms max to 37 ms / 122 ms on the same subset. --- tests/conftest.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f0bb548f6..e008bc0fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,9 +59,8 @@ def pytest_runtest_setup(item): # type: ignore[reportMissingParameterType] print() -@pytest.hookimpl(trylast=True) -def pytest_runtest_teardown() -> None: - # Freeze survivors so later full GC passes only cover one test's allocations +def pytest_collection_finish(session: pytest.Session) -> None: + # Freeze the import-time heap once so full GC passes only cover test allocations gc.collect() gc.freeze() From 4745a6e46cff636eea032be34c052662022afa68 Mon Sep 17 00:00:00 2001 From: DABH Date: Thu, 10 Sep 2026 15:04:52 -0500 Subject: [PATCH 3/3] Drop the unused session parameter from the collection hook --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index e008bc0fb..d3be0ce86 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,7 +59,7 @@ def pytest_runtest_setup(item): # type: ignore[reportMissingParameterType] print() -def pytest_collection_finish(session: pytest.Session) -> None: +def pytest_collection_finish() -> None: # Freeze the import-time heap once so full GC passes only cover test allocations gc.collect() gc.freeze()