Skip to content

fix(transport): eliminate startup hangs and endpoint hijacking in host-emulation IPC - #31

Merged
nehalkpatel merged 6 commits into
mainfrom
zmq-fixes
Aug 27, 2026
Merged

fix(transport): eliminate startup hangs and endpoint hijacking in host-emulation IPC#31
nehalkpatel merged 6 commits into
mainfrom
zmq-fixes

Conversation

@nehalkpatel

Copy link
Copy Markdown
Owner

Context

This started as a question about ZMQ slow-joiner exposure. The classic slow-joiner bug is not present — there is no PUB/SUB anywhere in this project. Every socket on both sides is ZMQ_PAIR, which has no subscriptions to propagate and blocks in its mute state rather than discarding, so a send with no peer surfaces as kTimeout instead of vanishing while Send() reports success. No XPUB/handshake work was warranted.

What the investigation did surface was a family of related defects: startup paths that hang, state that lies, and two processes silently sharing one endpoint.

What was wrong

Create() could hang forever. bind_cv_.wait() had no deadline, so when the server thread failed to bind — a bad path, a second instance — the catch-all swallowed the throw, the thread exited, and the constructor waited on a predicate that could never become true.

The constructor could std::terminate. If connect() threw, the exception escaped while server_thread_ was joinable, and ~std::thread aborts the process before Create()'s handler runs.

kConnected was a guess. It was set immediately after the asynchronous connect(), so it only ever meant "the constructor ran". WaitForConnection looped while kConnecting and therefore never iterated, leaving connect_timeout dead. shutdown_timeout was never read at all.

Endpoints could be silently stolen. This one overturned the original plan's premise. We assumed bind() fails with EADDRINUSE when an ipc path exists. It doesn't — libzmq unlinks the path first, unconditionally. Measured on this tree: bind over a stale file succeeds unaided, and bind over a path a live process is listening on also succeeds. libzmq removes the live owner's rendezvous name and takes its place. The original keeps its existing connections, because the inode outlives the name, but every later connect() reaches the thief. A second app instance, or a unit test run while the emulator is up, splits the bus in two with no error anywhere.

So stale-file cleanup was solving a non-problem while the real defect ran the other way. The emulator was worse still: it unlinked the socket file itself, unconditionally, making displacement certain rather than merely possible.

What changed

The state machine now says what it actually knows. kReady means "our socket is bound and connect() was issued", and documents that it claims nothing about the peer — peer liveness stays where it is genuinely observable, in the result of the Send you attempted. connect_timeout became startup_timeout with a real consumer; shutdown_timeout was deleted, since std::thread has no timed join and nothing could honour it.

The constructor is now total: never throws, never blocks past startup_timeout, always yields a queryable object. That is what makes the public constructor safe to leave public — a direct caller who ignores StartupStatus() gets a kFailed object whose every call returns kInvalidState, which is loud and bounded rather than an infinite hang.

Endpoint ownership is guarded by an flock-based EndpointLock, with a connect(2) liveness probe behind it for an owner that holds no lock. flock is the right primitive for two reasons: it is kernel-arbitrated, so there is no window to lose, and the lock lives on the open file description so the kernel drops it on process death — a SIGKILLed run leaves nothing stale. An O_EXCL marker would have needed its own liveness check, recreating the problem one level up. The lock file is deliberately never unlinked; removing it would let two processes hold locks on different inodes and both proceed.

The same guards are implemented on the Python side, with matching lock-file naming — that is load-bearing, not cosmetic. If the two disagreed on the path they would each lock a different file and exclude nothing.

The race, measured

A probe followed by a bind is two syscalls with a window between them. Contending it properly needs fork plus a shared-memory spin barrier — launching processes from a shell spreads arrivals over milliseconds and the first binder always wins, which makes the race look absent:

winners per run (12 contenders)
probe only 1, 3, 4, 4, 5, 5, 6, 7
probe + flock 1, 1, 1, 1, 1

Every winner above 1 is a process that believes it owns an endpoint libzmq has already taken from it. ConcurrentBindsProduceExactlyOneOwner makes this permanent.

Tests

Fixtures now synchronise on conditions instead of sleeps. Binds happen on the test thread before the serving thread exists (ordering, not timing); the post-construct sleep became a probe Send, which on PAIR succeeds only once a pipe exists and so is the readiness signal. _wait_for_process_ready had no success exit at all — it slept out its full timeout every call and treated "did not die in 1.1s" as ready; it now waits for the app's ipc file, which the transport binds inside Create().

Two genuine bugs fell out along the way: handler_called/received_data were a data race across the server thread regardless of any sleep, and test_zmq_transport's teardown sleep was load-bearing — it terminated the context before joining, and a context terminated under zmq::poll throws ETERM out of a thread with no handler.

Three C++ tests were verified against the pre-change tree by reverting the production files:

