Repository navigation
fix(kqueue): stop wakes every runner and drains submitted jobs - #367
Merged
Merged
Conversation
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.
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
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)
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.
Problem
On kqueue,
cwist_io_queue_run()was awhile (1)aroundkevent()with no timeout.cwist_io_queue_stop()only calledclose(kq_fd).kevent()when the descriptor is closed.cwist_scheduler_destroy()could then hang inpthread_join().io_queue.cdrains them.Fix (
src/sys/io/kqueue.c)The kqueue backend now has the same contract as
io_queue.c:runningflag and apending_jobscount. Runners return only once stopped and drained.submitreturns false after stop; it counts the job before checking the flag, so no in-flight submit is lost.stoptriggers anEVFILT_USERevent (ident 0). The event is registered withoutEV_CLEAR, so it stays active and every runner wakes, not just the first.destroy, after the join.destroyfirst removes the wake event, then frees any queued jobs that never ran.cwist_gc_scope_flush()after each job, asio_queue.cdoes. Without it,test_io_queue_full_gcwould fail on macOS.Tests
tests/test_io_queue_stop.c: works on every backend and fails within 5s instead of hanging. It checks three cases:test_scheduler,test_gc_job_handoff,test_io_queue_full_gc,test_full_gc_ownership_handoffandtest_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 clearedfuncandargin 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 asg_ran != JOBS.popnow copies the callback and argument inside the EBR critical section, and the node is never touched afterwards.io_select.c(portable fallback): it ran the lock-free Michael & Scott queue with no memory reclamation.popfreed the old sentinel right after its CAS, while another consumer or a producer could still be reading it.tail_lock. Consumers pop underhead_lockand copy the job out before unlocking. A node is freed only once nothing can reach it.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.