drain_workqueue() tsleep()s holding worker_lock β permanent kernel-thread/workqueue deadlock (drain never returns; worker starved; queue_work callers cascade-wedge)
Summary
Plain tsleep releases no locks (only lksleep does) - the drainer sleeps one tick at a time forever holding the very lock the worker needs to dequeue, so drain NEVER returns on a non-empty queue, the worker blocks in 'lwq', and every later queue_work wedges. Reached from destroy_workqueue (every DRM teardown) with any pending works; observed hanging shutdown itself. The OPPOSITE failure mode of DF-1977 with a distinct root cause. FULLY DETERMINISTIC module PoC (no GPU): drain_done=0 forever, wchan proof (wkdrain-holding vs lwq-blocked); lksleep fix validated on rebuilt drm.ko (drain completes, guest healthy).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4918 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.2 KB | β raw | |
| VERDICT.md | β | 3.3 KB | β raw | |
| df4918.c | β | 7.0 KB | view raw | |
| Makefile | β | 288 B | β download | |
| build.sh | β | 90 B | view raw | |
| run.sh | β | 214 B | view raw | |
| build.log | β | 970 B | view raw | |
| run.log | β | 3.0 KB | view raw | |
| run_fixed.log | β | 650 B | view raw | |
| env.txt | β | 324 B | view raw | |
| fix.diff | β | 794 B | view raw | |
| manifest.json | β | 1.5 KB | view raw | |
| verdict.json | β | 2.6 KB | view raw |
DF-4918 β drain_workqueue() sleeps holding worker_lock β permanent kernel-thread/workqueue deadlock
sys/dev/drm/linux_workqueue.c:276-281:
lockmgr(&worker->worker_lock, LK_EXCLUSIVE);
while (!STAILQ_EMPTY(&worker->ws_list_head)) {
tsleep(&drain_workqueue, 0, "wkdrain", 1); /* plain tsleep */
}
lockmgr(&worker->worker_lock, LK_RELEASE);
Plain tsleep() does not release held locks (only lksleep() does).
The worker thread needs that very lock to dequeue and run the remaining
works (process_all_work() re-acquires at linux_workqueue.c:91), so once
drain_workqueue() acquires the lock of a worker whose queue is
non-empty:
- the drainer loops forever, one tick at a time, holding the lock;
- the worker blocks forever in
lockmgr()(wchanlwq); - every later
queue_work()on the same workqueue blocks forever at linux_workqueue.c:134 β cascading wedge of arbitrary kernel threads.
Distinct from DF-1977 ("drain does not wait for in-flight works" β returns too early): here drain never returns and permanently wedges the wq. Root cause is different (sleep-with-lock-held vs missing in-flight wait).
Reached in-tree from destroy_workqueue() (linux_workqueue.c:246) β every
DRM driver teardown β and from direct drain_workqueue() callers, with
any works still pending on the queue at drain time.
Build (guest, root)
kldload drm # workqueue shim lives in drm.ko
cd /tmp/df4918 && make
Run
kldload /tmp/df4918/df4918.ko
sysctl hw.df4918_test=1 # ~16s; prints per-second state
sysctl hw.df4918_status # re-print frozen state
Expected
Unpatched (baseline run.log / ps output):
[DF4918] t+ 3s..14s: drain_done=0 filler_done=0 slow_done=1 probe_done=1
[DF4918] DEADLOCK CONFIRMED: drain_workqueue() never returned, ...
ps -ax -o wchan: lwq B1 df4918wq <- worker blocked on worker_lock
wkdrain B2 df4918drain <- drainer asleep HOLDING it
Patched (run_fixed.log): drain_done=1 filler_done=1 within seconds,
guest healthy.
The wedge is permanent (threads park; module refuses unload) β reset the
guest afterwards: vm.sh reset with-src.
DF-4918 VERDICT β REPRODUCED (deterministic deadlock, DoS)
What was run
Kernel module df4918.ko on the audit guest (DragonFly 6.5-DEVELOPMENT,
SMP 6 CPU, drm.ko stock and patched), root-loaded, sysctl-triggered:
- own workqueue
alloc_workqueue("df4918wq", WQ_UNBOUND, 1)β single worker (workers[0]), exactly likecreate_singlethread_workqueue. queue_work(slow_fn)β worker dequeues it and sleeps 3 s inside the func (process_all_work released worker_lock for the func body, linux_workqueue.c:88).queue_work(filler_fn)β sits inws_list_headwhile slow_fn runs: queue non-empty, lock free.- drain thread calls
drain_workqueue(wq)βis_draining=true, thenlockmgr(EXCLUSIVE)on workers[0] (succeeds β the lock is free),!STAILQ_EMPTYβtsleep(&drain_workqueue, 0, "wkdrain", 1)with the lock held. - probe thread calls
queue_work(wq, probe)(returns false immediately βis_drainingshort-circuits at :126; on a non-draining wq it would block forever on the held lock).
Result (baseline, stock drm.ko)
slow_done=1at t+3s (func finished, worker tried to re-acquire the lock at process_all_work:91 β blocked).drain_done=0,filler_done=0for the whole 14 s observation window and on every latersysctl hw.df4918_statuspoll β permanent.ps -ax -o wchanproof: workerdf4918wqblocked inlwq(theworker_locklockmgr wait channel β lockinit name at linux_workqueue.c:220), drainerdf4918drainasleep inwkdrainholding that lock. Deadlock closed end-to-end.- The guest had to be hard-reset afterwards; during one shutdown attempt
the wedged threads also hung the shutdown path (guest ended at
db>), i.e. the wedge propagates past the wq itself.
100 % deterministic: two independent baseline runs, identical outcome.
Fix validation
fix.diff (generated against the read-only sys/ tree; applied to the
guest's /usr/src copy, drm.ko rebuilt):
tsleep(...) β lksleep(&drain_workqueue, &worker->worker_lock, 0,
"wkdrain", 1) β releases the lock while sleeping, reacquires on return
(the same primitive the file itself uses at :110 and :382).
Patched kernel, identical test: drain_done=1 filler_done=1 slow_done=1
within seconds, "drain completed (no deadlock) β fixed kernel?", guest
healthy (run_fixed.log).
Impact
Permanent kernel-thread + workqueue wedge (local DoS class, same class as
DF-1976/DF-1977): every destroy_workqueue()/drain_workqueue() with
pending works wedges the caller, the worker(s), and (via worker_lock)
any queue_work() on that wq β driver unload/reset/suspend paths.
No memory-corruption component.
Kernel references
- sys/dev/drm/linux_workqueue.c:265-286 (drain_workqueue: tsleep with worker_lock held at :279)
- sys/dev/drm/linux_workqueue.c:88-91 (process_all_work lock protocol)
- sys/dev/drm/linux_workqueue.c:126-146 (queue_work lockmgr / cascade)
- sys/dev/drm/linux_workqueue.c:220 (lockinit "lwq" β the observed wchan)
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedPatched drm.ko (lksleep) completes the identical test: drain_done=1 filler_done=1 within seconds, no wedge, guest healthy (run_fixed.log).
findings/poc/DF-4918/run_fixed.log; findings/poc/DF-4918/fix.diff
Confirmed kernel references
Detail
Evidence (decisive lines)
run.log (DEADLOCK CONFIRMED + ps wchan lines: df4918wq blocked in 'lwq', df4918drain in 'wkdrain'); run_fixed.log (drain_done=1 filler_done=1 on patched drm.ko); fix.diff
PoC changes
seed was a concept only; module written from scratch: own single-worker workqueue, slow(3s)+filler works, dedicated drain and probe lwkt threads, sysctl hw.df4918_test/hw.df4918_status reporting; module refuses unload after the test (threads wedged)
Verified recommended fix
Replace the tsleep() in drain_workqueue() with lksleep(&drain_workqueue, &worker->worker_lock, 0, "wkdrain", 1) so the worker can drain while the drainer sleeps
Verdict
Deterministic permanent kernel-thread/workqueue deadlock reproduced on the audit guest: drain_workqueue() (linux_workqueue.c:276-281) tsleep()s holding worker_lock, starving the very worker that must drain the queue. Baseline: drain_done=0/filler_done=0 forever, worker blocked in wchan lwq, drainer asleep in wkdrain holding the lock (ps proof in run.log); the wedge also hung a guest shutdown once. Fix (lksleep) validated on rebuilt drm.ko: identical test completes (drain_done=1, filler_done=1), guest healthy. Distinct failure mode and root cause from DF-1977 (premature return / missing in-flight wait).
No comments yet.