py-sharedmemory 4.0.0__tar.gz → 4.1.1__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 (36) hide show
  1. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/CHANGELOG.md +19 -0
  2. {py_sharedmemory-4.0.0/py_sharedmemory.egg-info → py_sharedmemory-4.1.1}/PKG-INFO +20 -13
  3. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/README.md +19 -12
  4. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/py_sharedmemory/__init__.py +1 -1
  5. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/py_sharedmemory/convert.py +10 -1
  6. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/py_sharedmemory/factory.py +1 -2
  7. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/py_sharedmemory/sender.py +48 -6
  8. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1/py_sharedmemory.egg-info}/PKG-INFO +20 -13
  9. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/tests/conftest.py +10 -0
  10. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/tests/helpers.py +37 -5
  11. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/tests/test_convert.py +9 -0
  12. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/tests/test_processes.py +77 -0
  13. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/tests/test_queue.py +101 -1
  14. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/LICENSE +0 -0
  15. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/MANIFEST.in +0 -0
  16. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/benchmarks/__init__.py +0 -0
  17. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/benchmarks/benchmark.py +0 -0
  18. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/benchmarks/plot.py +0 -0
  19. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/benchmarks/results/windows-numpy.json +0 -0
  20. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/benchmarks/results/windows-numpy.png +0 -0
  21. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/examples/basic.py +0 -0
  22. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/examples/numpy_batches.py +0 -0
  23. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/examples/worker_pool.py +0 -0
  24. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/py_sharedmemory/py.typed +0 -0
  25. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/py_sharedmemory/receiver.py +0 -0
  26. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/py_sharedmemory.egg-info/SOURCES.txt +0 -0
  27. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/py_sharedmemory.egg-info/dependency_links.txt +0 -0
  28. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/py_sharedmemory.egg-info/requires.txt +0 -0
  29. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/py_sharedmemory.egg-info/top_level.txt +0 -0
  30. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/pyproject.toml +0 -0
  31. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/setup.cfg +0 -0
  32. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/tests/__init__.py +0 -0
  33. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/tests/test_benchmark.py +0 -0
  34. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/tests/test_objects.py +0 -0
  35. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/tests/test_package.py +0 -0
  36. {py_sharedmemory-4.0.0 → py_sharedmemory-4.1.1}/tests/test_threads.py +0 -0
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.1.1
4
+
5
+ - Unread messages no longer hold file descriptors on Linux and macOS: the sender unmaps
6
+ each block right after writing it. Unbounded pairs previously failed with
7
+ `Too many open files` after about 120 unread messages under macOS's default limit.
8
+ - Cap outstanding messages at the metadata queue's limit (32,767 on macOS), so a blocking
9
+ `put` on an unbounded pair waits instead of raising `queue.Full`.
10
+ - Explain a freed block in the receiver's `FileNotFoundError`, which happens when the
11
+ sender closes before a message is read.
12
+
13
+ ## 4.1.0
14
+
15
+ - Warn with a `RuntimeWarning` when leaving a `with sender:` block or exiting the
16
+ producing process discards unacknowledged messages, so a forgotten
17
+ `wait_for_all_ack()` no longer loses data silently. Explicit `close()` calls and
18
+ exits caused by exceptions stay silent, and closing still never blocks.
19
+ - Collect garbage between tests to avoid Python 3.12 resource-tracker reentrancy errors,
20
+ and add a stress test that checks every byte across consumer processes.
21
+
3
22
  ## 4.0.0
4
23
 
5
24
  - **Breaking:** the import package is renamed from `memory` to `py_sharedmemory` to match the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-sharedmemory
3
- Version: 4.0.0
3
+ Version: 4.1.1
4
4
  Summary: Fast multiprocessing.Queue alternative that moves large Python objects and NumPy arrays through shared memory
5
5
  Author: Tobias Würth
6
6
  License-Expression: MIT
@@ -190,9 +190,7 @@ consumer = ctx.Process(target=consume, args=(receiver,))
190
190
 
191
191
  `capacity` counts messages that were sent but not yet received and freed. When it is
192
192
  reached, `put()` blocks, so a fast producer cannot exhaust memory. Shared-memory use is
193
- at most about `capacity × message size`. `capacity=0` or `None` means unbounded; prefer
194
- a limit, because unread messages also hold open files on Linux and macOS (see the
195
- platform notes below).
193
+ at most about `capacity × message size`. `capacity=0` or `None` means unbounded.
196
194
 
197
195
  ### Timeouts and non-blocking calls
198
196
 
@@ -230,6 +228,11 @@ with sender:
230
228
  raise TimeoutError("Consumer did not receive everything")