Test Before
CreateFailsFastWhenBindEndpointIsUnbindable hangs (watchdog fires at 10s)
CreateRefusesToStealEndpointFromLiveOwner failshas_value() true, hijack succeeded
CreateSucceedsOverStaleSocketFile passes — not a regression test, and says so

The third is kept as a guard against the new probe over-rejecting: reading "file exists" as "owned" would refuse to start after any crash. On the Python side, test_second_emulator_refuses_to_steal_endpoint was likewise verified to fail against the previous unlink behaviour.

Verification

  • 33 tests green (was 29) in both host-debug and host-release
  • 30 consecutive serial unit runs clean
  • ctest -j8 clean across repeated runs — it was already red before this work, with the same fixtures failing from silent mutual corruption; per-pid endpoints fix that
  • ruff and mypy --strict clean across 13 files
  • Total test time 10.2s -> 4.1s, from removing the sleeps

Not included

Send's retry loop can never retry — total_timeout and SNDTIMEO are both 1000ms, so the first EAGAIN arrives at or after the deadline and max_attempts{3} is dead config. Filed as #30 and deliberately left out as a send-path defect rather than a startup one.

Also still open: the probe->bind sequence remains non-atomic for an owner that holds no lock, and kReady is never invalidated if the emulator dies mid-run.

🤖 Generated with Claude Code

nehalkpatel and others added 6 commits August 27, 2026 05:47
Create() could hang forever. The constructor waited on bind_cv_ with no
deadline, so when the server thread failed to bind -- a bad path, a second
instance -- the catch-all swallowed the throw, the thread exited, and the
predicate could never become true. Bound that wait with startup_timeout and
publish an outcome on every exit from the bind phase, including both catch
blocks. A failed bind now surfaces as kOperationFailed from Create().

The constructor could also std::terminate. If connect() threw, the exception
escaped while server_thread_ was joinable, and ~std::thread aborts the process
before Create()'s handler runs. The constructor is now total: it never throws,
never blocks past startup_timeout, and records failure in startup_error_ for
Create() -- or a direct constructor caller -- to read via StartupStatus().

kConnected was set immediately after the asynchronous connect(), so it only
ever meant "the constructor ran". WaitForConnection looped while kConnecting
and therefore never iterated, leaving connect_timeout dead. Renamed the states
to say what is actually known: kReady means our socket is bound and connect()
was issued, and documents that it claims nothing about the peer. Peer liveness
stays where it is genuinely observable -- the result of the Send you attempted.
connect_timeout becomes startup_timeout with a real consumer; shutdown_timeout
is deleted, as std::thread has no timed join and nothing could honour it.

Also refuse to bind an endpoint a live process already serves. libzmq unlinks
an ipc path before binding it, unconditionally, so it will silently displace a
live listener and take its rendezvous name -- a second app instance, or a unit
test run while the emulator is up, splits the bus in two with no error on
either side. A connect(2) probe now detects that and fails startup instead.
Stale files left by a killed process need no handling: the same unlink already
makes them a non-issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds test_zmq_transport_startup.cpp, in its own binary because its watchdog
hard-exits the process on a regression and must not take other tests with it.

Verified against the pre-change tree by reverting the production files:

  CreateFailsFastWhenBindEndpointIsUnbindable  hangs (watchdog fires at 10s)
  CreateRefusesToStealEndpointFromLiveOwner    fails (has_value() is true --
                                               the hijack succeeded)
  CreateSucceedsOverStaleSocketFile            passes

The third is deliberately not a regression test, and says so: libzmq's own
unlink always made stale files harmless. It guards the new liveness probe
against the opposite mistake -- reading "file exists" as "owned" would refuse
to start after any crash.

The live-owner test asserts more than the error code. It sends a frame to the
contested endpoint afterwards and requires the original owner's dispatcher to
answer it, which is what actually proves the endpoint was not stolen.

Also fixes three unchecked std::expected dereferences in the existing fixtures
-- one bare deref and two value_or(nullptr) followed by a deref. They were
latent before, when Create() could not fail; the startup fixes make them
reachable, so they would have become null derefs rather than test failures.

CTest gains TIMEOUT 60 on every unit test. A transport that used to wedge its
constructor forever should fail CI, not stall it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every fixture waited out a fixed duration where an ordering constraint or an
observable condition was available.

The C++ fixtures each slept 100ms hoping their emulator thread had bound, then
another 100ms hoping the transport had connected. The first is now an ordering
guarantee: the socket is a member bound on the test thread before the serving
thread is created, so the transport's connect() happens-after the bind by
thread creation alone. The second is a probe Send -- on a PAIR socket a send
succeeds only once a pipe to the peer exists, so success IS the readiness
signal, and the emulator loops already skip anything that fails to decode.

