Release handover connections directly instead of through disown() - #181
Conversation
WalkthroughThe relay now uses separate cleanup paths for detached handovers and WebSocket teardown. The new Linux integration test creates concurrent handover traffic, checks resource cleanup, and runs in CI against a single-worker relay. ChangesHandover cleanup
Estimated code review effort: 4 (Complex) | ~45 minutes Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
.github/workflows/ci.yml (1)
274-277: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valuerelay-handover.logwhen the test fails.The step only dumps the log if the relay never listens. If the leak assertions fail, no relay output is shown, so a CI failure gives the fd and tick counts without any relay context. Add a conditional dump before
exit $rc.♻️ Proposed change
bash tests/integration_handover_leak.sh ws://127.0.0.1:7788 "$pid" rc=$? + [ "$rc" -eq 0 ] || cat relay-handover.log 2>/dev/null || true stop "$pid"; wait "$pid" 2>/dev/null exit $rc🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/ci.yml around lines 274 - 277, Add a conditional failure-only dump of relay-handover.log after the cleanup commands in the integration handover test step and before exit $rc. Preserve the existing successful path without log output, and ensure the dump runs when the test command returns a nonzero status.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/integration_handover_leak.sh`:
- Line 74: Update the upgrade connection block at
tests/integration_handover_leak.sh:74-74 and the closereq connection block at
tests/integration_handover_leak.sh:85-85 to record connection failures or exit
non-zero instead of silently using exit 0; before evaluating the file-descriptor
and tick assertions, require at least the minimum number of successful
connections so the test confirms the generators exercised releaseHandover().
---
Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 274-277: Add a conditional failure-only dump of relay-handover.log
after the cleanup commands in the integration handover test step and before exit
$rc. Preserve the existing successful path without log output, and ensure the
dump runs when the test command returns a nonzero status.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 22bb510e-71d6-4e66-8099-a817dafc9570
📒 Files selected for processing (4)
.github/workflows/ci.ymltests/integration_handover_leak.shvendor/httpz.patchvendor/httpz/src/worker.zig
Fixes a pre-existing defect in the vendored epoll worker where a connection handed over in the same batch as a WebSocket upgrade could be dropped from every tracking list, leaking its slot and fd and pinning a CPU core at 100% indefinitely.
The bug
processSignalsnapshotshandover_listby takinginner.headand clearinginner, but the snapshot nodes keep theirnext/prev.disown()then dispatches on_state, which is still.handover, so it callshandover_list.remove()on a node that is no longer a member.The general rule is that a snapshot node's links are stale with respect to the live list, and
List.removerewriteshead/tailfrom exactly those two fields. Worker threads keep inserting into that live list throughout, so there is always something to corrupt. Two reachable shapes:[A(.close)]alone:prevandnextare both null, soremove(A)setshead = nullandtail = null, wiping an entry inserted after the snapshot was taken. No WebSocket involved.[B(.websocket), A(.close)]: the.websocketbranch neither removes its node nor releases it, soA.previs stillB.remove(A)leavesheadnull and writestail = B, a non-member; sinceList.insertonly assignsheadwhentail == null, every later handover appends behindBuntil the next drain resets the list.Either way the affected connections are in no list at all: the slot is never released, the fd is never closed, the
HTTPConnis never returned, and they are in neither timeout list, so no sweep can ever reach them.Worse, its epoll registration is the level-triggered
IN|RDHUPfromaccept, so once the peer sends anything or half-closes,epoll_waitreturns it every iteration andrun()skips it via the.handoverstate check. That is an unbounded busy spin on the worker's event-loop thread.This is the same defect class as the timeout-sweep fix that recently landed upstream, in the sibling code path.
Reachability
Remote and unauthenticated: alternate WebSocket upgrades with requests that end in a
.closehandover (Connection: close, an HTTP/1.0GET /for NIP-11, or an upgrade rejected by the per-IP limiter, which answers 429 and then hands over as.close).The fix
releaseHandover()releases a connection the snapshot already detached, without touching the list.disown()is still correct for its other callers, whose connections genuinely are members of the list they name.Clearing
next/prevat snapshot time would not work and is called out in a comment:remove()would then nullhead/tailoutright, discarding entries that worker threads inserted after the snapshot was taken.Verification
Reproduced first, then fixed. Measured under identical load (interleaved upgrades and close-handovers against a single worker), with a temporary probe compiled in to detect the corrupt
head == null, tail != nullstate directly rather than inferring it:The unfixed relay also sat at 99.6% CPU for over six minutes after the load stopped, confirming the busy spin.
tests/integration_handover_leak.shguards it in CI, asserting both symptoms: no CPU burn while idle, and no orphaned fds. Validated by mutation, since a test that cannot fail proves nothing:releaseHandover()back todisown(): fails with500 ticks over 5s(exactly one full core) and orphaned fds over the thresholdAn earlier, weaker version of this test passed on the unfixed build; it needed each upgrade held briefly so its handover sits adjacent to a close, which is the poisoning precondition.
Also green:
zig build test65/65, protocol suite 45/45, concurrency 2/2 at 200 connections, andscripts/verify-vendored-httpz.sh.Review follow-ups
.websocketnode in the snapshot. It does not: a lone.closenode wipes bothheadandtail. Corrected here and in the code comment, since the narrower claim would mislead the next reader into thinking only upgrade traffic can trigger it.ok. It now fails closed on a dead pid..handoverarm ofdisown()is now unreachable. Left as a plain remove rather thanunreachable, so that a wrong analysis degrades to a stale list entry instead of aborting a live relay, with a comment saying so..websocketnode's links are nulled, so the "nothing dereferences them" invariant is local rather than resting onList.insertoverwriting both fields.