231
229
  ```
232
230
 
231
+ Closing never blocks. If a `with sender:` block or the producing process ends normally
232
+ while messages are still unacknowledged, they are discarded and the sender emits a
233
+ `RuntimeWarning`, so forgetting to drain does not lose data silently. An explicit
234
+ `close()` or an exception leaving the `with` block discards without a warning.
235
+
233
236
  When both endpoints live in one process, drain before closing either of them. A sentinel
234
237
  such as `None` is a simple way to tell consumers that the stream has ended.
235
238
 
@@ -249,6 +252,7 @@ copy; other views, such as strided slices, arrive as contiguous copies.
249
252
  | `BrokenPipeError` | The endpoint is closed, or the producer's acknowledgement channel failed. |
250
253
  | `RuntimeError` | A second process tries to produce, or a used sender is passed to another process. |
251
254
  | Pickling errors | The object cannot be pickled or unpickled. Both endpoints stay usable, and failed messages are still freed. |
255
+ | `RuntimeWarning` (warning) | A `with sender:` block or the producing process ended normally and discarded unacknowledged messages. |
252
256
 
253
257
  ## API reference
254
258
 
@@ -265,9 +269,9 @@ for the underlying queues; it defaults to `multiprocessing.get_context()`.
265
269
  | `put(obj, block=True, timeout=None)` | Copy `obj` into a new shared-memory block and send it. |
266
270
  | `put_nowait(obj)` | `put(obj, block=False)`. |
267
271
  | `wait_for_all_ack(timeout=None) -> bool` | Wait until every message is received and freed; `False` on timeout. |
268
- | `close()` | Free unread messages and release resources. Runs automatically at normal process exit. |
272
+ | `close()` | Free unread messages silently and release resources. Runs automatically at normal process exit. |
269
273
  | `closed` | Whether the sender is closed. |
270
- | `with sender:` | Closes the sender on exit. |
274
+ | `with sender:` | Closes the sender on exit; warns if a clean exit discards unacknowledged messages. |
271
275
 
272
276
  ### `SharedMemoryReceiver`
273
277
 
@@ -290,13 +294,9 @@ with `OSError: [Errno 28] No space left on device` or crash with `SIGBUS`.
290
294
  **Linux:** outside containers, `/dev/shm` usually defaults to half of physical RAM.
291
295
 
292
296
  **macOS:** fully supported and tested on Apple Silicon. The default start method is
293
- `spawn`, so guard the entry point with `if __name__ == "__main__":`.
294
-
295
- **Open-file limit (Linux and macOS):** each unread message keeps two file descriptors
296
- open in the producer until it is received. macOS allows 256 per process by default and
297
- Linux usually 1,024, so an unbounded pair fails with `OSError: [Errno 24] Too many open
298
- files` after roughly 120 or 500 unread messages. Set a `capacity` well below that, or
299
- raise the limit with `ulimit -n`. The sender stays usable after this error.
297
+ `spawn`, so guard the entry point with `if __name__ == "__main__":`. An unbounded pair
298
+ holds at most 32,767 unread messages, the limit of macOS semaphores; beyond that `put`
299
+ waits or raises `queue.Full` like a full bounded pair.
300
300
 
301
301
  **Windows:** shared memory is backed by the page file, and each block disappears once
302
302
  the last handle closes, so nothing can leak after a crash.
@@ -304,6 +304,13 @@ the last handle closes, so nothing can leak after a crash.
304
304
  **"resource_tracker: There appear to be N leaked shared_memory objects":** fixed in
305
305
  3.0.1. Upgrade if you see this warning with the `fork` start method.
306
306
 
307
+ **`FileNotFoundError: Shared-memory block was freed before it was received`:** the
308
+ producer closed or exited before this message was read. Call `wait_for_all_ack()` before
309
+ closing the sender; the sender also warns when this happens.
310
+
311
+ **`OSError: [Errno 24] Too many open files` with many unread messages:** fixed in 4.1.1,
312
+ where unread messages no longer hold file descriptors.
313
+
307
314
  **`RuntimeError: A shared-memory pair supports one producing process`:** a second
308
315
  process called `put`. Create a separate pair for each producer process.
309
316
 
@@ -140,9 +140,7 @@ consumer = ctx.Process(target=consume, args=(receiver,))
140
140
 
141
141
  `capacity` counts messages that were sent but not yet received and freed. When it is
142
142
  reached, `put()` blocks, so a fast producer cannot exhaust memory. Shared-memory use is
143
- at most about `capacity × message size`. `capacity=0` or `None` means unbounded; prefer
144
- a limit, because unread messages also hold open files on Linux and macOS (see the
145
- platform notes below).
143
+ at most about `capacity × message size`. `capacity=0` or `None` means unbounded.
146
144
 
147
145
  ### Timeouts and non-blocking calls
148
146
 
@@ -180,6 +178,11 @@ with sender:
180
178
  raise TimeoutError("Consumer did not receive everything")
181
179
  ```
182
180
 
181
+ Closing never blocks. If a `with sender:` block or the producing process ends normally
182
+ while messages are still unacknowledged, they are discarded and the sender emits a
183
+ `RuntimeWarning`, so forgetting to drain does not lose data silently. An explicit
184
+ `close()` or an exception leaving the `with` block discards without a warning.
185
+
183
186
  When both endpoints live in one process, drain before closing either of them. A sentinel
184
187
  such as `None` is a simple way to tell consumers that the stream has ended.
185
188
 
@@ -199,6 +202,7 @@ copy; other views, such as strided slices, arrive as contiguous copies.
199
202
  | `BrokenPipeError` | The endpoint is closed, or the producer's acknowledgement channel failed. |
