From 88209b4e16f2830d4cdfd0da8763392ac45d576e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Mon, 7 Sep 2026 09:13:13 +0200 Subject: [PATCH 1/5] =?UTF-8?q?Pin=20the=20executor=20drive's=20drain=20qu?= =?UTF-8?q?eue=20to=20.userInitiated=20QoS=20=E2=80=94=20a=20job=20resumed?= =?UTF-8?q?=20from=20a=20background=20thread=20ran=20at=20background=20QoS?= =?UTF-8?q?=20and=20could=20starve=20for=20minutes,=20wedging=20settle()?= =?UTF-8?q?=20under=20a=20saturated=20parallel=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 ++ .../Internal/TestExecutorDrive.swift | 17 ++++++- Tests/SwiftModelTests/DriveJobQoSTests.swift | 46 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 Tests/SwiftModelTests/DriveJobQoSTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d91f859..51ea975a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes are documented here. The format follows [Keep a Changelog](h ## [Unreleased] +### Fixed + +- **`.modelTesting` tests no longer wedge or crawl under a saturated parallel run when a model task is resumed from a background-QoS thread.** The executor drive ran every test's jobs on one process-wide GCD concurrent queue that carried no QoS of its own. A queue without a QoS runs each block at the QoS of the thread that submitted it, and a drive job is submitted by whichever thread resumes the task — a `DispatchQueue.global(qos: .background)` callback, a `.background` Task, a low-QoS test double. Such a job was a background-QoS block, which macOS can leave unscheduled for minutes when a large test plan saturates the machine; while it was pending it counted as `outstanding`, so the executor reported itself busy, the inactivity watchdog never fired, `settle()` could never reach its fixpoint, and long enough the absolute ceiling fired with its "almost certainly a deadlock" wording — with no lock involved anywhere. Measured downstream in an 18-target plan as a smooth 4×–250× slowdown of `settle()`-heavy tests (1.4 s in isolation → 5–343 s) with a ~3 % chance of a 1500 s wedge per run, identical on 1.0.16 and 1.0.17. The drain queue now carries `qos: .userInitiated` and every job is submitted with `.enforceQoS`, so a job runs at its task's own priority whoever resumed it. `DriveJobQoSTests` parks a `node.task` on a continuation resumed from a background thread and reads the resumed job's `qos_class_self()`: 9 (`background`) before, the task's own priority after. This is the drive's stated "non-starvable" contract made true; no wait budget or timeout changed. + --- ## [1.0.17] — Hot-path performance: index-keyed accessors, lock-free registrar lookups, 10× faster collection reconcile diff --git a/Sources/SwiftModel/Internal/TestExecutorDrive.swift b/Sources/SwiftModel/Internal/TestExecutorDrive.swift index f5766599..2121af7c 100644 --- a/Sources/SwiftModel/Internal/TestExecutorDrive.swift +++ b/Sources/SwiftModel/Internal/TestExecutorDrive.swift @@ -133,7 +133,20 @@ func _makeTestExecutorBox() -> (any Sendable)? { /// runs. Each executor keeps its own `outstanding` counter, so per-test /// quiescence detection stays isolated. @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) -private let _sharedDrainQueue = DispatchQueue(label: "swift-model.test-drain.shared", attributes: .concurrent) +/// +/// The queue carries an explicit `.userInitiated` QoS, and every job is submitted with +/// `.enforceQoS` at the same level. A concurrent queue with no QoS of its own runs each +/// block at the QoS of the thread that submitted it, and a drive job is submitted by +/// whichever thread resumes the task — a `DispatchQueue.global(qos: .background)` +/// callback, a `.background` Task, a low-QoS test double. Under a saturated parallel run +/// a background-QoS block can stay unscheduled for minutes; while it is pending it counts +/// as `outstanding` (the executor reports itself busy), so the test's `settle()` can +/// never reach its fixpoint and the inactivity watchdog never fires — the exact evidence +/// the absolute-ceiling report describes, without any lock involved. Measured downstream +/// as a smooth 4×–250× slowdown of `settle()`-heavy tests under an 18-target plan with an +/// occasional 1500 s wedge, identical on releases before and after the lock changes. The +/// drive's contract is "non-starvable"; pinning the QoS is what makes that true. +private let _sharedDrainQueue = DispatchQueue(label: "swift-model.test-drain.shared", qos: .userInitiated, attributes: .concurrent) @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) final class _DrainTestExecutor: TaskExecutor, @unchecked Sendable { @@ -206,7 +219,7 @@ final class _DrainTestExecutor: TaskExecutor, @unchecked Sendable { lock.withLock { outstanding += 1; _lastEnqueueNs = _drainMonotonicNs() } Self._globalOutstanding.wrappingAdd(1, ordering: .relaxed) Self._globalLastActivityNs.store(_drainMonotonicNs(), ordering: .relaxed) - _sharedDrainQueue.async { + _sharedDrainQueue.async(qos: .userInitiated, flags: .enforceQoS) { unowned.runSynchronously(on: self.asUnownedTaskExecutor()) let toFire: [@Sendable () -> Void] = self.lock.withLock { self.outstanding -= 1 diff --git a/Tests/SwiftModelTests/DriveJobQoSTests.swift b/Tests/SwiftModelTests/DriveJobQoSTests.swift new file mode 100644 index 00000000..a97c223d --- /dev/null +++ b/Tests/SwiftModelTests/DriveJobQoSTests.swift @@ -0,0 +1,46 @@ +import Testing +import Foundation +import ConcurrencyExtras +@testable import SwiftModel + +#if canImport(Darwin) +/// Regression for the drive's QoS floor. A drive job is submitted by whichever thread +/// resumes the task; before the queue carried its own QoS, a resumption from a +/// background-QoS thread produced a background-QoS job that a saturated machine could +/// leave unscheduled for minutes while it counted as executor activity — `settle()` +/// never reached its fixpoint and the trait's absolute ceiling fired with no lock in +/// sight. Every job must run at `.userInitiated` or better, whoever resumed it. +@Model private struct QoSProbeModel: Sendable { + var resumedQoS: UInt32 = 0 + var done = false + + func onActivate() { + node.task { + // Park, then be resumed from a background-QoS GCD thread: the resumption + // enqueues this task's next job on the drive from that thread. + await withCheckedContinuation { (cont: CheckedContinuation) in + DispatchQueue.global(qos: .background).async { cont.resume() } + } + resumedQoS = qos_class_self().rawValue + done = true + } + } +} + +@Suite(.modelTesting(exhaustivity: .off)) +struct DriveJobQoSTests { + @Test func jobResumedFromBackgroundThreadNeverRunsAtBackgroundQoS() async { + let model = QoSProbeModel().withAnchor() + await expect { model.done } + let qos = model.resumedQoS + // qos_class_t raw values: userInteractive 0x21, userInitiated 0x19, default 0x15, + // utility 0x11, background 0x09 — higher is higher priority. The queue floor is + // `.userInitiated`; once scheduled, the runtime runs the job at the task's own + // priority (here the test task's, `.default`), which is the value observed. What + // must never happen is the submitting thread's `.background` leaking in: before + // the fix this read 9. + #expect(qos >= QOS_CLASS_DEFAULT.rawValue, + "drive job ran at QoS raw value \(qos); expected ≥ default (\(QOS_CLASS_DEFAULT.rawValue)) — background (\(QOS_CLASS_BACKGROUND.rawValue)) means the submitting thread's QoS leaked into the drive") + } +} +#endif From b6a6b68fe3e6118ce2782fcaef31f914b5aade09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Mon, 7 Sep 2026 11:52:34 +0200 Subject: [PATCH 2/5] Drive drain queue: QoS floor rather than .enforceQoS, and pin it as configuration 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 --- CHANGELOG.md | 2 +- .../Internal/TestExecutorDrive.swift | 40 +++++++++---- Tests/SwiftModelTests/DriveJobQoSTests.swift | 59 +++++++------------ 3 files changed, 50 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ea975a..926af4d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes are documented here. The format follows [Keep a Changelog](h ### Fixed -- **`.modelTesting` tests no longer wedge or crawl under a saturated parallel run when a model task is resumed from a background-QoS thread.** The executor drive ran every test's jobs on one process-wide GCD concurrent queue that carried no QoS of its own. A queue without a QoS runs each block at the QoS of the thread that submitted it, and a drive job is submitted by whichever thread resumes the task — a `DispatchQueue.global(qos: .background)` callback, a `.background` Task, a low-QoS test double. Such a job was a background-QoS block, which macOS can leave unscheduled for minutes when a large test plan saturates the machine; while it was pending it counted as `outstanding`, so the executor reported itself busy, the inactivity watchdog never fired, `settle()` could never reach its fixpoint, and long enough the absolute ceiling fired with its "almost certainly a deadlock" wording — with no lock involved anywhere. Measured downstream in an 18-target plan as a smooth 4×–250× slowdown of `settle()`-heavy tests (1.4 s in isolation → 5–343 s) with a ~3 % chance of a 1500 s wedge per run, identical on 1.0.16 and 1.0.17. The drain queue now carries `qos: .userInitiated` and every job is submitted with `.enforceQoS`, so a job runs at its task's own priority whoever resumed it. `DriveJobQoSTests` parks a `node.task` on a continuation resumed from a background thread and reads the resumed job's `qos_class_self()`: 9 (`background`) before, the task's own priority after. This is the drive's stated "non-starvable" contract made true; no wait budget or timeout changed. +- **`.modelTesting` tests no longer wedge or crawl under a saturated parallel run when a model task is resumed from a background-QoS thread.** The executor drive ran every test's jobs on one process-wide GCD concurrent queue that carried no QoS of its own. A queue without a QoS runs each block at the QoS of the thread that submitted it, and a drive job is submitted by whichever thread resumes the task — a `DispatchQueue.global(qos: .background)` callback, a `.background` Task, a low-QoS test double. Such a job was a background-QoS block, which macOS can leave unscheduled for minutes when a large test plan saturates the machine; while it was pending it counted as `outstanding`, so the executor reported itself busy, the inactivity watchdog never fired, `settle()` could never reach its fixpoint, and long enough the absolute ceiling fired with its "almost certainly a deadlock" wording — with no lock involved anywhere. Measured downstream in an 18-target plan as a smooth 4×–250× slowdown of `settle()`-heavy tests (1.4 s in isolation → 5–343 s) with a ~3 % chance of a 1500 s wedge per run, identical on 1.0.16 and 1.0.17. The drain queue now carries `qos: .userInitiated` as a floor: a job submitted from a lower-QoS thread is raised to it, one submitted from a higher-QoS thread keeps its QoS exactly as before, and once scheduled a job runs at its task's own priority whoever resumed it (measured with a probe that parks a `node.task` on a continuation resumed from a background thread and reads the resumed job's `qos_class_self()`: 9, `background`, before; the task's own priority after). Not `.enforceQoS`: forcing every job to one level changed the relative scheduling of drive jobs against work the drive cannot see — `Task.yield()` inside a task that prefers the drive resumes on the global pool, not on the drive — and on CI's 3-core runners that let a `settle()` fixpoint check outrun a yielding child it could not count. `DriveJobQoSTests` pins the floor as configuration; a behavioural version needs a background block to get scheduled inside the test budget, which is precisely what a saturated runner does not guarantee. This is the drive's stated "non-starvable" contract made true; no wait budget or timeout changed. --- diff --git a/Sources/SwiftModel/Internal/TestExecutorDrive.swift b/Sources/SwiftModel/Internal/TestExecutorDrive.swift index 2121af7c..a045b6bd 100644 --- a/Sources/SwiftModel/Internal/TestExecutorDrive.swift +++ b/Sources/SwiftModel/Internal/TestExecutorDrive.swift @@ -134,20 +134,34 @@ func _makeTestExecutorBox() -> (any Sendable)? { /// quiescence detection stays isolated. @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) /// -/// The queue carries an explicit `.userInitiated` QoS, and every job is submitted with -/// `.enforceQoS` at the same level. A concurrent queue with no QoS of its own runs each -/// block at the QoS of the thread that submitted it, and a drive job is submitted by -/// whichever thread resumes the task — a `DispatchQueue.global(qos: .background)` -/// callback, a `.background` Task, a low-QoS test double. Under a saturated parallel run -/// a background-QoS block can stay unscheduled for minutes; while it is pending it counts -/// as `outstanding` (the executor reports itself busy), so the test's `settle()` can -/// never reach its fixpoint and the inactivity watchdog never fires — the exact evidence -/// the absolute-ceiling report describes, without any lock involved. Measured downstream -/// as a smooth 4×–250× slowdown of `settle()`-heavy tests under an 18-target plan with an -/// occasional 1500 s wedge, identical on releases before and after the lock changes. The -/// drive's contract is "non-starvable"; pinning the QoS is what makes that true. +/// The queue carries an explicit `.userInitiated` QoS as a FLOOR. A concurrent queue with +/// no QoS of its own runs each block at the QoS of the thread that submitted it, and a +/// drive job is submitted by whichever thread resumes the task — a +/// `DispatchQueue.global(qos: .background)` callback, a `.background` Task, a low-QoS test +/// double. Under a saturated parallel run a background-QoS block can stay unscheduled for +/// minutes; while it is pending it counts as `outstanding` (the executor reports itself +/// busy), so the test's `settle()` can never reach its fixpoint and the inactivity +/// watchdog never fires — the exact evidence the absolute-ceiling report describes, +/// without any lock involved. Measured downstream as a smooth 4×–250× slowdown of +/// `settle()`-heavy tests under an 18-target plan with an occasional 1500 s wedge, +/// identical on releases before and after the lock changes. The drive's contract is +/// "non-starvable"; the floor is what makes that true. +/// +/// A floor, not `.enforceQoS`: with the queue's QoS set, a block submitted from a +/// lower-QoS thread is raised to `.userInitiated`, while one submitted from a higher-QoS +/// thread (the main thread, a `.userInteractive` task) keeps that QoS exactly as before. +/// Forcing every job to one level changed the relative scheduling of drive jobs against +/// work the drive cannot see — `Task.yield()` inside a task that prefers this executor +/// resumes on the global pool, not here — and on CI's 3-core runner that let a +/// `settle()` fixpoint check outrun a yielding child it could not count +/// (`ExecutorDrainSettleTests.settleIsLoadIndependentAcrossChildTasks`). The floor fixes +/// the starvation without touching that balance. private let _sharedDrainQueue = DispatchQueue(label: "swift-model.test-drain.shared", qos: .userInitiated, attributes: .concurrent) +/// The drain queue's QoS, for the regression test that pins the floor. +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) +var _drainQueueQoS: DispatchQoS { _sharedDrainQueue.qos } + @available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) final class _DrainTestExecutor: TaskExecutor, @unchecked Sendable { private let lock = NSLock() @@ -219,7 +233,7 @@ final class _DrainTestExecutor: TaskExecutor, @unchecked Sendable { lock.withLock { outstanding += 1; _lastEnqueueNs = _drainMonotonicNs() } Self._globalOutstanding.wrappingAdd(1, ordering: .relaxed) Self._globalLastActivityNs.store(_drainMonotonicNs(), ordering: .relaxed) - _sharedDrainQueue.async(qos: .userInitiated, flags: .enforceQoS) { + _sharedDrainQueue.async { unowned.runSynchronously(on: self.asUnownedTaskExecutor()) let toFire: [@Sendable () -> Void] = self.lock.withLock { self.outstanding -= 1 diff --git a/Tests/SwiftModelTests/DriveJobQoSTests.swift b/Tests/SwiftModelTests/DriveJobQoSTests.swift index a97c223d..bc963290 100644 --- a/Tests/SwiftModelTests/DriveJobQoSTests.swift +++ b/Tests/SwiftModelTests/DriveJobQoSTests.swift @@ -1,46 +1,31 @@ import Testing import Foundation -import ConcurrencyExtras @testable import SwiftModel -#if canImport(Darwin) -/// Regression for the drive's QoS floor. A drive job is submitted by whichever thread -/// resumes the task; before the queue carried its own QoS, a resumption from a -/// background-QoS thread produced a background-QoS job that a saturated machine could -/// leave unscheduled for minutes while it counted as executor activity — `settle()` -/// never reached its fixpoint and the trait's absolute ceiling fired with no lock in -/// sight. Every job must run at `.userInitiated` or better, whoever resumed it. -@Model private struct QoSProbeModel: Sendable { - var resumedQoS: UInt32 = 0 - var done = false +#if canImport(Dispatch) +import Dispatch - func onActivate() { - node.task { - // Park, then be resumed from a background-QoS GCD thread: the resumption - // enqueues this task's next job on the drive from that thread. - await withCheckedContinuation { (cont: CheckedContinuation) in - DispatchQueue.global(qos: .background).async { cont.resume() } - } - resumedQoS = qos_class_self().rawValue - done = true - } - } -} - -@Suite(.modelTesting(exhaustivity: .off)) +/// Pins the executor drive's QoS floor. +/// +/// The drive runs every test's jobs on one process-wide GCD concurrent queue. A +/// concurrent queue with no QoS of its own runs each block at the QoS of the thread +/// that submitted it, and a drive job is submitted by whichever thread resumes the +/// task — so a resumption from a background-QoS thread used to produce a background-QoS +/// job that a saturated machine could leave unscheduled for minutes while it counted as +/// executor activity: `settle()` never reached its fixpoint and the trait's absolute +/// ceiling fired with no lock in sight. With the queue's QoS set to `.userInitiated`, +/// such a job is raised to that floor (measured: the resumed job read +/// `qos_class_self()` = background (9) before, the task's own priority after). +/// +/// This test asserts the configuration rather than re-measuring the behaviour: a +/// behavioural check needs a background-QoS block to get scheduled inside the test's +/// budget, which is precisely what a saturated CI runner does not guarantee — the +/// first cut of this test timed out under the TSan job for that reason. struct DriveJobQoSTests { - @Test func jobResumedFromBackgroundThreadNeverRunsAtBackgroundQoS() async { - let model = QoSProbeModel().withAnchor() - await expect { model.done } - let qos = model.resumedQoS - // qos_class_t raw values: userInteractive 0x21, userInitiated 0x19, default 0x15, - // utility 0x11, background 0x09 — higher is higher priority. The queue floor is - // `.userInitiated`; once scheduled, the runtime runs the job at the task's own - // priority (here the test task's, `.default`), which is the value observed. What - // must never happen is the submitting thread's `.background` leaking in: before - // the fix this read 9. - #expect(qos >= QOS_CLASS_DEFAULT.rawValue, - "drive job ran at QoS raw value \(qos); expected ≥ default (\(QOS_CLASS_DEFAULT.rawValue)) — background (\(QOS_CLASS_BACKGROUND.rawValue)) means the submitting thread's QoS leaked into the drive") + @Test func drainQueueCarriesAUserInitiatedFloor() { + guard #available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) else { return } + #expect(_drainQueueQoS == .userInitiated, + "the drive's drain queue must carry a .userInitiated QoS floor; got \(_drainQueueQoS)") } } #endif From 0831763aeec1222af46dae485ca2a449f9339601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Mon, 7 Sep 2026 12:03:21 +0200 Subject: [PATCH 3/5] TEMPORARY DIAG: record QoS + queue for drive jobs and yields under the drain-settle shape (to be removed before merge) Co-Authored-By: Claude Fable 5.1 --- .../DriveQoSDiagnosticsTests.swift | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 Tests/SwiftModelTests/DriveQoSDiagnosticsTests.swift diff --git a/Tests/SwiftModelTests/DriveQoSDiagnosticsTests.swift b/Tests/SwiftModelTests/DriveQoSDiagnosticsTests.swift new file mode 100644 index 00000000..c123e293 --- /dev/null +++ b/Tests/SwiftModelTests/DriveQoSDiagnosticsTests.swift @@ -0,0 +1,82 @@ +import Testing +import Foundation +import ConcurrencyExtras +@testable import SwiftModel + +#if canImport(Darwin) +import Dispatch + +// TEMPORARY DIAGNOSTIC — prints, for CI log comparison, where drive jobs and +// their yielded continuations actually run (QoS + dispatch queue label), and the +// timing of the settle() fixpoint relative to the children's completion. +// Same shape as ExecutorDrainSettleTests.settleIsLoadIndependentAcrossChildTasks. + +private struct Sample: Sendable, CustomStringConvertible { + let child: Int, step: String, qos: UInt32, queue: String, tNs: UInt64 + var description: String { "child\(child) \(step) qos=\(qos) queue=\(queue) t=\(tNs / 1000)µs" } +} +private let samples = LockIsolated<[Sample]>([]) +private let t0 = LockIsolated(0) +private func now() -> UInt64 { DispatchTime.now().uptimeNanoseconds &- t0.value } +private func record(_ child: Int, _ step: String) { + let label = String(cString: __dispatch_queue_get_label(nil)) + samples.withValue { $0.append(Sample(child: child, step: step, qos: qos_class_self().rawValue, queue: label, tNs: now())) } +} + +@Model private struct DiagItem: Sendable, Identifiable { + let id: Int + var done = false + func onActivate() { + node.task { + record(id, "start") + for i in 0..<6 { await Task.yield(); record(id, "yield\(i)") } + done = true + record(id, "done") + } + } +} +@Model private struct DiagParent: Sendable { + var items: [DiagItem] = [] +} + +@Sendable private func underCPULoad(_ body: () async -> T) async -> T { + let stop = NSLock() + nonisolated(unsafe) var running = true + for _ in 0.. Date: Mon, 7 Sep 2026 12:18:52 +0200 Subject: [PATCH 4/5] Drive fixpoint: require idleness to survive a yield round-trip before declaring quiescence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Internal/TestExecutorDrive.swift | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftModel/Internal/TestExecutorDrive.swift b/Sources/SwiftModel/Internal/TestExecutorDrive.swift index a045b6bd..801341d0 100644 --- a/Sources/SwiftModel/Internal/TestExecutorDrive.swift +++ b/Sources/SwiftModel/Internal/TestExecutorDrive.swift @@ -400,7 +400,26 @@ extension TestAccess { let lastActivity = max(self._lastActivityNsLocked, exec.activityNs) let sinceActivity = _drainMonotonicNs() &- lastActivity if sinceActivity >= graceNs { - return .reached // idle, and no activity of any kind for a full grace window + // Idle and quiet for a full grace window — but a task that has + // YIELDED is invisible to `outstanding` while the runtime hops its + // continuation through the global executor on the way back to this + // executor's `enqueue`. On a starved 3-core CI runner that hop was + // measured at 773 ms with the drive otherwise idle (children's + // jobs ended at 0.7 ms, their yielded continuations ran at 774 ms, + // settle fired at 807 ms — inside the second hop), so no grace + // window bounds it. Yield from THIS task: it queues behind every + // pending yielded child in the same global executor, so when + // control returns those children have been re-enqueued + // (outstanding > 0) or have run (activity stamped). Quiescence is + // declared only if the system is still idle and quiet after that + // round trip — an ordering signal, not a wall-clock one. + await Task.yield() + let stillIdle = exec.isExecutorIdle && bg.isIdle && main.isIdle && !self.context.hasPendingStartTask + let activityAfter = max(self._lastActivityNsLocked, exec.activityNs) + if stillIdle && activityAfter == lastActivity { + return .reached + } + continue // the yield surfaced pending work; go around again } // Idle but recent activity — wait out the remainder of the // grace (non-starvable), then re-check; a resuming task will From 834772712e7b0378c11c5acf61a513378fc10ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Mon, 7 Sep 2026 12:29:25 +0200 Subject: [PATCH 5/5] Remove the temporary drive QoS diagnostic test (findings recorded on #70) Co-Authored-By: Claude Fable 5.1 --- .../DriveQoSDiagnosticsTests.swift | 82 ------------------- 1 file changed, 82 deletions(-) delete mode 100644 Tests/SwiftModelTests/DriveQoSDiagnosticsTests.swift diff --git a/Tests/SwiftModelTests/DriveQoSDiagnosticsTests.swift b/Tests/SwiftModelTests/DriveQoSDiagnosticsTests.swift deleted file mode 100644 index c123e293..00000000 --- a/Tests/SwiftModelTests/DriveQoSDiagnosticsTests.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Testing -import Foundation -import ConcurrencyExtras -@testable import SwiftModel - -#if canImport(Darwin) -import Dispatch - -// TEMPORARY DIAGNOSTIC — prints, for CI log comparison, where drive jobs and -// their yielded continuations actually run (QoS + dispatch queue label), and the -// timing of the settle() fixpoint relative to the children's completion. -// Same shape as ExecutorDrainSettleTests.settleIsLoadIndependentAcrossChildTasks. - -private struct Sample: Sendable, CustomStringConvertible { - let child: Int, step: String, qos: UInt32, queue: String, tNs: UInt64 - var description: String { "child\(child) \(step) qos=\(qos) queue=\(queue) t=\(tNs / 1000)µs" } -} -private let samples = LockIsolated<[Sample]>([]) -private let t0 = LockIsolated(0) -private func now() -> UInt64 { DispatchTime.now().uptimeNanoseconds &- t0.value } -private func record(_ child: Int, _ step: String) { - let label = String(cString: __dispatch_queue_get_label(nil)) - samples.withValue { $0.append(Sample(child: child, step: step, qos: qos_class_self().rawValue, queue: label, tNs: now())) } -} - -@Model private struct DiagItem: Sendable, Identifiable { - let id: Int - var done = false - func onActivate() { - node.task { - record(id, "start") - for i in 0..<6 { await Task.yield(); record(id, "yield\(i)") } - done = true - record(id, "done") - } - } -} -@Model private struct DiagParent: Sendable { - var items: [DiagItem] = [] -} - -@Sendable private func underCPULoad(_ body: () async -> T) async -> T { - let stop = NSLock() - nonisolated(unsafe) var running = true - for _ in 0..