Skip to content

SHM transport is assumed from TCP reachability - publishers hard-fail across shared-memory boundaries (WSL, containers, user sessions) #262

Description

@KonradPilch

What is the issue?

ezmsg's shared-memory segments are created by the GraphServer process and handed to clients by name to attach in their own process (shm.py):

  • server side: SharedMemory(size=..., create=True) inside the server's SHMInfo,
  • client side: GraphService.create_shm(...) returns the segment name over TCP, and the client runs SharedMemory(name=name, create=False) (in SHMContext).

This silently assumes that anything that can reach the GraphServer over TCP also shares its shared-memory namespace. That assumption breaks in increasingly common setups where TCP crosses a boundary SHM cannot:

  • WSL2 <-> Windows: WSL forwards 127.0.0.1 ports both ways, so a Windows client connects happily to a Linux-side GraphServer, but then SharedMemory(name='psm_xxxxxxxx', create=False) raises FileNotFoundError: [WinError 2], because the segment exists only in the Linux kernel.
  • Containers with host/forwarded networking but no shared /dev/shm.
  • Two user sessions on one machine: same OS, reachable TCP, but SHM access denied -> PermissionError.

The asymmetry that makes it a bug rather than a limitation

The subscriber side already treats SHM as a capability to be probed: the channel handshake in messagechannel.py attaches inside a try/except (ValueError, OSError) and answers the publisher with Command.SHM_ATTACH_FAILED, falling back to TCP for that channel. So, subscriber-side boundary crossings degrade gracefully by design.

On the other hand, the publisher side has no such path: Publisher setup calls graph_service.create_shm(num_buffers, buf_size) (in pubclient.py) and attaches the returned name unguarded. The FileNotFoundError/PermissionError propagates out of publisher creation and takes the whole pipeline down, with a traceback pointing at multiprocessing/shared_memory.py rather than at the transport decision that caused it.

Manual 'escape hatches' exist (force_tcp=True per OutputStream, EZMSG_FORCE_TCP=1 process-wide, EZMSG_ALLOW_LOCAL). In fact, in
test_attach.py, we lean one of these escape hatches:

# Force TCP on ack messages to ensure delivery
# and avoid SHM deallocation between processes
ACK = ez.OutputStream(str, force_tcp=True)

The thing is that these workarounds require the user to already know exactly what went wrong, which is precisely what the error does not tell them.

Steps to reproduce

  1. Inside WSL2: start a GraphServer (ezmsg serve --address 0.0.0.0:25978).
  2. From Windows (same machine): run any pipeline against it, e.g. EZMSG_GRAPHSERVER_ADDR=127.0.0.1:25978 python examples/ezmsg_intro.py.
  3. Publisher creation fails with FileNotFoundError: ... 'psm_xxxxxxxx' from SharedMemory.__init__. (Same-OS variant: run server and client as two different users -> PermissionError.)

Contrast: a subscriber-only client crossing the same boundary degrades to TCP via SHM_ATTACH_FAILED.

Solution criteria

  • A client that can reach the graph over TCP must never hard-fail because an SHM assumption was wrong. The worst outcome should be TCP transport plus a one-time diagnostic naming the boundary.
  • SHM eligibility must be established by capability (try to attach), not by address heuristics. For example, 127.0.0.1 does not imply a shared SHM namespace (WSL forwarding, containers) and same-host does not imply permission (user sessions).
  • No regression for the true same-namespace case: SHM remains the default fast path, decided once per publisher/channel, not per message.
  • If any case remains unrecoverable, the error must name the transport decision: topic, publisher, graph address, and a pointer to force_tcp / EZMSG_FORCE_TCP, but never a bare shared_memory.py traceback.
  • Observability: the profiling snapshot already labels channels LOCAL/SHM/TCP. A fallen-back publisher should be visible there the same way.

Possible solutions

  1. Mirror the subscriber's fallback on the publisher path (surgical). Wrap the publisher's create_shm-then-attach in the same capability probe: on OSError, release the lease and continue with force_tcp-equivalent behaviour for that publisher (the TCP path already exists and is exercised wherever force_tcp=True is used today), logging one warning naming the boundary. This restores symmetry with SHM_ATTACH_FAILED and fixes WSL, containers, and permission splits in one move.
  2. Cheap namespace probe at session start. When a client first connects, it attaches a tiny well-known probe segment the server creates at boot (or reads a per-boot token from it). Success -> SHM eligible for the session. Failure -> the client requests TCP-only leases up front. Avoids per-publisher discovery cost and lets the graph server know (and report) which clients are SHM-capable.
  3. Identity handshake heuristic (defense in depth, not sufficient alone). Exchange a machine/boot identity in the client handshake and require equality before offering SHM. Catches the cross-kernel cases (WSL, containers) early with zero SHM syscalls, but misses same-host permission splits, so it complements options 1) and 2) rather than replacing them.
  4. At minimum (if no behaviour change is wanted): catch the attach failure at both creation sites in pubclient.py and re-raise with an actionable message ("publisher for topic X cannot attach SHM created by GraphServer at Y, so you are likely across a WSL/container/user boundary, so set force_tcp=True or EZMSG_FORCE_TCP=1"), and document the boundary limitation alongside force_tcp.

Note: fixing this also de-fangs the companion issue about the test suite attaching to foreign servers. The failures there were this defect surfacing through tests. But the test-isolation problem is worth fixing independently (stale foreign graph state remains harmful even with graceful TCP fallback).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions