Pin the executor drive's drain queue to .userInitiated QoS: a job resumed from a background thread could starve for minutes and wedge settle() under a saturated parallel run - #70
Conversation
…sumed from a background thread ran at background QoS and could starve for minutes, wedging settle() under a saturated parallel run Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…onfiguration Forcing every drive job to .userInitiated changed how drive jobs are scheduled against work the drive cannot see: Task.yield() inside a task that prefers the drive resumes on the global pool, not on the drive (verified with a counting executor: one enqueue for a six-yield task), and on CI's 3-core runners a settle() fixpoint check then outran a yielding child it could not count (ExecutorDrainSettleTests.settleIsLoadIndependentAcrossChildTasks). A queue QoS without .enforceQoS raises low-QoS submissions to the floor and leaves higher ones as they were, which is all the starvation fix needs. The regression test now asserts the queue's configured QoS. The behavioural version resumed a task from a background GCD block, which the TSan job's saturated runner could not schedule inside the test budget. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Second push: the queue QoS is now a floor (no Why: the first push failed CI's macOS parallel jobs on
Locally neither failure reproduces even under a 10-thread |
…e drain-settle shape (to be removed before merge) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… declaring quiescence A yielded task is invisible to the executor's outstanding count while the runtime hops its continuation through the global executor back to enqueue. On CI's 3-core runner the DIAG arms measured that hop at 773 ms with the drive idle: children's jobs ended at 0.7 ms, their yielded continuations ran at 774 ms, settle fired at 807 ms — inside the next hop. No grace window bounds a starved runner. Settle's task now yields from inside the fixpoint check; it queues behind every pending yielded child in the same global executor, so on return those children are re-enqueued or have run, and quiescence is declared only if the system is still idle and quiet — an ordering signal, not a clock. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Third push (3677a8d): the drive's fixpoint now requires idleness to survive a What the two diagnostic arms showed (DriveQoSDiagnosticsTests on this branch vs the same test on main's queue in draft #71, both on the 3-core macOS runner):
The fix: when the drive looks idle and quiet, settle's task yields; it queues behind every pending yielded child in the same global executor, so on return those children are re-enqueued or have run. Quiescence is declared only if still idle and quiet after that round trip — an ordering signal rather than a wall-clock one, consistent with the no-timeout-hunting rule. The diagnostic test stays in for this CI run and will be removed before merge; #71 is closed. Locally: 870 passed in both modes; 0/40 premature under load. |
|
All 7 jobs green on 3677a8d (macOS parallel and TSan included — the two that failed on the first two pushes). Fourth push removes the temporary DriveQoSDiagnosticsTests; no other change. Ready for review. |
|
Converted to draft — do not merge in this form. The parallel-apple after-arm (5 full-plan runs on 8347727 vs 4 on 1.0.17) shows: the settle-heavy |
|
The evidence this PR was withdrawn on has been retracted, so its status is now open rather than rejected. The downstream after-arm that reported this branch regressing a settle-heavy test 2.3–9× never built this branch. What that changes, precisely:
What still stands on this branch's own evidence, independent of that arm:
Staying in draft for now, because the semantic-quiescence work in progress replaces the fixpoint rule that both halves of this PR are working around — if that lands, the round-trip is unnecessary by construction and the floor can be judged on its own. Revisit once that is settled. |
…strumentation Implements steps 1–2 of Docs/test-quiescence-redesign.md §10 (spike + compute-but-don't-use), leaving every existing verdict path untouched. * `ModelWorkUnit` — per-`TaskCancellable` running/parked state, stored as a signed counter of non-parked activities (start 1, park decrements, running iff > 0) so nested parks compose. Replaces the `LockIsolated<Bool>` `hasStartedRunning` box, so no extra allocation per task. * `withModelParked` — public primitive that finds the current work unit via a task-local set once per task body (so it propagates into child tasks) and marks it parked for the duration of `body`. No-op passthrough outside a model task. * Hook 1: `node.forEach` / `node.onChange` park around their own `next()` only, never around the body — this makes any AsyncSequence (incl. swift-async-algorithms `debounce`/`throttle`) park with zero adoption. `_DedupBox` parks around its upstream wait for the same reason. * `AnyContext.semanticQuiescence` — the new answer (no running unit in the tree + both call queues idle), computed in `_driveToStableFixpoint` beside the existing one. Disagreements are tallied, and traced per-check with `SWIFT_MODEL_QUIESCENCE_TRACE=1` (/tmp/swift-model-quiescence-trace.log, summary at exit). The existing answer still decides every verdict. * `SemanticQuiescenceTests` — foreign-clock parked/unparked, `Task.yield()` loop is running (the PR #70 blind spot), forEach parked-vs-delivering, await-free compute loop running, passthrough outside a model task, nesting. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Closing after measuring the round-trip's cost on 1.0.20 (the measurement the last comment said was owed): 12 interleaved paired full-suite |
Root cause of the rare full-plan wedge the parallel-apple simulator session measured on both 1.0.16 and 1.0.17 (1 in ~30 full-plan runs, 1500 s absolute-ceiling reports across ~79 tests, never in isolation), and of the smooth 4×–250× slowdown of
settle()-heavy tests under that plan (1.4 s isolated → 5–343 s).Mechanism. The drive's
_sharedDrainQueuewas a process-wide GCD concurrent queue with no QoS. A queue without a QoS runs each block at the QoS of the submitting thread, and a drive job is submitted by whichever thread resumes the task. A resumption from aDispatchQueue.global(qos: .background)callback, a.backgroundTask or a low-QoS test double therefore produced a background-QoS job. Under a saturated machine such a block can stay unscheduled for minutes; while pending it counts asoutstanding, so the executor reports itself busy (the inactivity watchdog never fires),settle()cannot reach its fixpoint, and eventually the absolute ceiling fires with its "almost certainly a deadlock" wording — with no lock involved. It is load-dependent (smooth), full-plan-only, and predates every recent change, which is the profile that was measured. No lock cycle was ever present; every candidate lock order was audited and is correct.Fix. The drain queue is created with
qos: .userInitiatedand every job is submitted with.enforceQoSat that level. Once scheduled, the runtime runs the job at its task's own priority, which is the intended behaviour; the submitter's QoS can no longer drag it down. This makes the drive's documented "non-starvable" contract true. No wait budget, deadline or timeout changed.Evidence.
DriveJobQoSTestsparks anode.taskon a continuation resumed from a background GCD thread and readsqos_class_self()in the resumed job. On main it reads 9 (QOS_CLASS_BACKGROUND); with the fix it reads the task's own priority (default, 21). A standalone GCD probe confirmed the propagation rule for unspecified-QoS versus pinned queues independently. The 79 collateral reports in the downstream hangs are explained by the global-quiescence fail-gate: one pending job holds every other unmetexpectin the process to its ceiling (1,045 tests passed after the wedge started in the kept log).Gate:
scripts/test869 passed (parallel and serial); regression test red on main, green here. 10× loop and TSan perci.ymlrunning on this commit and will be posted below. Darwin-only test (qos_class_self); the fix itself is plain GCD and compiles everywhereDispatchdoes.🤖 Generated with Claude Code