Skip to content

fix(kqueue): stop wakes every runner and drains submitted jobs - #367

Merged
gg582 merged 4 commits into
devfrom
fix/kqueue-queue-stop
Oct 13, 2026
Merged

gg582 merged 4 commits into
devfrom
fix/kqueue-queue-stop

Conversation

@gg582

@gg582 gg582 commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Problem

On kqueue, cwist_io_queue_run() was a while (1) around kevent() with no timeout. cwist_io_queue_stop() only called close(kq_fd).

  • Hang: BSD kernels need not wake a thread already blocked in kevent() when the descriptor is closed. cwist_scheduler_destroy() could then hang in pthread_join().
  • Leak: job wrappers still queued at shutdown were leaked. The Linux io_queue.c drains them.
  • Descriptor reuse: the closed fd number could be reused while a runner was still waiting on it.
  • No coverage: the macOS CI job never ran the scheduler or queue tests, so none of this was caught.

Fix (src/sys/io/kqueue.c)

The kqueue backend now has the same contract as io_queue.c:

  • Stop rules: a running flag and a pending_jobs count. Runners return only once stopped and drained. submit returns false after stop; it counts the job before checking the flag, so no in-flight submit is lost.
  • Wake: stop triggers an EVFILT_USER event (ident 0). The event is registered without EV_CLEAR, so it stays active and every runner wakes, not just the first.
    • A runner that wakes while another runner holds the last jobs backs off for 1ms instead of spinning.
  • Close: the descriptor is closed only in destroy, after the join. destroy first removes the wake event, then frees any queued jobs that never ran.
  • Full GC: runners call cwist_gc_scope_flush() after each job, as io_queue.c does. Without it, test_io_queue_full_gc would fail on macOS.

Tests

  • New tests/test_io_queue_stop.c: works on every backend and fails within 5s instead of hanging. It checks three cases:
    1. 4 idle runners wake on stop.
    2. 2000 jobs submitted before stop all run, and submit fails after stop.
    3. Destroying a stopped queue that no runner ever ran frees its jobs.
  • macOS kqueue CI job: now also runs test_scheduler, test_gc_job_handoff, test_io_queue_full_gc, test_full_gc_ownership_handoff and test_io_queue_stop.

Fixes the new test found in the other backends

  • io_queue.c (Linux), use-after-free: after running a job, the runner cleared func and arg in the popped node. By then that node was the queue's sentinel. Another runner could already have popped past it, retired it and freed it, and the allocator could have reused the memory as a newly submitted job node. Clearing it made that job silently skip. TSan CI caught this as g_ran != JOBS.
    • Fix: pop now copies the callback and argument inside the EBR critical section, and the node is never touched afterwards.
    • Check: with the queue and the test built with TSan and pinned to 2 CPUs, the old code failed 9 of 500 runs and the fix failed 0 of 500.
  • io_select.c (portable fallback): it ran the lock-free Michael & Scott queue with no memory reclamation. pop freed the old sentinel right after its CAS, while another consumer or a producer could still be reading it.
    • Fix: switched to the Michael & Scott two-lock queue. Producers link under tail_lock. Consumers pop under head_lock and copy the job out before unlocking. A node is freed only once nothing can reach it.
    • Check: built on Linux against test_io_queue_stop, it passed 300 of 300 plain runs and 200 of 200 TSan runs, with no race reports.
  • test_latency_probe: the test now waits for each round to finish before starting the next one, instead of coalescing eventfd writes. Without that, a slow runner hung the test.

gg582 added 2 commits October 12, 2026 22:04
cwist_io_queue_run() looped forever on kevent() and cwist_io_queue_stop()
only closed the descriptor. BSD kernels need not wake a thread already
blocked in kevent() on close, so cwist_scheduler_destroy() could hang in
pthread_join(); the queued job wrappers leaked, and the closed number could
be reused while a runner still waited on it.

Match io_queue.c: a running flag and a pending-job count, runners exit only
once stopped and drained, and submit refuses after stop. Stop triggers a
level-triggered EVFILT_USER wake (ident 0) so every runner returns, and the
descriptor is closed only in destroy, after the join, which also frees jobs
nobody ran. Runners now flush the full-GC scope after each job like
io_queue.c does.

New test_io_queue_stop covers idle runners waking, jobs submitted before
stop all running, and destroying an unrun queue; it and the scheduler and
full-GC queue tests now run in the macOS kqueue CI job.
…fd writes

The eventfd is not a semaphore, so writes that land before the callback
reads it merge into one read. On a slow runner (TSan, PR #367 run) fewer
than ROUNDS callbacks ran, the loop never stopped, and the watchdog fired
at pthread_join; TSan then reported the watchdog's plain read of
rounds_done. Wait for each round's callback before the next write, and
make rounds_done atomic.
gg582 added 2 commits October 13, 2026 13:08
The popped node becomes the queue's new sentinel. While its job runs,
another runner can pop the next job, retire this node and have it freed,
and the allocator can hand the memory back to submit() as a new job
node. The runner then cleared node->func on that live node, so the job
was skipped. test_io_queue_stop caught it under TSan (2000 jobs, fewer
ran).

Copy the callback and argument out inside the EBR critical section in
pop and never touch the node again. Same change in the select fallback.
The select fallback ran the lock-free Michael & Scott queue without any
memory reclamation: pop freed the old sentinel right after its CAS, while
another consumer or a producer could still be reading that node (its next
pointer, or the tail it was about to link from).

Switch to the Michael & Scott two-lock queue. Producers link under
tail_lock, consumers pop under head_lock and copy the job out before
unlocking, and a node is freed only after it stopped being the sentinel,
when nothing can reach it any more. This is the portable fallback, so a
lock on each end costs little.
@gg582
gg582 merged commit 878e50d into dev Oct 13, 2026
25 checks passed
gg582 added a commit that referenced this pull request Oct 13, 2026
…fd writes

The eventfd is not a semaphore, so writes that land before the callback
reads it merge into one read. On a slow runner (TSan, PR #367 run) fewer
than ROUNDS callbacks ran, the loop never stopped, and the watchdog fired
at pthread_join; TSan then reported the watchdog's plain read of
rounds_done. Wait for each round's callback before the next write, and
make rounds_done atomic.

(cherry picked from commit c73cab4)
@gg582
gg582 deleted the fix/kqueue-queue-stop branch October 13, 2026 06:47
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.

1 participant