Skip to content

Signals: onSignal / signal / onTeardown — async work on request, and work that outlives its model - #85

Merged
mansbernhardt merged 7 commits into
mainfrom
claude/spike-async-oncancel
Sep 25, 2026
Merged

mansbernhardt merged 7 commits into
mainfrom
claude/spike-async-oncancel

Conversation

@mansbernhardt

@mansbernhardt mansbernhardt commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Adds signals: async handlers that run on request, plus a guaranteed final call when their model is removed. This covers work that has to finish after a model is gone (an audio fade-out, a final analytics flush), which today has no testable home.

Problem

onCancel is synchronous and runs after the whole removed subtree is sealed. So node.task started from it is dropped (reported since #83), and apps fall back to raw Task.detached. Tests can't see that work, and it starves under parallel testing. imagien (parallel-apple) had around 7 such sites, plus a hand-built graceful-shutdown registry.

API

public enum SignalCause { case requested, removed }

node.onSignal(key, once: false, cancelPrevious: false) { cause in … }
node.onSignal { cause in … }                        // any key
node.onTeardown { … }                               // removal only

await node.signal(key, to: [.self, .descendants])   // reach like send(_:to:)
await node.signal(to: …)                            // every handler
  • signal runs every matching handler concurrently and returns when they're all done. It's repeatable, and cancelling the caller cancels the runs it started.
  • Every handler gets one final call with .removed when its model is removed. once means at most one run in total.
  • Runs of one handler never overlap. With cancelPrevious, a new run cancels the running one and starts once it has unwound.
  • Cancelling the returned Cancellable, or cancelAll(for:) on its key, unregisters the handler, so it doesn't run on removal either.

Hosting and tests

  • Runs are never hosted by their own model. In production they're plain tasks.
  • Under .modelTesting they run on the test's executor in a store the harness owns, so settle() waits for them.
  • Work the test caused that is still running at the end of the test is reported as an active task.
  • When the test ends, the harness's own teardown runs the remaining final calls after the exhaustivity check (unchecked). The scope waits for them before it returns, cancelling whatever is still parked.

Validated against a real app

imagien converted every site on a branch pinned to this PR:

  • the cross-fade;
  • the reporter flushes (one handler replaced an onShutdown + onCancel { Task.detached } pair and its once-guard);
  • the audio-session teardown;
  • its whole preference-based shutdown registry, now signal(...).

TeardownResetOrderingProbeTests passed 9/9 over 3 parallel runs with no serial CI pass. It was red before, which forced a separate serial pass. Their feedback drove four fixes here:

  • cancelPrevious waits for the cancelled run to unwind;
  • caller cancellation propagates to runs;
  • end-of-test final calls run instead of being skipped;
  • scope exit waits for cancelled runs to unwind.

The "reason for removal" question was settled without new API: callers announce with a signal, then remove.

Docs

  • DocC on all new symbols, and SignalCause in the topic list.
  • Docs/Lifecycle.md has a new section, "Signals and work that outlives a model".
  • Docs/Testing.md has testing notes, and advice to prefer TestClock over ImmediateClock for work that sleeps.
  • Contributor notes in Docs/Contributing/TestInfrastructure.md.
  • CHANGELOG entry.

Testing

  • New tests: SignalTests and TeardownWorkTests.
  • Local runs: the full suite passes in parallel ×3 and serially ×1, with no compiler warnings.

🤖 Generated with Claude Code

mansbernhardt and others added 7 commits September 25, 2026 15:09
…y the test harness

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… removal

onSignal(key, once:, cancelPrevious:) registers an async handler; signal(key, to:)
reaches handlers like send(_:to:) (default: self + descendants), runs them
concurrently and returns when they're done. Every handler also gets one final
call with cause .removed when its model is removed, hosted outside the model
(plain task in production, the test harness's store in tests). onTeardown is a
removal-only handler. Cancelling a registration unregisters it.

The harness's own end-of-test teardown does not start removal calls; removals
the test triggers are tracked by settle() and reported if still running.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Runs of one handler never overlap, matching forEach(cancelPrevious:). CI on
Linux and macOS serial caught the new run starting before the cancelled one
had finished.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… unchecked

- Cancelling the task that called signal() cancels the runs that call started,
  like a task group, so a deadline wrapped around a signal reaches the handlers.
- Removal calls caused by the harness's own end-of-test teardown are no longer
  skipped: they start after the exhaustion check (neither checked nor
  reported), are driven until quiet, and whatever is still parked is
  cancelled. 'Let the model go at scope exit, then assert its cleanup ran'
  works again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Cleanup in a cancelled run (defer { stop(); release() }) has now happened
before withModelTesting returns. The wait is bounded by the drive reaching
quiescence, not by wall-clock time, so a run that ignores cancellation
can't hang the test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Full DocC on onSignal/signal/onTeardown/SignalCause, SignalCause in the
  topic list, a Lifecycle guide section and testing notes in Docs/Testing.md,
  plus a contributor note in Docs/Contributing/TestInfrastructure.md.
- CHANGELOG entry under [Unreleased].
- Remove spike markers; rename the harness store to signalWork; move
  SignalHandler to Internal/; put the TaskCancellable lock-order note where
  the hoist now happens.
- The #83 teardown-registration issue now points at onTeardown.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mansbernhardt mansbernhardt changed the title SPIKE: onSignal / signal / onTeardown — work that outlives a model Signals: onSignal / signal / onTeardown — async work on request, and work that outlives its model Sep 25, 2026
@mansbernhardt
mansbernhardt merged commit 9376dda into main Sep 25, 2026
7 checks passed
@mansbernhardt
mansbernhardt deleted the claude/spike-async-oncancel branch September 25, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant