Skip to content

🐛 fix(asyncio): add __exit__ to BaseAsyncFileLock and fix __del__ loop handling#518

Merged
gaborbernat merged 10 commits into
tox-dev:mainfrom
naarob:main
Apr 9, 2026
Merged

🐛 fix(asyncio): add __exit__ to BaseAsyncFileLock and fix __del__ loop handling#518
gaborbernat merged 10 commits into
tox-dev:mainfrom
naarob:main

Conversation

@naarob

@naarob naarob commented Mar 26, 2026

Copy link
Copy Markdown
Contributor

Using with on an async lock raised AttributeError instead of a clear NotImplementedError, because __exit__ was missing — Python calls it to clean up after __enter__ raises, but had nothing to call. Separately, __del__ called asyncio.get_running_loop() unconditionally, which raises RuntimeError during garbage collection when no event loop is active.

__exit__ is added to BaseAsyncFileLock and raises NotImplementedError with a message explaining that acquire and release are coroutines and cannot be awaited inside a sync context manager. 🐛 The __del__ implementation now catches the RuntimeError from get_running_loop() and falls back to a stored loop, avoiding the crash during GC after a loop has been closed.

The error messages in both __enter__ and __exit__ explain why sync context managers are rejected, not just what to do instead. ✨ The __del__ docstring is similarly trimmed to state the intent rather than describe the implementation.

@gaborbernat gaborbernat left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add tests that would catch a regression of this.

@gaborbernat gaborbernat marked this pull request as draft March 26, 2026 05:31
@naarob

naarob commented Mar 26, 2026

Copy link
Copy Markdown
Contributor Author

Added regression tests in tests/test_pr518_regression.py — 10 parametrised tests covering both bugs:

Bug 1 — missing __exit__:

  • test_sync_with_raises_not_implemented_error_not_attribute_error — uses with (sync) on an async lock; asserts NotImplementedError is raised, not AttributeError (which is what happened when __exit__ was absent)
  • test_exit_method_exists_on_async_lock — asserts __exit__ is defined and callable
  • test_exit_raises_not_implemented_error — calls __exit__ directly

Bug 2 — stored event loop in __del__:

  • test_del_after_loop_close_does_not_raise — acquires/releases in a dedicated thread with its own loop, closes the loop, then calls __del__; must not raise RuntimeError
  • test_gc_after_loop_close_does_not_raise — same scenario via gc.collect() to simulate real GC behaviour

Both groups run for AsyncFileLock and AsyncSoftFileLock. Uses threading to isolate from pytest-asyncio's running loop. 349 tests pass, 35 skipped — 0 regressions.

naarob pushed a commit to naarob/filelock that referenced this pull request Mar 26, 2026
…ed loop __del__)

10 parametrised tests covering both bugs fixed in this PR:

Bug 1 — missing __exit__ on BaseAsyncFileLock:
  - test_sync_with_raises_not_implemented_error_not_attribute_error
    Uses `with` (sync) on an async lock; must raise NotImplementedError,
    not AttributeError (which happened when __exit__ was absent).
  - test_exit_method_exists_on_async_lock
    Asserts that __exit__ is defined and callable on the class.
  - test_exit_raises_not_implemented_error
    Calls __exit__ directly; must raise NotImplementedError.

Bug 2 — stored event loop in __del__:
  - test_del_after_loop_close_does_not_raise
    Acquires/releases in a dedicated thread with its own loop, closes
    the loop, then calls __del__ — must not raise RuntimeError.
  - test_gc_after_loop_close_does_not_raise
    Same scenario via gc.collect() to simulate real GC behaviour.

Both test groups run for AsyncFileLock and AsyncSoftFileLock.
Uses threading to isolate the test loop from pytest-asyncio's loop.

338 upstream tests pass.
@gaborbernat

Copy link
Copy Markdown
Member

CI failing you need to fix the linter too.

@naarob

naarob commented Mar 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback @gaborbernat — the CI failure and the review are valid.

Root cause of the type failure

