slick-queue-py 2.1.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/API_DIFFERENCES.md +25 -7
  2. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/CHANGELOG.md +74 -0
  3. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/PKG-INFO +43 -4
  4. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/README.md +42 -3
  5. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/slick_queue_py.egg-info/SOURCES.txt +1 -0
  6. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/slick_queue_py.py +164 -13
  7. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_items_per_slot.py +3 -4
  8. slick_queue_py-2.3.0/tests/test_segment_lifetime.py +410 -0
  9. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_traits.py +8 -12
  10. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/.github/FUNDING.yml +0 -0
  11. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/.github/workflows/ci.yml +0 -0
  12. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/.github/workflows/publish-conda.yml +0 -0
  13. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/.github/workflows/publish-pypi.yml +0 -0
  14. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/.github/workflows/publish-release.yml +0 -0
  15. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/.github/workflows/release.yml +0 -0
  16. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/.gitignore +0 -0
  17. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/.pypirc.template +0 -0
  18. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/BUILDING.md +0 -0
  19. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/CMakeLists.txt +0 -0
  20. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/LICENSE +0 -0
  21. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/MANIFEST.in +0 -0
  22. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/atomic_ops.py +0 -0
  23. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/atomic_ops_ext.cpp +0 -0
  24. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/conda.recipe/meta.yaml +0 -0
  25. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/pyproject.toml +0 -0
  26. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/requirements.txt +0 -0
  27. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/setup.cfg +0 -0
  28. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/setup.py +0 -0
  29. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/cleanup_shm.py +0 -0
  30. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/cpp_consumer.cpp +0 -0
  31. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/cpp_multi_producer.cpp +0 -0
  32. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/cpp_producer.cpp +0 -0
  33. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/cpp_read_last_tester.cpp +0 -0
  34. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/cpp_work_stealing_consumer.cpp +0 -0
  35. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/run_test.py +0 -0
  36. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_atomic_cursor.py +0 -0
  37. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_atomic_ops.py +0 -0
  38. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_interop.py +0 -0
  39. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_local_mode.py +0 -0
  40. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_loss_count.py +0 -0
  41. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_modern_format.py +0 -0
  42. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_multi_producer.py +0 -0
  43. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_queue.py +0 -0
  44. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_read_last.py +0 -0
  45. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_read_last_interop.py +0 -0
  46. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_reset_detection.py +0 -0
  47. {slick_queue_py-2.1.0 → slick_queue_py-2.3.0}/tests/test_wrap_invariants.py +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  This document explains the intentional API differences between the C++ `slick::queue<T>` and Python `SlickQueue` implementations.
4
4
 
5
- Both track the same version line; this document describes slick-queue v2.1.0.
5
+ Both track the same version line; this document describes slick-queue v2.3.0.
6
6
 
7
7
  ## Core Compatibility
8
8
 
@@ -227,27 +227,44 @@ q[idx][:4] = struct.pack("<i", 42) # Manual packing
227
227
  {
228
228
  slick::queue<T> q(size, "name");
229
229
  // ... use queue ...
230
- } // Destructor automatically cleans up
230
+ q.remove_shm(); // Coordinator, once no peer will attach again; q's mapping stays valid
231
+ } // Destructor unmaps; it never removes the segment
232
+
233
+ slick::shm::shared_memory::remove("name"); // By name, without a queue - e.g. at startup
231
234
  ```
232
235
 
233
236
  **Python:**
234
237
  ```python
235
238
  # Option 1: Manual cleanup
236
- q = SlickQueue(name='name', size=size, element_size=elem_size, create=True)
239
+ q = SlickQueue(name='name', size=size, element_size=elem_size)
237
240
  try:
238
241
  # ... use queue ...
239
242
  finally:
240
243
  q.close()
241
- q.unlink() # Only call from creator process
244
+ q.unlink() # Only from the process that coordinates the queue's lifetime
242
245
 
243
- # Option 2: Context manager (recommended)
244
- with SlickQueue(name='name', size=size, element_size=elem_size, create=True) as q:
246
+ # Option 2: Context manager - closes, never unlinks
247
+ with SlickQueue(name='name', size=size, element_size=elem_size) as q:
245
248
  # ... use queue ...
246
- pass # Automatic cleanup
249
+ pass
250
+
251
+ SlickQueue.remove('name') # By name, without attaching - e.g. at startup
247
252
  ```
248
253
 
249
254
  **Rationale:** Python's garbage collection is non-deterministic, so explicit cleanup or context managers are needed.
250
255
 
256
+ Neither side deletes a segment on its own, matching C++ v2.3.0: not the C++ destructor, and not
257
+ Python's `close()`, context manager or process exit. Python keeps its segments out of
258
+ `multiprocessing`'s resource tracker for this, since the tracker would otherwise unlink them when
259
+ the process exits.
260
+
261
+ | C++ | Python |
262
+ |-----|--------|
263
+ | `remove_shm()` | `remove_shm()` - same contract, unlinks by name on every call |
264
+ | - | `unlink()` - unlinks once, valid after `close()`; a repeat is a no-op |
265
+ | `slick::shm::shared_memory::remove(name)` | `SlickQueue.remove(name)` - also accepts a `/`-prefixed name |
266
+ | `shm_name()` - `""` for a local queue | `get_shm_name()` - `None` for a local queue, `/`-prefixed on POSIX |
267
+
251
268
  ---
252
269
 
253
270
  ## Interoperability Examples
@@ -339,6 +356,7 @@ int main() {
339
356
  | Element access | Pointer | `memoryview` | Python memory safety |
340
357
  | Type safety | Templates | `struct` module | Language difference |
341
358
  | Cleanup | RAII (destructor) | Explicit or context manager | Python GC is non-deterministic |
359
+ | Segment removal | `remove_shm()` | `remove_shm()`, `unlink()`, `SlickQueue.remove()` | Neither side unlinks on its own |
342
360
  | `read()` data | `T*` into the ring | `bytes` copy | No safe raw pointers in Python |
343
361
  | Overwritten-in-flight data | Caller sees a volatile pointer | Copy may hold a newer record's bytes | Detect with `loss_count()` |
344
362
  | Feature configuration | `Traits` template parameter | `traits=` argument | Python has no templates |
@@ -1,5 +1,79 @@
1
1
  # Changelogs
2
2
 
3
+ ## [v2.3.0] - 2026-10-17
4
+
5
+ Tracks C++ slick-queue v2.3.0: no queue removes its shared-memory segment on its
6
+ own any more, and `remove_shm()` is how the coordinating process does. The
7
+ version moves from 2.1.0 to 2.3.0 to stay on the C++ number. C++ v2.2.0 added
8
+ only `shm_name()`, which Python already had as `get_shm_name()`. The
9
+ shared-memory layout is unchanged, so 2.1.0 peers in either language
10
+ interoperate as before.
11
+
12
+ ### Segment lifetime (behavior change)
13
+
14
+ - On POSIX, a segment is no longer unlinked when the process that created it
15
+ exits. `SharedMemory` registered every segment with `multiprocessing`'s
16
+ resource tracker, which unlinks what is registered when the process exits, so
17
+ a creator that exited first stranded every peer still attached on an orphaned
18
+ mapping, while its restart silently created a fresh segment under the same
19
+ name that those peers never saw. C++ v2.3.0 stopped its destructor doing the
20
+ same. A name now lives until `unlink()`, `remove_shm()` or
21
+ `SlickQueue.remove(name)`, and a restarted process reattaches to the existing
22
+ segment and keeps its contents. Windows is unaffected: a section dies with its
23
+ last handle.
24
+ - The queue sends the resource tracker nothing at all: `track=False` on Python
25
+ 3.13+, and on older POSIX Pythons a `SharedMemory` subclass that maps the
26
+ segment without registering it. With no register/unregister pair there is
27
+ nothing to race between threads, or between `multiprocessing` children, which
28
+ share their parent's tracker.
29
+ - Migration: code that relied on the cleanup at exit now leaves segments in
30
+ `/dev/shm`, and its next run attaches to them - a creator joins the existing
31
+ segment instead of initializing it, the contents are the old run's, and a
32
+ creator with a different geometry raises `ValueError`. Call `SlickQueue.remove(name)` at startup for a clean start, or
33
+ `unlink()` / `remove_shm()` at shutdown once no peer will attach again.
34
+
35
+ ### Added
36
+
37
+ - `remove_shm() -> bool`, matching C++: unlinks the segment's name so the next
38
+ queue created with it starts fresh, leaving open mappings - this queue's own
39
+ included - valid. It unlinks by name on every call, and leaves `unlink()`
40
+ nothing to do afterwards, so a later `unlink()` cannot delete a replacement
41
+ created under the name. Returns `False` for a local-memory queue or when the
42
+ name is already gone, and always `True` for a shared queue on Windows.
43
+ - `SlickQueue.remove(name) -> bool`, matching C++
44
+ `slick::shm::shared_memory::remove()`: unlinks a segment by name without
45
+ attaching to it. Accepts the `/`-prefixed value of `get_shm_name()`, and is
46
+ always `True` on Windows.
47
+
48
+ ### Fixed
49
+
50
+ - `unlink()` was a silent no-op once `close()` had run, because `close()` drops
51
+ the `SharedMemory` object it went through. The documented `shared_memory`
52
+ idiom - close every handle, then unlink the name once - therefore leaked the
53
+ segment on POSIX. The name is now kept alongside the mapping, so `unlink()`
54
+ works after `close()`, and repeating it is a no-op, so it cannot delete a
55
+ segment a later run has created under the same name.
56
+ - Before Python 3.13 on POSIX, a process that only attached to a queue unlinked
57
+ its segment when it exited, because attaching registered the name with that
58
+ process's resource tracker too.
59
+
60
+ ### Tests
61
+
62
+ - New `tests/test_segment_lifetime.py` with 12 tests, registered with CTest:
63
+ `unlink()` after `close()` and repeated, `close()` and the context manager
64
+ keeping the segment, `remove_shm()` on local and shared queues, `unlink()`
65
+ after `remove_shm()` sparing a replacement segment, `remove()` by name, a
66
+ creator's and an attacher's exit in a fresh interpreter, no resource-tracker
67
+ traffic at all, a child forked while another thread is constructing a queue,
68
+ and spawned children attaching at once.
69
+ - `test_traits.py` and `test_items_per_slot.py` open their hand-built raw
70
+ segments through the library's opener, so those handles and the queue no
71
+ longer both claim a name in the resource tracker.
72
+
73
+ Checked against C++ slick-queue v2.3.0 with the interop tests on Windows and on
74
+ Linux, run twice on Linux so a segment left behind would fail the second run,
75
+ and on Linux under Python 3.12 (the subclass) and 3.14 (`track=False`).
76
+
3
77
  ## [v2.1.0] - 2026-09-24
4
78
 
5
79
  Tracks C++ slick-queue v2.1.0. Adds `items_per_slot`, so one control slot can
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slick-queue-py
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Lock-free MPMC queue with C++ interoperability via shared memory
5
5
  Home-page: https://github.com/SlickQuant/slick-queue-py
6
6
  Author: Slick Quant
@@ -600,11 +600,47 @@ Get memoryview for writing to reserved slot.
600
600
 
601
601
  #### `close()`
602
602
 
603
- Close the shared memory connection. Always call this before unlinking.
603
+ Close the shared memory connection. Never deletes the segment.
604
604
 
605
605
  #### `unlink()`
606
606
 
607
- Delete the shared memory segment. Only call from the process that created it.
607
+ Delete the shared memory segment this queue is attached to. Valid before or after `close()`
608
+ (close every handle, then unlink once), and a no-op when repeated, so it cannot delete a segment
609
+ a later run has since created under the same name. A no-op for local memory mode.
610
+
611
+ #### `remove_shm() -> bool`
612
+
613
+ Unlink the segment's name so the next queue created with it starts fresh, matching C++
614
+ `remove_shm()`. Mappings already open, including this queue's own, stay valid. Unlike `unlink()`
615
+ it unlinks by name on every call. Returns `False` for a local-memory queue or if the name is
616
+ already gone, and always `True` for a shared queue on Windows.
617
+
618
+ #### `SlickQueue.remove(name) -> bool`
619
+
620
+ Unlink a segment by name without attaching to it, matching C++
621
+ `slick::shm::shared_memory::remove()`. Typically called at startup to clear a segment a previous
622
+ run left behind. Accepts the `/`-prefixed value of `get_shm_name()`. Always `True` on Windows.
623
+
624
+ #### `get_shm_name() -> Optional[str]`
625
+
626
+ The segment name to pass to C++ (`/`-prefixed on POSIX), or `None` for a local-memory queue.
627
+
628
+ #### Segment lifetime
629
+
630
+ No queue deletes its segment on its own: not `close()`, not the context manager, and not process
631
+ exit, matching C++ slick-queue v2.3.0. On POSIX a name outlives every mapping until it is
632
+ unlinked, so a departing process that unlinked it while a peer was still attached would leave that
633
+ peer reading an orphaned mapping while a restarted process silently created a fresh segment under
634
+ the same name. The process that coordinates the queue's lifetime calls `unlink()`, `remove_shm()`
635
+ or `SlickQueue.remove(name)` once no peer will attach again; until then a restarted process
636
+ reattaches to the existing segment and keeps its contents. On Windows a segment is freed when its
637
+ last handle closes.
638
+
639
+ The queue keeps its segments out of `multiprocessing`'s resource tracker, which would otherwise
640
+ unlink them when the process exits - before Python 3.13, even in a process that only attached. It
641
+ sends the tracker nothing at all: `track=False` on Python 3.13+, and on older POSIX Pythons a
642
+ `SharedMemory` subclass that maps the segment without registering it, so there is no
643
+ register/unregister pair to race between threads or between processes sharing one tracker.
608
644
 
609
645
  ### AtomicCursor
610
646
 
@@ -920,6 +956,9 @@ python tests/test_reset_detection.py
920
956
 
921
957
  # Wrapping-producer record/size invariants
922
958
  python tests/test_wrap_invariants.py
959
+
960
+ # Segment lifetime: unlink(), remove_shm(), remove(), process exit
961
+ python tests/test_segment_lifetime.py
923
962
  ```
