β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-4918

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4918 Β· 13 files
FileTypeDescriptionSize
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() (wchan lwq);
  • 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.

VERDICT.md
↓ download raw

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:

  1. own workqueue alloc_workqueue("df4918wq", WQ_UNBOUND, 1) β†’ single worker (workers[0]), exactly like create_singlethread_workqueue.
  2. 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).
  3. queue_work(filler_fn) β€” sits in ws_list_head while slow_fn runs: queue non-empty, lock free.
  4. drain thread calls drain_workqueue(wq) β†’ is_draining=true, then lockmgr(EXCLUSIVE) on workers[0] (succeeds β€” the lock is free), !STAILQ_EMPTY β†’ tsleep(&drain_workqueue, 0, "wkdrain", 1) with the lock held.
  5. probe thread calls queue_work(wq, probe) (returns false immediately β€” is_draining short-circuits at :126; on a non-draining wq it would block forever on the held lock).

Result (baseline, stock drm.ko)

  • slow_done=1 at t+3s (func finished, worker tried to re-acquire the lock at process_all_work:91 β†’ blocked).
  • drain_done=0, filler_done=0 for the whole 14 s observation window and on every later sysctl hw.df4918_status poll β€” permanent.
  • ps -ax -o wchan proof: worker df4918wq blocked in lwq (the worker_lock lockmgr wait channel β€” lockinit name at linux_workqueue.c:220), drainer df4918drain asleep in wkdrain holding 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

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched 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
↓ fix.diffper-fix-DF-4918

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).