200
203
  | `RuntimeError` | A second process tries to produce, or a used sender is passed to another process. |
201
204
  | Pickling errors | The object cannot be pickled or unpickled. Both endpoints stay usable, and failed messages are still freed. |
205
+ | `RuntimeWarning` (warning) | A `with sender:` block or the producing process ended normally and discarded unacknowledged messages. |
202
206
 
203
207
  ## API reference
204
208
 
@@ -215,9 +219,9 @@ for the underlying queues; it defaults to `multiprocessing.get_context()`.
215
219
  | `put(obj, block=True, timeout=None)` | Copy `obj` into a new shared-memory block and send it. |
216
220
  | `put_nowait(obj)` | `put(obj, block=False)`. |
217
221
  | `wait_for_all_ack(timeout=None) -> bool` | Wait until every message is received and freed; `False` on timeout. |
218
- | `close()` | Free unread messages and release resources. Runs automatically at normal process exit. |
222
+ | `close()` | Free unread messages silently and release resources. Runs automatically at normal process exit. |
219
223
  | `closed` | Whether the sender is closed. |
220
- | `with sender:` | Closes the sender on exit. |
224
+ | `with sender:` | Closes the sender on exit; warns if a clean exit discards unacknowledged messages. |
221
225
 
222
226
  ### `SharedMemoryReceiver`
223
227
 
@@ -240,13 +244,9 @@ with `OSError: [Errno 28] No space left on device` or crash with `SIGBUS`.
240
244
  **Linux:** outside containers, `/dev/shm` usually defaults to half of physical RAM.
241
245
 
242
246
  **macOS:** fully supported and tested on Apple Silicon. The default start method is
243
- `spawn`, so guard the entry point with `if __name__ == "__main__":`.
244
-
245
- **Open-file limit (Linux and macOS):** each unread message keeps two file descriptors
246
- open in the producer until it is received. macOS allows 256 per process by default and
247
- Linux usually 1,024, so an unbounded pair fails with `OSError: [Errno 24] Too many open
248
- files` after roughly 120 or 500 unread messages. Set a `capacity` well below that, or
249
- raise the limit with `ulimit -n`. The sender stays usable after this error.
247
+ `spawn`, so guard the entry point with `if __name__ == "__main__":`. An unbounded pair
248
+ holds at most 32,767 unread messages, the limit of macOS semaphores; beyond that `put`
249
+ waits or raises `queue.Full` like a full bounded pair.
250
250
 
251
251
  **Windows:** shared memory is backed by the page file, and each block disappears once
252
252
  the last handle closes, so nothing can leak after a crash.
@@ -254,6 +254,13 @@ the last handle closes, so nothing can leak after a crash.
254
254
  **"resource_tracker: There appear to be N leaked shared_memory objects":** fixed in
255
255
  3.0.1. Upgrade if you see this warning with the `fork` start method.
256
256
 
257
+ **`FileNotFoundError: Shared-memory block was freed before it was received`:** the
258
+ producer closed or exited before this message was read. Call `wait_for_all_ack()` before
259
+ closing the sender; the sender also warns when this happens.
260
+
261
+ **`OSError: [Errno 24] Too many open files` with many unread messages:** fixed in 4.1.1,
262
+ where unread messages no longer hold file descriptors.
263
+
257
264
  **`RuntimeError: A shared-memory pair supports one producing process`:** a second
258
265
  process called `put`. Create a separate pair for each producer process.
259
266
 
@@ -25,5 +25,5 @@ from .factory import create_shared_memory_pair
25
25
  from .receiver import SharedMemoryReceiver
26
26
  from .sender import SharedMemorySender
27
27
 
28
- __version__ = "4.0.0"
28
+ __version__ = "4.1.1"
29
29
  __all__ = ["SharedMemoryReceiver", "SharedMemorySender", "__version__", "create_shared_memory_pair"]
@@ -1,5 +1,6 @@
1
1
  """Serialize protocol-5 pickle data through a single shared-memory block."""
2
2
 
3
+ import errno
3
4
  import pickle
4
5
  import sys
5
6
  from contextlib import suppress
@@ -60,7 +61,15 @@ def serialize(data: Any) -> tuple[SharedMemory, SharedMemoryMessage]:
60
61
  def deserialize(message: SharedMemoryMessage) -> Any:
61
62
  """Unpickle with owned local buffers and always close the shared mapping."""
62
63
  _validate_message(message)
63
- allocation = SharedMemory(name=message.name, **_ATTACH_OPTIONS)
64
+ try:
65
+ allocation = SharedMemory(name=message.name, **_ATTACH_OPTIONS)
66
+ except FileNotFoundError as error:
67
+ raise FileNotFoundError(
68
+ errno.ENOENT,
69
+ "Shared-memory block was freed before it was received; the sender probably "
70
+ "closed without calling wait_for_all_ack()",
71
+ message.name,
72
+ ) from error
64
73
  try:
65
74
  if message.size > allocation.size:
66
75
  raise ValueError("Shared-memory block is smaller than its metadata")
