Skip to content

Files are held open past their scope: 11 tests fail on any non-refcounting interpreter (#516's class, new instances) #701

Description

@Masterplanner25

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:

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:

  1. 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.
  2. Instrument open/Path.open in the failing test to record a traceback per handle, and dump the ones still open at teardown.
  3. 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.
  • It keeps the door open on LIMITS-001: runtime throughput is the bootstrapping blocker — ~650-850K instr/sec, no JIT #173's remaining directions: any future non-CPython or free-threaded runtime hits this immediately.

Affected versions

Confirmed on main at 5.8.0+. Almost certainly present since the workflow framework was added.

Activity

  1. Masterplanner25 commented on Aug 31, 2026

    @Masterplanner25
    OwnerAuthor

    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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions