fix(transport): eliminate startup hangs and endpoint hijacking in host-emulation IPC - #31
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 askTimeoutinstead of vanishing whileSend()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. Ifconnect()threw, the exception escaped whileserver_thread_was joinable, and~std::threadaborts the process beforeCreate()'s handler runs.kConnectedwas a guess. It was set immediately after the asynchronousconnect(), so it only ever meant "the constructor ran".WaitForConnectionlooped whilekConnectingand therefore never iterated, leavingconnect_timeoutdead.shutdown_timeoutwas never read at all.Endpoints could be silently stolen. This one overturned the original plan's premise. We assumed
bind()fails withEADDRINUSEwhen 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 laterconnect()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.
kReadymeans "our socket is bound andconnect()was issued", and documents that it claims nothing about the peer — peer liveness stays where it is genuinely observable, in the result of theSendyou attempted.connect_timeoutbecamestartup_timeoutwith a real consumer;shutdown_timeoutwas deleted, sincestd::threadhas 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 ignoresStartupStatus()gets akFailedobject whose every call returnskInvalidState, which is loud and bounded rather than an infinite hang.Endpoint ownership is guarded by an
flock-basedEndpointLock, with aconnect(2)liveness probe behind it for an owner that holds no lock.flockis 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 — aSIGKILLed run leaves nothing stale. AnO_EXCLmarker 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
forkplus 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:flockEvery winner above 1 is a process that believes it owns an endpoint libzmq has already taken from it.
ConcurrentBindsProduceExactlyOneOwnermakes 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_readyhad 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 insideCreate().Two genuine bugs fell out along the way:
handler_called/received_datawere a data race across the server thread regardless of any sleep, andtest_zmq_transport's teardown sleep was load-bearing — it terminated the context before joining, and a context terminated underzmq::pollthrowsETERMout of a thread with no handler.Three C++ tests were verified against the pre-change tree by reverting the production files:
CreateFailsFastWhenBindEndpointIsUnbindableCreateRefusesToStealEndpointFromLiveOwnerhas_value()true, hijack succeededCreateSucceedsOverStaleSocketFileThe 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_endpointwas likewise verified to fail against the previous unlink behaviour.Verification
host-debugandhost-releasectest -j8clean across repeated runs — it was already red before this work, with the same fixtures failing from silent mutual corruption; per-pid endpoints fix thatruffandmypy --strictclean across 13 filesNot included
Send's retry loop can never retry —total_timeoutandSNDTIMEOare both 1000ms, so the firstEAGAINarrives at or after the deadline andmax_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
kReadyis never invalidated if the emulator dies mid-run.🤖 Generated with Claude Code