Signals: onSignal / signal / onTeardown — async work on request, and work that outlives its model - #85
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
onCancelis synchronous and runs after the whole removed subtree is sealed. Sonode.taskstarted from it is dropped (reported since #83), and apps fall back to rawTask.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
signalruns every matching handler concurrently and returns when they're all done. It's repeatable, and cancelling the caller cancels the runs it started..removedwhen its model is removed.oncemeans at most one run in total.cancelPrevious, a new run cancels the running one and starts once it has unwound.Cancellable, orcancelAll(for:)on its key, unregisters the handler, so it doesn't run on removal either.Hosting and tests
.modelTestingthey run on the test's executor in a store the harness owns, sosettle()waits for them.Validated against a real app
imagien converted every site on a branch pinned to this PR:
onShutdown+onCancel { Task.detached }pair and its once-guard);signal(...).TeardownResetOrderingProbeTestspassed 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:cancelPreviouswaits for the cancelled run to unwind;The "reason for removal" question was settled without new API: callers announce with a signal, then remove.
Docs
SignalCausein the topic list.Docs/Lifecycle.mdhas a new section, "Signals and work that outlives a model".Docs/Testing.mdhas testing notes, and advice to preferTestClockoverImmediateClockfor work that sleeps.Docs/Contributing/TestInfrastructure.md.Testing
SignalTestsandTeardownWorkTests.🤖 Generated with Claude Code