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