Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ All notable changes are documented here. The format follows [Keep a Changelog](h

## [Unreleased]

### Added

- **Signals: `onSignal` / `signal` / `onTeardown` — async work on request, and work that outlives its model.** `onCancel` runs synchronously during teardown, so it can't `await`. And a `Task` started from it is invisible to tests (and starves under parallel test load), while `node.task` can no longer start there — the whole removed subtree is already sealed. Apps ended up with raw `Task.detached` fallbacks and hand-built shutdown registries.
- `node.onSignal(key, once:cancelPrevious:) { cause in … }` registers an async handler. `await node.signal(key, to:)` reaches handlers like `send(_:to:)` reaches event listeners (default: the model and its descendants; `.ancestors` and `.dependencies` work too), runs them concurrently, and returns when they have finished. Signals are repeatable.
- Every handler also gets one final call when its model is removed, with `SignalCause.removed`, versus `.requested` for a signal. The final call runs after the model is gone, so handlers capture what they need.
- `node.onTeardown { … }` is the removal-only form, for things like fading out audio after a player's model is removed.
- Runs of one handler never overlap (`cancelPrevious` cancels the running one first). `once` runs a handler at most once in total. Cancelling the returned `Cancellable` unregisters the handler. Cancelling the caller of `signal` cancels the runs it started.
- Runs are never hosted by their own model: in production they are plain tasks, and under `.modelTesting` they run on the test's executor. `settle()` waits for them. A run the test caused that is still going at the end is reported as an active task. Removals caused by the harness's own end-of-test teardown run after the exhaustivity check (unchecked), and the scope waits for them, cancelling what is still parked, before it returns.
- Handlers live in each model's existing task registry, and `signal` reaches them through the same `reduceHierarchy` routing events use, so there's no new traversal. `SignalTests` and `TeardownWorkTests` cover reach, keys, repeatability, `once`, unregistering, serialization, cancellation, a fade stepping on a frozen `TestClock` after removal, whole-tree release outside the harness, and the end-of-test semantics.

### Fixed

- **A user `onCancel` handler that started new work during a sealed model's teardown drain was silently dropped.** `AnyContext.onRemoval` seals a model's `Cancellations` store, then drains it synchronously via `cancelAll()`/`cancelAll(for:)`. If one of the drained `onCancel` closures itself called `node.task { }`, a nested `node.onCancel { }`, or `forEach`, that registration landed on the already-sealed store: `Cancellations.register` cancelled it immediately before its body ever ran, with no signal — indistinguishable from the expected-silent case of a registration racing teardown from a different thread. `Cancellations.register` now checks a thread-local, `threadLocals.isDrainingCancellations`, set only around the synchronous drain in `cancelAll()`/`cancelAll(for:)`, and calls `reportIssue` (outside the lock) when a same-thread registration lands on a store sealed by the drain it's running inside of. A cross-thread race against teardown still stays silent, as intended — including a `Task { }` spawned from an `onCancel` handler that registers later: a thread-local, unlike a `@TaskLocal`, isn't inherited by that task. `OnCancelDuringTeardownRegistrationTests` is the regression coverage.
Expand Down
4 changes: 4 additions & 0 deletions Docs/Contributing/TestInfrastructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ A per-test 30 s wall-clock cap is enforced by the `.modelTesting` trait. Hangs s

**`SWIFT_MODEL_TIMEOUT_SCALE`** — multiplier on every test-infrastructure timeout: `expect` (5 s default), in-test `settle` (5 s), cleanup `settle` (25 s), trait cap (30 s), `waitUntil`'s drive backstop (120 s), the drive's termination ceiling (2× the trait ceiling), the meta-test bounds, and **every `waitUntil` call** (default 5 s and any explicit `timeout:` arg). Note the drive's *fail* verdicts are evidence-based, not wall-clock (runaway-fire bound; see `Docs/test-determinism-executor-drain.md` Update 27) — the scale only stretches backstops and budgets, never the discriminator. Defaults to `1.0` for fast local feedback. CI sets this to `3` so the `.deferential` `.background` QoS callbacks have wall-clock to actually fire on small parallel-saturated runners. Bump to 2–4 in any environment where you see meta-test or budget timeouts that aren't real bugs. Explicit `waitUntil(..., timeout: X)` is scaled too — that's deliberate, so individual tests don't need to know about CI tolerance.

## Signal-handler runs (`onSignal`, `onTeardown`)

A handler's run is never hosted by its own model: the model may be removed mid-run, or already be gone for the final `.removed` call. Under `.modelTesting` runs are registered in `TestAccess.signalWork` (a `Cancellations` owned by the test, never sealed with the tree) and spawned on the executor captured when the handler was registered. So the drive and `settle()` see them (`hasPendingStartWork`), and `checkExhaustion(checkTasks:)` reports them. At scope exit, `beginHarnessTeardown()` makes `SignalHandler.onCancel` defer its removal call (`deferRemovalCall`). The deferred calls start after the exhaustion check and `onRemoval()`, get driven to quiescence, and then `cancelSignalWorkAndAwaitUnwind` cancels what's left and waits for it to unwind (bounded by the drive, not wall-clock).

## `GlobalTickScheduler` (GTS) — settle's deadline source

`Sources/SwiftModel/Internal/GlobalTickScheduler.swift` is the GCD-backed deadline scheduler that every wait primitive (`expect`, `settle`, `waitUntil`, the per-test trait cap) routes through. Key design points worth knowing before touching it:
Expand Down
37 changes: 36 additions & 1 deletion Docs/Lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,42 @@ node.cancellationContext(for: saveFlowID) { // group
node.cancelAll(for: saveFlowID) // cancels both
```

To **cancel-in-flight** — replace an ongoing operation each time a function is called — use `.cancelInFlight()` (id synthesised from the call site) or `.cancel(for: id, cancelInFlight: true)`. Nested work can join its parent's context with `.inheritCancellationContext()`, and `node.onCancel { … }` runs cleanup on cancellation.
To **cancel-in-flight** — replace an ongoing operation each time a function is called — use `.cancelInFlight()` (id synthesised from the call site) or `.cancel(for: id, cancelInFlight: true)`. Nested work can join its parent's context with `.inheritCancellationContext()`, and `node.onCancel { … }` runs cleanup on cancellation. For cleanup that has to `await`, see [Signals and work that outlives a model](#signals-and-work-that-outlives-a-model).

### Signals and work that outlives a model

`onCancel` runs synchronously while the model is being torn down, so it can't `await` anything. And a `Task` started from it is invisible to tests, while `node.task` can no longer start at that point. Work that has to finish *after* the model is gone — fading out audio, flushing a last analytics batch — goes in `onTeardown`:

```swift
func onActivate() {
let player = player // capture what the work needs
let clock = node.continuousClock
node.onTeardown {
defer { player.stop() } // also when cancelled
await player.fadeOut(over: .seconds(1), on: clock)
}
}
```

When the work should also run **on request** — flush before leaving, save before syncing — register a signal handler instead. `signal` reaches handlers like `send` reaches event listeners (by default the model and its descendants), runs them all concurrently, and returns once they're done. Every handler also gets one final call when its model is removed:

```swift
enum Lifecycle: Hashable, Sendable { case flush, leave }

// ExperienceReporter
node.onSignal(Lifecycle.flush) { cause in
await reporter.flush(final: cause == .removed)
}

// The leaving side: announce while the tree is live, then remove.
func leave() async {
await node.signal(Lifecycle.leave)
experience = nil
await node.signal(Lifecycle.flush)
}
```

The `cause` tells a handler whether its model is still live (`.requested`: use `node` as usual) or already gone (`.removed`: use only captured values). Runs of one handler never overlap: a new request waits for the running one, or with `cancelPrevious: true` cancels it first. `once: true` runs a handler at most once in total, on the first request or on removal. Cancelling the returned `Cancellable` unregisters the handler. Cancelling the task that called `signal` cancels the runs that call started, so a deadline around a signal reaches the handlers.

### Transactions

Expand Down
15 changes: 15 additions & 0 deletions Docs/Testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,21 @@ await clock.advance(by: .seconds(1))
await expect(model.secondsElapsed == 1)
```

Prefer `TestClock` for anything that sleeps inside model work. `ImmediateClock` "sleeps" by awaiting detached background tasks, which run outside the test's scheduler and can starve when tests run in parallel.

### Signals and teardown work

`await node.signal(…)` returns once every handler it reached has finished, so you can assert on the result right after it. Work started by `onTeardown` or a handler's final `.removed` call runs on the test's executor even though its model is gone. `settle()` waits for it, and if the test removed the model and the work is still running when the test ends, it's reported as an active task. A fade parked on a `TestClock` steps forward as you advance the clock:

```swift
host.player = nil // starts the player's onTeardown fade
await settle() // fade parked on its next sleep
await clock.advance(by: .seconds(1))
await expect(events.wasCalled(with: "stopped"))
```

Models still alive when the test ends are removed by the test harness. Their final calls run *after* the exhaustivity check, so what they do is neither checked nor reported. The scope waits for them before returning and cancels whatever is still parked, such as a fade on a clock nobody advances. So a test can let a model go at the end of `withModelTesting` and then assert that its cleanup ran.

### Refactor-resilient tests

SwiftModel tests assert **final state**, not the sequence of actions or effects that produced it. There is no action enum to enumerate and no `send`/`receive` script to keep in sync — you call a method and assert the outcome:
Expand Down
1 change: 1 addition & 0 deletions Sources/SwiftModel/Documentation.docc/SwiftModel.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import Testing
### Async Work

- ``Cancellable``
- ``SignalCause``

### Observation

Expand Down
18 changes: 12 additions & 6 deletions Sources/SwiftModel/Internal/Cancellables.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,18 @@ final class TaskCancellable: Cancellable, InternalCancellable, @unchecked Sendab
let _hasStartedRunningBox: LockIsolated<Bool>
var hasStartedRunning: Bool { _hasStartedRunningBox.value }

init(modelName: String, taskName: String, fileAndLine: FileAndLine, context: AnyContext, hasStartedRunningBox: LockIsolated<Bool>, task: @escaping @Sendable (@escaping @Sendable () -> Void) -> Task<Void, Error>) {
convenience init(modelName: String, taskName: String, fileAndLine: FileAndLine, context: AnyContext, hasStartedRunningBox: LockIsolated<Bool>, task: @escaping @Sendable (@escaping @Sendable () -> Void) -> Task<Void, Error>) {
// `context.cancellations` is resolved HERE, before the designated init takes
// `lock` — see the AB-BA note there.
self.init(modelName: modelName, taskName: taskName, fileAndLine: fileAndLine, cancellations: context.cancellations, hasStartedRunningBox: hasStartedRunningBox, task: task)
}

init(modelName: String, taskName: String, fileAndLine: FileAndLine, cancellations: Cancellations, hasStartedRunningBox: LockIsolated<Bool>, task: @escaping @Sendable (@escaping @Sendable () -> Void) -> Task<Void, Error>) {
// Assigned before `cancellations.register(self)` below publishes this
// instance to any settle thread — see `_hasStartedRunningBox`.
self._hasStartedRunningBox = hasStartedRunningBox
// Resolve the registry ONCE, before `lock` is taken. `AnyContext.cancellations`
// The registry is resolved by the caller, before `lock` is taken (the convenience
// init evaluates `context.cancellations` up front). `AnyContext.cancellations`
// acquires the per-context hierarchy lock (H); this instance's `lock` is T.
// Evaluating `context.cancellations` *inside* `lock { }` — as the capture-list
// expression below used to — orders this init T→H, while teardown runs H→T:
Expand All @@ -90,15 +97,14 @@ final class TaskCancellable: Cancellable, InternalCancellable, @unchecked Sendab
// which holds it across its entire body (`Context.transaction`), so the drain
// still runs under H. Citing `onRemoval` alone gets this dismissed on review.
//
// Hoisting is free — the value is needed on the first line anyway, so this
// takes and releases H exactly where it already did, just once. The ordering
// is now uniformly H-before-T and the cycle is gone by construction.
// Hoisting is free — the value is needed first anyway, so this takes and
// releases H exactly where it already did, just once. The ordering is now
// uniformly H-before-T and the cycle is gone by construction.
//
// Same family as the `reduceHierarchy` (#29) and `memoize` (#30) inversions:
// whenever a leaf lock is held, do not evaluate anything that reaches a
// context lock — including capture-list expressions, which are evaluated at
// closure-formation time, i.e. inside the enclosing critical section.
let cancellations = context.cancellations
self.cancellations = cancellations
let id = cancellations.nextId
self.id = id
Expand Down
13 changes: 12 additions & 1 deletion Sources/SwiftModel/Internal/Cancellations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ final class Cancellations: @unchecked Sendable {
lock { _sealed = true }
}

/// Sealed stores are being torn down (model removal / end-of-test teardown); an
/// `onCancel()` arriving from one is a removal, not a user cancellation.
var isSealed: Bool {
lock { _sealed }
}

/// Registered cancellables of a given type (signal-handler lookup).
func registered<T>(of type: T.Type) -> [T] {
lock { registered.values.compactMap { $0 as? T } }
}

func register(_ c: InternalCancellable) {
let shouldImmediatelyCancel: Bool = lock {
if _sealed { return true }
Expand All @@ -53,7 +64,7 @@ final class Cancellations: @unchecked Sendable {
let subject = (c as? TaskCancellable).map {
"Task '\($0.taskName)' on `\($0.modelName)`"
} ?? "A cancellable"
let message = "\(subject) was registered while a model is being deactivated (from an `onCancel` handler); it is cancelled immediately and never runs. Work that must outlive a model belongs to a model that outlives it (e.g. start it with the parent's `node.task`)."
let message = "\(subject) was registered while a model is being deactivated (from an `onCancel` handler); it is cancelled immediately and never runs. Register work that must run after removal while the model is live, with `node.onTeardown { … }` (or a signal handler's final call)."
if let fileAndLine = (c as? TaskCancellable)?.fileAndLine {
reportIssue(message, fileID: fileAndLine.fileID, filePath: fileAndLine.filePath, line: fileAndLine.line, column: fileAndLine.column)
} else {
Expand Down
12 changes: 12 additions & 0 deletions Sources/SwiftModel/Internal/ModelAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@ class ModelAccess: ModelAccessReference, @unchecked Sendable {
/// Default: no-op. `TestAccess` overrides to fire `_noteActivity`.
func taskBodyStarted() {}

/// The store that hosts signal-handler runs (`onSignal`, `onTeardown`). A run is
/// never hosted by its own model, which may be removed while it runs — or already
/// be gone, for the final `.removed` call. `nil` in production: runs are plain
/// tasks. `TestAccess` returns a store it owns, so runs stay visible to `settle()`
/// and the end-of-test task check after their model is gone.
var signalWorkStore: Cancellations? { nil }

/// While the test harness tears the model tree down at the end of a test, removal
/// calls are deferred until after the exhaustion check (so they are neither checked
/// nor reported). Returns `true` if `start` was deferred. Production: `false`.
func deferRemovalCall(_ start: @escaping @Sendable () -> Void) -> Bool { false }

/// Records that a reactive body (`node.forEach` / `node.onChange`) delivered
/// an element, keyed by its source location. Powers `settle()`'s runaway
/// diagnostic: a registration that keeps firing right up to a settle timeout
Expand Down
Loading
Loading