From 69abb927267782fe6fe632865cfa628708eef530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Tue, 29 Sep 2026 20:17:39 +0200 Subject: [PATCH] Stop settle() spinning while a task is pending its first run `_driveToStableFixpoint` holds its quiet window open while a registered task has not started (`hasPendingStartWork`). When that was the only pending work, every wait in the loop resolved synchronously on idle queues, so the loop re-checked in a tight spin without giving up its thread. A task spawned without the harness executor starts on the cooperative pool; once enough tests spun they held every pool thread, the pending tasks never started, and the pool-hosted trait-cap watchdogs starved as well. Downstream this pinned ~10 cores for 23+ minutes in a single settle(). Sleep a 1 ms poll interval on the timer in that state instead, freeing the thread. SettlePendingStartSpinTests reproduces the deadlock on a single-thread task executor: it hit its 30 s trait cap before the fix and passes in under 0.1 s after it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ .../Internal/TestExecutorDrive.swift | 21 +++++- .../SettlePendingStartSpinTests.swift | 73 +++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 Tests/SwiftModelTests/SettlePendingStartSpinTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c71cf..f87c98c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes are documented here. The format follows [Keep a Changelog](h ## [Unreleased] +### Fixed + +- **`settle()` could spin forever on a CPU core while a model task was waiting to start, and starve the whole test process.** The drive loop holds its quiet window open while a registered task has not run yet. When that was the only pending work, every wait in the loop returned at once, so the loop re-checked in a tight spin without giving up its thread. A task spawned without the harness executor (for example from a main-queue callback) starts on the cooperative pool. Once enough tests spun at the same time they held every pool thread, so those tasks never started and no settle could finish. The `.modelTesting` trait-cap watchdogs run on the same pool, so they starved too and never cancelled the test. Downstream, one `settle()` pinned about 10 cores for over 23 minutes, most of it in `hasPendingStartTask`'s tree walk. + - While a task that hasn't started is the only thing keeping the model from idle, the loop now sleeps a 1 ms poll interval on the timer, freeing its thread, instead of re-checking at once. + - `SettlePendingStartSpinTests` reproduces the deadlock on a single-thread task executor: a pending task needs the thread `settle()` runs on. Before the fix the test hit its 30 s trait cap; after it, it passes in under 0.1 s. + --- ## [1.1.2] — Over-aligned storage and element ids no longer crash (wasm `LocalStorage`) + WASM runtime smoke in CI diff --git a/Sources/SwiftModel/Internal/TestExecutorDrive.swift b/Sources/SwiftModel/Internal/TestExecutorDrive.swift index ea73711..54e2b55 100644 --- a/Sources/SwiftModel/Internal/TestExecutorDrive.swift +++ b/Sources/SwiftModel/Internal/TestExecutorDrive.swift @@ -352,7 +352,22 @@ extension TestAccess { await exec.waitUntilIdleOrDeadline(checkDeadline) if !bg.isIdle { await bg.waitForCurrentItems(deadline: checkDeadline) } if !main.isIdle { await main.waitForCurrentItems(deadline: checkDeadline) } - let idleNow = exec.isExecutorIdle && bg.isIdle && main.isIdle && !self.hasPendingStartWork + let queuesIdle = exec.isExecutorIdle && bg.isIdle && main.isIdle + let idleNow = queuesIdle && !self.hasPendingStartWork + if queuesIdle && !idleNow { + // Only a task pending its first run holds the window open, and none + // of the waits above suspended: they all resolve at once on idle + // queues, and a continuation resumed inside its own body does not + // suspend the task. Re-checking straight away would spin on this + // thread — and a pending task whose first job needs it (one spawned + // without the harness executor runs on the cooperative pool, which + // enough spinning waits fill up) could never start, while the + // pool-hosted trait-cap watchdogs starve too. Give the thread up for + // a poll interval instead; a task start is not an event the drive + // can await (the start may not even see this test's `ModelAccess`). + await _gtsSleep(Self._pendingStartPollNs, hangDeadlineNs: hangDeadlineNs) + continue + } if idleNow { // Debounce against COMPLETIONS too, not just writes and // enqueues (`exec.activityNs` when idle = max(birth, @@ -387,6 +402,10 @@ extension TestAccess { return .reached } + /// How often `_driveToStableFixpoint` re-checks while the only thing keeping the model + /// from idle is a task that has not started yet. Cadence only — never a verdict. + static var _pendingStartPollNs: UInt64 { 1_000_000 } // 1 ms + /// How often the busy-side waits in `_driveToStableFixpoint` wake to /// re-inspect the runaway-fire delta (and the termination ceiling) while /// the model stays continuously busy. Cadence only — never a verdict. diff --git a/Tests/SwiftModelTests/SettlePendingStartSpinTests.swift b/Tests/SwiftModelTests/SettlePendingStartSpinTests.swift new file mode 100644 index 0000000..a5d8861 --- /dev/null +++ b/Tests/SwiftModelTests/SettlePendingStartSpinTests.swift @@ -0,0 +1,73 @@ +#if canImport(Dispatch) +import Foundation +import Dispatch +import Testing +import ConcurrencyExtras +@testable import SwiftModel + +/// Regression coverage for `settle()` spinning while a model task is pending its first run. +/// +/// **The bug.** `_driveToStableFixpoint` holds its quiet window open while any registered task +/// has not started running (`hasPendingStartWork`). With the drain executor, background queue +/// and main-observation queue all idle, each of the loop's waits resolved synchronously — a +/// continuation resumed inside its own body does not suspend the task — so the loop re-checked +/// in a tight spin without ever giving up its thread. A task whose first job needs that thread +/// can then never start: a task spawned without the harness executor (e.g. from a main-queue +/// callback) runs on the cooperative pool, and once enough tests spin at once they hold every +/// pool thread. The pool-hosted trait-cap watchdogs are starved too, so nothing ever cancels +/// the wait. Downstream this pinned ~10 cores for 23+ minutes in one `settle()`. +/// +/// **The test.** The same deadlock on one thread. `settle()` runs on a single-thread task +/// executor, and a registered task that has not started yet needs that thread to get going: +/// its body yields on the thread many times before it counts as started, so it cannot finish +/// during the few suspensions `settle` makes before its drive loop. Without the fix the loop +/// spins on the thread, the task never runs again, and settle only ends when the trait cap +/// cancels it. With the fix settle suspends while it waits, the task starts, and settle +/// reaches its fixpoint at once. +@Suite(.modelTesting) +struct SettlePendingStartSpinTests { + @Model + struct Counter { + var count = 0 + } + + @Test func settleYieldsItsThreadWhileATaskIsPendingStart() async throws { + guard #available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) else { return } + let model = Counter().withAnchor() + await settle() + + let thread = SingleThreadTaskExecutor() + await withTaskExecutorPreference(thread) { + // Hop onto `thread` first: the preference only takes effect at a suspension point. + await Task.yield() + let started = LockIsolated(false) + _ = TaskCancellable( + modelName: "Counter", taskName: "pending start", fileAndLine: FileAndLine(fileID: #fileID, filePath: #filePath, line: #line, column: #column), + cancellations: model.node._context!.cancellations, hasStartedRunningBox: started + ) { onDone in + Task(executorPreference: thread) { + defer { onDone() } + // Each yield needs `thread` again, so this reaches "started" only while + // `settle` gives the thread up. + for _ in 0..<1_000 { await Task.yield() } + started.setValue(true) + model.count = 1 + } + } + await settle() + #expect(started.value) + } + await expect(model.count == 1) + } +} + +@available(macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, *) +private final class SingleThreadTaskExecutor: TaskExecutor, @unchecked Sendable { + private let queue = DispatchQueue(label: "SettlePendingStartSpinTests.single-thread") + + func enqueue(_ job: consuming ExecutorJob) { + let job = UnownedJob(job) + queue.async { job.runSynchronously(on: self.asUnownedTaskExecutor()) } + } +} +#endif