Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<UInt64>`) + WASM runtime smoke in CI
Expand Down
21 changes: 20 additions & 1 deletion Sources/SwiftModel/Internal/TestExecutorDrive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
73 changes: 73 additions & 0 deletions Tests/SwiftModelTests/SettlePendingStartSpinTests.swift
Original file line number Diff line number Diff line change
@@ -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
Loading