fix(wfengine): guard stopped/workerStarted with mutex (bookshelf-ke3w) #1029
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-ke3w"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
sync.MutextoEnginestruct protecting thestoppedandworkerStartedbool fieldsStartWorkerholds the lock across check + start + set — idempotent under concurrent callsStoplocks for the guard check+set, drains and closes pools outside the lockDrainWorkerlocks for the guard check, drains outside, locks again to resetworkerStartedWorkerStarted()inexport_test.goreads under the lockengine_concurrent_test.go: black-box race tests asserting 10 concurrentStartWorkercalls invokestartWorkerexactly once, and 10 concurrentStopcalls invoke drain exactly onceSetDrainTimeoutForTest(-1)comment inworker_lifecycle_test.goTest plan
go test -race ./internal/wfengine/...passes (no race detector reports)engine_concurrent_test.gospecs: StartWorker idempotency + Stop idempotency under 10 concurrent goroutines — passmake testpasses across all internal packagesCloses bead bookshelf-ke3w on merge.
CODE REVIEW: APPROVED
Reviewed diff only (no test re-runs; CI is the behavioral source of truth).
Phase 0: DEMO verification
No DEMO block required for this bead type (concurrency guard / internal refactor with no user-facing surface). Proceeding directly to spec compliance and code quality.
Phase 1: Spec compliance
All four stated goals are met:
sync.Mutexadded toEngine;StartWorkerholds the lock for check+start+set;StopandDrainWorkerlock for the guard check/set but drain outside the lock.Phase 2: Code quality
Concurrency correctness — detailed findings:
NO DEADLOCK — PASS
startWorkerandstopWorkerarefuncfields (engine.go:189–190), notEnginemethods. They cannot re-acquiree.mu.drainWorkerBounded(engine.go:1133–1155) also holds no lock. SoStartWorkerholdinge.muviadefer e.mu.Unlock()while callinge.startWorker(ctx)is safe — there is no re-entrant lock path.Lock/unlock pairing is clean on every path:
StartWorker(engine.go:1029–1041):Lock()+defer Unlock()— all three exit paths (idempotent return, error return, success return) unlock correctly.DrainWorker(engine.go:1066–1080): early-return path does explicitUnlockbeforereturn nil; normal path doesUnlock, drain, re-Lock,Unlock— both paths fully paired. On drain error, the function returns without the re-lock, leavingworkerStarted=true(correct: drain failed so the worker may still be live, keeping the state true enables a retry).Stop(engine.go:1093–1115): early-return path does explicitUnlockbeforereturn nil; normal path setsstopped=true, capturesworkerWasStarted, thenUnlockbefore the drain. Fully paired on all exit paths.NO REMAINING RACE — PASS
Every read and write of
stoppedandworkerStartedis undere.mu:StartWorker: readworkerStarted(line 1032) and writeworkerStarted=true(line 1038) — both underdefer Unlock.DrainWorker: reade.stopped || !e.workerStarted(line 1068) under lock; writeworkerStarted=false(line 1078) under re-acquired lock.Stop: reade.stopped(line 1095) and writee.stopped=true(line 1097) under lock; reade.workerStartedintoworkerWasStarted(line 1100) under same lock before unlocking.WorkerStarted()in export_test.go (line 368–371): readse.workerStartedunderLock()+defer Unlock(). ✓No unsynchronized access remains.
-racewill find nothing.StartWorker idempotency — PASS
Check (
workerStarted), call (startWorker), and set (workerStarted=true) are all within the scope ofdefer e.mu.Unlock(). Concurrent goroutines queue on the lock; whichever gets it first will either seeworkerStarted=trueand early-return, or start and set it — the next waiter will then seeworkerStarted=trueand return. Exactly one worker is ever started.Stop-after-Start ordering — PASS
Stopsetsstopped=trueand readsworkerWasStartedatomically under the lock before unlocking. Subsequent concurrentStopcalls seestopped=trueand return immediately. The drain happens exactly once.Captured-state pattern — PASS
StopcapturesworkerWasStarted := e.workerStartedwhile holding the lock (engine.go:1100), then unlocks (line 1101), then branches on the captured value. No shared mutable state is read after the unlock. Correct.DrainWorker resetting workerStarted — PASS (with one MINOR)
The re-lock at engine.go:1077 correctly gates the
workerStarted=falsewrite. However:[MINOR] internal/wfengine/engine.go:1072–1079 — DrainWorker re-lock creates a logical TOCTOU window for workerStarted
Between the first
Unlock(line 1072, after the guard check) and the re-Lock(line 1077, after drain), a concurrentStartWorkercall can acquire and release the lock, setworkerStarted=true, and start a new worker. DrainWorker then re-locks and unconditionally writesworkerStarted=false, silently orphaning the new worker (it runs but is no longer tracked; futureDrainWorker/Stopcalls will skip it). This is not a data race (the-racedetector won't catch it) but a logical race requiring the caller to overlapStartWorkerandDrainWorker. The DrainWorker docstring says "StartWorker may be called again after DrainWorker returns" — not during — so this is an edge case requiring unusual API misuse. This issue is architecturally pre-existing (the PR did not introduce the unlock-drain-relock structure; it only added the lock). Suggested fix for a follow-up: after drain, re-check!e.stoppedunder the re-lock before writingworkerStarted=false, or document the "do not call StartWorker while DrainWorker is in flight" constraint in the function's doc comment.Tests — PASS
engine_concurrent_test.go:package wfengine_test— black-box ✓sync.WaitGroup) ✓sync/atomic.Int32for the shared counter — no data race on the counter itself ✓StartWorkeridempotency: assertsstartCount == 1andWorkerStarted() == trueafter 10 concurrent calls ✓Stopidempotency: assertsstopCount == 1after 10 concurrent calls; the stub wired asstopWorkeris whatdrainWorkerBoundedcalls ✓WorkerStarted()shim reads under lock ✓No white-box tests. No coverage exclusions added. No new
.golangci.ymlexclusions observed in the diff.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
2a443499e3d41b732160