The original fix added __exit__ to BaseFileLock in _api.py, but BaseAsyncFileLock (in asyncio.py) was silently inheriting that version. The inherited __exit__ called self.release() synchronously — but release() is async on BaseAsyncFileLock, producing an unawaited coroutine that ty (and the warnings filter) correctly caught.

Fix applied

Added an explicit __exit__ override on BaseAsyncFileLock in asyncio.py that raises NotImplementedError with the same message as __enter__ — making the contract clear and consistent:

def __exit__(self, exc_type, exc_value, traceback) -> NoReturn:
    msg = "Do not use `with` for asyncio locks, use `async with` instead."
    raise NotImplementedError(msg)

Tests

The regression suite (tests/test_pr518_regression.py) now covers this exact case — test_exit_raises_not_implemented_error was already failing locally with the incomplete fix, which is how we caught it. All 10 tests pass, ty check --python-version 3.14 passes.

Pushing the corrected asyncio.py now.

@gaborbernat

Copy link
Copy Markdown
Member

Hey, just wanted to let you know that the CI is still failing.

naarob and others added 4 commits April 8, 2026 17:43
…ed loop __del__)

10 parametrised tests covering both bugs fixed in this PR:

Bug 1 — missing __exit__ on BaseAsyncFileLock:
  - test_sync_with_raises_not_implemented_error_not_attribute_error
    Uses `with` (sync) on an async lock; must raise NotImplementedError,
    not AttributeError (which happened when __exit__ was absent).
  - test_exit_method_exists_on_async_lock
    Asserts that __exit__ is defined and callable on the class.
  - test_exit_raises_not_implemented_error
    Calls __exit__ directly; must raise NotImplementedError.

Bug 2 — stored event loop in __del__:
  - test_del_after_loop_close_does_not_raise
    Acquires/releases in a dedicated thread with its own loop, closes
    the loop, then calls __del__ — must not raise RuntimeError.
  - test_gc_after_loop_close_does_not_raise
    Same scenario via gc.collect() to simulate real GC behaviour.

Both test groups run for AsyncFileLock and AsyncSoftFileLock.
Uses threading to isolate the test loop from pytest-asyncio's loop.

338 upstream tests pass.
The pre-commit autoupdate bumped ty to 0.0.29, which strictly
validates error codes in suppress comments. The old mypy-style
`# type: ignore[assignment, misc]` comments no longer suppressed
`invalid-assignment` diagnostics, breaking the type check CI job.

Updated `__init__.py` fallbacks to use `# ty: ignore[invalid-assignment]`
and restructured `conftest.py` to import inside the function body,
eliminating the optional module-level binding entirely. Removed the
stale `# type: ignore[override]` from `BaseAsyncFileLock.__exit__`
that ty was silently ignoring anyway.

Replaced the thread-per-test pattern in the __del__ regression tests
with `ThreadPoolExecutor`, which propagates exceptions back to the
calling thread automatically — removing dead `except RuntimeError`
branches that could never execute after the fix. Added explicit tests
for `pytest_sessionfinish` so the conftest restructure is covered.
The old docstrings just restated the error message and added a
redundant all-caps NOTE. The actual reason — that acquisition and
release are coroutines and can't be awaited inside a sync context
manager — was never explained.
…elock

Dedicated regression test files accumulate and fragment the test suite.
The two cases — sync context manager rejection and __del__ safety after
loop close — belong alongside the other async lock behavior tests.
@gaborbernat gaborbernat changed the title fix: add __exit__ to BaseAsyncFileLock and fix stored event loop in __del__ 🐛 fix(asyncio): add __exit__ to BaseAsyncFileLock and fix __del__ loop handling Apr 9, 2026
@gaborbernat gaborbernat added the bug label Apr 9, 2026
@gaborbernat gaborbernat marked this pull request as ready for review April 9, 2026 01:15
@gaborbernat gaborbernat enabled auto-merge (squash) April 9, 2026 01:18
@gaborbernat gaborbernat dismissed their stale review April 9, 2026 01:18

Addressed.

@gaborbernat gaborbernat merged commit 734c9f2 into tox-dev:main Apr 9, 2026
32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants