Skip to content

Stop a failed shared-memory create from emitting a stray resource_tracker unregister — Closes #340 - #341

Draft
conradbzura wants to merge 4 commits into
wool-labs:mainfrom
conradbzura:340-guard-failed-shared-memory-create
Draft

Stop a failed shared-memory create from emitting a stray resource_tracker unregister — Closes #340#341
conradbzura wants to merge 4 commits into
wool-labs:mainfrom
conradbzura:340-guard-failed-shared-memory-create

Conversation

@conradbzura

@conradbzura conradbzura commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

Route Wool's two SharedMemory(create=True) sites through a helper that suppresses the stray resource_tracker.unregister a construction emits when it fails partway through.

SharedMemory.__init__ unlinks from inside the except OSError handler wrapping the block that truncates, stats and maps 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 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 raises KeyError in 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 unlink is guarded by self._track. That guard does not help a create: track defaults to True, so it is satisfied. Probed directly on all three supported interpreters:

3.11.12 3.12.10 3.13.3
failed create emits a stray unregister yes yes yes
tracker KeyError traceback on stderr yes yes yes

The 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_unlink half 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

_attach rebound 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 own unlink removes; 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 _attach a two-line caller that keeps its version gate. Rename _attach_lock and _reinit_attach_lock to _tracker_lock and _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

  • Restore what this module displaced, not an import-time snapshot. The at-fork handler previously reset the hooks unconditionally, so any fork in a process that had imported Wool clobbered instrumentation another library installed after import — even with no window ever opened. Track each shim with the hook it displaced and restore only that.
  • Neutralise a shim before unwinding. A third party that wraps the hook inside the window keeps our shim in its delegation chain for the life of the process, where it would go on matching this segment's name against a thread ident the interpreter is free to reuse. A liveness flag makes a stranded shim a pass-through.
  • Bound the acquisition. DEFAULT_LOCK_TIMEOUT already 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 _create and route both call sites

_create(name, size) opens the window over unregister only. LocalDiscovery.__enter__ and LocalDiscovery.Publisher._shared_memory_factory both use it.

__enter__'s FileExistsError fallback to _attach is 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 whole try/except deadlocks 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_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. Assert on the residual set instead, where the creator's registration either survives or does not.

Test design notes

Inject at os.ftruncate, not mmap.mmap. Only a create truncates, so failing it breaks creates and leaves every attach working. That matters because publish remaps 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_ledger fixture 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 real KeyError in 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:

Mutant Caught by
Create suppression removed 2 unit + 2 integration
register suppressed on the create path 7 unit
Unregister shim forwards unconditionally 6 unit, incl. the strengthened #336 test
__enter__ site left unrouted 1 unit + 1 integration
Block site left unrouted 1 unit + 1 integration
Name clause dropped from the match 4 unit
Resource-type clause dropped from the match 3 unit
Restore only on the returning path whole suite wedges — the lock is never released
Restore only one of the two hooks 1 unit
Identity guard dropped from the restore 1 unit
Segment created one byte long 1 unit
__enter__ halves the size it requests 1 unit
Suppression window widened across the fallback 1 integration
At-fork registration deleted 1 integration
At-fork handler stops replacing the lock 1 integration

The 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.py holds at 6 missed lines — the same pre-existing set as before this change, with the _unregister pass-through gap inherited from #336 now covered and the new bounded-acquire path covered by a test rather than a pragma. No pragma added 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

# Test Suite Given When Then Coverage Target
1 TestLocalDiscovery A namespace no process currently owns An owner enters its context and leaves it It should hold exactly one tracked segment for the life of the context and none after it Create success path; registration deliberately not suppressed
2 TestLocalDiscovery A namespace an owner has already entered and left, and a filesystem that fails the truncation A fresh owner enters that namespace It should propagate the error without unregistering a name the tracker is not holding Create failure path at LocalDiscovery.__enter__
3 TestLocalDiscovery A namespace whose create already failed, and a filesystem that has since recovered That same namespace is entered again It should track the retried segment — the same name is what a stranded shim would still match Hook restoration, scoped to the failed name
4 TestLocalDiscovery An owner whose create already failed A second, unrelated namespace is entered and left It should track that segment and release it on exit Restoration is not name-wide
5 TestLocalDiscovery Any capacity spanning the point at which the address space outgrows one page The namespace is entered and every declared slot is written and read back It should map the whole address space, whatever the capacity _create's size argument
6 TestLocalDiscovery A construction that unregisters an unrelated segment while the window is open An owner enters its context It should let that unregister reach the tracker Suppression narrowed to one segment
7 TestLocalDiscoveryPublisher An owned namespace and a publisher on it A worker is added and then dropped It should track the block alongside the address space and release it on the drop Create success path at the pool factory
8 TestLocalDiscoveryPublisher An owned namespace, a worker already added and dropped, and a filesystem that fails the truncation That worker is added again It should propagate the error without unregistering a name the tracker is not holding Create failure path at the pool factory
9 TestLocalDiscoveryPublisher An arbitrary sequence of add, update and drop events, and an arbitrary subset of them whose block creation fails The sequence is published serially and the owner exits It should never unregister a name the tracker is not holding, whichever creates failed The balance invariant, now including failures
10 Module-level _suppressing An open window, and any resource differing in name, in type, or in both That resource is registered and unregistered inside the window It should let both calls through Narrowing on name and resource type, both configurations
11 Module-level _suppressing An open window whose block raises The exception propagates It should leave a later segment tracked and hold no shim Restoration on the raising path
12 Module-level _suppressing An open window whose block rebinds the tracker's hook The window closes It should leave that wrapper installed The identity guard's documented promise
13 Module-level _suppressing Another thread holding the lock, and a short bound A second window is opened It should raise TimeoutError naming the wait The bounded acquisition
14 Module-level _create An attach of a name no segment holds, followed by a create of that same name The attach raises and the create follows It should track the created segment Cross-path interaction on one name
15 TestCrossProcessTracker An independent interpreter whose filesystem fails the truncation It fails to create the namespace segment and exits It should exit 0 with no tracker traceback on stderr __enter__, across a real process boundary
16 TestCrossProcessTracker The same, failing one worker announcement The publish raises and the interpreter exits It should exit 0 with no tracker traceback on stderr The pool factory, across a real process boundary
17 TestCrossProcessTracker A namespace this process owns, and an interpreter entering it as a non-owner Its create raises FileExistsError and the attach runs It should attach and exit within the bound The lock is released before the fallback
18 TestCrossProcessTracker An interpreter with a thread parked inside an open window It forks and the child enters a namespace of its own It should report the child entering and exiting 0 The at-fork handler
19 TestCrossProcessTracker An interpreter creating a segment larger than the system can back, patching nothing It runs to completion and its tracker drains It should exit 0 yet print a tracker KeyError Negative control, independent of both Wool and the injection technique
20 Module-level _attach A segment created by this process and a mapping that raises after it is opened The segment is attached by name It should leave the creator's registration in the tracker's cache Strengthened #336 assertion, now shared with the create path

_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
conradbzura force-pushed the 340-guard-failed-shared-memory-create branch from 3d01b01 to 3bdeda3 Compare July 28, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stop a failed shared-memory create from emitting a stray resource_tracker unregister

1 participant