@@ -21,8 +21,7 @@ def create_shared_memory_pair(
21
21
 
22
22
  Args:
23
23
  capacity: Maximum messages sent but not yet received and freed; `put` blocks
24
- when it is reached. `0` or `None` means unbounded, but on Linux and macOS
25
- every unread message also holds two open file descriptors.
24
+ when it is reached. `0` or `None` means unbounded.
26
25
  ctx: Multiprocessing context for the underlying queues, such as
27
26
  `multiprocessing.get_context("spawn")`. Defaults to the default context;
28
27
  use the same context to start the worker processes.
@@ -3,10 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import sys
6
7
  import threading
7
8
  import time
9
+ import warnings
8
10
  from multiprocessing.queues import Queue
9
11
  from multiprocessing.shared_memory import SharedMemory
12
+ from multiprocessing.synchronize import SEM_VALUE_MAX # type: ignore[attr-defined] # not in typeshed
10
13
  from multiprocessing.util import Finalize
11
14
  from queue import Empty, Full
12
15
  from types import TracebackType
@@ -17,6 +20,14 @@ from .convert import SharedMemoryMessage, release_shared_memory, serialize
17
20
  if TYPE_CHECKING:
18
21
  from multiprocessing.sharedctypes import Synchronized
19
22
 
23
+ # A POSIX block lives on by name until it is unlinked, so the sender can drop its mapping
24
+ # and file descriptors right after writing. Windows frees a block with its last handle.
25
+ _UNMAP_AFTER_WRITE = sys.platform != "win32"
26
+
27
+ # The metadata queue holds at most SEM_VALUE_MAX unread items (32,767 on macOS); beyond
28
+ # that its put would raise queue.Full even for a blocking put on an unbounded sender.
29
+ _MAX_OUTSTANDING = SEM_VALUE_MAX
30
+
20
31
 
21
32
  def _validate_capacity(capacity: int | None) -> None:
22
33
  if capacity is None:
@@ -33,7 +44,9 @@ class SharedMemorySender:
33
44
  Each `put` copies one object into a new shared-memory block that this sender owns
34
45
  until a receiver acknowledges it. The sender is thread-safe, but only one process
35
46
  may produce: pass it to that process as a `Process` argument before the first
36
- `put`. Call `wait_for_all_ack` before `close`, because closing frees unread blocks.
47
+ `put`. Call `wait_for_all_ack` before closing, because closing frees unread blocks.
48
+ Leaving a `with` block or exiting the process normally while messages are still
49
+ unacknowledged emits a `RuntimeWarning`.
37
50
  """
38
51
 
39
52
  def __init__(
@@ -112,8 +125,9 @@ class SharedMemorySender:
112
125
  def close(self) -> None:
113
126
  """Free unread messages, stop the acknowledgement thread, and close the queues.
114
127
 
115
- Idempotent. Unread messages are lost, so drain with `wait_for_all_ack` first.
116
- Also runs automatically when the producing process exits normally.
128
+ Idempotent. Unread messages are discarded silently, so drain with
129
+ `wait_for_all_ack` first. Also runs automatically when the producing process
130
+ exits normally, warning if that discards messages.
117
131
  """
118
132
  self._check_process()
119
133
  errors: list[Exception] = []
@@ -157,7 +171,12 @@ class SharedMemorySender:
157
171
  exc: BaseException | None,
158
172
  traceback: TracebackType | None,
159
173
  ) -> None:
160
- self.close()
174
+ try:
175
+ # An exception already signals the failure; only warn on a clean exit.
176
+ if exc_type is None:
177
+ self._warn_if_discarding()
178
+ finally:
179
+ self.close()
161
180
 
162
181
  def __getstate__(self) -> dict[str, Any]:
163
182
  if self._process_id is not None:
@@ -174,6 +193,26 @@ class SharedMemorySender:
174
193
  if self._process_id is not None and self._process_id != os.getpid():
175
194
  raise RuntimeError("An initialized sender cannot be used in another process")
176
195
 
196
+ def _close_at_exit(self) -> None:
197
+ try:
198
+ self._warn_if_discarding()
199
+ finally:
200
+ self.close()
201
+
202
+ def _warn_if_discarding(self) -> None:
203
+ # Explicit close() discards silently; implicit closes make the data loss visible.
204
+ self._check_process()
205
+ with self._condition:
206
+ unacknowledged = 0 if self._closed else len(self._blocks)
207
+ if unacknowledged:
208
+ noun = "message" if unacknowledged == 1 else "messages"
209
+ warnings.warn(
210
+ f"Closing the sender discards {unacknowledged} unacknowledged {noun} that "
211
+ "receivers may not have read; call wait_for_all_ack() first",
212
+ RuntimeWarning,
213
+ stacklevel=3,
214
+ )
215
+
177
216
  def _ensure_open(self) -> None:
178
217
  if self._closed:
179
218
  raise BrokenPipeError("Sender is closed") from self._ack_error
@@ -186,7 +225,8 @@ class SharedMemorySender:
186
225
  with self._condition:
187
226
  self._ensure_open()
188
227
  self._start_ack_thread()
189
- while self.capacity and self._pending_count >= self.capacity:
228
+ limit = min(self.capacity or _MAX_OUTSTANDING, _MAX_OUTSTANDING)
229
+ while self._pending_count >= limit:
190
230
  if not block:
191
231
  raise Full
192
232
  remaining = None if deadline is None else deadline - time.monotonic()
@@ -206,6 +246,8 @@ class SharedMemorySender:
206
246
  raise
207
247
  try:
208
248
  self._ensure_open()
249
+ if _UNMAP_AFTER_WRITE:
250
+ allocation.close()
209
251
  self._data_queue.put_nowait(message)
210
252
  except BaseException:
211
253
  self._release_message(allocation.name)
@@ -243,7 +285,7 @@ class SharedMemorySender:
243
285
  self._ack_thread = threading.Thread(
244
286
  target=self._process_acknowledgements, name="shared-memory-acks", daemon=True
245
287
  )
246
- self._finalizer = Finalize(self, self.close, exitpriority=10)
288
+ self._finalizer = Finalize(self, self._close_at_exit, exitpriority=10)
247
289
  self._ack_thread.start()
248
290
  except BaseException:
249
291
  if self._finalizer is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-sharedmemory
3
- Version: 4.0.0
3
+ Version: 4.1.1
4
4
  Summary: Fast multiprocessing.Queue alternative that moves large Python objects and NumPy arrays through shared memory
5
5
  Author: Tobias Würth
6
6
  License-Expression: MIT
@@ -190,9 +190,7 @@ consumer = ctx.Process(target=consume, args=(receiver,))
190
190
 
191
191
  `capacity` counts messages that were sent but not yet received and freed. When it is
192
192
  reached, `put()` blocks, so a fast producer cannot exhaust memory. Shared-memory use is
193
- at most about `capacity × message size`. `capacity=0` or `None` means unbounded; prefer
194
- a limit, because unread messages also hold open files on Linux and macOS (see the
195
- platform notes below).
193
+ at most about `capacity × message size`. `capacity=0` or `None` means unbounded.
196
194
 
197
195
  ### Timeouts and non-blocking calls
198
196
 
@@ -230,6 +228,11 @@ with sender:
230
228
  raise TimeoutError("Consumer did not receive everything")
231
229
  ```
232
230
 
231
+ Closing never blocks. If a `with sender:` block or the producing process ends normally
232
+ while messages are still unacknowledged, they are discarded and the sender emits a
233
+ `RuntimeWarning`, so forgetting to drain does not lose data silently. An explicit
234
+ `close()` or an exception leaving the `with` block discards without a warning.
235
+
233
236
  When both endpoints live in one process, drain before closing either of them. A sentinel
234
237
  such as `None` is a simple way to tell consumers that the stream has ended.
235
238
 
@@ -249,6 +252,7 @@ copy; other views, such as strided slices, arrive as contiguous copies.
249
252
  | `BrokenPipeError` | The endpoint is closed, or the producer's acknowledgement channel failed. |
250
253
  | `RuntimeError` | A second process tries to produce, or a used sender is passed to another process. |
251
254
  | Pickling errors | The object cannot be pickled or unpickled. Both endpoints stay usable, and failed messages are still freed. |
255
+ | `RuntimeWarning` (warning) | A `with sender:` block or the producing process ended normally and discarded unacknowledged messages. |
252
256
 
253
257
  ## API reference
254
258
 
@@ -265,9 +269,9 @@ for the underlying queues; it defaults to `multiprocessing.get_context()`.
265
269
  | `put(obj, block=True, timeout=None)` | Copy `obj` into a new shared-memory block and send it. |
266
270
  | `put_nowait(obj)` | `put(obj, block=False)`. |
267
271
  | `wait_for_all_ack(timeout=None) -> bool` | Wait until every message is received and freed; `False` on timeout. |
268
- | `close()` | Free unread messages and release resources. Runs automatically at normal process exit. |
272
+ | `close()` | Free unread messages silently and release resources. Runs automatically at normal process exit. |
269
273
  | `closed` | Whether the sender is closed. |
270
- | `with sender:` | Closes the sender on exit. |
274
+ | `with sender:` | Closes the sender on exit; warns if a clean exit discards unacknowledged messages. |
271
275
 
272
276
  ### `SharedMemoryReceiver`
273
277
 
@@ -290,13 +294,9 @@ with `OSError: [Errno 28] No space left on device` or crash with `SIGBUS`.
290
294
  **Linux:** outside containers, `/dev/shm` usually defaults to half of physical RAM.
291
295
 
292
296
  **macOS:** fully supported and tested on Apple Silicon. The default start method is
293
- `spawn`, so guard the entry point with `if __name__ == "__main__":`.
294
-
295
- **Open-file limit (Linux and macOS):** each unread message keeps two file descriptors
296
- open in the producer until it is received. macOS allows 256 per process by default and
297
- Linux usually 1,024, so an unbounded pair fails with `OSError: [Errno 24] Too many open
298
- files` after roughly 120 or 500 unread messages. Set a `capacity` well below that, or
299
- raise the limit with `ulimit -n`. The sender stays usable after this error.
297
+ `spawn`, so guard the entry point with `if __name__ == "__main__":`. An unbounded pair
298
+ holds at most 32,767 unread messages, the limit of macOS semaphores; beyond that `put`
299
+ waits or raises `queue.Full` like a full bounded pair.
300
300
 
301
301
  **Windows:** shared memory is backed by the page file, and each block disappears once
302
302
  the last handle closes, so nothing can leak after a crash.
@@ -304,6 +304,13 @@ the last handle closes, so nothing can leak after a crash.
304
304
  **"resource_tracker: There appear to be N leaked shared_memory objects":** fixed in
305
305
  3.0.1. Upgrade if you see this warning with the `fork` start method.
306
306
 
307
+ **`FileNotFoundError: Shared-memory block was freed before it was received`:** the
308
+ producer closed or exited before this message was read. Call `wait_for_all_ack()` before
309
+ closing the sender; the sender also warns when this happens.
310
+
311
+ **`OSError: [Errno 24] Too many open files` with many unread messages:** fixed in 4.1.1,
312
+ where unread messages no longer hold file descriptors.
313
+
307
314
  **`RuntimeError: A shared-memory pair supports one producing process`:** a second
308
315
  process called `put`. Create a separate pair for each producer process.
309
316
 
@@ -1,3 +1,4 @@
1
+ import gc
1
2
  import multiprocessing as mp
2
3
 
3
4
  import pytest
@@ -5,6 +6,15 @@ import pytest
5
6
  from py_sharedmemory import create_shared_memory_pair
6
7
 
7
8
 
9
+ @pytest.fixture(autouse=True)
10
+ def collect_garbage():
11
+ # Queues left in reference cycles finalize their semaphores whenever the collector
12
+ # runs. On Python 3.12 that raises ReentrantCallError if it interrupts this thread
13
+ # inside the resource tracker, so collect at a safe point after every test.
14
+ yield
15
+ gc.collect()
16
+
17
+
8
18
  @pytest.fixture
9
19
  def make_pair():
10
20
  pairs = []
@@ -1,4 +1,5 @@
1
1
  import multiprocessing.shared_memory as sm
2
+ import warnings
2
3
  from queue import Queue
3
4
 
4
5
  import pytest
@@ -53,7 +54,34 @@ def consume(receiver, count, results):
53
54
  results.put(values)
54
55
 
55
56
 
57
+ PAYLOAD_BYTES = 1_000_000
58
+
59
+
60
+ def make_payload(index):
61
+ """Alternate in-band bytes and out-of-band NumPy buffers with index-specific content."""
62
+ if index % 2:
63
+ return bytes([index % 251]) * PAYLOAD_BYTES
64
+ import numpy as np
65
+
66
+ return np.full(PAYLOAD_BYTES // 4, index, dtype=np.int32)
67
+
68
+
69
+ def payload_intact(index, payload):
70
+ if isinstance(payload, bytes):
71
+ return len(payload) == PAYLOAD_BYTES and payload.count(index % 251) == PAYLOAD_BYTES
72
+ return payload.shape == (PAYLOAD_BYTES // 4,) and bool((payload == index).all())
73
+
74
+
75
+ def verify_payloads(receiver, results):
76
+ with receiver:
77
+ while (message := receiver.get(timeout=10)) is not None:
78
+ index, payload = message
79
+ results.put((index, payload_intact(index, payload)))
80
+
81
+
56
82
  def exit_with_unreceived_data(sender, ready_to_exit):
83
+ # test_process_exit_with_unread_messages_warns covers the exit warning itself.
84
+ warnings.filterwarnings("ignore", "Closing the sender discards", RuntimeWarning)
57
85
  sender.put(bytearray(65_536))
58
86
  if not ready_to_exit.wait(10):
59
87
  raise TimeoutError("Parent did not read the metadata")
@@ -61,11 +89,15 @@ def exit_with_unreceived_data(sender, ready_to_exit):
61
89
 
62
90
 
63
91
  def abandon_metadata(sender, result_pipe):
64
- with sender:
65
- for _ in range(1_000):
66
- sender.put(b"x")
67
- result_pipe.send(list(sender._blocks))
68
- result_pipe.close()
92
+ def abandon():
93
+ with sender:
94
+ for _ in range(1_000):
95
+ sender.put(b"x")
96
+ result_pipe.send(list(sender._blocks))
97
+ result_pipe.close()
98
+
99
+ with pytest.warns(RuntimeWarning, match="discards 1000 unacknowledged"):
100
+ abandon()
69
101
 
70
102
 
71
103
  def try_foreign_sender(sender, result_pipe):
@@ -242,6 +242,15 @@ def test_cleanup_tolerates_already_unlinked_blocks():
242
242
  assert_unlinked(info.name)
243
243
 
244
244
 
245
+ def test_freed_block_reports_a_clear_error():
246
+ handle, info = serialize(42)
247
+ release_shared_memory(handle)
248
+ with pytest.raises(FileNotFoundError, match="freed before it was received") as caught:
249
+ deserialize(info)
250
+ assert caught.value.filename == info.name
251
+ assert isinstance(caught.value.__cause__, FileNotFoundError)
252
+
253
+
245
254
  def test_cleanup_ignores_names_that_are_already_gone():
246
255
  # Windows never raises here because unlink is a no-op, so simulate POSIX.
247
256
  handle = Mock()
@@ -11,8 +11,10 @@ from .helpers import (
11
11
  consume,
12
12
  exit_with_unreceived_data,
13
13
  join_process,
14
+ make_payload,
14
15
  produce,
15
16
  try_foreign_sender,
17
+ verify_payloads,
16
18
  )
17
19
 
18
20
 
@@ -94,6 +96,32 @@ def test_multiple_consumer_processes(make_pair, ctx):
94
96
  results.join_thread()
95
97
 
96
98
 
99
+ def test_large_messages_arrive_intact_across_consumer_processes(make_pair, ctx):
100
+ sender, receiver = make_pair(4, ctx=ctx)
101
+ results = ctx.Queue()
102
+ consumers = [ctx.Process(target=verify_payloads, args=(receiver, results)) for _ in range(3)]
103
+ for process in consumers:
104
+ process.start()
105
+ try:
106
+ for index in range(60):
107
+ sender.put((index, make_payload(index)), timeout=10)
108
+ for _ in consumers:
109
+ sender.put(None, timeout=10)
110
+ received = dict(results.get(timeout=15) for _ in range(60))
111
+ assert sender.wait_for_all_ack(timeout=10)
112
+ # Every byte of every payload is checked in the consumer that received it.
113
+ assert received == dict.fromkeys(range(60), True)
114
+ for process in consumers:
115
+ join_process(process)
116
+ finally:
117
+ for process in consumers:
118
+ if process.is_alive():
119
+ process.terminate()
120
+ process.join(5)
121
+ results.close()
122
+ results.join_thread()
123
+
124
+
97
125
  def test_normal_process_exit_cleans_unreceived_shared_memory(make_pair, ctx):
98
126
  sender, receiver = make_pair(1, ctx=ctx)
99
127
  ready_to_exit = ctx.Event()
@@ -165,6 +193,55 @@ raise SystemExit(process.exitcode)
165
193
  """
166
194
 
167
195
 
196
+ LOW_FILE_LIMIT = """
197
+ import resource
198
+
199
+ from py_sharedmemory import create_shared_memory_pair
200
+
201
+ # macOS defaults to 256 open files; unread messages must not hold descriptors.
202
+ resource.setrlimit(resource.RLIMIT_NOFILE, (256, 256))
203
+ sender, receiver = create_shared_memory_pair(0)
204
+ for index in range(1_000):
205
+ sender.put(index)
206
+ assert [receiver.get(timeout=10) for _ in range(1_000)] == list(range(1_000))
207
+ assert sender.wait_for_all_ack(timeout=10)
208
+ sender.close()
209
+ receiver.close()
210
+ """
211
+
212
+
213
+ @pytest.mark.skipif(sys.platform == "win32", reason="POSIX file-descriptor limits")
214
+ def test_unread_messages_hold_no_file_descriptors():
215
+ result = subprocess.run(
216
+ [sys.executable, "-c", LOW_FILE_LIMIT],
217
+ cwd=Path(__file__).resolve().parents[1],
218
+ capture_output=True,
219
+ text=True,
220
+ timeout=60,
221
+ )
222
+ assert result.returncode == 0, result.stderr
223
+
224
+
225
+ UNREAD_AT_EXIT = """
226
+ from py_sharedmemory import create_shared_memory_pair
227
+
228
+ sender, receiver = create_shared_memory_pair(1)
229
+ sender.put(b"never received")
230
+ """
231
+
232
+
233
+ def test_process_exit_with_unread_messages_warns():
234
+ result = subprocess.run(
235
+ [sys.executable, "-c", UNREAD_AT_EXIT],
236
+ cwd=Path(__file__).resolve().parents[1],
237
+ capture_output=True,
238
+ text=True,
239
+ timeout=30,
240
+ )
241
+ assert result.returncode == 0, result.stderr
242
+ assert "RuntimeWarning: Closing the sender discards 1 unacknowledged message" in result.stderr
243
+
244
+
168
245
  @pytest.mark.skipif("fork" not in mp.get_all_start_methods(), reason="Requires fork")
169
246
  def test_forked_producer_leaves_no_resource_tracker_entries():
170
247
  # A fresh interpreter has no resource tracker running before the fork.
@@ -1,10 +1,13 @@
1
1
  import multiprocessing as mp
2
2
  import os
3
3
  import pickle
4
+ import sys
4
5
  import threading
5
6
  import time
7
+ import warnings
6
8
  from concurrent.futures import ThreadPoolExecutor
7
9
  from contextlib import suppress
10
+ from multiprocessing.shared_memory import SharedMemory
8
11
  from queue import Empty, Full
9
12
  from unittest.mock import Mock
10
13
 
@@ -92,6 +95,38 @@ def test_full_nonblocking_and_immediate_timeouts(make_pair, kwargs):
92
95
  assert receiver.get(timeout=3) == 3
93
96
 
94
97
 
98
+ @pytest.mark.parametrize("capacity", [0, None, 5])
99
+ def test_outstanding_messages_never_exceed_the_metadata_queue_limit(
100
+ make_pair, monkeypatch, capacity
101
+ ):
102
+ # macOS limits a multiprocessing queue to 32,767 unread items; simulate a tiny limit.
103
+ monkeypatch.setattr(sender_module, "_MAX_OUTSTANDING", 2)
104
+ sender, receiver = make_pair(capacity)
105
+ sender.put(1)
106
+ sender.put(2)
107
+ with pytest.raises(Full):
108
+ sender.put_nowait(3)
109
+ with pytest.raises(Full):
110
+ sender.put(3, timeout=0.05)
111
+ assert receiver.get(timeout=3) == 1
112
+ sender.put(3, timeout=3)
113
+ assert [receiver.get(timeout=3) for _ in range(2)] == [2, 3]
114
+ assert sender.wait_for_all_ack(timeout=3)
115
+
116
+
117
+ @pytest.mark.parametrize("unmap", [False, True])
118
+ def test_sender_unmaps_blocks_only_where_names_keep_them_alive(make_pair, monkeypatch, unmap):
119
+ monkeypatch.setattr(sender_module, "_UNMAP_AFTER_WRITE", unmap)
120
+ sender, receiver = make_pair()
121
+ sender.put(42)
122
+ allocation = next(iter(sender._blocks.values()))
123
+ assert (allocation.buf is None) is unmap
124
+ if not (unmap and sys.platform == "win32"):
125
+ # Windows frees an unmapped block at once, which is why it never unmaps.
126
+ assert receiver.get(timeout=3) == 42
127
+ assert sender.wait_for_all_ack(timeout=3)
128
+
129
+
95
130
  def test_put_timeout_waits_for_capacity(make_pair):
96
131
  sender, _ = make_pair()
97
132
  sender.put(1)
@@ -203,7 +238,9 @@ def test_failed_decode_is_acknowledged_and_next_message_succeeds(make_pair):
203
238
  sender, receiver = make_pair()
204
239
  sender.put(42)
205
240
  name = next(iter(sender._blocks))
206
- sender._blocks[name].buf[0] = ord("!")
241
+ corrupt = SharedMemory(name=name)
242
+ corrupt.buf[0] = ord("!")
243
+ corrupt.close()
207
244
  with pytest.raises(pickle.UnpicklingError):
208
245
  receiver.get(timeout=3)
209
246
  assert sender.wait_for_all_ack(timeout=3)
@@ -269,6 +306,69 @@ def test_context_managers_close_unused_endpoints(make_pair):
269
306
  assert receiver.closed
270
307
 
271
308
 
309
+ def send_then_leave(sender, count, *, fail=False):
310
+ """Leave a `with sender:` block without draining; return the abandoned block names."""
311
+ with sender:
312
+ for index in range(count):
313
+ sender.put(index)
314
+ if fail:
315
+ raise ValueError("producer failed")
316
+ return list(sender._blocks)
317
+
318
+
319
+ @pytest.mark.parametrize(
320
+ ("count", "text"),
321
+ [(1, "1 unacknowledged message that"), (2, "2 unacknowledged messages that")],
322
+ )
323
+ def test_clean_with_exit_warns_about_discarded_messages(make_pair, count, text):
324
+ sender, _ = make_pair(0)
325
+ with pytest.warns(RuntimeWarning, match=f"discards {text}") as caught:
326
+ names = send_then_leave(sender, count)
327
+ assert caught[0].filename == __file__ # Points at the user's `with` statement
328
+ assert sender.closed
329
+ for name in names:
330
+ assert_unlinked(name)
331
+
332
+
333
+ @pytest.mark.parametrize("ending", ["drained", "exception", "close"])
334
+ def test_drained_failed_and_explicit_closes_do_not_warn(make_pair, ending):
335
+ sender, receiver = make_pair()
336
+ with warnings.catch_warnings():
337
+ warnings.simplefilter("error")
338
+ if ending == "drained":
339
+ with sender:
340
+ sender.put(1)
341
+ assert receiver.get(timeout=3) == 1
342
+ assert sender.wait_for_all_ack(timeout=3)
343
+ elif ending == "exception":
344
+ with pytest.raises(ValueError, match="producer failed"):
345
+ send_then_leave(sender, 1, fail=True)
346
+ else:
347
+ sender.put(1)
348
+ sender.close()
349
+ assert sender.closed
350
+
351
+
352
+ def test_process_exit_cleanup_warns_about_discarded_messages(make_pair):
353
+ sender, _ = make_pair()
354
+ sender.put(1)
355
+ with pytest.warns(RuntimeWarning, match="discards 1 unacknowledged message"):
356
+ sender._finalizer()
357
+ assert sender.closed
358
+
359
+
360
+ def test_warning_as_error_still_closes_the_sender(make_pair):
361
+ sender, _ = make_pair()
362
+ sender.put(1)
363
+ name = next(iter(sender._blocks))
364
+ with warnings.catch_warnings():
365
+ warnings.simplefilter("error", RuntimeWarning)
366
+ with pytest.raises(RuntimeWarning, match="unacknowledged"), sender:
367
+ pass
368
+ assert sender.closed
369
+ assert_unlinked(name)
370
+
371
+
272
372
  def test_ack_channel_failure_closes_without_joining_its_own_thread():
273
373
  class BrokenAckQueue(LocalQueue):
274
374
  def get(self, **kwargs):
File without changes