Summary
A subscriber's MessageChannel can be permanently and silently killed when a publisher grows its shared-memory segment mid-stream (i.e. when a single message exceeds the current buf_size). The reattach path in MessageChannel.monitor uses bare asserts that are not covered by the loop's caught-exception set, so any transient inconsistency during the resize raises out of the receive loop, ends the channel task, and the subscriber stops receiving from that publisher forever — with no error surfaced to the application. Downstream units that depend on that stream simply stall.
Observed in ezmsg 3.9.0 on a real multi-process pipeline: a bursty upstream (data acquisition catch-up flood) occasionally emits messages far larger than the default 64 KB buf_size, forcing the grow, after which the consuming process silently stops receiving.
Where
Subscriber side — src/ezmsg/core/messagechannel.py, MessageChannel.monitor:
if self.shm is not None and self.shm.name != shm_name:
shm_entries = self.cache.keys()
self.cache.clear()
self.shm.close()
await self.shm.wait_closed()
try:
self.shm = await GraphService(self._graph_address).attach_shm(shm_name)
except ValueError:
logger.info("Invalid SHM received from publisher; may be dead")
raise
for id in shm_entries:
self.cache.put_from_mem(self.shm[id % self.num_buffers]) # (1)
assert self.shm is not None # (2)
assert MessageMarshal.msg_id(self.shm[buf_idx]) == msg_id # (3)
self.cache.put_from_mem(self.shm[buf_idx])
The enclosing try only catches transport errors:
except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError):
logger.debug(...)
So:
- (3) — if the buffer at
buf_idx in the new segment does not carry the expected msg_id (a race window once the publisher has advanced/reused slots during/after the grow), the AssertionError is not caught → propagates out of monitor → the channel task dies. The subscriber silently stops receiving from this publisher for the remainder of the run.
- (1) — the repopulation loop reads carried-over cache entries out of the new segment; a slot that didn't survive the grow/copy raises
UninitializedMemory, which is likewise uncaught and fatal.
Publisher side — src/ezmsg/core/pubclient.py, PublisherClient.broadcast (the grow):
if self._shm.buf_size < total_size:
new_shm = await GraphService(self._graph_address).create_shm(self._num_buffers, total_size * 2)
for i in range(self._num_buffers):
try:
with self._shm.buffer(i, readonly=True) as from_buf:
with new_shm.buffer(i) as to_buf:
MessageMarshal.copy_obj(from_buf, to_buf)
except UninitializedMemory:
pass
self._shm.close() # can raise BufferError: cannot close exported pointers exist
await self._shm.wait_closed()
self._shm = new_shm
self._shm.close() here can raise BufferError: cannot close exported pointers exist if a memoryview into the old segment is still leased when the grow happens. That also propagates out of broadcast (uncaught), killing the publisher's send path.
Why it wedges silently
The failure is an AssertionError/UninitializedMemory/BufferError escaping the channel (or publisher) task rather than a transport exception. monitor's except list does not include these, so the coroutine terminates in its finally (closing the SHM) with no application-visible error. From the app's perspective the stream just goes quiet; nothing raises, nothing logs at warning/error level.
Impact
Any pipeline where cross-process messages can occasionally exceed buf_size (bursty sources, variable-size frames, catch-up floods after a stall) is exposed. Because it only triggers on the grow, it can run for a long time and then wedge under load — and the symptom (a downstream unit stops producing) points nowhere near the transport.
Reproduction
The grow path itself is easy to drive across processes; the wedge additionally needs the racy interleaving (publisher advancing/reusing slots while the subscriber reattaches), which requires real concurrency and is hard to make deterministic. A scaffold that forces the cross-process grow (small buf_size, one oversized message mid-stream, publisher and subscriber in separate processes) reliably exercises (1)/(2)/(3):
from dataclasses import dataclass
from collections.abc import AsyncGenerator
import ezmsg.core as ez
@dataclass
class Blob:
seq: int
payload: bytes
class Gen(ez.Unit):
OUTPUT = ez.OutputStream(Blob, num_buffers=4, buf_size=4096, allow_local=False)
@ez.publisher(OUTPUT)
async def spawn(self) -> AsyncGenerator:
for seq, size in enumerate([8, 8, 16384, 8, 8]): # 16384 > buf_size -> grow
yield self.OUTPUT, Blob(seq, b"x" * size)
raise ez.Complete
class Recv(ez.Unit):
INPUT = ez.InputStream(Blob)
@ez.subscriber(INPUT)
async def on_msg(self, msg: Blob) -> None:
... # count / assert all 5 arrive in order
class Sys(ez.Collection):
PUB = Gen()
SUB = Recv()
def network(self):
return ((self.PUB.OUTPUT, self.SUB.INPUT),)
def process_components(self):
return (self.PUB, self.SUB) # separate processes -> cross-process SHM
ez.run(SYSTEM=Sys())
Suggested fix
Make the reattach path resilient rather than fatal:
- Guard the repopulation loop against
(UninitializedMemory, AssertionError) per slot (skip a slot that didn't survive the grow).
- Replace the bare
assert on the published buf_idx with an explicit check: on msg_id mismatch (or lost SHM), log a warning, _acknowledge(msg_id) to release the publisher's backpressure for that buffer, and continue — dropping at most one message instead of killing the channel.
- On the publisher side, handle the
BufferError from close() when exported pointers are still held during the grow (e.g. defer the close / retry after leases drain) so broadcast cannot die on a resize.
Happy to open a PR — I have a working patch for the subscriber-side hardening plus a cross-process grow test, and it passes the existing core suite (404 passed).
Candidate patch (subscriber-side hardening)
--- a/src/ezmsg/core/messagechannel.py
+++ b/src/ezmsg/core/messagechannel.py
@@ -7,7 +7,7 @@ from uuid import UUID
from contextlib import contextmanager, suppress
from .shm import SHMContext
-from .messagemarshal import MessageMarshal
+from .messagemarshal import MessageMarshal, UninitializedMemory
from .backpressure import Backpressure
from .messagecache import MessageCache
from .graphserver import GraphService
@@ -267,6 +267,14 @@ class Channel:
shm_name = await read_str(reader)
if self.shm is not None and self.shm.name != shm_name:
+ # Publisher grew its shared-memory segment (a message
+ # exceeded buf_size, see PublisherClient.broadcast) and
+ # handed us a new name; detach and reattach. This path is
+ # racy under load, and a transient inconsistency here must
+ # NOT escape the receive loop: an uncaught error would
+ # kill this channel task and silently, permanently stall
+ # the subscriber. Recover instead (log + ack to keep the
+ # publisher's backpressure moving).
shm_entries = self.cache.keys()
self.cache.clear()
self.shm.close()
@@ -282,11 +290,41 @@ class Channel:
)
raise
+ # Repopulate carried-over entries from the new segment. A
+ # slot that didn't survive the grow/copy (uninitialized or
+ # already reused) is skipped rather than fatal.
for id in shm_entries:
- self.cache.put_from_mem(self.shm[id % self.num_buffers])
+ try:
+ self.cache.put_from_mem(self.shm[id % self.num_buffers])
+ except (UninitializedMemory, AssertionError):
+ pass
+
+ if self.shm is None:
+ logger.warning(
+ f"channel:{self.id} -> pub:{self.pub_id}: no SHM for "
+ f"msg {msg_id}; acking to release publisher"
+ )
+ self._acknowledge(msg_id)
+ continue
+
+ # The published buffer must carry the id we were told to read.
+ # After a mid-stream resize a stale/reused slot can mismatch;
+ # drop this message rather than deliver wrong bytes, but still
+ # ack so the publisher's backpressure for this buffer frees
+ # (otherwise it wedges once it wraps num_buffers sends).
+ try:
+ slot_msg_id = MessageMarshal.msg_id(self.shm[buf_idx])
+ except UninitializedMemory:
+ slot_msg_id = None
+ if slot_msg_id != msg_id:
+ logger.warning(
+ f"channel:{self.id} -> pub:{self.pub_id}: SHM buffer "
+ f"{buf_idx} holds msg {slot_msg_id} != expected "
+ f"{msg_id} (post-resize skew); dropping + ack"
+ )
+ self._acknowledge(msg_id)
+ continue
- assert self.shm is not None
- assert MessageMarshal.msg_id(self.shm[buf_idx]) == msg_id
self.cache.put_from_mem(self.shm[buf_idx])
(This addresses the subscriber-side silent kill. The publisher-side BufferError-on-close during the grow is left for discussion — deferring/retrying the close until leases drain seems the natural fix.)
Summary
A subscriber's
MessageChannelcan be permanently and silently killed when a publisher grows its shared-memory segment mid-stream (i.e. when a single message exceeds the currentbuf_size). The reattach path inMessageChannel.monitoruses bareasserts that are not covered by the loop's caught-exception set, so any transient inconsistency during the resize raises out of the receive loop, ends the channel task, and the subscriber stops receiving from that publisher forever — with no error surfaced to the application. Downstream units that depend on that stream simply stall.Observed in ezmsg 3.9.0 on a real multi-process pipeline: a bursty upstream (data acquisition catch-up flood) occasionally emits messages far larger than the default 64 KB
buf_size, forcing the grow, after which the consuming process silently stops receiving.Where
Subscriber side —
src/ezmsg/core/messagechannel.py,MessageChannel.monitor:The enclosing
tryonly catches transport errors:So:
buf_idxin the new segment does not carry the expectedmsg_id(a race window once the publisher has advanced/reused slots during/after the grow), theAssertionErroris not caught → propagates out ofmonitor→ the channel task dies. The subscriber silently stops receiving from this publisher for the remainder of the run.UninitializedMemory, which is likewise uncaught and fatal.Publisher side —
src/ezmsg/core/pubclient.py,PublisherClient.broadcast(the grow):self._shm.close()here can raiseBufferError: cannot close exported pointers existif a memoryview into the old segment is still leased when the grow happens. That also propagates out ofbroadcast(uncaught), killing the publisher's send path.Why it wedges silently
The failure is an
AssertionError/UninitializedMemory/BufferErrorescaping the channel (or publisher) task rather than a transport exception.monitor'sexceptlist does not include these, so the coroutine terminates in itsfinally(closing the SHM) with no application-visible error. From the app's perspective the stream just goes quiet; nothing raises, nothing logs at warning/error level.Impact
Any pipeline where cross-process messages can occasionally exceed
buf_size(bursty sources, variable-size frames, catch-up floods after a stall) is exposed. Because it only triggers on the grow, it can run for a long time and then wedge under load — and the symptom (a downstream unit stops producing) points nowhere near the transport.Reproduction
The grow path itself is easy to drive across processes; the wedge additionally needs the racy interleaving (publisher advancing/reusing slots while the subscriber reattaches), which requires real concurrency and is hard to make deterministic. A scaffold that forces the cross-process grow (small
buf_size, one oversized message mid-stream, publisher and subscriber in separate processes) reliably exercises(1)/(2)/(3):Suggested fix
Make the reattach path resilient rather than fatal:
(UninitializedMemory, AssertionError)per slot (skip a slot that didn't survive the grow).asserton the publishedbuf_idxwith an explicit check: onmsg_idmismatch (or lost SHM), log a warning,_acknowledge(msg_id)to release the publisher's backpressure for that buffer, andcontinue— dropping at most one message instead of killing the channel.BufferErrorfromclose()when exported pointers are still held during the grow (e.g. defer the close / retry after leases drain) sobroadcastcannot die on a resize.Happy to open a PR — I have a working patch for the subscriber-side hardening plus a cross-process grow test, and it passes the existing core suite (404 passed).
Candidate patch (subscriber-side hardening)
(This addresses the subscriber-side silent kill. The publisher-side
BufferError-on-close during the grow is left for discussion — deferring/retrying the close until leases drain seems the natural fix.)