fix: make the gossip autoconnect task abortable - #2733
Conversation
Session close could hang forever under peer churn. `Runtime::close_inner` waits on `terminate_all_async()`, which waits for every tracked task with no timeout, and gossip's autoconnect was spawned with `spawn` -- so the cancellation token never reached it, and it sat in `connect_peer()` against a peer that was itself shutting down. That does not meet `spawn`'s documented contract, which requires the task to be cancellable or to finish in finite time; connecting to a remote peer is neither. `spawn_abortable` is what the multicast scouting side already uses for the same work (`autoconnect_all`, orchestrator.rs:343 and :360). The task stays tracked, so shutdown still waits for it, but it now observes the token and finishes. `token.cancel()` is only reached from `terminate_all` and `terminate_all_async`, so nothing changes during normal operation. Adds a regression test, which fails without this change with "close operation timed out" and passes with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The close-deadlock fix is still not upstream -- eclipse-zenoh/zenoh#2733 is open and gossip.rs is unchanged on release/1.10.0 and on main -- so the patch stays. It applies to 1.10.0 without a rebase; only the pinned revision moves, to the one zenoh-c 1.10.0's lockfile resolves. Worth the bump for two fixes that land on this topology: multicast scouting now works on loopback (macOS refused Scout from a loopback-bound socket, and Hello omitted loopback locators, which broke loopback-only hosts everywhere), and the runtime reconnects on transport error paths it used to miss. Also drop the ZENOHCXX_BUILD_* spellings, which 1.10.0 no longer declares at all, and correct the dependency table -- zenoh-cpp was listed at 1.4.0 and zenoh-c, the patched one, was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQCyThaFqnYekXQyPUufMm
|
The cause is actually a regression of PR #2012 due to PR #2173
Issue #2675 is also due to this. We hit this independently and arrived at the same diagnosis, so first: this analysis is correct, and The fix looks incomplete, though: there is a second call site with the same defect, and this PR does not cover it. self.spawn(async move {
if let Ok(zid) = this.peer_connector_retry(peer).await {That is the configured Reproducing it without gossip at allThe gossip route needs two peers and multicast churn. The configured route needs neither, which makes for a smaller repro:
The trigger condition is narrower than "unreachable"A refused connection returns immediately and the retry sleep that follows is already cancellable via This is also why we think #2675 ("session.close() blocks ~10s ... when the peer link has active bursty packet loss") is the same root cause reported from the user side — packet loss on an established link stalls the same await. Severity boundThe stall does not stack across endpoints — three configured endpoints still cost one One more site worth a look, not asserted
AI disclaimer - much of this was found when I was doing work with an agent, and I had it investigate and write things up |
Description
Session close could hang forever under peer churn.
Runtime::close_innerwaits onterminate_all_async(), which waits for every tracked task with no timeout, and gossip's autoconnect was spawned withspawn-- so the cancellation token never reached it, and it sat inconnect_peer()against a peer that was itself shutting down.That does not meet
spawn's documented contract, which requires the task to be cancellable or to finish in finite time; connecting to a remote peer is neither.spawn_abortableis what the multicast scouting side already uses for the same work (autoconnect_all, orchestrator.rs:343 and :360). The task stays tracked, so shutdown still waits for it, but it now observes the token and finishes.token.cancel()is only reached fromterminate_allandterminate_all_async, so nothing changes during normal operation.Adds a regression test, which fails without this change with "close operation timed out" and passes with it.
What does this PR do?
Spawns gossip's autoconnect task with
spawn_abortableinstead ofspawn, sothat session close can no longer wait on it for ever.
It also adds a regression test,
zenoh/tests/close_under_peer_churn.rs.Why is this change needed?
Two peer sessions that discover each other over multicast and then open and close repeatedly deadlock forever inside session close. One process alone always finishes. Two, started together, hang within a handful of cycles and never
recover.
The wait is in
Runtime::close_inner, on its first await - found by instrumenting the two lines, not by reading them. The marker beforeterminate_all_asyncprints; the one after it never does, somanager.close()is never reached and the transports are never closed:terminate_all_async()is unbounded - it waits for every tracked task, with no timeout:and the task it waits for is gossip's autoconnect, spawned with
spawn:token.cancel()therefore does nothing to it, and it is sitting inconnect_peer()against a peer that is itself shutting down.spawn's own documentation says it is for a task thatand
terminate_allputs that obligation explicitly on the caller:Connecting to a remote peer is not finite, and this task holds no cancellation token, so neither branch of the contract holds. The equivalent task on the multicast scouting path - the same job, connecting to a peer that discovery just turned up - already uses
spawn_abortable:autoconnect_all(orchestrator.rs:343,:360) and the scoutingresponder(orchestrator.rs:223,:343,:355). Gossip looks like the odd one out.spawn_abortablestill tracks the task, soterminate_all_asyncstill waits for it; the task simply now observes the token and finishes instead of never.token.cancel()is only ever reached fromterminate_allandterminate_all_async, so this changes nothing during normal operation - aborts happen only at shutdown.How to reproduce
A ~20-line program, default config, nothing else:
One process alone always completes all 50 cycles. Two hang, typically by cycle 3-12; the last line each prints is the cycle it died on, and it never recovers (observed >10 minutes). While hung, every zenoh runtime worker is idle in
keventwith nothing runnable, so this is a wakeup that never arrives rather than contention with a running thread.Related Issues
Related to #2409 and tokio-rs/tokio#7892 (the
block_in_placefamily).Not fixed by #2637, which was the obvious candidate - same file, same family (
block_in_placeparking thectrl_lockholder on a tokio mutex under peer churn, which is exactly this workload). It was applied in full and measured first: still hung 6 runs out of 8. Its two hunks are interminate_peer_connector_zid's callers; this is a different task in the same function that is never cancelled. Both look worth having; only this one closes this hang.🏷️ Label-Based Checklist
Based on the labels applied to this PR, please complete these additional requirements:
Labels:
bug🐛 Bug Fix Requirements
Since this PR is labeled as a bug fix, please ensure:
Why this matters: Bugs without tests often reoccur.
Instructions:
- [ ]to- [x])This checklist updates automatically when labels change, but preserves your checked boxes.