Skip to content

Fix kqueue running a ready completion twice when over 256 changes are submitted - #257

Open
endel wants to merge 6 commits into
mitchellh:mainfrom
endel:kqueue-receipts
Open

endel wants to merge 6 commits into
mitchellh:mainfrom
endel:kqueue-receipts

Conversation

@endel

@endel endel commented Oct 28, 2026

Copy link
Copy Markdown

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's kevent reports again every ready completion an earlier batch registered, and submit() pushes it onto the intrusive completion queue again. A queued completion also stays registered, so the wait in tick() 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 an EV_DELETE for each completion it disarms from the queue, and checks whether the list is full only after the write; the assert(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_RECEIPT and an event list exactly as long as the change list, so submit() 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 same tick(). Nothing in the completion queue is then registered with kqueue, so tick() 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 the NOTE in submit() 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.pop under Loop.run, and a hardware watchpoint caught events[changes] = ev in tick() 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 fmt of #224's tests), which change the same code in submit() and tick(). 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 main moved to Zig 0.17, so it was tested with Zig 0.16.0:

  • macOS (aarch64, kqueue): zig build test, 72 passed
  • Linux (aarch64, epoll and io_uring): 146 passed, 3 skipped
  • zig build -Demit-example -Demit-bench, and zig build for every target in the CI matrix

And on current main with 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 main fails 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

endel and others added 6 commits September 18, 2026 23:08
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
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.

2 participants