You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Files are held open past their scope: 11 tests fail on any non-refcounting interpreter (#516's class, new instances) #701
The workflow/resume path holds file handles open past their scope and relies on CPython's refcounting to close them. On CPython this is invisible — the handle drops the moment the last reference goes. On any implementation that defers finalisation to GC it is a real leak, and eleven tests fail.
This is the class #516 named — "the store depends on CPython refcounting to commit" — in different places. #516 fixed one instance; these are more.
How it was found
Standing PyPy up to measure #173. PyPy is not being adopted (see that issue — it is slower for the CLI and only ~1.7–2.2x for long runs), but it works as a detector: it makes deferred finalisation visible, and deferred finalisation is what a refcounting dependency looks like from the outside.
Reproduction
PyPy 7.3.23 / Python 3.11.15, tests/ run in chunks:
PermissionError: [WinError 32] The process cannot access the file because
it is being used by another process:
'C:\Users\...\Temp\tmpccvpe4mi\demo.nd'
at tempfile.py:893 in onerror -> _os.unlink(path)
The failing set (11), all in one chunk:
test_nodus_workflow_framework.py::WorkflowFrameworkCompatibilityTests
::test_rehydrate_skips_dead_lettered_runs
::test_resume_clears_wait_registration
::test_resume_workflow_updates_resume_count
::test_run_inventory_returns_scoped_counts_and_pagination
::test_run_inventory_supports_retry_wait_time_and_cursor_filters
::test_run_workflow_records_completed_framework_run
(and others in the same class)
test_resume_topology_validation.py::TopologyValidationTests
::test_legacy_run_with_inserted_step_refuses_with_real_cause
Single test in isolation, PyPy: fails in 12.9s with the WinError 32 above.
This is not a chunking artifact — controlled
The identical chunk was run on CPython 3.11.9: 782 passed, 0 failed. Two other failures in a different chunk were confirmed to be ordering artifacts by the same control and are excluded from the list above. The 11 are PyPy-only and real.
Expected behavior
Every file the runtime opens should be closed deterministically — a with block, an explicit close(), or an owner with a documented lifecycle. Correctness should not depend on the interpreter's reclamation strategy.
Fix direction
The holder is not yet identified, and an AST sweep for the obvious shape did not find it. Scanning src/ for open( / io.open( / X.open( calls not directly in a with yields only four sites, and all four are deliberate or correctly closed:
site
verdict
tooling/runner.py:77
trace file, intentionally held for the run
builtins/subprocess_module.py:127,128
redirect targets handed to the child
orchestration/task_graph.py:258
raw os.open, closed in a finally
So the handle is reached some other way — pathlib.Path.open(), a file object stored on an object and freed only with it, a handle captured in a reference cycle, or a child process that has not exited. On Windows WinError 32 covers all of those, which is why the message alone does not localise it.
Suggested approach, in order:
Reproduce under PyPy with gc.collect() forced before tempdir teardown — if the failure disappears, it is a cycle or a deferred __del__, which narrows it to an object holding the handle rather than a live child process.
Instrument open/Path.open in the failing test to record a traceback per handle, and dump the ones still open at teardown.
Fix the owner, then re-run the same chunk under PyPy as the regression check — CPython cannot detect this class, which is the whole point.
Do not fix this by making the tests clean up harder. The tests are correct; a runtime that leaks a handle for a file it read is the defect, and it is the same defect on CPython — just unobservable there.
Why it is worth fixing despite PyPy not being adopted
It is a latent resource leak on CPython too: a long-running embedded host that loads many modules holds every one of those handles until the GC happens to run. On Windows that also blocks the file from being replaced or deleted.
#516 establishes the precedent that this class is a real bug, not a portability nicety.
Correction: I filed this wrong. It was one line, and it was in the test.
The cause is a bare open() in tests/test_nodus_workflow_framework.py:
code=open(path, "r", encoding="utf-8").read()
CPython drops the refcount the instant .read() returns and closes the file; PyPy defers to GC, so the handle is still open at TemporaryDirectory cleanup and Windows refuses. Closing it turns 10 failed / 24 passed into 34 passed under PyPy, with CPython unaffected. Fixed in #703.
This issue's central claim was false:
Do not fix this by making the tests clean up harder. The tests are correct; a runtime that leaks a handle for a file it read is the defect, and it is the same defect on CPython — just unobservable there.
The runtime was not leaking. There is no latent CPython defect here. The paragraph in this issue arguing "it is a latent resource leak on CPython too" describes something that does not exist.
How I got there, since the mistake is more reusable than the bug: my AST sweep for unclosed open() covered src/ and not tests/. It found four sites in product code, I checked each, all four were deliberate or correctly closed — and I read that empty result as evidence the leak was somewhere subtler in the runtime (a Path.open, a reference cycle, a live child process), and wrote that inference into the issue as if it were a finding. It was one line in a file I had already opened to read the repro.
The lesson: when a scan comes up empty, widen the scan before widening the theory. An empty result from searching the wrong directory is indistinguishable from a hard problem, and it invites exactly the elaborate hypothesis I reached for.
Also worth correcting: this issue claimed 11 failures of one kind. There were two kinds. Ten were the handle. The eleventh — test_resume_topology_validation — is a different mode entirely, survives the fix, and is now #704: a resume that should be refused runs to completion under PyPy, 3/3 standalone. That one is real and unexplained, and it is the finding worth keeping from this whole thread.
Closing as mis-diagnosed. The real work moved to #703 (merged fix) and #704 (open).
Summary
The workflow/resume path holds file handles open past their scope and relies on CPython's refcounting to close them. On CPython this is invisible — the handle drops the moment the last reference goes. On any implementation that defers finalisation to GC it is a real leak, and eleven tests fail.
This is the class #516 named — "the store depends on CPython refcounting to commit" — in different places. #516 fixed one instance; these are more.
How it was found
Standing PyPy up to measure #173. PyPy is not being adopted (see that issue — it is slower for the CLI and only ~1.7–2.2x for long runs), but it works as a detector: it makes deferred finalisation visible, and deferred finalisation is what a refcounting dependency looks like from the outside.
Reproduction
PyPy 7.3.23 / Python 3.11.15,
tests/run in chunks:The failing set (11), all in one chunk:
Single test in isolation, PyPy: fails in 12.9s with the
WinError 32above.This is not a chunking artifact — controlled
The identical chunk was run on CPython 3.11.9: 782 passed, 0 failed. Two other failures in a different chunk were confirmed to be ordering artifacts by the same control and are excluded from the list above. The 11 are PyPy-only and real.
Expected behavior
Every file the runtime opens should be closed deterministically — a
withblock, an explicitclose(), or an owner with a documented lifecycle. Correctness should not depend on the interpreter's reclamation strategy.Fix direction
The holder is not yet identified, and an AST sweep for the obvious shape did not find it. Scanning
src/foropen(/io.open(/X.open(calls not directly in awithyields only four sites, and all four are deliberate or correctly closed:tooling/runner.py:77builtins/subprocess_module.py:127,128orchestration/task_graph.py:258os.open, closed in afinallySo the handle is reached some other way —
pathlib.Path.open(), a file object stored on an object and freed only with it, a handle captured in a reference cycle, or a child process that has not exited. On WindowsWinError 32covers all of those, which is why the message alone does not localise it.Suggested approach, in order:
gc.collect()forced before tempdir teardown — if the failure disappears, it is a cycle or a deferred__del__, which narrows it to an object holding the handle rather than a live child process.open/Path.openin the failing test to record a traceback per handle, and dump the ones still open at teardown.Do not fix this by making the tests clean up harder. The tests are correct; a runtime that leaks a handle for a file it read is the defect, and it is the same defect on CPython — just unobservable there.
Why it is worth fixing despite PyPy not being adopted
#516establishes the precedent that this class is a real bug, not a portability nicety.Affected versions
Confirmed on
mainat 5.8.0+. Almost certainly present since the workflow framework was added.