test_zmq_transport's teardown slept 100ms before terminating its context. That
sleep was load-bearing: the serving loop has no handler, and a context
terminated under zmq::poll throws ETERM out of the thread, which is
std::terminate. Stopping the loop and joining first removes the sleep and the
hazard together.

The 50ms "connect time" sleep before the unsolicited-data send was never what
made that test work -- the socket had no SNDTIMEO, so its send already blocked
until the pipe came up. It now has bounded timeouts and asserts the result. The
50ms "give handler time to execute" sleep was likewise unnecessary:
HostUart::Receive runs the handler before building the ack, so a reply in hand
already happens-after the handler. But handler_called and received_data were a
genuine data race across the server thread, sleep or no sleep, and are now an
atomic and a mutex-guarded vector.

_wait_for_process_ready had no success exit at all: it slept out its full
timeout on every call and treated "did not die in 1.1s" as ready. It now waits
for the app's ipc socket file to appear, which the transport binds inside
Create(), and raises on timeout instead of falling through. The docstring is
explicit that this proves the bind and nothing further. Verified both branches:
a process that exits early and one that runs without ever binding each fail
with a specific message.

Test endpoints no longer collide with the emulator's real ones -- the transport
fixture had been binding ipc:///tmp/device_emulator.ipc, byte-identical to
HostBoard::Endpoints and DeviceEmulator's defaults.

Total test time drops from 10.2s to 4.1s. 50 consecutive unit runs are green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The connect(2) liveness probe added earlier narrowed the endpoint-hijack window
but could not close it: probing and binding are two syscalls, and another
process can bind in between. libzmq then unlinks whichever socket file it finds,
so both processes end up believing they own the endpoint and neither sees an
error.

Measured, with 12 processes released together by a shared spin barrier so the
window is genuinely contended: the probe alone yields between 1 and 7 winners
per run. flock yields exactly 1, every run.

flock is the right primitive here for two reasons. It is arbitrated by the
kernel, so there is no window to lose. And the lock lives on the open file
description, so the kernel drops it when the fd closes -- including on process
death -- which means a SIGKILLed run leaves nothing stale behind. An O_EXCL
lock file would have needed its own liveness check, recreating the problem one
level up. The lock file itself is deliberately never unlinked: removing it
would let two processes hold locks on different inodes and both proceed.

The probe stays, behind the lock, as the answer for an owner that holds no lock
-- an older build, or anything else listening on that path.

ConcurrentBindsProduceExactlyOneOwner makes the measurement permanent. It forks
twelve contenders through the same barrier and requires exactly one to succeed;
with the lock disabled it reports 4-5 winners.

Test fixtures now derive their endpoints from the pid. gtest_discover_tests
gives each case its own process, so the fixed paths meant `ctest -j` cases
contended for one endpoint -- silently corrupting each other before this change
and failing loudly after. ctest -j8 was already red before any of this work; it
now passes repeatedly. Fixtures also remove their own lock files, which the
transport cannot do for itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Debug preset builds with CODE_COVERAGE, so every run of an instrumented
binary drops a .profraw wherever it was invoked from -- usually the repo root,
where it showed up as untracked noise after each test run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Python side had the same defect as the C++ transport, and worse. libzmq
unlinks an ipc path before binding it, so a second emulator would displace a
running one and take its rendezvous name -- the original keeps its existing
connections, because the inode outlives the name, but every later connect()
reaches the newcomer, with no error on either side. On top of that, run()
unlinked the socket file itself, unconditionally, which made the displacement
certain rather than merely possible.

That unlink is gone. It was never needed: libzmq's own unlink already makes a
file left by a killed process harmless, which is the only case it was meant to
cover.

In its place, the same two guards the C++ side uses, in the same order. A new
endpoint module carries an flock-based EndpointLock -- atomic, so nothing can
slip between the check and the bind, and released by the kernel on process
death, so a crashed run leaves nothing that blocks the next one -- with a
connect(2) liveness probe behind it for an owner that holds no lock. The lock
file naming deliberately matches zmq_transport.cpp; the two must agree or they
do not exclude each other.

start() now waits for the bind outcome rather than spinning on a flag until a
timeout. A failure surfaces immediately with its cause instead of five seconds
later as "failed to start within timeout", and run() publishes that outcome on
every path out of the bind phase, so start() cannot wait for something that
will never arrive.

test_endpoint_ownership.py covers it: a second emulator refusing to start while
the first keeps its endpoint (verified to fail against the previous unlink
behaviour), a stale file not blocking startup, and a lock released by killing
its holder. The last is the reason for flock over an O_EXCL marker, which would
have needed its own liveness check -- the very problem being solved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nehalkpatel
nehalkpatel merged commit 7e16ba5 into main Aug 27, 2026
1 check passed
@nehalkpatel
nehalkpatel deleted the zmq-fixes branch August 27, 2026 06:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant