fix(wfengine): close DrainWorker TOCTOU (bookshelf-81hq) #1388
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-81hq"
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
DrainWorker() unlocked to drain outside the mutex, then unconditionally wrote
workerStarted=false. If a newer worker generation started while the old one
was still draining (e.g. two overlapping DrainWorker calls racing a
StartWorker in between), the second DrainWorker call to finish would clobber
the newer worker's
workerStarted=true, silently orphaning it.Fix: add a
workerGencounter incremented on every successful StartWorker.DrainWorker snapshots the generation before releasing the lock to drain, and
only clears workerStarted afterward if the generation is unchanged — so a
newer worker's flag is never clobbered.
Not a data race (fields already mutex-guarded, -race clean before and after)
— a purely logical TOCTOU. Follow-up from PR #1029 / bookshelf-ke3w review.
Test plan
internal/wfengine/engine_concurrent_test.go(
package wfengine_test) deterministically drives the race via controllablestub channels: G1 begins draining and blocks, G2 drains and completes first
(clearing workerStarted), a new StartWorker runs, then G1 finishes and must
not clobber the new worker's flag.
go test -race ./internal/wfengine/...green, 100% coverage on the package.golangci-lint run ./internal/wfengine/...clean.Docs: N/A because internal engine correctness, no user-facing surface.
Closes bead bookshelf-81hq on merge.
Security Review (PR #1388 / bookshelf-81hq)
Scope:
internal/wfengine/engine.go,internal/wfengine/engine_concurrent_test.go—workerGencounter closing the DrainWorker/StartWorker TOCTOU.[MINOR] internal/wfengine/engine.go:731-748 — Doc comment note only, no fix needed
The new doc comment correctly calls out that a concurrent
StartWorkerracing an in-flightDrainWorkerstill allows two goroutines to invokee.drainWorkerBounded()concurrently on the same underlyingwsobject (worth confirming go-workflows'WorkerServer.WaitForCompletion/Starttolerate concurrent invocation), and thatStartWorker's earlyif e.workerStarted { return nil }no-op means a caller racing mid-drain can silently no-op instead of actually restarting the poller once drain finishes. Both behaviors predate this diff (the prior code had the identical race, just with a worse consequence — unconditionally clobbering the flag). This PR strictly narrows the blast radius (no longer orphans a newer worker'sworkerStartedflag) without introducing a new hang/deadlock:workerGenis a monotonically increasing counter compared for equality, so there is no path whereworkerStartedgets stuck false while a real worker is running, or vice versa, from this change alone. Flagging as MINOR only as a paper trail — no change requested.Other angles checked, none flagged:
workerGengate throughStartWorker(increments only after a successfule.startWorker) andDrainWorker(snapshot-before-unlock, clear-only-if-unchanged-after). No path leavesworkerStartedpermanently desynced from reality or wedges the mutex — the lock is always released via the existingUnlock()calls and the new logic adds only a plain uint64 compare under the same lock.wfDB/diagBackend; the retention sweep and SSE monitor lifecycle (lifecycleStarted) are untouched by this diff. No stale-state or cleanup-worker impact.e.mu), no request-supplied data, no logging of sensitive values.engine_concurrent_test.gostayspackage wfengine_test(black-box), uses the existing exportedNewTestEngine/WorkerStarted()test seams — no new white-box access. Deterministic interleaving via channels (g1Entered/g1Blocking) +atomic.Int32call counter, no wall-clock timing assertions, no flake risk per the standard's flake-prevention checklist.newWithFactory's struct literal and two closures (StartBulkCoversGenerateByIDsWorkflow,StartRecalcLibraryMatchScoresWorkflow) wrapped onto multiple lines — cosmetic, no behavior change.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Code Review — PR #1388 (bookshelf-81hq)
Reviewed the diff only (
origin/main...origin/bd-bookshelf-81hq). CI green istaken as behavioral truth; I did not re-run tests.
Correctness walkthrough of the
workerGenfixTraced the interleaving the fix targets:
StartWorkeronly incrementsworkerGen/setsworkerStarted=truewhile holdinge.mu(engine.go:696-698),and
DrainWorkersnapshotsstartGenundere.mubefore unlocking(
engine.go:745-750) and compares undere.muafter re-locking(
engine.go:756-759). All reads/writes ofworkerGenare inside the lock onboth sides — no bare/unsynchronized access, consistent with the
-racecleanCI run.
Walked the actual trigger scenario (confirmed it requires two overlapping
DrainWorkercalls, not a loneStartWorkerracing a singleDrainWorker—a solo
StartWorkerduring a single drain always no-ops becauseworkerStartedstays
trueuntil the drain's own finalLock/Unlock): G1 and G2 both drainconcurrently with
startGen=1; G2 finishes first and clears the flag(
gen==startGen); a realStartWorkerthen runs, bumpinggento 2; G1finishes later, sees
gen(2) != startGen(1), and correctly skips clearingworkerStarted. Verified there is no reverse case where the flag is lefttrueafter a worker was actually drained (workerGenonly advances on asuccessful
StartWorker, andStartWorkeris a no-op wheneverworkerStartedis still
true). The bookkeeping fix is correct and complete for theworkerStarted/workerGenpair itself.Findings
[MAJOR] internal/wfengine/engine.go:731-744 (DrainWorker doc comment) — fix closes the flag race but not the underlying workerSet race it now explicitly documents/tests
The new doc comment (and the new test) establish overlapping
DrainWorker/StartWorkercalls on the same
Engineas an expected scenario the code "handles." But the fixonly synchronizes the Engine's own
workerStarted/workerGenbookkeeping — it doesnot synchronize the underlying
workerSet(internal/wfengine/worker_set.go,unchanged by this diff) that
drainWorkerBounded/e.stopWorker/e.startWorkeractually call.
drainWorkerBoundedrunse.stopWorker()(=ws.waitForCompletion,worker_set.go:183-187) outsidee.mu, and it mutatess.drainedand readss.workerswith no lock of its own (worker_set.go:163-187). MeanwhileStartWorker'scall into
ws.start()(which also mutatess.workers/s.drained,worker_set.go:163-177) runs undere.mu— bute.muis exactly what the drainpath releases for the length of the real drain. So the same overlapping-drain scenario
this PR's own test constructs (two concurrent
DrainWorkercalls, one of which lets areal
StartWorkerrun in between) will, with the real (non-stub)waitForCompletion/start, produce concurrent unsynchronized writes toworkerSet.drained/workerSet.workersfrom two goroutines — a genuine data race that the new test's mocked
stop/startclosures (plain functions with no shared workerSet state) cannot exercise, so
-racestays clean without proving the real path is safe. This is pre-existing workerSet
design, but this PR is the one that newly documents and tests the overlap as a
supported/considered scenario without covering the layer where the actual race lives.
Suggested fix: either (a) add a dedicated
drainMu sync.MutexinEnginethatDrainWorker/StartWorkerhold for the full duration of the realstopWorker/startWorkercall (serializing calls intoworkerSetwithout blockingthe flag bookkeeping under
e.mu), or (b) makeDrainWorkerreject/wait rather thansilently proceed when a drain is already in flight (e.g. a
draining boolguarded bye.mu), so twoDrainWorkercalls never call intoworkerSetconcurrently. Theweaker option is to at least narrow the new doc comment so it doesn't imply the overlap
is safe end-to-end — but a real fix is preferable since production
App.StopWorker(
internal/app/app.go:1280-1297) already runsDrainWorkeron a spawned goroutine,making a doubled call plausible if
StopWorkeris ever invoked twice concurrently(e.g. racing shutdown + test cleanup paths).
Other checks (no findings)
internal/wfengine/engine_concurrent_test.gois black-box (package wfengine_test), var-at-top, oneExpectperIt, and the new spec is fullydeterministic — synchronization is via unbuffered/closed channels and
sync.WaitGroup, nosleep/wall-clock timing, no asserting on asyncside-effects (assertions run only after
wg.Wait())..golangci.ymlchanges;gofmton the diff'sengine.gois clean (thestruct-literal realignment in
newWithFactoryis puregofmt, not abehavior change).
DrainWorkerpath was already covered by
worker_lifecycle_test.gobefore this PR.REVIEW VERDICT: 0 blocker, 1 major, 0 minor
11caacd8967dc7d92c93Code Review — PR #1388 (bookshelf-81hq) — RE-REVIEW after concurrency deep-fix
Walked every interleaving in
worker_set.go's new leader/follower dedup +eager-drained design, cross-checked against
engine.go'sworkerGenTOCTOUguard and both new test files.
Deadlock / hang freedom
waitForCompletion()'s leader path has exactly one exit:mu.Lock() → draining=false, drainErr=err, close(done) → mu.Unlock()(
internal/wfengine/worker_set.go:322-326). There is no early return betweendraining=true(line 300) andclose(done)that could skip the close, soevery leader invocation unconditionally closes
drainDone— a followerparked on
<-done(line 305) cannot hang forever under normal (non-panic)operation. Confirmed no leader path swallows the close.
Locking discipline
All reads/writes of
draining,drainDone,drainErr,s.drained, ands.workershappen unders.mu, including the follower's re-lock after<-doneto readdrainErr(worker_set.go:302-307). The two genuinelyblocking calls —
drainWorkers(fns)(leader) and<-done(follower) — bothrun outside the lock, matching the documented intent.
start()(
worker_set.go:255-283) never readsdraining/drainDoneat all, only thenon-blocking
s.drainedbool, soEngine.mu(held acrossStartWorker→start()) is never held across a drain — confirmed byengine_concurrent_test.go'sStartWorker idempotencyspec.Panic prevention (double
WaitForCompletion)For the intended same-generation overlap (
Stop||DrainWorker,DrainWorker||DrainWorker), the leader/follower dedup correctly prevents asecond call into the underlying go-workflows
Worker.WaitForCompletion—confirmed by the
worker_set_test.go"real Start/Drain overlap" spec, whichexercises real
*goworker.Workerinstances (not just the field-onlybookshelf-ti0zfregression test) and would panic/-raceabort if eitherdouble-drain or start-during-drain occurred.
[MINOR] worker_set.go:292-330 — drain leader/follower dedup is not generation-scoped
draining/drainDone/drainErrare single, ungenerationed fields. IfwaitForCompletion()'s leader for generation K is still blocked past its30s
drainWorkerBoundedtimeout (orphaned goroutine, per the originalbookshelf-ti0zf race) and a concurrent
StartWorkerrebuilds generationK+1 in the interim, a third, later call to
DrainWorker/StoptargetingK+1 can observe
s.draining == true(still true from K's orphaned leader)and become a follower of K's drain, not K+1's. It returns success
without ever calling
WaitForCompletionon K+1's workers, and K+1'spollers keep running untouched.
This is honestly disclosed by the PR itself —
engine.go:731-740'sDrainWorkerdoc says outright "this guard only prevents the new workerfrom being silently orphaned, it does not make the drain itself apply to
the new generation," and the
worker_set_test.go"real Start/Drainoverlap" spec explicitly exercises and accepts this
either-dedup-or-fresh-drain nondeterminism as "safe" (no crash/panic). It
also matches the pre-existing
DrainWorker/Stopguidance to avoidoverlapping calls where possible. Given it's a triple-stacked race
(timeout-induced orphan + concurrent Start + concurrent second Drain) and
explicitly tested/documented rather than a silent regression, I'm grading
this MINOR rather than MAJOR — but recommend a follow-up bead to key
draining/drainDoneto a generation stamp (e.g. capturelen(s.workers)-independent counter alongside
s.workersand compare) so a drain callalways targets the CURRENT generation instead of whichever one happens to
be
drainingat call time. Worth flagging becauseEngine.Stop()closeswfDB/diagBackendright after itsdrainWorkerBounded()call(
engine.goStop()), so in the (documented-as-discouraged) overlap casea still-running generation's pollers would keep polling against soon-to-be-
closed DB pools — not introduced by this diff (pre-existing lack of an
e.stoppedcheck inStartWorker), but worth linking in the follow-up.Other checks
package wfengine_test(
worker_set_test.go:1,engine_concurrent_test.go:1) — black-box,correct.
StartForRaceTest(line ~1851) is a thinpassthrough to production
ws.start();DrainingForRaceTest(line ~1859)reads the real
w.ws.drainingfield underw.ws.mu— both drive realproduction code paths, consistent with
internal/wfengine's existing*ForTestseam convention (not export-to-game-blackbox)..golangci.yml/scripts/check-coverage.shchanges — confirmed viadiff, no new exclusions.
engine.goworkerGenTOCTOU guard (DrainWorkersnapshottinge.workerGenbefore releasing the lock, only clearingworkerStartedifunchanged) is correctly exercised by the new
engine_concurrent_test.go"DrainWorker TOCTOU: newer worker generationstarted mid-drain" spec with a deterministic channel-based interleaving —
good regression coverage for the exact scenario it targets.
s.drained = truedoes not corrupt the normal single-caller flow: adrain that errors still leaves
drained = truecorrectly (the realunderlying
Worker.WaitForCompletionstill permanently closes eachqueue's channel regardless of per-queue error), and a subsequent
already-drained call correctly short-circuits via the
s.drained && !s.drainingno-op branch (worker_set.go:308-311).REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Security/Availability re-review — PR #1388 (bookshelf-81hq)
Scope:
git diff origin/main...origin/bd-bookshelf-81hq(5 files, +330/-25), focused on the workerSet start/drain serialization fix ininternal/wfengine/worker_set.go+engine.go.Traced every leader-exit path in
workerSet.waitForCompletion(worker_set.go:306-340) and howEngine.DrainWorker/Engine.Stopconsume it.[MAJOR] internal/wfengine/worker_set.go:306-340 — leader/follower drain dedup is generation-unaware; a follower can silently report "drained" for a stale generation while the true-current generation keeps running un-drained
waitForCompletion's dedup is keyed purely on the booleans.draining, with no generation/epoch tag tying a follower's wait to the specific worker generation it thinks it's draining. Reachable sequence:DrainWorker(orStop) begins draining generation A;drainWorkerBounded(engine.go:826-843) wraps the real call in a 30s bound and orphans the goroutine if it fires first — this is an intentional, documented behavior for CI/DB-latency stalls, not a hypothetical.DrainWorkerclearsworkerStarted(generation-matched viaworkerGen) and a subsequentStartWorkersucceeds, callingws.start()which — correctly, per this PR's fix — rebuilds fresh Worker instances for generation B while A's orphaned drain is still in flight at the ws level (s.drainingstill true).DrainWorker, orEngine.Stopat true shutdown) arrives whiles.drainingis still true from A's orphaned goroutine. It becomes a follower on A'sdrainDone, not a new leader for B — worker_set.go:308-316. When A's orphaned drain eventually finishes, the follower gets A'sdrainErrand returns, believing generation B has been drained, when in fact generation B's pollers were never touched.Engine.Stop(engine.go:786-811) has no generation check analogous toDrainWorker'sworkerGenguard — it unconditionally closese.wfDBande.diagBackendafterdrainWorkerBoundedreturns "success". If step 3's follower call was insideStop, this closes the DB pools out from under generation B's still-live pollers, which will start failing every DB round-trip (in-flight covers/metadata/scan/enrich activities lost or erroring) instead of being drained cleanly — exactly the "background engine wedge / lost work" class this review was asked to hunt for.The new
worker_set_test.go"real Start/Drain overlap" spec explicitly documents and accepts this ambiguity ("the second drain call... may either dedup against the still-in-flight generation-K leader OR end up performing the first-ever drain of generation K+1 — both are safe") — but "safe" there is scoped to "no panic/no-racefailure", not "drains the generation the caller actually asked to drain". That's a real, if narrow, correctness gap the comment doesn't flag as a limitation anywhere a caller (Engine.Stop) would see it.Reachability in production is narrow — it requires the 30s drain-timeout to actually fire (documented as rare/CI-only) plus an overlapping
StartWorker+ second drain within that window; today's only multi-cycle Start/Stop caller is the e2e harness (App.StartWorker/StopWorker, explicitly for "per-journey" reuse), not steady-state production (which starts/stops the worker exactly once). That narrows it to MAJOR rather than BLOCKER, but it's the direct trade for eliminating the double-WaitForCompletionpanic: the old code crashed loudly on this race, the new code can now succeed silently on the wrong generation.Fix: tag
drainDone/drainErr(anddraining) with a generation counter (mirrorEngine.workerGen, or simply compare the follower's owns.workerssnapshot identity against what the in-flight leader captured). A follower should only dedup against a leader draining the same generation it observes at entry; if the current generation differs from the in-flight drain's generation, it should become the leader for its own generation instead of following a stale one. Alternatively, threadEngine.workerGenthrough tows.waitForCompletion/ws.startso the ws layer refuses to conflate generations at all.[MINOR] internal/wfengine/worker_set.go:306-340 — no
recover()around the leader's blocking drain; an unrelated panic insidedrainWorkers/WaitForCompletion(not the double-call case this PR targets) leavess.drainingpermanentlytrueand any waiting follower blocked forever on<-doneIn practice an unrecovered panic in this goroutine (spawned via
drainWorkerBounded, engine.go:833) crashes the whole process (Go terminates the program on an unhandled goroutine panic), so this doesn't manifest as a silent forever-wedge in isolation — it's a full crash instead, andstart()itself never blocks ondrainDoneso a fresh generation can still start after a crash-free restart. Still, worth a defensiverecover()+close(done)in adeferso any future non-double-call panic degrades to a returned error on followers rather than a hard process crash. Low priority — not a regression introduced by this diff (the underlying call was never protected), just noting since the doc comment now makes strong claims about follower safety that assume the leader always reachesclose(done).Confirmed scope of the change is purely internal worker lifecycle plumbing (
workerSet/Enginefields, generation counter, drain synchronization) — no authn/authz surface, no request-derived input, no new logging of secrets/PII, no SQL/HTTP boundary touched. Nothing here implicates multi-user scoping, SSRF, or CSP.REVIEW VERDICT: 0 blocker, 1 major, 1 minor
7dc7d92c93ebc0a6954debc0a6954db45cb2ce8bSecurity/Availability re-review (round 3) — PR #1388 / bookshelf-81hq
Scope:
internal/wfengine/{engine.go,worker_set.go,worker_set_test.go,engine_concurrent_test.go,export_test.go}diff vsorigin/main. Focus: can the generation-scoped leader/follower drain dedup leave the engine (all background workflow processing: covers/metadata/enrich/scan/bookdrop) permanently wedged, and does it actually close the previously-reported "Engine.Stop closes DB pools under a live newer-generation worker" hole.Trace: every exit path of
workerSet.waitForCompletion()draining && drainGen == generation): unlocks, blocks only on<-d.done(never holdsmuwhile waiting). Thedpointer it captured is read atomically with thedrainGen/generationcheck under the same lock acquisition, so it can never capture a staledfor a mismatched generation.drained && !draining): returns immediately — correct, since a real leader already resolved this exact generation.leaderGen := generation,draining = true,drainGen = leaderGen,drained = true, allocates a fresh*drainResultand unlocks before the blockingrunDrain. On return it re-locks and clearsdrainingonly ifdrainGen == leaderGen(i.e., a newer generation's leader hasn't since taken over the shared flag) — then unconditionally setsd.err/close(d.done)on its own locald, outside the lock.Because each leader owns a private, generation-scoped
*drainResult(never a shared mutable field), an orphaned older-generation leader that finishes late can never corrupt or fail to resolve the channel a same-generation follower is waiting on — it only ever writes to thedit allocated for itself, regardless of whats.drain/s.drainGenhave been overwritten to in the meantime by a newer leader. Verified this holds for the "stale orphan" scenario the PR is fixing (older leader still in flight pastdrainWorkerBounded's 30s bound, newer generation rebuilt viaStartWorker, a third caller arrives): the third caller's generation check correctly fails to dedup against the staledrainGen, so it becomes the CURRENT generation's own leader and genuinely drains the live pollers — it does not fall through to any code path that would leave it unresolved.Panic path:
runDrainwrapsdrainWorkers(fns)inrecover()and synthesizes an error, so a panicking per-queueWaitForCompletionstill reaches thed.err = err; close(d.done)lines in the caller — a same-generation follower can't be stranded by a leader panic.No path leaves
drainingstucktruefor the CURRENT generation without a correspondingd.doneclose, and no path leaves a follower's captureddunresolved. I did not find a hang/DoS in the diff.Does this close the prior finding, or just narrow it?
Closes it. On
origin/main,workerSethad no generation concept at all —draining/drainwere single un-scoped fields, so ANY caller withdraining==truewould dedup onto whatever drain was in flight regardless of which worker generation it belonged to. That's exactly the hole: an orphaned old-generation drain (bounded 30s caller returned, real goroutine still running) would cause a later caller — includingEngine.Stop()— to falsely "succeed" without ever draining the new generation's live pollers, beforeStop()closeswfDB/diagBackendout from under them. The newgeneration/drainGenpair makes the dedup conditional ondrainGen == generation, so a caller for a rebuilt generation always leads its own real drain instead of silently no-op'ing via a stale dedup.internal/wfengine/worker_set_test.go's new "cross-generation drain" spec reproduces this ordering directly and is deliberately RED against the pre-fix dedup logic (Eventuallywould time out) — it's a real regression test, not just a plausibility check.Note this does not change the separate, pre-existing, and intentionally-documented trade-off that
drainWorkerBounded's 30s timeout can still letEngine.Stop()proceed and close pools if a genuinely slow (not orphaned/stale-generation) drain exceeds the bound — that's an existing, explicitly-commented production guard against a 9-minute CI hang, unrelated to and unmodified by this diff.Test coverage
The four new specs in
worker_set_test.go+ the one inengine_concurrent_test.godirectly exercise: (1) realStart/Drainoverlap against actual go-workflowsWorkerinstances (not just field pokes), (2) the cross-generation false-dedup scenario deterministically, (3) the same-generation follower dedup deterministically, and (4) leader panic recovery. This is good regression coverage that matches the hazards traced above rather than just asserting non-determinism.Surface / scoping check
Purely internal worker-lifecycle synchronization state (
workerGen,generation,drainGen,draining,drain *drainResult) — no request-derived input, no auth/authz surface, no per-user scoping concern, nothing newly logged (existingWarnon drain timeout is unchanged), no SQL/network I/O added. Not applicable to multi-user scoping, injection, or CSP rules.Findings
No blockers, no majors.
[MINOR] internal/wfengine/engine.go:685-700 (StartWorker) —
StartWorkerdoes not checke.stoppedbefore starting a new worker generation (only checkse.workerStarted), so aStartWorkercall that races afterEngine.Stop()has already sete.stopped = trueand begun draining can still start a brand-new worker generation. This is pre-existing/unchanged behavior (not introduced by this diff), and it'sworkerGen/generation-adjacent enough to flag given this PR's exact focus on Start/Drain/Stop races — worth a follow-up bead to gateStartWorkeron!e.stopped, but does not block this PR.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Round-3 Code Review — PR #1388 (bookshelf-81hq)
Reviewed
git diff origin/main...origin/bd-bookshelf-81hqat headb45cb2ce: generation-scoped drain dedup (workerSet.generation/drainGen/*drainResult),Engine.workerGen-guardedDrainWorker,runDrainrecover, and the new deterministic cross-generation spec.Correctness walkthrough (verified against the actual diff, not just the doc comments)
waitForCompletion()'s leader/follower gate is exactlys.draining && s.drainGen == s.generation(worker_set.go). Traced the 3-generation interleaving (K orphaned in flight, K+1 leads its own drain, K+2 arrives later): each leader capturesleaderGen := s.generationunder the lock and only clearss.drainingin its post-drain unlock blockif s.drainGen == leaderGen. BecausedrainGenis only ever overwritten by a strictly newer leader (serialized throughs.mu), an orphaned older leader can never clobber a newer leader's in-flight state, and a caller for a newer generation never dedups onto a stale*drainResult. This closes the exact hole the round-2 re-review found.d.err = err; close(d.done)unconditionally after the drain call returns (worker_set.go, end ofwaitForCompletion).runDrain'sdefer recover()guaranteess.runDrain(fns)always returns normally (never propagates a panic), so the only way to skipclose(d.done)would be a panic ins.drains()while still holdings.mubefore the firstUnlock()— that risk is pre-existing (identical shape existed before this PR) and not introduced/worsened by this diff, so not scoring it against round 3.generation,drainGen,draining,drain,drained,workersare read/written only unders.mu.Lock(), except the finald.err = err; close(d.done)pair, which is safe without the lock because each leader owns an exclusive*drainResultit alone writes, and followers only ever readd.errafter receiving from the closedd.donechannel — a valid happens-before edge via channel close/receive, not a data race (confirmed by -race per the completion comment).start()only inspects/writess.drained/s.generation/s.workersunders.mu, copies the per-worker start closures, and unlocks before callingstartWorkers(fns)outside the lock — it never blocks ondrainGen/draining/drain.done.Engine.muis never held acrosse.startWorker/e.stopWorker(both release before their respective blocking calls) — consistent with pre-existing structure.workerGenguard (engine.goDrainWorker): correctly snapshotsstartGenbefore releasinge.mufor the (bounded) drain and only clearsworkerStartedife.workerGen == startGenon return — mirrors the ws-level pattern one layer up.Tests
worker_set_test.go, "workerSet cross-generation drain") drives the realws.start()/ws.waitForCompletion()via theStartForRaceTest/WaitForCompletionForRaceTest/DrainingForRaceTestseams (thin passthroughs to production code, consistent with wfengine's standing export_test allowlist) and uses a genuinely deterministic proof (generation K is never cancelled/released for the spec's lifetime, so the only way the third call can return within the boundedEventuallywindow is by leading its own drain of K+1) — this is a real RED/GREEN regression test, not a flaky timing assertion.runDrain's recover path is exercised directly viaRunDrainPanicForTest(thin passthrough), and the same-generation follower branch is now covered deterministically (separate from the nondeterministic "real Start/Drain overlap" spec). All test files declarepackage wfengine_test(black-box)..golangci.ymlorscripts/check-coverage.shchanges in this diff — no new lint/coverage exclusions.Findings
[MINOR] internal/wfengine/engine.go workerGen field vs internal/wfengine/worker_set.go generation field — two independent, uncoordinated generation counters now exist (
Engine.workerGenat the engine layer,workerSet.generationat the worker-set layer), tracking overlapping but not identical concepts (Engine tracks "has StartWorker run since the last completed drain"; workerSet tracks "which batch of live Worker instances is current"). They happen to stay in lockstep today becauseEngine.StartWorker/DrainWorkerare the only callers ofws.start/ws.waitForCompletion, but the duplication is a latent maintenance trap if a third caller is ever added at either layer. Not a bug in this diff — flagging for awareness; no fix required now, worth a doc note tying the two together explicitly (e.g. "Engine.workerGen and workerSet.generation are deliberately parallel, not shared, because ...").[MINOR] internal/wfengine/engine.go Stop() / internal/wfengine/worker_set.go "GENERATION SCOPING" doc — generation-scoping fixes the false-dedup hole, but a residual, pre-existing limitation remains undocumented at the
Engine.Stop()call site: ifdrainWorkerBounded's 30s timeout previously orphaned an older generation's real drain goroutine that is still running whenStop()later closeswfDB/diagBackend, that orphaned goroutine's in-flight go-workflows calls will hit closed connection pools (this is unchanged by round 3 — round 3 only guaranteesStop()itself now drains the current generation correctly, not that all older orphaned generations have finished). This was already implicitly accepted (the new cross-generation spec explicitly asserts "leaves the orphaned older generation's drain still running") butEngine.Stop()'s doc comment doesn't call this out the wayDrainWorker()'s does. Suggest a one-line addition toStop()'s doc, or a follow-up bead, to keep this documented pre-existing limitation from being lost. Does not block this PR — pre-existing, not introduced/worsened here.No blockers or majors found. The generation-scoping fix correctly closes the cross-generation dedup hole identified in the round-2 re-review,
doneis closed on every leader exit path (including the new recover()), all shared fields are lock-protected except the intentionally lock-free channel-synchronizedd.err/close(d.done)pair,start()remains non-blocking, and the new tests exercise real production code paths deterministically.REVIEW VERDICT: 0 blocker, 0 major, 2 minor