Severity: Enhancement (callers with strict cleanup contracts cannot observe native descriptor close failures after release)
Summary
UnixFileLock._release() intentionally suppresses os.close() errors after a
successful native unlock. That default is understandable for compatibility,
especially because filelock already has a regression test preserving the
FUSE/Docker bind-mount EIO case.
I would like an opt-in release cleanup policy for callers that need close
failures to be reported as cleanup debt after ownership has been released. This
is separate from native unlock failure handling: once flock(fd, LOCK_UN)
succeeds, the object can truthfully publish an unlocked state, but callers with
strict durability or cleanup contracts may still need to know that descriptor
close failed.
Environment
- package/version:
filelock 3.29.7
- source: commit
1efb8932c08789deb08ad93a91d8996cc36bb9cc
- reproduced platform: macOS, local APFS filesystem with fault injection
- affected APIs: Unix native sync and async file locks
- related issue: this complements, but does not replace, the native release
state-loss report
Reproduction
-
Run the attached filelock_release_close_error_policy.py with
filelock==3.29.7 on a Unix-like platform.
-
Observe:
filelock_version=3.29.7
injected_close_error=True
release_error=None
lock_is_locked=False
stored_descriptor=None
The script injects an OSError(EIO) from os.close() after the real close has
run. release() returns without surfacing the cleanup failure, and the lock
object reports that it is no longer locked.
Observed vs expected
Observed:
Post-unlock descriptor close failure is always suppressed by the Unix native
backend.
Expected:
The compatibility default may keep suppressing close failures, but callers can
opt into a documented policy that reports close failures as post-release cleanup
errors without falsely retaining lock ownership.
Root cause (verified in source, 3.29.7)
UnixFileLock._release()
clears lock_file_fd, unlocks with fcntl.flock(fd, LOCK_UN), and then wraps
os.close(fd) in suppress(OSError).
BaseFileLock.release()
does not provide a caller-visible cleanup-error policy.
The current behavior is deliberate. The public test
test_release_suppresses_eio_on_close
injects OSError(EIO, "Input/output error") from os.close() and asserts that
release completes.
Python documents os.close(fd) as the low-level descriptor close operation, and
the os module documents that OS-level failures raise OSError.
Suggested fix
Add a keyword-only release cleanup policy while preserving the current default.
For example:
FileLock(path, release_close_error_policy="suppress") # current default
FileLock(path, release_close_error_policy="raise")
Suitable designs include:
suppress: current behavior, preserving compatibility and the existing
FUSE/Docker bind-mount regression test.
raise: surface a filelock-specific cleanup exception after the lock has
been released and the object state has committed to unlocked.
collect: store the cleanup exception on a documented result, callback, or
diagnostics field without changing default control flow.
The important invariants are:
- A failed native unlock remains a release failure and must not be confused
with a post-unlock close failure.
- A close failure after successful native unlock must not falsely keep
is_locked == True.
- Context managers should compose this policy with the dual-failure contract
if both the protected body and cleanup fail.
- Async wrappers should preserve the same policy as sync release.
- The default remains backward compatible unless maintainers intentionally
choose a breaking change.
Verification
- Keep the existing
test_release_suppresses_eio_on_close behavior for the
default policy.
- Add a
release_close_error_policy="raise" test that injects OSError(EIO)
from os.close() and verifies the cleanup exception is visible after native
unlock succeeds.
- Verify
is_locked == False after a close-only failure when the native unlock
succeeded.
- Verify a native
LOCK_UN failure still follows the transactional release
state contract and is not downgraded to cleanup debt.
- Verify direct context-manager, acquire-proxy context-manager, and async
behavior use the same policy.
I did not find an existing issue or PR requesting a caller-selectable native
close-error policy. The existing test records the compatibility default, not an
opt-in strict cleanup contract.
Current workaround
Callers can flush and fsync any holder metadata before release, then accept
that filelock suppresses Unix native close failures. There is no public API for
observing that suppressed close error today.
Reproduction script
filelock_release_close_error_policy.py
"""Show that UnixFileLock suppresses close errors after native unlock."""
from __future__ import annotations
import errno
import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
import filelock
from filelock import FileLock
from filelock import _unix as unix_backend
def main() -> int:
if sys.platform == "win32":
print("This reproduction targets UnixFileLock.")
return 2
real_close = os.close
with tempfile.TemporaryDirectory() as directory:
lock = FileLock(Path(directory) / "resource.lock", timeout=0)
lock.acquire()
fd = lock._context.lock_file_fd # noqa: SLF001 - inspect demonstrated state
assert fd is not None
injected_close_error = False
def close_with_eio(candidate_fd: int) -> None:
nonlocal injected_close_error
real_close(candidate_fd)
if candidate_fd == fd:
injected_close_error = True
raise OSError(errno.EIO, "injected close failure")
release_error: BaseException | None = None
with patch.object(unix_backend.os, "close", side_effect=close_with_eio):
try:
lock.release()
except BaseException as exc: # capture what callers receive
release_error = exc
print(f"filelock_version={filelock.__version__}")
print(f"injected_close_error={injected_close_error}")
print(f"release_error={type(release_error).__name__ if release_error else None}")
print(f"lock_is_locked={lock.is_locked}")
print(f"stored_descriptor={lock._context.lock_file_fd!r}") # noqa: SLF001
reproduced = (
injected_close_error
and release_error is None
and not lock.is_locked
and lock._context.lock_file_fd is None # noqa: SLF001
)
return 0 if reproduced else 1
if __name__ == "__main__":
raise SystemExit(main())
Severity: Enhancement (callers with strict cleanup contracts cannot observe native descriptor close failures after release)
Summary
UnixFileLock._release()intentionally suppressesos.close()errors after asuccessful native unlock. That default is understandable for compatibility,
especially because filelock already has a regression test preserving the
FUSE/Docker bind-mount
EIOcase.I would like an opt-in release cleanup policy for callers that need close
failures to be reported as cleanup debt after ownership has been released. This
is separate from native unlock failure handling: once
flock(fd, LOCK_UN)succeeds, the object can truthfully publish an unlocked state, but callers with
strict durability or cleanup contracts may still need to know that descriptor
close failed.
Environment
filelock 3.29.71efb8932c08789deb08ad93a91d8996cc36bb9ccstate-loss report
Reproduction
Run the attached
filelock_release_close_error_policy.pywithfilelock==3.29.7on a Unix-like platform.Observe:
The script injects an
OSError(EIO)fromos.close()after the real close hasrun.
release()returns without surfacing the cleanup failure, and the lockobject reports that it is no longer locked.
Observed vs expected
Observed:
Expected:
Root cause (verified in source, 3.29.7)
UnixFileLock._release()clears
lock_file_fd, unlocks withfcntl.flock(fd, LOCK_UN), and then wrapsos.close(fd)insuppress(OSError).BaseFileLock.release()does not provide a caller-visible cleanup-error policy.
The current behavior is deliberate. The public test
test_release_suppresses_eio_on_closeinjects
OSError(EIO, "Input/output error")fromos.close()and asserts thatrelease completes.
Python documents
os.close(fd)as the low-level descriptor close operation, andthe
osmodule documents that OS-level failures raiseOSError.Suggested fix
Add a keyword-only release cleanup policy while preserving the current default.
For example:
Suitable designs include:
suppress: current behavior, preserving compatibility and the existingFUSE/Docker bind-mount regression test.
raise: surface a filelock-specific cleanup exception after the lock hasbeen released and the object state has committed to unlocked.
collect: store the cleanup exception on a documented result, callback, ordiagnostics field without changing default control flow.
The important invariants are:
with a post-unlock close failure.
is_locked == True.if both the protected body and cleanup fail.
choose a breaking change.
Verification
test_release_suppresses_eio_on_closebehavior for thedefault policy.
release_close_error_policy="raise"test that injectsOSError(EIO)from
os.close()and verifies the cleanup exception is visible after nativeunlock succeeds.
is_locked == Falseafter a close-only failure when the native unlocksucceeded.
LOCK_UNfailure still follows the transactional releasestate contract and is not downgraded to cleanup debt.
behavior use the same policy.
I did not find an existing issue or PR requesting a caller-selectable native
close-error policy. The existing test records the compatibility default, not an
opt-in strict cleanup contract.
Current workaround
Callers can flush and
fsyncany holder metadata before release, then acceptthat filelock suppresses Unix native close failures. There is no public API for
observing that suppressed close error today.
Reproduction script
filelock_release_close_error_policy.py