Repository navigation
Conversation
When a completion's fd is already ready at submission, the kevent() call in submit() returns its event and the callback runs from the completion queue. If that callback returns .rearm, the kqueue filter stays registered but the completion is left in the .dead state. A later cancel then sees a finished completion and does nothing, so the callback keeps firing after the owner believes it cancelled, and possibly freed, the completion. Assisted-by: Claude Opus 5
Add tests for two bugs: Bug mitchellh#209: tick(0) EV_DELETE flush - kqueue tick(0) may leave stale kernel filters when disarming. Six tests added covering multiple disarms, write/read disarm on same fd, rapid cycles, no-op tick, stale filter detection, and the kevent-event-processing race path (fires without fix, passes with fix). Bug mitchellh#170: rearm from non-kqueue completions - when a completion that completes without kqueue registration returns .rearm, it is pushed to submissions with .dead state which gets routed to stop_completion instead of start. Integration test added (fails without fix, passes with fix). Queue unit tests added verifying re-enqueue across queues, drain-and-refill, and rapid cycle invariants. (cherry picked from commit efdf2cb)
…xt in submit Bug mitchellh#209: kqueue tick(0) EV_DELETE flush - Changed early exit condition from 'if (wait == 0) break' to 'if (wait == 0 and changes == 0) break' so pending EV_DELETE kevent changes from the event-processing loop are flushed before tick(0) returns. Bug mitchellh#170: rearm state correction - When a non-kqueue completion (close, shutdown, threadpool fallback) returns .rearm, set flags.state to .adding before pushing to the submissions queue. Previously .dead state caused submit() to route through stop_completion (cancel) instead of start() (resubmit). Issue mitchellh#169: defensive next-pointer clear in submit() - Explicitly clear c.next before push to completions queue when processing kevent-returned events in submit(). This prevents an assertion failure if a completion is present in multiple queues concurrently (race condition). (cherry picked from mitchellh#224; the .rearm arm is merged with this fork's keep-active fix: a completion still registered with kqueue stays .active, one never registered is resubmitted as .adding) (cherry picked from commit a7502d6)
Assisted-by: Claude Opus 5 (cherry picked from commit 6f8278b)
Zig 0.17 removes array multiplication. @Splat builds the same array on 0.16 too. Assisted-by: Claude Opus 5.5
…uns once submit() took events with each batch of 256 changes. Read and write filters are level-triggered, so every batch reported again the completions earlier batches had queued: queueing one twice corrupted the intrusive queue, and a queued completion stayed registered, so the wait in tick() could run its callback a second time. When that callback freed it, the queue then read freed memory. With receipts submit() only learns which changes failed; ready completions arrive through the wait. Nothing left in the completion queue is registered, so tick() no longer deletes from it, which also removes its write past the 256-entry change list once the wait's own disarms had filled it. Assisted-by: Claude Opus 5.5
endel
added a commit
to endel/quic-zig
that referenced
this pull request
Oct 28, 2026
Over 256 ready registrations in one submit() queued a completion twice and ran its callback again after it was freed (mitchellh/libxev#257). Assisted-by: Claude Opus 5.5
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.
On kqueue, when
submit()registers more than 256 changes at once and some are already ready, a completion can be queued twice, and its callback run a second time after the first run freed it.tick()can then also write past its 256-entry change list, onto the stack.Queued twice.
submit()passes an event list with each batch of 256 changes. Read and write filters are added level-triggered, so each batch'skeventreports again every ready completion an earlier batch registered, andsubmit()pushes it onto the intrusive completion queue again. A queued completion also stays registered, so the wait intick()can report it and run its callback, and then the queue runs it again. When the first callback frees the completion, as a server does with a connection that reads EOF, popping the queue reads freed memory.Past the change list.
tick()writes anEV_DELETEfor each completion it disarms from the queue, and checks whether the list is full only after the write; theassert(changes <= events.len)is compiled out in ReleaseFast. When the wait's own disarms have already filled the list, the next write lands past the array.This registers with
EV_RECEIPTand an event list exactly as long as the change list, sosubmit()gets one receipt per change and no pending events. A change that failed is queued with its error, as before; everything else is reported once, by the wait in the sametick(). Nothing in the completion queue is then registered with kqueue, sotick()no longer deletes anything from it, and the write past the list goes with it. A completion whose registration failed and that asks to rearm is registered again, rather than left counted active and never reported. This also removes theNOTEinsubmit()about memory corruption from processing completions there, which may well have been this.We hit this in a game server on quic-zig on macOS: about 1,000 WebSocket clients disconnecting together crashed a ReleaseFast build within 3 to 5 rounds. Guard Malloc caught the use-after-free in
queue.popunderLoop.run, and a hardware watchpoint caughtevents[changes] = evintick()overwriting the server's state on the stack. With this change it ran 8 rounds of 1,024 clients, and 8 of 512 under Guard Malloc.Stacked
Only the last commit is this change. The ones before it are #245 and #224 (with a
zig fmtof #224's tests), which change the same code insubmit()andtick(). I'll rebase this once they're merged or closed.Test
"kqueue: more ready reads than one submit batch each complete once": 600 pipes, each with a byte written and its write end closed, so every read is ready before it is submitted. Each callback must run once, and the loop must end with nothing active. The test raises the open-file soft limit to 1,200 if it is lower, and is skipped if the hard limit is below that. Without the change, a callback runs a second time and its read returns EOF.
The branch sits on #224, from before
mainmoved to Zig 0.17, so it was tested with Zig 0.16.0:zig build test, 72 passedzig build -Demit-example -Demit-bench, andzig buildfor every target in the CI matrixAnd on current
mainwith Zig 0.17.0, together with our other open PRs: macOS 75 passed, 1 skipped; Linux 155 passed, 5 skipped; the examples, benchmarks and every CI target build.CI on
mainfails until #250 moves the workflow to Zig 0.17, so this PR's checks will fail the same way until then.🤖 Generated with Claude Code