Stop a failed shared-memory create from emitting a stray resource_tracker unregister — Closes #340 - #341
Draft
conradbzura wants to merge 4 commits into
Draft
Conversation
This was referenced Jul 28, 2026
_attach rebinds the resource tracker's register and unregister hooks for the duration of one SharedMemory constructor, so an attach-only mapping is never tracked. A create needs the same window, but suppressing only the unregister: a segment this process creates should stay registered, since that entry is what its own unlink removes. Lift the machinery into a _suppressing context manager parameterised by which hooks to suppress, leaving _attach a two-line caller. Restore each hook to what it displaced rather than to an import-time snapshot, so a fork changes nothing in a child that never opened a window and leaves another library's instrumentation intact. Neutralise a shim before unwinding, so one a third party wrapped cannot keep matching a recycled thread ident. Bound the acquisition, matching the precedent set for this module's other lock. The lock and its fork handler are renamed to match, now that they no longer serve attaches alone. No behaviour changes to the attach path.
SharedMemory.__init__ unlinks from inside the except OSError handler that wraps the block truncating, stating and mapping the segment, and register runs after that block. Below 3.13 unlink unregisters unconditionally; on 3.13 and up it unregisters whenever track is set, which it is for a create. So on every supported version a construction that fails mid-way unregisters a name that was never registered. The tracker's cache is a set, so removing an absent name raises KeyError in the tracker process, which prints a traceback to stderr and sets an exit code nothing waits on. That is the same observable wool-labs#336 removed from the attach path, reached instead by a failure inside that block: ENOSPC from the truncation on an exhausted /dev/shm, or ENOMEM from the mapping. A shm_open failure raises ahead of the block and is unaffected. Route both create sites through a _create helper that opens the suppression window over the unregister alone. Unlike the attach case the shm_unlink the same handler performs is correct here, since the segment it destroys is the one the call just created, so nothing is left over.
test__attach_should_suppress_the_unregister_when_the_mapping_fails asserted only that the ledger recorded no violation, and passed with the suppression removed entirely. The creator's entry is live at that point, so an unsuppressed unregister is absorbed as an ordinary discard and records nothing to see. Assert on the residual set instead, where the creator's registration either survives the failed attach or does not. That mechanism is shared with the create path, so leaving it unpinned would have meant neither path had a test for it.
Unit tests drive both create sites through the public API — entering a LocalDiscovery context and publishing a worker — with os.ftruncate made to fail. Truncation is the injection point because only a create truncates, so an attach still works; a broken mapping would take out the address-space remap every publish performs before it ever reached the block under test. Each failure case first completes one successful cycle. The ledger fixture classifies an unregister only for a name it has seen registered, and a first-time failed create names something it never saw, which is forwarded unclassified. Without that arrangement the assertions pass whether or not the suppression is there at all. Integration tests run the same two sites in real interpreters, since the KeyError is printed by the tracker process while the interpreter exits 0. A pure-stdlib control reproduces the fault, so the silence the other two assert is falsifiable. None carry a version dimension: the fault reproduces unpatched on every supported version, so splitting them by version would assert a conditionality that does not exist.
conradbzura
force-pushed
the
340-guard-failed-shared-memory-create
branch
from
July 28, 2026 17:10
3d01b01 to
3bdeda3
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
Route Wool's two
SharedMemory(create=True)sites through a helper that suppresses the strayresource_tracker.unregistera construction emits when it fails partway through.SharedMemory.__init__unlinks from inside theexcept OSErrorhandler wrapping the block that truncates, stats and maps the segment, andregisterruns after that block. Below 3.13unlinkunregisters unconditionally; on 3.13 and up it unregisters whenevertrackis set, which it is for a create. So a construction that fails mid-way unregisters a name that was never registered. The tracker's per-type cache is a set, so removing an absent name raisesKeyErrorin the tracker process, which prints a traceback to stderr and sets an exit code nothing waits on — the same observable #336 removed from the attach path, reached instead by the truncation or the mapping failing.This corrects the issue's stated scope, and #340 has been corrected in place. The issue said 3.13 was unaffected because
unlinkis guarded byself._track. That guard does not help a create:trackdefaults toTrue, so it is satisfied. Probed directly on all three supported interpreters:KeyErrortraceback on stderrThe fix therefore carries no version gate, unlike #336's attach fix. That is also its best CI property: the new code runs on every matrix leg with no fixture forcing a version the interpreter is not on.
The
shm_unlinkhalf that #336 had to leave to CPython (bpo-38119) has no counterpart here. On an attach it destroys a segment the process does not own; on a create it destroys the one the call just made, which is correct. Nothing is left over.Closes #340
Proposed changes
Extract the suppression window from
_attach_attachrebound both tracker hooks for one constructor call. A create needs the same window but a different policy — the segment must stay registered on success, since that entry is what its ownunlinkremoves; only the failure path is wrong.Move the machinery into a
_suppressing(name, *, register, unregister=True)context manager. Name the parameters for the hooks rather than switching on a single boolean, so both call sites read without consulting the callee. Leave_attacha two-line caller that keeps its version gate. Rename_attach_lockand_reinit_attach_lockto_tracker_lockand_reinit_tracker_state, since they no longer serve attaches alone. Committed separately as a behaviour-preserving refactor.Harden the window against three hazards review surfaced
DEFAULT_LOCK_TIMEOUTalready governs this module's other lock, and Bound LocalDiscovery's lock acquisition and stop its busy-spin #316 bounded that one for the same reason. An unbounded acquire turns a wedged holder into a process-wide stall with nothing to diagnose.Add
_createand route both call sites_create(name, size)opens the window overunregisteronly.LocalDiscovery.__enter__andLocalDiscovery.Publisher._shared_memory_factoryboth use it.__enter__'sFileExistsErrorfallback to_attachis unaffected: the exception propagates out of the context manager, releasing the non-reentrant lock, before the handler runs. Pin that with a subprocess test rather than leaving it to inspection — a refactor that widened the window across the wholetry/exceptdeadlocks rather than fails, and three of the fifteen reviewers independently wrote exactly that refactor while reviewing.Give the existing attach assertion teeth
test__attach_should_suppress_the_unregister_when_the_mapping_failsasserted only that the ledger recorded no violation — and passed with the suppression removed entirely. The creator's entry is live at that point, so an unsuppressed unregister is absorbed as an ordinary discard and records nothing. Assert on the residual set instead, where the creator's registration either survives or does not.Test design notes
Inject at
os.ftruncate, notmmap.mmap. Only a create truncates, so failing it breaks creates and leaves every attach working. That matters becausepublishremaps the address space before reaching the block factory; a broken mapping would take out the arrangement rather than the target.Seed the name before every failure test. The
tracker_ledgerfixture classifies an unregister only for a name it has seen registered. A first-time failed create names something it never saw, so the stray is forwarded unclassified — leaving the assertion green whether or not the fix is present, while planting a realKeyErrorin the session's tracker.Run the liveness properties in a subprocess. The lock is process-global, so a wedge inside pytest strands every later test in the session; a wedge in a child is reaped by the harness timeout and reported against the test that caused it.
Derive the page boundary rather than hard-coding it. The capacity property crosses the point where a segment outgrows one page, which is where an under-sized request first becomes observable. That point is capacity 255 on Linux's 4 KiB pages and 1023 on macOS arm64's 16 KiB pages, so a fixed example tuned to either is a no-op on the other.
Carry no version dimension on the create tests. The fault reproduces unpatched on every supported version, so a legacy/native split would run identical code twice and encode a conditionality that does not exist.
Mutation results
Each mutant, and the tests that caught it:
registersuppressed on the create path__enter__site left unrouted__enter__halves the size it requestsThe last eight all survived the first round of tests; review found them, and they now fail. Both unrouted-site mutants are caught by exactly one unit test and one integration test each, so the two call sites have independent guards.
Coverage
Unit 98.86% against a floor of 98; integration 86.46% against 70.
local.pyholds at 6 missed lines — the same pre-existing set as before this change, with the_unregisterpass-through gap inherited from #336 now covered and the new bounded-acquire path covered by a test rather than a pragma. Nopragmaadded or removed.Subprocess children earn no coverage credit, so the integration tests are behavioural evidence and the unit suite carries the coverage. That division is stated in the integration module docstring.
Test cases
TestLocalDiscoveryTestLocalDiscoveryLocalDiscovery.__enter__TestLocalDiscoveryTestLocalDiscoveryTestLocalDiscovery_create'ssizeargumentTestLocalDiscoveryTestLocalDiscoveryPublisherTestLocalDiscoveryPublisherTestLocalDiscoveryPublisher_suppressing_suppressing_suppressing_suppressingTimeoutErrornaming the wait_createTestCrossProcessTracker__enter__, across a real process boundaryTestCrossProcessTrackerTestCrossProcessTrackerFileExistsErrorand the attach runsTestCrossProcessTrackerTestCrossProcessTrackerKeyError_attach