924
963
 
925
964
  Or run everything with pytest:
@@ -963,7 +1002,7 @@ The interop tests verify:
963
1002
 
964
1003
  1. **Buffer Cleanup Warning**: You may see a `BufferError: cannot close exported pointers exist` warning during garbage collection. This is a **harmless warning** caused by Python's ctypes creating internal buffer references that persist beyond explicit cleanup. It occurs during program exit and **does not affect functionality, performance, or correctness**. The queue works perfectly despite this warning.
965
1004
 
966
- 2. **UserWarning**: On Linux you may see `UserWarning: resource_tracker: There appear to be 4 leaked shared_memory objects to clean up at shutdown`. This is a **harmless warning** caused by Python's ctypes creating internal buffer references that persist beyond explicit cleanup. It occurs during program exit and **does not affect functionality, performance, or correctness**. The queue works perfectly despite this warning.
1005
+ 2. **UserWarning**: On Linux you may see `UserWarning: resource_tracker: There appear to be 4 leaked shared_memory objects to clean up at shutdown`. `SlickQueue` keeps its own segments out of the resource tracker (see [Segment lifetime](#segment-lifetime)), so the warning comes from a `multiprocessing.shared_memory.SharedMemory` your code opened directly, such as one holding an `AtomicCursor`. The tracker unlinks those segments at exit; pass `track=False` (Python 3.13+) to keep them.
967
1006
 
968
1007
  ## Architecture
969
1008
 
@@ -564,11 +564,47 @@ Get memoryview for writing to reserved slot.
564
564
 
565
565
  #### `close()`
566
566
 
567
- Close the shared memory connection. Always call this before unlinking.
567
+ Close the shared memory connection. Never deletes the segment.
568
568
 
569
569
  #### `unlink()`
570
570
 
571
- Delete the shared memory segment. Only call from the process that created it.
571
+ Delete the shared memory segment this queue is attached to. Valid before or after `close()`
572
+ (close every handle, then unlink once), and a no-op when repeated, so it cannot delete a segment
573
+ a later run has since created under the same name. A no-op for local memory mode.
574
+
575
+ #### `remove_shm() -> bool`
576
+
577
+ Unlink the segment's name so the next queue created with it starts fresh, matching C++
578
+ `remove_shm()`. Mappings already open, including this queue's own, stay valid. Unlike `unlink()`
579
+ it unlinks by name on every call. Returns `False` for a local-memory queue or if the name is
580
+ already gone, and always `True` for a shared queue on Windows.
581
+
582
+ #### `SlickQueue.remove(name) -> bool`
583
+
584
+ Unlink a segment by name without attaching to it, matching C++
585
+ `slick::shm::shared_memory::remove()`. Typically called at startup to clear a segment a previous
586
+ run left behind. Accepts the `/`-prefixed value of `get_shm_name()`. Always `True` on Windows.
587
+
588
+ #### `get_shm_name() -> Optional[str]`
589
+
590
+ The segment name to pass to C++ (`/`-prefixed on POSIX), or `None` for a local-memory queue.
591
+
592
+ #### Segment lifetime
593
+
594
+ No queue deletes its segment on its own: not `close()`, not the context manager, and not process
595
+ exit, matching C++ slick-queue v2.3.0. On POSIX a name outlives every mapping until it is
596
+ unlinked, so a departing process that unlinked it while a peer was still attached would leave that
597
+ peer reading an orphaned mapping while a restarted process silently created a fresh segment under
598
+ the same name. The process that coordinates the queue's lifetime calls `unlink()`, `remove_shm()`
599
+ or `SlickQueue.remove(name)` once no peer will attach again; until then a restarted process
600
+ reattaches to the existing segment and keeps its contents. On Windows a segment is freed when its
601
+ last handle closes.
602
+
603
+ The queue keeps its segments out of `multiprocessing`'s resource tracker, which would otherwise
604
+ unlink them when the process exits - before Python 3.13, even in a process that only attached. It
605
+ sends the tracker nothing at all: `track=False` on Python 3.13+, and on older POSIX Pythons a
606
+ `SharedMemory` subclass that maps the segment without registering it, so there is no
607
+ register/unregister pair to race between threads or between processes sharing one tracker.
572
608
 
573
609
  ### AtomicCursor
574
610
 
@@ -884,6 +920,9 @@ python tests/test_reset_detection.py
884
920
 
885
921
  # Wrapping-producer record/size invariants
886
922
  python tests/test_wrap_invariants.py
923
+
924
+ # Segment lifetime: unlink(), remove_shm(), remove(), process exit
925
+ python tests/test_segment_lifetime.py
887
926
  ```
888
927
 
889
928
  Or run everything with pytest:
@@ -927,7 +966,7 @@ The interop tests verify:
927
966
 
928
967
  1. **Buffer Cleanup Warning**: You may see a `BufferError: cannot close exported pointers exist` warning during garbage collection. This is a **harmless warning** caused by Python's ctypes creating internal buffer references that persist beyond explicit cleanup. It occurs during program exit and **does not affect functionality, performance, or correctness**. The queue works perfectly despite this warning.
929
968
 
930
- 2. **UserWarning**: On Linux you may see `UserWarning: resource_tracker: There appear to be 4 leaked shared_memory objects to clean up at shutdown`. This is a **harmless warning** caused by Python's ctypes creating internal buffer references that persist beyond explicit cleanup. It occurs during program exit and **does not affect functionality, performance, or correctness**. The queue works perfectly despite this warning.
969
+ 2. **UserWarning**: On Linux you may see `UserWarning: resource_tracker: There appear to be 4 leaked shared_memory objects to clean up at shutdown`. `SlickQueue` keeps its own segments out of the resource tracker (see [Segment lifetime](#segment-lifetime)), so the warning comes from a `multiprocessing.shared_memory.SharedMemory` your code opened directly, such as one holding an `AtomicCursor`. The tracker unlinks those segments at exit; pass `track=False` (Python 3.13+) to keep them.
931
970
 
932
971
  ## Architecture
933
972
 
@@ -39,5 +39,6 @@ tests/test_queue.py
39
39
  tests/test_read_last.py
40
40
  tests/test_read_last_interop.py
41
41
  tests/test_reset_detection.py
42
+ tests/test_segment_lifetime.py
42
43
  tests/test_traits.py
43
44
  tests/test_wrap_invariants.py
@@ -29,8 +29,11 @@ Supported on Python 3.8+ (uses multiprocessing.shared_memory).
29
29
  """
30
30
  from __future__ import annotations
31
31
 
32
- __version__ = '2.1.0'
32
+ __version__ = '2.3.0'
33
33
 
34
+ import inspect
35
+ import mmap
36
+ import os
34
37
  import struct
35
38
  import sys
36
39
  import time
@@ -40,6 +43,81 @@ from atomic_ops import AtomicReservedInfo, AtomicUInt64, AtomicCursor, check_pla
40
43
  # Use Python's built-in shared memory (available in Python 3.8+)
41
44
  from multiprocessing.shared_memory import SharedMemory
42
45
 
46
+ # Python >= 3.13 supports SharedMemory(track=False), which keeps a segment out of
47
+ # multiprocessing's resource tracker.
48
+ _SHM_SUPPORTS_TRACK = 'track' in inspect.signature(SharedMemory.__init__).parameters
49
+
50
+ if not _SHM_SUPPORTS_TRACK and sys.platform != 'win32':
51
+ import _posixshmem
52
+
53
+ class _UntrackedSharedMemory(SharedMemory):
54
+ """``SharedMemory(track=False)`` for POSIX Python < 3.13, where that
55
+ argument does not exist.
56
+
57
+ The base class touches the resource tracker in exactly two places, and
58
+ only those are replaced: ``__init__``, which registers the name, and
59
+ ``unlink()``, which unregisters it. This ``__init__`` sets the same
60
+ attributes the base one does, which ``close()``, ``buf``, ``name``,
61
+ ``size``, ``__del__`` and ``__reduce__`` rely on.
62
+
63
+ Sending the tracker nothing at all is what makes this safe. Registering
64
+ and then retracting at once needed a lock against other threads, which a
65
+ fork could leave held in the child, and still raced against other
66
+ processes sharing the tracker - multiprocessing children share their
67
+ parent's - which answers an UNREGISTER for a name it no longer holds by
68
+ printing a KeyError traceback.
69
+ """
70
+
71
+ def __init__(self, name: str, create: bool = False, size: int = 0):
72
+ flags = os.O_RDWR
73
+ if create:
74
+ if size <= 0:
75
+ raise ValueError("'size' must be a positive number different from zero")
76
+ flags |= os.O_CREAT | os.O_EXCL
77
+ self._name = '/' + name
78
+ self._fd = _posixshmem.shm_open(self._name, flags, mode=self._mode)
79
+ try:
80
+ if create:
81
+ os.ftruncate(self._fd, size)
82
+ size = os.fstat(self._fd).st_size
83
+ self._mmap = mmap.mmap(self._fd, size)
84
+ except OSError:
85
+ os.close(self._fd)
86
+ self._fd = -1
87
+ if create: # only a segment this call made is ours to delete
88
+ try:
89
+ _posixshmem.shm_unlink(self._name)
90
+ except OSError:
91
+ pass
92
+ raise
93
+ self._size = size
94
+ self._buf = memoryview(self._mmap)
95
+
96
+ def unlink(self) -> None:
97
+ if self._name:
98
+ _posixshmem.shm_unlink(self._name)
99
+
100
+
101
+ def _open_shm(name: str, create: bool, size: int = 0) -> SharedMemory:
102
+ """Create or attach to a segment without registering it with
103
+ multiprocessing's resource tracker.
104
+
105
+ A registered name is unlinked by the tracker when the process exits, which on
106
+ POSIX is the lifetime C++ slick-queue v2.3.0 dropped: a peer still attached
107
+ keeps reading the orphaned mapping while a restarted process silently
108
+ creates a fresh segment under the same name, so the two never meet again.
109
+ Python before 3.13 registers on attach as well as on create, so any peer's
110
+ exit removed the segment. The name now outlives every process until
111
+ remove_shm(), unlink() or remove() is called. Windows has no tracker; a
112
+ section there dies with its last handle.
113
+ """
114
+ if _SHM_SUPPORTS_TRACK:
115
+ return SharedMemory(name=name, create=create, size=size, track=False)
116
+ if sys.platform == 'win32':
117
+ return SharedMemory(name=name, create=create, size=size)
118
+ return _UntrackedSharedMemory(name, create, size)
119
+
120
+
43
121
  # Layout constants
44
122
  # Shared memory header layout (64 bytes total):
45
123
  # Offset 0-7: std::atomic<reserved_info> (8 bytes)
@@ -318,6 +396,9 @@ class SlickQueue:
318
396
 
319
397
  self.use_shm = name is not None
320
398
  self._shm: Optional[SharedMemory] = None
399
+ # Tracker-form name of the mapping. Outlives close(), which drops _shm, so
400
+ # unlink() still knows what to unlink - see unlink().
401
+ self._shm_name: Optional[str] = None
321
402
  self._local_buf: Optional[bytearray] = None
322
403
  self.size = None
323
404
  self._own = False
@@ -344,11 +425,10 @@ class SlickQueue:
344
425
  raise ValueError("size and element_size required when creating")
345
426
  total = self._layout_data_offset() + self.element_size * self.size
346
427
  try:
347
- self._shm = SharedMemory(name=self.name, create=True, size=total)
348
- # print(f"**** create new shm {self.name}")
428
+ self._shm = _open_shm(self.name, create=True, size=total)
349
429
  except FileExistsError:
350
- # print(f"**** shm already exists, opening {self.name}")
351
- self._shm = SharedMemory(name=self.name, create=False)
430
+ self._shm = _open_shm(self.name, create=False)
431
+ self._shm_name = self._shm._name
352
432
 
353
433
  # Use CAS on init_state to determine ownership (matches C++ queue.h:618-648)
354
434
  buf = self._shm.buf
@@ -428,7 +508,8 @@ class SlickQueue:
428
508
  raise ValueError("element_size must be provided when opening existing shared memory")
429
509
 
430
510
  # Open existing shared memory (size parameter not needed/ignored)
431
- self._shm = SharedMemory(name=self.name, create=False)
511
+ self._shm = _open_shm(self.name, create=False)
512
+ self._shm_name = self._shm._name
432
513
  buf = self._shm.buf
433
514
 
434
515
  # Wait for initialization to complete (matches C++ queue.h:558-562)
@@ -1199,18 +1280,88 @@ class SlickQueue:
1199
1280
  pass
1200
1281
 
1201
1282
  def unlink(self) -> None:
1202
- """Unlink (delete) the shared memory segment.
1283
+ """Unlink (delete) the shared memory segment this queue is attached to.
1284
+
1285
+ Only applicable for shared memory mode; a no-op for local memory mode. No
1286
+ queue ever does this on its own - not close(), not the context manager and
1287
+ not process exit - so call it from the process that coordinates the queue's
1288
+ lifetime, once no peer will attach again.
1289
+
1290
+ Valid after :meth:`close`, which is the documented ``shared_memory`` idiom -
1291
+ close every handle, then unlink the name once. Deleting a name never touches
1292
+ mappings that already exist, open or closed. Unlinking twice is a no-op, so a
1293
+ repeat cannot delete a segment a later run has since created under the same
1294
+ name; :meth:`remove_shm` is the C++-style call that unlinks on every call.
1295
+ """
1296
+ prefixed = self._shm_name
1297
+ if prefixed is None:
1298
+ return
1299
+ self._shm_name = None # unlink once, however many times this is called
1300
+ SlickQueue.remove(prefixed)
1301
+
1302
+ def remove_shm(self) -> bool:
1303
+ """Unlink this queue's segment name so the next queue created with it starts
1304
+ fresh. Matches C++ ``remove_shm()``.
1305
+
1306
+ Mappings that are already open, including this queue's own, stay valid until
1307
+ they are closed. Unlike :meth:`unlink` it unlinks by name on every call, so
1308
+ it deletes whatever holds the name at the time.
1309
+
1310
+ After this call :meth:`unlink` is a no-op, whatever the result: a name
1311
+ removed here, or one already gone, can be taken by a replacement segment,
1312
+ which the pending unlink() would otherwise delete.
1203
1313
 
1204
- Only applicable for shared memory mode. Does nothing for local memory mode.
1314
+ Returns:
1315
+ True if the name was removed; False for a local-memory queue or if
1316
+ removal failed (on POSIX, typically because the name is already gone).
1317
+ Always True for a shared queue on Windows, where the segment is freed
1318
+ when its last handle closes and there is no name to remove.
1205
1319
  """
1206
1320
  if not self.use_shm:
1207
- return # Nothing to unlink for local memory
1321
+ return False
1322
+ self._shm_name = None # unlink() is done with this name - see above
1323
+ return SlickQueue.remove(self.name)
1324
+
1325
+ @staticmethod
1326
+ def remove(name: str) -> bool:
1327
+ """Unlink a shared memory segment by name without attaching to it. Matches
1328
+ C++ ``slick::shm::shared_memory::remove()``.
1329
+
1330
+ Typically called once at startup to clear a segment a previous run left
1331
+ behind, which a creator would otherwise reattach to with its old contents::
1332
+
1333
+ SlickQueue.remove("my_queue")
1334
+ q = SlickQueue(name="my_queue", size=1024, element_size=64)
1208
1335
 
1336
+ A leading ``/`` is accepted, so the value of :meth:`get_shm_name` can be
1337
+ passed back.
1338
+
1339
+ Returns:
1340
+ True if the segment was removed (always True on Windows).
1341
+
1342
+ Warning:
1343
+ Only call this when no process will attach again. On POSIX the name is
1344
+ unlinked immediately, so the next creator gets a fresh segment while any
1345
+ process still mapped to the old one keeps reading the orphaned copy. On
1346
+ Windows this is a no-op - a section disappears once its last handle
1347
+ closes.
1348
+ """
1349
+ if name.startswith('/'):
1350
+ name = name[1:]
1351
+ if sys.platform == 'win32':
1352
+ return True
1353
+ # Straight to shm_unlink rather than SharedMemory.unlink(), which before
1354
+ # Python 3.13 also retracts a tracker registration this module never keeps -
1355
+ # see _open_shm().
1209
1356
  try:
1210
- if self._shm:
1211
- self._shm.unlink()
1212
- except Exception:
1213
- pass
1357
+ from _posixshmem import shm_unlink
1358
+ except ImportError: # pragma: no cover - POSIX always has it
1359
+ return False
1360
+ try:
1361
+ shm_unlink('/' + name)
1362
+ except OSError: # not there, or not ours to unlink
1363
+ return False
1364
+ return True
1214
1365
 
1215
1366
  def __enter__(self):
1216
1367
  """Context manager entry."""
@@ -11,8 +11,6 @@ from pathlib import Path
11
11
 
12
12
  sys.path.insert(0, str(Path(__file__).parent.parent))
13
13
 
14
- from multiprocessing.shared_memory import SharedMemory
15
-
16
14
  from atomic_ops import AtomicCursor
17
15
  from slick_queue_py import (
18
16
  SlickQueue,
@@ -21,6 +19,7 @@ from slick_queue_py import (
21
19
  SLOT_SIZE,
22
20
  HEADER_MAGIC_OFFSET,
23
21
  ITEMS_PER_SLOT_OFFSET,
22
+ _open_shm,
24
23
  )
25
24
 
26
25
  SLQ1 = 0x534C5131
@@ -37,7 +36,7 @@ def _marker(q):
37
36
 
38
37
 
39
38
  def _set_raw_marker(q, marker):
40
- raw = SharedMemory(name=q._shm.name, create=False)
39
+ raw = _open_shm(q._shm.name, create=False)
41
40
  try:
42
41
  struct.pack_into("<I", raw.buf, HEADER_MAGIC_OFFSET, marker)
43
42
  finally:
@@ -364,7 +363,7 @@ def test_zero_in_header_reads_as_one():
364
363
  name = _unique("slq_ips_legacy")
365
364
  creator = SlickQueue(name=name, size=8, element_size=8)
366
365
  try:
367
- raw = SharedMemory(name=creator._shm.name, create=False)
366
+ raw = _open_shm(creator._shm.name, create=False)
368
367
  try:
369
368
  struct.pack_into("<I", raw.buf, ITEMS_PER_SLOT_OFFSET, 0)
370
369
  finally:
@@ -0,0 +1,410 @@
1
+ """
2
+ Tests for shared-memory segment lifetime (matching C++ slick-queue v2.3.0).
3
+
4
+ No queue unlinks its segment on its own - not close(), not the context manager and
5
+ not process exit through multiprocessing's resource tracker. The name lives until
6
+ unlink(), remove_shm() or SlickQueue.remove() is called.
7
+ """
8
+ import os
9
+ import signal
10
+ import struct
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import threading
15
+ import time
16
+ import warnings
17
+ from multiprocessing import resource_tracker
18
+ from pathlib import Path
19
+
20
+ ROOT = Path(__file__).parent.parent
21
+ sys.path.insert(0, str(ROOT))
22
+
23
+ from slick_queue_py import SlickQueue
24
+
25
+ IS_WINDOWS = sys.platform == 'win32'
26
+
27
+ # A fresh interpreter, so it has its own resource tracker - a multiprocessing child
28
+ # would share the parent's, and the parent's exit would be what unlinked the name.
29
+ CHILD = r"""
30
+ import struct, sys
31
+ sys.path.insert(0, sys.argv[1])
32
+ from slick_queue_py import SlickQueue
33
+ name, mode, value = sys.argv[2], sys.argv[3], int(sys.argv[4])
34
+ if mode == 'create':
35
+ q = SlickQueue(name=name, size=16, element_size=8)
36
+ assert q._own, "child expected to create the segment"
37
+ else:
38
+ q = SlickQueue(name=name, element_size=8)
39
+ idx = q.reserve()
40
+ q[idx][:8] = struct.pack('<Q', value)
41
+ q.publish(idx)
42
+ q.close()
43
+ """
44
+
45
+
46
+ def _unique(prefix):
47
+ return f"{prefix}_{os.getpid()}"
48
+
49
+
50
+ def _run_child(name, mode, value):
51
+ result = subprocess.run(
52
+ [sys.executable, "-c", CHILD, str(ROOT), name, mode, str(value)],
53
+ capture_output=True, text=True, timeout=60)
54
+ assert result.returncode == 0, f"child failed:\n{result.stdout}\n{result.stderr}"
55
+ # The tracker warns about every registered name it cleans up at shutdown
56
+ assert "resource_tracker" not in result.stderr, result.stderr
57
+ return result
58
+
59
+
60
+ def _values(q):
61
+ """Every value published so far, from the start of the queue."""
62
+ out = []
63
+ cursor = 0
64
+ while True:
65
+ data, size, cursor = q.read(cursor)
66
+ if data is None:
67
+ return out
68
+ out.append(struct.unpack('<Q', data[:8])[0])
69
+
70
+
71
+ def _expect_gone(name):
72
+ try:
73
+ q = SlickQueue(name=name, element_size=8)
74
+ except FileNotFoundError:
75
+ return
76
+ q.close()
77
+ raise AssertionError(f"segment {name!r} still exists")
78
+
79
+
80
+ def test_unlink_after_close_removes_segment():
81
+ """unlink() after close() was a silent no-op: close() dropped the SharedMemory
82
+ object unlink() went through, so the documented idiom leaked the segment."""
83
+ name = _unique("lt_unlink_after_close")
84
+ SlickQueue.remove(name)
85
+ q = SlickQueue(name=name, size=16, element_size=8)
86
+ q.close()
87
+ q.unlink()
88
+ _expect_gone(name)
89
+
90
+
91
+ def test_unlink_twice_is_noop():
92
+ """A repeated unlink() must not delete a segment a later run created under the
93
+ same name."""
94
+ name = _unique("lt_unlink_twice")
95
+ SlickQueue.remove(name)
96
+ first = SlickQueue(name=name, size=16, element_size=8)
97
+ first.close()
98
+ first.unlink()
99
+
100
+ second = SlickQueue(name=name, size=16, element_size=8)
101
+ try:
102
+ assert second._own
103
+ first.unlink()
104
+ reopened = SlickQueue(name=name, element_size=8) # still there
105
+ reopened.close()
106
+ finally:
107
+ second.close()
108
+ second.unlink()
109
+
110
+
111
+ def test_close_and_context_manager_keep_segment():
112
+ name = _unique("lt_close_keeps")
113
+ SlickQueue.remove(name)
114
+ keeper = SlickQueue(name=name, size=16, element_size=8) # holds it on Windows
115
+ try:
116
+ with SlickQueue(name=name, element_size=8) as q:
117
+ idx = q.reserve()
118
+ q[idx][:8] = struct.pack('<Q', 7)
119
+ q.publish(idx)
120
+ q.close()
121
+ reopened = SlickQueue(name=name, element_size=8)
122
+ try:
123
+ assert _values(reopened) == [7]
124
+ finally:
125
+ reopened.close()
126
+ finally:
127
+ keeper.close()
128
+ keeper.unlink()
129
+
130
+
131
+ def test_remove_shm_local_queue_returns_false():
132
+ q = SlickQueue(size=16, element_size=8)
133
+ try:
134
+ assert q.remove_shm() is False
135
+ finally:
136
+ q.close()
137
+
138
+
139
+ def test_remove_shm_keeps_open_mapping_valid():
140
+ name = _unique("lt_remove_shm")
141
+ SlickQueue.remove(name)
142
+ q = SlickQueue(name=name, size=16, element_size=8)
143
+ try:
144
+ idx = q.reserve()
145
+ q[idx][:8] = struct.pack('<Q', 42)
146
+ q.publish(idx)
147
+
148
+ assert q.remove_shm() is True
149
+ # The mapping outlives its name
150
+ assert _values(q) == [42]
151
+ idx = q.reserve()
152
+ q[idx][:8] = struct.pack('<Q', 43)
153
+ q.publish(idx)
154
+ assert _values(q) == [42, 43]
155
+
156
+ if not IS_WINDOWS:
157
+ # The next creator starts fresh rather than reattaching
158
+ assert q.remove_shm() is False # already gone
159
+ fresh = SlickQueue(name=name, size=16, element_size=8)
160
+ try:
161
+ assert fresh._own
162
+ assert _values(fresh) == []
163
+ finally:
164
+ fresh.close()
165
+ fresh.unlink()
166
+ finally:
167
+ q.close()
168
+ q.unlink()
169
+
170
+
171
+ def test_remove_by_name():
172
+ name = _unique("lt_remove_static")
173
+ SlickQueue.remove(name)
174
+ q = SlickQueue(name=name, size=16, element_size=8)
175
+ shm_name = q.get_shm_name()
176
+ q.close()
177
+ # get_shm_name()'s value, '/'-prefixed on POSIX, is accepted back
178
+ assert SlickQueue.remove(shm_name) is True
179
+ _expect_gone(name)
180
+ # Windows has no name to remove, so it always reports success
181
+ assert SlickQueue.remove(name) is IS_WINDOWS
182
+
183
+
184
+ def test_creator_exit_keeps_segment():
185
+ """A creator's exit used to unlink the name on POSIX through the resource
186
+ tracker, stranding any peer on an orphaned mapping. On Windows a section dies
187
+ with its last handle, which no one else holds here."""
188
+ if IS_WINDOWS:
189
+ return
190
+ name = _unique("lt_creator_exit")
191
+ SlickQueue.remove(name)
192
+ _run_child(name, 'create', 11)
193
+ q = SlickQueue(name=name, element_size=8)
194
+ try:
195
+ assert _values(q) == [11]
196
+ # A restarted creator reattaches and keeps the contents
197
+ again = SlickQueue(name=name, size=16, element_size=8)
198
+ try:
199
+ assert not again._own
200
+ finally:
201
+ again.close()
202
+ finally:
203
+ q.close()
204
+ q.unlink()
205
+
206
+
207
+ def test_attacher_exit_keeps_segment():
208
+ """Before Python 3.13 attaching registered the name with the attacher's resource
209
+ tracker too, so any peer's exit unlinked the segment on POSIX."""
210
+ name = _unique("lt_attacher_exit")
211
+ SlickQueue.remove(name)
212
+ q = SlickQueue(name=name, size=16, element_size=8)
213
+ try:
214
+ _run_child(name, 'open', 21)
215
+ _run_child(name, 'open', 22)
216
+ reopened = SlickQueue(name=name, element_size=8)
217
+ try:
218
+ assert _values(reopened) == [21, 22]
219
+ finally:
220
+ reopened.close()
221
+ finally:
222
+ q.close()
223
+ q.unlink()
224
+
225
+
226
+ def test_unlink_after_remove_shm_spares_replacement():
227
+ """Regression: remove_shm() left unlink()'s pending name in place, so once the
228
+ old segment was removed and a replacement created under the same name, the old
229
+ queue's unlink() deleted the replacement. Also when remove_shm() finds the name
230
+ already gone, since a replacement can take it just the same."""
231
+ name = _unique("lt_rm_unlink")
232
+ for gone_first in (False, True):
233
+ SlickQueue.remove(name)
234
+ old = SlickQueue(name=name, size=16, element_size=8)
235
+ try:
236
+ if gone_first:
237
+ SlickQueue.remove(name) # someone else got there first
238
+ assert old.remove_shm() is IS_WINDOWS
239
+ else:
240
+ assert old.remove_shm() is True
241
+ replacement = SlickQueue(name=name, size=16, element_size=8)
242
+ try:
243
+ if not IS_WINDOWS:
244
+ assert replacement._own
245
+ old.unlink()
246
+ SlickQueue(name=name, element_size=8).close() # still there
247
+ finally:
248
+ replacement.close()
249
+ replacement.unlink()
250
+ finally:
251
+ old.close()
252
+
253
+
254
+ def test_never_talks_to_resource_tracker():
255
+ """No REGISTER or UNREGISTER for a queue's segment reaches multiprocessing's
256
+ resource tracker - not on create, attach, close or unlink. Before Python 3.13
257
+ the library registered each name and then retracted it at once, which raced
258
+ between processes sharing one tracker and needed a lock that a fork could
259
+ leave held (see the two tests below)."""
260
+ name = _unique("lt_no_tracker")
261
+ SlickQueue.remove(name)
262
+ calls = []
263
+ orig_register, orig_unregister = resource_tracker.register, resource_tracker.unregister
264
+ resource_tracker.register = lambda n, rtype: calls.append(("register", n))
265
+ resource_tracker.unregister = lambda n, rtype: calls.append(("unregister", n))
266
+ try:
267
+ creator = SlickQueue(name=name, size=16, element_size=8)
268
+ opener = SlickQueue(name=name, element_size=8)
269
+ second = SlickQueue(name=name, size=16, element_size=8) # attaches
270
+ for q in (second, opener, creator):
271
+ q.close()
272
+ creator.unlink()
273
+ finally:
274
+ resource_tracker.register, resource_tracker.unregister = orig_register, orig_unregister
275
+ SlickQueue.remove(name)
276
+ assert calls == [], calls
277
+
278
+
279
+ def test_fork_during_construction_does_not_deadlock():
280
+ """A child forked while another thread is constructing a queue can still
281
+ construct one. Python < 3.13 held a module lock around SharedMemory() for the
282
+ tracker fallback, and a fork inside that window handed the child a copy of
283
+ the lock that nothing would ever release, so its next construction hung."""
284
+ if not hasattr(os, 'fork'):
285
+ return
286
+ name = _unique("lt_fork")
287
+ SlickQueue.remove(name)
288
+ keeper = SlickQueue(name=name, size=16, element_size=8)
289
+ stop = threading.Event()
290
+
291
+ def churn():
292
+ while not stop.is_set():
293
+ SlickQueue(name=name, element_size=8).close()
294
+
295
+ thread = threading.Thread(target=churn, daemon=True)
296
+ thread.start()
297
+ try:
298
+ with warnings.catch_warnings():
299
+ # Python 3.12+ warns about forking a multi-threaded process
300
+ warnings.simplefilter("ignore", DeprecationWarning)
301
+ for attempt in range(20):
302
+ pid = os.fork()
303
+ if pid == 0:
304
+ try:
305
+ SlickQueue(name=name, element_size=8).close()
306
+ except BaseException:
307
+ os._exit(1)
308
+ os._exit(0)
309
+ deadline = time.monotonic() + 5
310
+ while True:
311
+ done, status = os.waitpid(pid, os.WNOHANG)
312
+ if done:
313
+ break
314
+ if time.monotonic() > deadline:
315
+ os.kill(pid, signal.SIGKILL)
316
+ os.waitpid(pid, 0)
317
+ raise AssertionError(
318
+ f"forked child {attempt} hung constructing a queue")
319
+ time.sleep(0.01)
320
+ assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, \
321
+ f"forked child {attempt} failed (status {status})"
322
+ finally:
323
+ stop.set()
324
+ thread.join()
325
+ keeper.close()
326
+ keeper.unlink()
327
+
328
+
329
+ RELATED_PROCESSES = r"""
330
+ import multiprocessing
331
+ import sys
332
+ sys.path.insert(0, sys.argv[1])
333
+ from slick_queue_py import SlickQueue
334
+
335
+
336
+ def churn(name, n):
337
+ for _ in range(n):
338
+ SlickQueue(name=name, element_size=8).close()
339
+
340
+
341
+ if __name__ == '__main__':
342
+ name = sys.argv[2]
343
+ SlickQueue.remove(name)
344
+ q = SlickQueue(name=name, size=16, element_size=8)
345
+ ctx = multiprocessing.get_context('spawn')
346
+ procs = [ctx.Process(target=churn, args=(name, 200)) for _ in range(4)]
347
+ for p in procs:
348
+ p.start()
349
+ for p in procs:
350
+ p.join()
351
+ q.close()
352
+ q.unlink()
353
+ # Every worker must succeed: max() let a killed one's negative code hide
354
+ # behind any worker that exited 0
355
+ codes = [p.exitcode for p in procs]
356
+ if codes != [0] * len(procs):
357
+ sys.exit(f"worker exit codes: {codes}")
358
+ """
359
+
360
+
361
+ def test_related_processes_do_not_race_in_tracker():
362
+ """multiprocessing children share their parent's resource tracker, so before
363
+ Python 3.13 two of them attaching at once could send REGISTER, REGISTER,
364
+ UNREGISTER, UNREGISTER and make the tracker print a KeyError traceback. The
365
+ tracker writes to the stderr it inherited, so the scenario runs in a fresh
366
+ interpreter whose stderr this test captures. A spawned child re-imports the
367
+ script by path, hence a file rather than -c."""
368
+ with tempfile.TemporaryDirectory() as tmp:
369
+ script = Path(tmp) / "related_processes.py"
370
+ script.write_text(RELATED_PROCESSES)
371
+ result = subprocess.run(
372
+ [sys.executable, str(script), str(ROOT), _unique("lt_related")],
373
+ capture_output=True, text=True, timeout=120)
374
+ assert result.returncode == 0, f"script failed:\n{result.stdout}\n{result.stderr}"
375
+ assert "KeyError" not in result.stderr, result.stderr
376
+ assert "resource_tracker" not in result.stderr, result.stderr
377
+
378
+
379
+ def run_all_tests():
380
+ tests = [(name, obj) for name, obj in sorted(globals().items())
381
+ if name.startswith("test_") and callable(obj)]
382
+
383
+ print("=" * 70)
384
+ print("Segment lifetime tests")
385
+ print("=" * 70)
386
+
387
+ passed = 0
388
+ failed = 0
389
+ for name, test_func in tests:
390
+ try:
391
+ print(f"\n[TEST] {name}...", end=" ")
392
+ test_func()
393
+ print("[PASSED]")
394
+ passed += 1
395
+ except Exception as e:
396
+ print("[FAILED]")
397
+ print(f" Error: {e}")
398
+ import traceback
399
+ traceback.print_exc()
400
+ failed += 1
401
+
402
+ print("\n" + "=" * 70)
403
+ print(f"Results: {passed} passed, {failed} failed")
404
+ print("=" * 70)
405
+ return failed == 0
406
+
407
+
408
+ if __name__ == '__main__':
409
+ success = run_all_tests()
410
+ sys.exit(0 if success else 1)
@@ -9,8 +9,6 @@ from pathlib import Path
9
9
 
10
10
  sys.path.insert(0, str(Path(__file__).parent.parent))
11
11
 
12
- from multiprocessing.shared_memory import SharedMemory
13
-
14
12
  from slick_queue_py import (
15
13
  SlickQueue,
16
14
  QueueTraits,
@@ -24,6 +22,7 @@ from slick_queue_py import (
24
22
  INIT_STATE_OFFSET,
25
23
  INIT_STATE_READY,
26
24
  K_INVALID_INDEX,
25
+ _open_shm,
27
26
  )
28
27
 
29
28
 
@@ -475,9 +474,12 @@ def test_differently_configured_queues_coexist():
475
474
 
476
475
 
477
476
  def _make_raw_segment(name, size, element_size, magic):
478
- """Build a segment by hand so a marker the library never writes can be tested."""
477
+ """Build a segment by hand so a marker the library never writes can be tested.
478
+
479
+ Through the library's own opener, so this handle and the queue that attaches to
480
+ the same name do not both claim it in the resource tracker before Python 3.13."""
479
481
  total = HEADER_SIZE + SLOT_SIZE * size + element_size * size
480
- shm = SharedMemory(name=name, create=True, size=total)
482
+ shm = _open_shm(name, create=True, size=total)
481
483
  buf = shm.buf
482
484
  buf[:HEADER_SIZE] = bytes(HEADER_SIZE)
483
485
  struct.pack_into("<I I", buf, SIZE_OFFSET, size, element_size)
@@ -505,10 +507,7 @@ def test_attach_rejects_segment_without_marker():
505
507
  raise AssertionError("expected RuntimeError for an unmarked segment")
506
508
  finally:
507
509
  shm.close()
508
- try:
509
- shm.unlink()
510
- except Exception:
511
- pass
510
+ SlickQueue.remove(name)
512
511
 
513
512
 
514
513
  def test_attach_rejects_unknown_feature_bits():
@@ -526,10 +525,7 @@ def test_attach_rejects_unknown_feature_bits():
526
525
  raise AssertionError("expected RuntimeError for unknown feature bits")
527
526
  finally:
528
527
  shm.close()
529
- try:
530
- shm.unlink()
531
- except Exception:
532
- pass
528
+ SlickQueue.remove(name)
533
529
 
534
530
 
535
531
  # ------------------------------------------------------------------ reserve()
File without changes
File without changes
File without changes