Prevent LocalDiscovery teardown from crashing on shared-memory double-unlink — Closes #291#302
Merged
conradbzura merged 4 commits intoJul 14, 2026
Conversation
Unlinking a shared-memory segment that another process had already removed raised FileNotFoundError out of the owner's context exit, aborting pool teardown, and the raise skipped the atexit unregister so the fallback fired a second FileNotFoundError at interpreter shutdown. The failure is reliable under rapid same-namespace pool teardown and respawn, where a dying process's multiprocessing resource tracker unlinks the deterministically named segment out from under the owner. Owner exit now disarms the atexit fallback before unlinking and treats a missing segment as a no-op, the fallback closure suppresses the same error at shutdown, and the publisher block finalizer applies the same disarm-before-unlink ordering so a failed unlink can never leave a fallback armed.
Add regression tests for the double-unlink crash and the atexit fallback double-fire, then pin the surrounding teardown contracts: owner exit unlinks the segment, exceptional exit still tears down, non-owners neither arm fallbacks nor unlink, namespaces stay reusable across rapid enter/exit cycles, and overlapping same-namespace generations unwind cleanly. Recording atexit wrappers pin the disarm-before-unlink ordering for both the owner exit and the publisher block finalizer, where an unsuppressed unlink error is the only observable seam. Hypothesis properties generalize the regression cases across arbitrary lifecycle interleavings, vanishing-segment masks, and publisher add/drop sequences. Reword two overclaiming test docstrings to match what their bodies assert.
Exercise the reported reproduction shapes end to end with real worker pools: rapid same-namespace teardown and respawn, LIFO overlap, and owner-first exit with a post-overlap respawn. A cross-process test proves the genuine failure mechanism by letting an independent interpreter's resource tracker unlink the shared segment and asserting the owner still exits cleanly through real interpreter shutdown. A strict xfail pins the known surviving-pool worker leak so the suite flags its fix loudly when it lands.
A leaked-context owner interpreter shuts down with the atexit fallback still armed after an independent attacher's resource tracker unlinked the segment. The pre-fix bare lambda leaves an atexit traceback on stderr at shutdown; the suppressing closure shuts down silently. This is the first behavioral pin for the fallback's own suppression, which coverage cannot see because it runs in an unmeasured interpreter.
2dca9a2 to
c4c6faf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Prevent
LocalDiscoveryteardown from crashing the interpreter when a shared-memory segment is unlinked twice.The owner's
__exit__unlinked its segment unguarded, so a segment already reclaimed by another process's resource tracker raisedFileNotFoundErrorout of teardown — and becauseatexit.unregisterran after that raising unlink, the shutdown fallback stayed armed and fired a second unlink at interpreter exit. Two bugs compounding: the first crashed the caller's teardown, the second crashed the interpreter.Fix the ordering (disarm the fallback before unlinking, so a failed unlink can never leave it armed) and route every unlink in the module through a single
_unlink_quietlyhelper. The module previously carried four "unlink and tolerate failure" sites under two different policies — the address-space paths suppressed onlyFileNotFoundErrorwhile the block paths swallowed everyOSError, justified by a Windows rationale that does not hold (SharedMemory.unlinkis a documented no-op on Windows). The real reason suppression is needed is narrower and verifiable: any process that attached to a segment may have unlinked it first (bpo-38119), which surfaces asENOENT._unlink_quietlytherefore swallowsFileNotFoundErrorsilently and downgrades every otherOSErrorto aResourceWarningrather than raising it. Teardown can no longer replace an exception the caller is already unwinding, and a genuine leak stays observable instead of being silently swallowed.Two adjacent reclamation paths that could not fire are fixed alongside it.
Publisher._adddelegated failure cleanup to_drop, which scans the address space for a ref that_addonly writes on the last line of itstry— so on any failure the ref was never there,_dropfound nothing, and the block leaked with itsatexithandler armed for the pool's lifetime. And re-entering oneLocalDiscoveryinstance silently demoted it to non-owner while stranding the first entry's fallback; instances are now single-use.Trade-offs
LocalDiscoveryinstances are now single-use. A second__enter__raisesRuntimeErrorwhether or not the first has exited, so sequentially reusing an instance — which worked before — now fails. Nothing in the codebase relied on it, every internal caller already constructs a fresh instance perwith, and failing loudly is the point: the silent alternative corrupted ownership state and leaked the namespace.An unexpected unlink failure now warns instead of raising. A non-
ENOENTOSErrorduring teardown emits aResourceWarningrather than propagating. This is deliberate — a raising teardown replaces the caller's in-flight exception — but it means a genuinely unreclaimable segment no longer stops the program.ResourceWarningis in Python's default ignore filter, so it is silent in a normal run and visible under-X dev,-W, or pytest, matching how CPython treats an unclosed socket.Two known bugs are pinned as strict xfails rather than fixed here.
capacityis not enforced (POSIX page-rounding inflates the segment to a full page regardless, #299), and a surviving pool's worker leaks after an owner-first exit (#298). Both tests flip toXPASS— and, being strict, to hard failures — the moment those issues land, forcing the markers to be removed.Closes #291
Proposed changes
Order teardown so a failed unlink cannot leave the fallback armed
__exit__now unregisters theatexitfallback before closing and unlinking. Previously the unregister sat after the unlink, so any unlink failure skipped it and left the handler armed to fire a second time at shutdown — the crash #291 reports.Route every unlink through one policy
Add the module-private
_unlink_quietly(shared_memory)and call it from all four teardown sites:__enter__'s fallback closure,__exit__,Publisher._shared_memory_factory's fallback closure, andPublisher._shared_memory_finalizer. It swallowsFileNotFoundError— the segment is already gone, which is the expected outcome under bpo-38119 — and emits aResourceWarningfor any otherOSError.This replaces the two divergent policies with one, and it is what makes teardown non-raising: an exception escaping
__exit__or anatexithandler would otherwise replace the exception the caller was unwinding, or crash the interpreter at shutdown. Swallowing everything would have traded that bug for a silent leak, so an unexpected failure warns instead.Release the block a failed publish acquired
Publisher._addacquires a per-worker block, packs the metadata into it, and only then writes the worker's ref into the address space. On any failure it called_drop, which reclaims a block by scanning the address space for that ref — but the ref lands on the last line of thetry, so on every failure path it was never written._dropcould not find it,releasenever ran, and the block survived with itsatexithandler armed until the publisher closed. Worse, the leak was sticky: a later successful add of the same uid took the refcount to 2, so its matching drop returned it to 1 and the block was never unlinked even on a clean drop.Release what
_additself acquired instead of inferring it from address-space state.ResourcePool.releaseis a documented no-op for a key that was never cached, so it is also correct whenacquireis what raised.Guard
LocalDiscoveryagainst re-entryEntering the same instance twice overwrote
_address_spaceand_ownerwhile leaving_cleanuppointing at the first entry's closure, so both exits took the non-owner branch, the fallback was never disarmed, and the namespace stayed occupied for the process lifetime. Decorate__enter__with the existing@noreentryutility so a second entry raisesRuntimeErrorinstead of silently corrupting the instance's ownership state. The guard binds to the instance, not the namespace — distinct instances sharing a namespace compare equal but guard independently.Document the teardown contract on the public surface
LocalDiscoveryis public but carried no statement of the behavior this change is built around. Its class docstring now states the contract once: create-or-attach ownership, the owner's exit unlinking the segment for every still-attached peer, the shutdown fallback for a context never exited, the single-use guard, and a pointer to_unlink_quietlyfor the failure semantics. ThePublisher/Subscriberexamples were also corrected — as written they raisedFileNotFoundError, because they showed no owning context.Test cases
TestLocalDiscoveryTestLocalDiscoveryFileNotFoundErrorbetween cyclesTestLocalDiscoveryLocalDiscoveryenters and exits the same namespaceatexitfallback is registered or unregisteredTestLocalDiscoveryLocalDiscoveryinstance already enteredRuntimeErrorTestLocalDiscoveryTestLocalDiscoveryTestLocalDiscoveryFileNotFoundErrorTestLocalDiscoveryatexitfallback is disarmedTestLocalDiscoveryTestLocalDiscoveryTestLocalDiscoveryTestLocalDiscoveryENOENTis the expected caseTestLocalDiscoveryFileNotFoundErrorTestLocalDiscoveryPermissionErrorTestLocalDiscoveryPermissionErrorResourceWarningnaming the segmentTestLocalDiscoveryPermissionErrorValueErrorand the owner exitsValueErrorpropagates, not the teardown failureTestLocalDiscoveryTestLocalDiscoveryTestLocalDiscoveryPublisherpublish("worker-dropped", …)is calledTestLocalDiscoveryPublisheratexitfallback is disarmedTestLocalDiscoveryPublisherTestLocalDiscoveryPublisherTestLocalDiscoveryPublisherRuntimeErrorTestLocalDiscoveryPublisherTestLocalDiscoveryPublisherTestLocalDiscoveryPublisherTestLocalDiscoveryPublisherTestLocalDiscoveryPublisherTestLocalDiscoveryPublishercapacity=8RuntimeErrorxfail(strict)pending #299)TestSameNamespaceRespawnTestOverlappingNamespaceLifecyclesTestOverlappingNamespaceLifecyclesTestOverlappingNamespaceLifecyclesxfail(strict)pending #298)TestCrossProcessTeardownTestCrossProcessTeardown