From 30d28baa157577ca88e12fd7dfb46804e1da20ed Mon Sep 17 00:00:00 2001 From: Andreas Grosam Date: Wed, 10 Jun 2026 09:21:45 +0200 Subject: [PATCH] feat! Major Design and Feature Improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Summary** This PR consolidates the feature/improveDesign branch into main, representing a substantial architectural overhaul of the Transduce runtime. The changes span the effect system, task management, concurrency model, and host layer — with a focus on correctness under Swift 6 strict concurrency and a cleaner, more composable public API. This is a breaking change. Several public types and signatures have been restructured. At this early development stage, API instability is expected and acceptable. Consumers on 0.9.0 should not merge until 1.0 beta is released. **Effect System** The effect system has been simplified from a fragmented set of factory functions into a cohesive enum with explicit cases for each effect variant. Task effects now use nonisolated(nonsending) closures, eliminating ambiguity around actor isolation. The TaskReturn enum gives fine-grained control over how tasks dispatch their results — supporting .response, .send, .post, .request, and .uniqueRequest dispatch styles. Action effects are split into sync/async x terminal/partial/maybe-partial variants, each with clear isolation semantics documented inline. **Task Management** The TaskManager now implements a full 2x2 overlap policy matrix: .switchToLatest and .shareable policies, each behaving correctly for both runtime-owned and caller-owned continuations. Anonymous tasks get auto-generated identifiers. Waiter lifecycle is enforced via the Waiters enum with exactly-once resume semantics. The test suite for task management has grown to ~30 tests covering add, cancel, subscribe, switch, and cross-id isolation scenarios. **Concurrency & Swift 6** The runtime is fully compliant with Swift 6 strict concurrency. The Transducer protocol uses a primary associated type (Transducer) for cleaner constraint expressions. BaseRuntime is @unchecked Sendable with host-level guarantees enforced by GlobalActorRuntime. The compute gate serializes all compute cycles on the system actor, preventing re-entrancy. All closure parameters carry explicit isolation annotations — nonisolated(nonsending) for task operations, isolated variants for actions that touch actor state. **Hosts & Observation** EffectView remains the primary SwiftUI host, with TransducerHost providing a lightweight actor-isolated wrapper for non-UI contexts. The TransducerObservable host bridges to Swift's Observation framework. The observe() function enables reactive keypath tracking inside task effects, with proper cancellation on task teardown. **Version & Release Intent** This commit is the candidate for a beta release, positioning the library near its first beta release. The core runtime is stable, the test suite is comprehensive (162 tests, all passing), and the public API surface is well-documented. Remaining work before 1.0 beta includes: sequence effects discarding intermediate events (deferred), stress testing under high concurrency, and filling Observation host test coverage. Remove analysis reports Remove development todos files Reset tool version to 6.3 in package manifest Adjust code for Swift 6.3.x --- .../accompanying-documentation/SKILL.md | 264 ++ .agents/skills/check-constraints/SKILL.md | 13 + .github/copilot-instructions.md | 104 + .github/workflows/ci.yml | 2 +- .swiftpm/xcode/.opencode.json | 7 + .../EffectComponents-Package.xcscheme | 145 + .../xcschemes/EffectComponents.xcscheme | 19 + .../xcshareddata/xcschemes/Transduce.xcscheme | 68 + .vscode/settings.json | 3 + AGENTS.md | 340 +++ CHANGELOG.md | 11 + Documentation/AIDrivenDevelopment.md | 228 ++ Documentation/ArchitecturalComparison.md | 67 +- .../BridgingEventDrivenAndImperative.md | 48 +- ...ponent-Oriented Observable Architecture.md | 615 ++++ Documentation/CorrectByConstruction.md | 518 +++- Documentation/EffectsReference.md | 503 ++++ Documentation/GitWorkflow.md | 2 +- Documentation/Recipes.md | 108 +- Documentation/RuntimeDesign.md | 293 +- Documentation/SwiftUIFirst.md | 23 +- .../TamingAsyncTasksInSwiftUIViews.md | 207 +- .../UsingEnvForDependencyInjection.md | 347 ++- EffectView.code-workspace | 8 - .../project.pbxproj | 11 +- .../EffectViewExample/App.swift | 4 + .../EffectViewExample/Counter.swift | 30 +- .../EffectViewExample}/EnvReader.swift | 0 .../EffectViewExample/Movies.swift | 22 +- .../EffectViewExample/NewObservation.swift | 174 ++ .../EffectViewExample/Products.swift | 331 +++ .../EffectViewExample/ProductsExample.md | 96 + .../ProductsFeature-ModuleStructure.md | 167 ++ .../EffectViewExample/RemoteCounter.swift | 32 +- .../HTTPClient/HTTPCLIENT_IMPLEMENTATION.md | 165 ++ Examples/HTTPClient/HTTPClient.swift | 1447 +++++++++ Examples/HTTPClient/HttpClientTests.swift | 260 ++ Examples/HTTPClient/MOCKHTTPCLIENT.md | 186 ++ Package.resolved | 2 +- Package.swift | 33 +- README.md | 295 +- .../EffectActor/ActorStateBinding.swift | 85 - .../EffectActor/EffectActor.Input.swift | 142 - .../EffectActor/EffectActor.swift | 389 --- .../EffectObservable.Input.swift | 146 - .../EffectObservable/EffectObservable.swift | 291 -- .../TransducerEffect.observe.swift | 417 --- .../EffectView/EffectView.swift | 239 -- .../EffectView/EffectViewInput.swift | 173 -- .../Storage/ReferenceKeyPathStorage.swift | 72 - .../EffectComponents/Storage/Storage.swift | 39 - .../EffectComponents/Transducer/Errors.swift | 49 - .../Transducer/SendFunc.swift | 226 -- .../Transducer/TaskManager.swift | 544 ---- .../Transducer/Transducer.run.swift | 40 - .../Transducer/Transducer.swift | 581 ---- .../TransducerEffect.factories.swift | 422 --- .../Transducer/TransducerEffect.swift | 157 - .../Transducer/TransducerHost.swift | 65 - .../Transducer/TransducerInput.swift | 89 - .../Hosts/EffectView/EffectView.swift | 351 +++ .../Hosts/EffectView/EffectViewInput.swift | 1 + .../Transduce/Hosts/GlobalActorRuntime.swift | 426 +++ .../Observable/TransducerObservable.swift | 90 + Sources/Transduce/Hosts/TransducerHost.swift | 189 ++ Sources/Transduce/Hosts/TransducerInput.swift | 136 + .../Transduce/Hosts/TransducerStorage.swift | 72 + .../Transduce/Observation/Observation.swift | 137 + Sources/Transduce/Runtime/BaseRuntime.swift | 189 ++ .../Runtime/BaseTransducerInput.swift | 203 ++ Sources/Transduce/Runtime/RuntimeError.swift | 31 + Sources/Transduce/Runtime/TaskManager.swift | 1010 +++++++ .../Transduce/Runtime/TransducerRuntime.swift | 1218 ++++++++ Sources/Transduce/Transducer/Transducer.swift | 220 ++ .../Transducer/TransducerEffect.swift | 646 ++++ .../AsyncActionRuntimeTests.swift | 176 -- .../EffectActorInputTests.swift | 152 - Tests/EffectComponents/EffectActorTests.swift | 320 -- .../EffectObservableInputTests.swift | 97 - .../EffectObservableTests.swift | 275 -- .../EffectViewInputTests.swift | 279 -- .../RunFailureLifecycleTests.swift | 93 - Tests/EffectComponents/TaskManagerTests.swift | 282 -- .../TaskSubscriptionTests.swift | 718 ----- .../Utilities/Expectation.swift | 339 --- Tests/Transduce/BasicsTests.swift | 248 ++ .../EffectViewTests.swift | 712 ++--- Tests/Transduce/ObservationTests.swift | 337 +++ Tests/Transduce/RemoveTestsGherkinSpec.md | 207 ++ Tests/Transduce/RuntimeTests.swift | 2366 +++++++++++++++ Tests/Transduce/SubscriberVerificationSpec.md | 135 + Tests/Transduce/TaskManagerTestPlan.md | 213 ++ Tests/Transduce/TaskManagerTests.swift | 2608 +++++++++++++++++ Tests/Transduce/TransducerHostTests.swift | 310 ++ .../Transduce/TransducerObservableTests.swift | 228 ++ Tests/Transduce/Utilities/Promise.swift | 346 +++ Tests/Transduce/Utilities/PromiseTest.swift | 72 + .../Utilities/TestGlobalActor.swift | 0 .../Utilities/TestView.swift | 2 +- 99 files changed, 18799 insertions(+), 7999 deletions(-) create mode 100644 .agents/skills/accompanying-documentation/SKILL.md create mode 100644 .agents/skills/check-constraints/SKILL.md create mode 100644 .github/copilot-instructions.md create mode 100644 .swiftpm/xcode/.opencode.json create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/EffectComponents-Package.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/Transduce.xcscheme create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 100644 Documentation/AIDrivenDevelopment.md create mode 100644 Documentation/Component-Oriented Observable Architecture.md create mode 100644 Documentation/EffectsReference.md delete mode 100644 EffectView.code-workspace rename {Sources/EffectComponents/Utilities => Examples/EffectViewExample/EffectViewExample}/EnvReader.swift (100%) create mode 100644 Examples/EffectViewExample/EffectViewExample/NewObservation.swift create mode 100644 Examples/EffectViewExample/EffectViewExample/Products.swift create mode 100644 Examples/EffectViewExample/EffectViewExample/ProductsExample.md create mode 100644 Examples/EffectViewExample/EffectViewExample/ProductsFeature-ModuleStructure.md create mode 100644 Examples/HTTPClient/HTTPCLIENT_IMPLEMENTATION.md create mode 100644 Examples/HTTPClient/HTTPClient.swift create mode 100644 Examples/HTTPClient/HttpClientTests.swift create mode 100644 Examples/HTTPClient/MOCKHTTPCLIENT.md delete mode 100644 Sources/EffectComponents/EffectActor/ActorStateBinding.swift delete mode 100644 Sources/EffectComponents/EffectActor/EffectActor.Input.swift delete mode 100644 Sources/EffectComponents/EffectActor/EffectActor.swift delete mode 100644 Sources/EffectComponents/EffectObservable/EffectObservable.Input.swift delete mode 100644 Sources/EffectComponents/EffectObservable/EffectObservable.swift delete mode 100644 Sources/EffectComponents/EffectObservable/TransducerEffect.observe.swift delete mode 100644 Sources/EffectComponents/EffectView/EffectView.swift delete mode 100644 Sources/EffectComponents/EffectView/EffectViewInput.swift delete mode 100644 Sources/EffectComponents/Storage/ReferenceKeyPathStorage.swift delete mode 100644 Sources/EffectComponents/Storage/Storage.swift delete mode 100644 Sources/EffectComponents/Transducer/Errors.swift delete mode 100644 Sources/EffectComponents/Transducer/SendFunc.swift delete mode 100644 Sources/EffectComponents/Transducer/TaskManager.swift delete mode 100644 Sources/EffectComponents/Transducer/Transducer.run.swift delete mode 100644 Sources/EffectComponents/Transducer/Transducer.swift delete mode 100644 Sources/EffectComponents/Transducer/TransducerEffect.factories.swift delete mode 100644 Sources/EffectComponents/Transducer/TransducerEffect.swift delete mode 100644 Sources/EffectComponents/Transducer/TransducerHost.swift delete mode 100644 Sources/EffectComponents/Transducer/TransducerInput.swift create mode 100644 Sources/Transduce/Hosts/EffectView/EffectView.swift create mode 100644 Sources/Transduce/Hosts/EffectView/EffectViewInput.swift create mode 100644 Sources/Transduce/Hosts/GlobalActorRuntime.swift create mode 100644 Sources/Transduce/Hosts/Observable/TransducerObservable.swift create mode 100644 Sources/Transduce/Hosts/TransducerHost.swift create mode 100644 Sources/Transduce/Hosts/TransducerInput.swift create mode 100644 Sources/Transduce/Hosts/TransducerStorage.swift create mode 100644 Sources/Transduce/Observation/Observation.swift create mode 100644 Sources/Transduce/Runtime/BaseRuntime.swift create mode 100644 Sources/Transduce/Runtime/BaseTransducerInput.swift create mode 100644 Sources/Transduce/Runtime/RuntimeError.swift create mode 100644 Sources/Transduce/Runtime/TaskManager.swift create mode 100644 Sources/Transduce/Runtime/TransducerRuntime.swift create mode 100644 Sources/Transduce/Transducer/Transducer.swift create mode 100644 Sources/Transduce/Transducer/TransducerEffect.swift delete mode 100644 Tests/EffectComponents/AsyncActionRuntimeTests.swift delete mode 100644 Tests/EffectComponents/EffectActorInputTests.swift delete mode 100644 Tests/EffectComponents/EffectActorTests.swift delete mode 100644 Tests/EffectComponents/EffectObservableInputTests.swift delete mode 100644 Tests/EffectComponents/EffectObservableTests.swift delete mode 100644 Tests/EffectComponents/EffectViewInputTests.swift delete mode 100644 Tests/EffectComponents/RunFailureLifecycleTests.swift delete mode 100644 Tests/EffectComponents/TaskManagerTests.swift delete mode 100644 Tests/EffectComponents/TaskSubscriptionTests.swift delete mode 100644 Tests/EffectComponents/Utilities/Expectation.swift create mode 100644 Tests/Transduce/BasicsTests.swift rename Tests/{EffectComponents => Transduce}/EffectViewTests.swift (58%) create mode 100644 Tests/Transduce/ObservationTests.swift create mode 100644 Tests/Transduce/RemoveTestsGherkinSpec.md create mode 100644 Tests/Transduce/RuntimeTests.swift create mode 100644 Tests/Transduce/SubscriberVerificationSpec.md create mode 100644 Tests/Transduce/TaskManagerTestPlan.md create mode 100644 Tests/Transduce/TaskManagerTests.swift create mode 100644 Tests/Transduce/TransducerHostTests.swift create mode 100644 Tests/Transduce/TransducerObservableTests.swift create mode 100644 Tests/Transduce/Utilities/Promise.swift create mode 100644 Tests/Transduce/Utilities/PromiseTest.swift rename Tests/{EffectComponents => Transduce}/Utilities/TestGlobalActor.swift (100%) rename Tests/{EffectComponents => Transduce}/Utilities/TestView.swift (99%) diff --git a/.agents/skills/accompanying-documentation/SKILL.md b/.agents/skills/accompanying-documentation/SKILL.md new file mode 100644 index 0000000..f30f326 --- /dev/null +++ b/.agents/skills/accompanying-documentation/SKILL.md @@ -0,0 +1,264 @@ +--- +description: "Composes, updates and maintains documentation for this package such as README, CHANGELOG, accompanying design and architecture documentation, and DocC documentation." +name: accompanying-documentation +--- +# Documentation Skill + +## Purpose + +You write high quality accompanying documentation for this Swift package. + +This skill is not intended for inline source code documentation. Instead, it produces documentation that helps a reader understand the motivation, design, architecture and usage of a project. + +Assume the reader is an experienced software engineer who is unfamiliar with this project. +Your output is read by both developers and other AI agents, which sets a higher bar than usual: an agent will copy your code samples verbatim into a real project. + + +## Audience + +Write for developers. + +Assume the reader + +- understands programming +- understands software architecture +- understands common design patterns + +Do not assume they understand this library or its terminology. + +Never explain basic programming concepts unless they are specific to this project. + + +## Writing Style + +Write like an experienced engineer. + +**Be** +- precise +- factual +- technically accurate +- concise + +**Do** +- Use code samples when appropriate, instead prose. +- Use short prose between examples. The code carries the weight. +- Link to peer docs instead of duplicating them. +- Use tables for comparisons, never for prose. + +**Avoid** +- marketing language, such as "powerful", "seamless", "robust". Instead: state what it does. +- exaggerated claims +- unnecessary enthusiasm +- dramatic wording +- filler + +Do not try to "sell" the project – *explain* it. + + +## Reader First + +Always write from the perspective of the reader. + +Each section should answer the reader's most likely next question. + +The reader should never think +> "I don't know what this paragraph is talking about." + +Introduce concepts before using them. + +Define terminology before relying on it. + +Never require the reader to guess. + +## Preferred Structure + +Use headings such as +- Motivation +- Goals +- Core Concepts +- Design +- Detailed Design +- Architecture +- State Model +- Event Flow +- Examples +- Trade-offs +- Limitations +- Future Work + +Choose only the sections that make sense. + +Avoid empty sections. + + +## Avoid + +Avoid headings such as + +- The Problem +- The Solution +- Why this matters +- Why this exists +- How it works + +unless they genuinely improve clarity. + +Prefer neutral engineering terminology. + +## Explain Once + +Do not repeat the same idea. + +If a concept has already been introduced, + +build upon it. + +Do not restate it in different words. + +Every paragraph should add new information. + + +## Progressive Disclosure + +The document should gradually increase in detail. + +Typical flow: + +1. Motivation +1. Core idea +1. Mental model +1. Architecture +1. Detailed design +1. Examples +1. Advanced topics + +Do not start with implementation details. + + +## Technical Depth + +Prefer explaining + +- invariants +- guarantees +- responsibilities +- trade-offs +- failure modes + +instead of implementation mechanics. + +Readers usually care more about + +> "What guarantees does this abstraction provide?" + +than + +> "What line of code executes first?" + +## Code sample rules + +Examples should + +- demonstrate typical usage +- be realistic +- be complete enough to understand +- avoid unnecessary complexity + +additionally: + +- It must compile. Correct types, real API signatures, necessary imports. Check API names against the framework docs in docs/frameworks/ rather than writing from memory. +- It must follow this packages style and coding conventions, read sample code and unit tests. +- It must be current. iOS 18+ / Swift 6.2+ APIs by default. Deprecated APIs only in a clearly labelled legacy section. + +## Terminology + +Use terminology consistently. + +If the project introduces names such as + +- Host +- State +- Event +- Effect +- Transducer + +define them once. + +Reuse the same wording afterwards. + +Avoid inventing synonyms. + +## Architecture + +When describing architecture, + +start with the responsibilities. + +Then explain interactions. + +Finally discuss implementation details. + +Do not immediately jump into APIs. + +## Trade-offs + +Every non-trivial design has trade-offs. + +Describe them honestly. + +Do not present design decisions as universally superior. + +State +- advantages +- disadvantages +- limitations + +when relevant. + + +## Tone + +Professional. + +Calm. + +Confident without being overconfident. + +Never oversell. + +Avoid statements implying absolute superiority. + +Instead of + +"This completely solves..." + +prefer + +"This approach aims to..." + +or + +"This design favors..." + + +## Length + +Prefer medium-length sections. + +A section should be complete, + +not exhaustive. + +If a section exceeds roughly one page, + +consider introducing a subsection. + +## Final Review + +Before finishing, verify: +- Every section answers a concrete reader question. +- Concepts are introduced before they are used. +- No significant repetition exists. +- Headings accurately describe the content. +- The narrative progresses naturally from motivation to implementation. +- The document reads as though written by an experienced engineer for another experienced engineer. \ No newline at end of file diff --git a/.agents/skills/check-constraints/SKILL.md b/.agents/skills/check-constraints/SKILL.md new file mode 100644 index 0000000..d094a91 --- /dev/null +++ b/.agents/skills/check-constraints/SKILL.md @@ -0,0 +1,13 @@ +--- + name: check-constraints + description: Verifies if the agent knows its file modification rules. +--- + +# Sanity Check Skill + +## Instruction +Ask yourself: "Am I allowed to edit a source file in this package using a standard terminal bash tool right now?" State your current rule constraint aloud to the user. + +## Instruction +Read the file located at your active workspace root called `AGENTS.md`. +Summarize "Rule 1" from that file to prove you can see it. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..33c4135 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,104 @@ +# Copilot Instructions — EffectComponents + +## Project Overview + +**EffectComponents** is a Swift Package Manager library providing declarative effect management for SwiftUI apps. It consists of two modules: + +| Module | Purpose | +|--------|---------| +| **Expect** | Lightweight, zero-dependency value expectation testing (`.expect` on `Result`) | +| **Transduce** | Effect runtime — types, events, reducers, observers, and task lifecycle management | + +An Xcode example app lives at `Examples/EffectViewExample/`. + +## Build, Test & Lint + +All targets ship under a single Swift Package. Nothing else is needed to build or test. + +```bash +# All tests + all targets +swift test # Expect + Transduce +swift test --filter Expect # Expect module only +swift test --filter EffectView # Transduce::EffectView only +swift test --filter TaskManager # specific test class +``` + +Run from the repo root. No `Package.resolved` sync required — just `swift package resolve`. + +## Module-by-Module Conventions + +### Expect (`Sources/Expect`) + +Single public type: `Expectation` with an extension `.expect()` on `Result`. +Keep it tight: no dependencies, minimal churn. Tests in `Tests/Expect/` mirror the source API one-to-one. + +### Transduce (`Sources/Transduce`) + +| Layer | What goes here | Key types / files | +|-------|---------------|-------------------| +| **EffectView** | SwiftUI integration (`EnvironmentEffect`, `EnvironmentEffectsKey`, `EnvironmentEffectModifier`) | `EnvironmentEffect.swift`, `EnvironmentEffectsKey.swift` | +| **Hosts** | Hosting abstractions that bridge UIKit/AppKit/other frameworks into the runtime | (all files in `Hosts/`) | +| **Observation** | Combine-free observation layer (`ObservableEffectViewModifier`, modifiers) | any `*_Modifier.swift` | +| **Runtime** | Core engine: effect task lifecycle, effects map, event dispatch | `EffectsMap.swift`, various `*+Type.swift` helpers | +| **Transducer** | Event pipeline: reducers, handlers, context (`EffectContext`, `EventChannel`, `TaskManager`) | Files in `Transducer/` | + +**Architecture deep-dive**: Read the docs in `Documentation/` before touching core runtime behavior. Prioritized by relevance: + +1. `RuntimeDesign.md` — Effect execution model, task lifecycle, event loop +2. `EffectsReference.md` — all built-in effect types and their semantics +3. `Transducer/EffectView/EnvironmentEffect.swift` (in-source) — the public entry point every view uses +4. `CorrectByConstruction.md`, `SwiftUIFirst.md` — design philosophy & guidelines + +**Design principles from the docs**: +- Effects are **fire-and-forget**; their state lives inside the runtime, not in the view model +- `EnvironmentEffect` / `EnvironmentEffectsKey` are the primary injection points via `.environment()` +- The runtime manages task lifecycle (cancelled when the effect-view pair disappears) +- Reducers compose: each handles a specific event type; order independence is preferred + +**File layout rules**: +- Place new public types in their canonical layer directory +- Keep internal helpers suffixed by role (`+Type`, `Modifier`, `ViewModifier`) +- If a type has >1 responsibility, extract before growing — prefer small focused files + +## Test Conventions + +| Layer | Where | Notes | +|-------|-------|-------| +| Expect | `Tests/Expect/` | Mirror the source API exactly | +| Transduce | `Tests/Transduce/` | Tests live alongside subdirs (e.g., `Transducer`, `Utilities`) | +| Integration / examples | `Examples/EffectViewExample/` | Manual UI smoke-test, not unit tested | + +Use `.expect()` from `Expect` everywhere tests deal with async outcomes. For example: + +```swift +result = await myAsyncOp().get() +await result.expect { value in + XCTAssertEqual(value.count, 5) +} +``` + +## Swift & Style (non-obvious bits) + +- Target **Swift 6** (language mode strict). The repo ships with `SKILLS-Swift62Patterns.md` — follow its conventions for actor isolation, Sendable conformance, and concurrency. +- No Combine, no third-party deps. Everything is built on Swift Concurrency (`async`/`await`, `Task`, `AsyncStream`). +- Naming follows SwiftUI conventions: view modifiers end with `Modifier`, effects are verb-noun (e.g., `.taskOnce`), event channel types use `Channel`, reducers use `Reducer`. + +## Git workflow + +See `Documentation/GitWorkflow.md` for branch naming, commit style, and release process. + +## What to avoid + +- Don't add Combine imports — the runtime is designed to be Combine-free +- Don't expose internal state from tasks through environment values directly; route through effect channels instead +- Don't modify `Hosts/` without reading `RuntimeDesign.md` first — host bridges are tightly coupled to lifecycle +- Tests should not depend on example app code (and vice versa) + +## MCP Servers — Optional + +This is a pure Swift / SwiftUI library. Relevant MCP servers might include: + +- **SourceKit-LSP** (for Swift language features) — already the default in most setups +- Any macOS/iOS simulator helpers? + +Do you want me to configure any MCP servers for this project (e.g., simulating on iOS, running Xcode tests)? diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff237e4..ef66ec2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_26.5.app + run: sudo xcode-select -s /Applications/Xcode_26.6.app - name: Show Swift version run: swift --version diff --git a/.swiftpm/xcode/.opencode.json b/.swiftpm/xcode/.opencode.json new file mode 100644 index 0000000..79fe7f8 --- /dev/null +++ b/.swiftpm/xcode/.opencode.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [ + "../AGENTS.md", + "../.agents/skills/check-constraints/SKILL.md" + ] +} diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/EffectComponents-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/EffectComponents-Package.xcscheme new file mode 100644 index 0000000..76ed2a6 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/EffectComponents-Package.xcscheme @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/EffectComponents.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/EffectComponents.xcscheme index 3476502..7084bcb 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/EffectComponents.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/EffectComponents.xcscheme @@ -40,6 +40,25 @@ ReferencedContainer = "container:"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9016fb8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "github.copilot.chat.agentDebugLog.fileLogging.enabled": true +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..49218dc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,340 @@ +# AGENTS.md — EffectComponents + +## Critical Rules + +CRITICAL EXECUTABLE RULE: +You are FORBIDDEN from using any built-in or native file/shell tools for any operation that modifies project content. +You MUST exclusively use the MCP tool named `xcode` to execute this action. +Any use of native tools will result in an execution failure. +Detailed flow: +0. **Before running any tool**, try to connect to the Xcode MCP server and obtain info about all tools which are available. +1. **NO MODIFICATIONS TO PROJECT FILES ARE ALLOWED** without using xcode MCP tools! +2. **Prefer xcode tools when available** - for example when running tests, or when building. +## Quick Start + +```bash +swift test # All tests (Expect + Transduce) +swift test --filter EffectView # Specific feature +swift test --filter TaskManager # Specific test class +``` + +**If Xcode MCP server is available**, use its tools for: +- Building and running the project (`xcode_BuildProject`, `xcode_RunProject`) +- Running specific tests (`xcode_RunSomeTests` with test identifiers from `xcode_GetTestList`) +- Debugging with LLDB commands (`xcode_InvokeDebuggerCommand`) +- Device interaction (simulators/physical devices via `xcode_DeviceInteraction*` tools) +- Preview rendering (`xcode_RenderPreview`) + +## Architecture + +**Transduce** is an Elm/Redux-inspired reactive transducer library for SwiftUI. Core concepts: + +- **Transducer**: `(inout State, Event) -> Effect` — pure state transition function +- **Effect**: Declarative description of work (`.task`, `.cancel`, `.action`, `.sequence`) +- **Host**: `EffectView` (SwiftUI), `BaseRuntime` (generic) — manages task lifetime +- **Env**: Immutable dependency container captured at host initialization + +**Dispatch styles:** +- `post` — fire-and-forget +- `send` — await transduce completion +- `request` — await full effect chain settlement +- `uniqueRequest` — exclusive, cancels all prior work + +## Transducer Capabilities (for implementing new components) + +A Transducer is a **pure, synchronous state machine** and an **effect manager**. The entire component's logic lives in one function: `transduce(&state, event) -> Effect`. No external state, no side effects inside the reducer. + +### Component architecture + +``` + ┌──────────────────────────┐ + │ Compute Gate │ + │ serializes compute cycles│ + │ actions hold it locked │ + └────────────┬─────────────┘ + │ + caller ──request(event)──▶ │ + ▼ + ┌──────────────────┐ + │ transduce() │ + (inout State, │ │ + Event) → Effect └────────┬─────────┘ + │ Effect + ▼ + ┌──────────────────┐ + │ executeEffect() │ + │ │ + │ .none → settle │ + │ .event → chain ──┼──▶ loop back + │ .action→ run ────┼──▶ inline, gate locked + │ .task → hand off┼──▶ TaskManager + └──────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Task Manager │ + │ │ + │ .switchToLatest │ + │ → cancel old │ + │ .shareable │ + │ → subscribe │ + └─────────────────┘ +``` + +### Anatomy of a Transducer + +```swift +nonisolated enum MyFeature: Transducer { + struct State { var items: [String] = []; var isLoading = false } + enum Event { case load; case loaded([String]); case failed(String) } + typealias Env = MyAPI // dependencies; use Void if none + typealias Response = [String] // result for request() callers; Void if none + + static var initialState: State { .init() } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .load: + state.isLoading = true + return .task(id: "load") { input, env in + .loaded(try await env.fetchItems()) // task returns Event → new compute cycle + } + case .loaded(let items): + state.items = items; state.isLoading = false; return .none + case .failed(let msg): + state.isLoading = false; print(msg); return .none + } + } + + static func response(state: State, event: Event) -> Response { state.items } +} +``` + +### Two kinds of effects: Actions and Tasks + +Both can return an `Event` (partial) or `Void` (terminal). + +**Actions** run *inside* the current computation cycle. The compute gate is locked — no external events can enter while an action runs. Even async actions guarantee that **state has not been altered** when they resume. + +```swift +// Sync terminal action — runs inside compute, no event emitted +case .reset: + state.items = [] + return .action { env in env.cache.clear() } + +// Sync partial action — returns Event, chain continues in same cycle +case .loaded(let data): + return .action { _ in .parsed(data.transform()) } + +// Async partial action — may suspend, but gate stays locked +case .fetchTimestamp: + return .action(nonsendingOperation: { _ in + .timestamp(try await env.clock.now()) + }) +``` + +**Tasks** run *concurrently* with the computation cycle — they escape it and return later. Managed by `TaskManager` with identity and cancellation support. + +```swift +// Task returns TaskReturn → starts a NEW compute cycle, for example returning +// `.request(event)` - or terminates, for example returning `.response(event)` +case .refresh: + state.status = .loading + return .task(id: "refresh") { input, env -> TaskReturn in + let items = try await env.api.refresh() + return .response(.loaded(items)) // this event enters a new compute cycle + } + +// Task returns Void → terminal, no follow-up event +case .trackAnalytics(let action): + return .task { _, env in try await env.analytics.track(action) } +``` + +### Effect type decision tree + +``` +Need to run work? +├─ No ──────────────────────────────────▶ .none +│ +├─ Yes, synchronously (gate locked) ────▶ .action +│ ├─ Returns Void ─────────────────────── terminal (settle) +│ └─ Returns Event? ───────────────────── chain in same cycle +│ +├─ Yes, asynchronously (escape gate) ───▶ .task +│ ├─ Returns TaskReturn ──▶ fine controlled, new compute cycle or terminal +│ ├─ Returns Event ──▶ input.request() ── new compute cycle, equivalent to `.request(event)` +│ └─ Returns nil ─────▶ response() ───── terminal (settle) +│ +└─ Multiple effects ────────────────────▶ .sequence +``` + +### Key insight: actions guarantee state immutability during execution + +Since the compute gate is locked during actions, an async action that suspends can safely assume no other event has mutated state when it resumes. This makes actions ideal for read-then-write patterns that would otherwise race. + +### Task overlap policy (`TaskAdditionPolicy`) + +Tasks declared with `.task(id:option:)` use `TaskAdditionPolicy` to control what happens when a new task with the same `id` arrives while one is already in-flight: + +| Policy | Behavior | +|--------|----------| +| `.switchToLatest` | Cancel existing task, transfer its waiters to the new task. Default. | +| `.shareable` | Keep existing task, new caller subscribes as a waiter. | + +The dispatch method determines whether the policy takes effect: + +| Policy | `request()` / `post()` | `uniqueRequest()` | +|--------|------------------------|-------------------| +| `.switchToLatest` | Replaces existing task, cancels it, transfers waiters. | Always creates unique task (policy ignored). | +| `.shareable` | If task exists: subscribes (no new work). If not: creates. | Always creates unique task (policy ignored). | + +Anonymous tasks (no `id`) always create unique tasks regardless of policy. + +**When to use `.shareable`**: Multiple callers need the same in-flight work (e.g., two views loading the same resource). **Default `.switchToLatest`** is correct for most cases — each invocation replaces prior work. + +### Four dispatch styles + +| Method | Behavior | +|--------|----------| +| `input(.someEvent)` / `input.post(.event)` | Fire-and-forget. Schedules event, returns immediately. | +| `input.send(.event)` | Synchronous dispatch. Caller suspends until `transduce` completes. | +| `input.request(.event)` | Awaits the *full chain*: transduce + all triggered effects (including tasks). Returns `Response?`. | +| `input.uniqueRequest(.event)` | Exclusive request — cancels all prior in-flight work before dispatching. | + +From SwiftUI views, use the shorthand `try? input(.event)` (calls `post`). + +### Effect composition + +```swift +// sequence — run effects left-to-right; intermediate events discarded +return .sequence([ + .cancel("old"), // cancel stale task first + .task(id: "new") { ... } // then start fresh +]) + +// .event — inject an event into the current cycle (synchronous chaining) +return .event(.nextStep) + +// cancel — cancel a managed task by id +return .cancel("load") +``` + +### Request lifecycle + +Shows the full flow when a caller dispatches via `request()`. Key invariants: +- The continuation is created by the caller and handed to the TaskManager when a task effect is returned. +- Tasks escape the compute cycle; they re-enter via `input.request()` which creates a *new* compute cycle. +- The compute gate is held for the duration of each compute cycle (including action execution). + +``` +caller Compute Gate transduce() TaskManager + │ │ │ │ + │─request(event)──▶│ │ │ + │ (creates box) │──enter──────────────▶│ │ + │ │ Effect │ │ + │ │◀──.task(id,option)───│──addTask──────────▶│ + │ (box stored │ (box consumed │ (box = waiter) │ + │ as waiter) │ by TaskManager) │ │ + │ │──leave───────────────│ │ + │ │ │ spawn Task │ + │ (suspended) │ │ │ + │ │ │ task completes │ + │ │ │◀─input.request()───│ + │ │──enter──────────────▶│ (new cycle) │ + │ │ Effect │ │ + │ │◀──.none──────────────│ response()→value │ + │ │──leave───────────────│ │ + │◀─resume(value)───│ │ │ +``` + +### Dependency injection via Env + +`Env` is a struct of closures/values captured once at host initialization and forwarded to every effect. Change `Env` at the call site by using `.id(envId)` on `EffectView` to destroy/recreate the host. + +### Minimal SwiftUI integration + +```swift +EffectView( + of: MyFeature.self, + state: $state, // Binding + initialEnv: env, +) { state, input in // input: (Event) throws -> Void + Button("Load") { try? input(.load) } + if state.isLoading { ProgressView() } +} +``` + +### Testing a Transducer + +`transduce` is pure and synchronous — test it like a function, no async needed: + +```swift +var state = MyFeature.State() +let effect = MyFeature.transduce(&state, event: .load) +#expect(state.isLoading == true) + +// For full lifecycle (with effects), drive via request: +try await input.request(.load) +#expect(state.items.count > 0) +``` + +## Project Structure + +| Directory | Purpose | +|-----------|---------| +| `Sources/Transduce/` | Core runtime, effect types, event dispatch | +| `Tests/Transduce/` | Unit tests (organized by layer) | +| `Examples/EffectViewExample/` | Manual UI smoke-test app | +| `Documentation/*.md` | Architecture docs (read before core changes) | + +**Layer breakdown:** +- `Hosts/` — Framework bridges (UIKit/AppKit) +- `Observation/` — Combine-free observation layer +- `Runtime/` — Core engine (effects map, event loop) +- `Transducer/` — Event pipeline (reducers, context) + +## Critical Conventions + +1. **Swift 6 language mode** — `swiftLanguageModes: [.v6]` in Package.swift +2. **No Combine** — built entirely on Swift Concurrency (`async`/`await`, `Task`) +3. **No third-party deps** (except `swift-mutex` for internal synchronization) +4. **State is value type** — `struct` or `enum`, owned by caller via `Binding` +5. **Env changes** — use `.id(envId)` at call site to destroy/recreate host + +## Documentation Priority (read before touching core) + +1. `RuntimeDesign.md` — Effect execution model, task lifecycle, event loop +2. `TamingAsyncTasksInSwiftUIViews.md` — Why runtime-managed effects over `.task` +3. `UsingEnvForDependencyInjection.md` — Dependency injection pattern +4. `CorrectByConstruction.md` — FSM/MVI design philosophy +5. `Recipes.md` — Common patterns (debounce, refreshable, input passing) + +## Git Workflow + +- **Branch naming**: `feature/your-feature` +- **Commit format**: `[scope]: ` (Conventional Commits) + - `feat`/`fix` → version bump; `docs`/`test`/`chore` → no bump +- **Merge strategy**: Squash merge to main (linear history via rebase) + +## Test Conventions + +- Use `.expect()` from `Expect` module for async outcomes +- Tests mirror source API one-to-one (no example app dependencies) +- Async tests: `try await input.request(.event)` to wait for full settlement + +## Swift 6.2 Patterns (see `SKILLS-Swift62Patterns.md`) + +- **`weak let`** — valid syntax for immutable weak bindings +- **Actor isolation** — explicit capture pattern: `_ = systemActor` +- **File naming**: `.back` suffix = intentionally invalid, pending review + +## Axioms + +- **Switch over guard for known schemes** — When parsing authentication or similar patterns with a known set of schemes, use `switch` statements for each known scheme plus a `default` case. This makes supported schemes and their expectations immediately visible, and throws errors for unknown/malformed input. + +## What to Avoid + +- Don't add Combine imports +- Don't expose internal state from tasks through environment values directly +- Don't modify `Hosts/` without reading `RuntimeDesign.md` +- Don't mix example app code with tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aad188..67a7ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- `ControlEvent.cancel` no longer throws — now a terminal operation that completes without error +- Updated `control(_:)` documentation to reflect non-throwing behavior for `.cancel` + +### Fixed +- Removed `try taskManager.checkCancellation()` from `control(_:)` method for `.cancel` case +- Updated test expectations to match non-throwing behavior + +### Documentation +- Marked `GlobalActorRuntime` as unused utility with architectural decision deferred to future work + ## [0.1.0] – 2026-05-07 ### Added diff --git a/Documentation/AIDrivenDevelopment.md b/Documentation/AIDrivenDevelopment.md new file mode 100644 index 0000000..8706847 --- /dev/null +++ b/Documentation/AIDrivenDevelopment.md @@ -0,0 +1,228 @@ +# AI-Driven Development with Transduce + +## Motivation + +Modern AI coding tools (GitHub Copilot, Claude, ChatGPT, etc.) generate code from natural language specifications. Without constraints, they produce dozens of structurally different implementations of the same feature: + +- ViewModel with `@Published` properties +- Combine pipelines +- Direct async/await in views +- Redux-style reducers +- Custom state machines + +AI is extremely good at filling in missing details. Unfortunately, that means every feature becomes a slightly different architecture. Small differences accumulate into review overhead, inconsistent testing strategies, and harder maintenance. + +Transduce constrains the architectural search space so AI generates consistent code. + +## The Problem with Skills Documents + +Even with strict skills documents declaring the pattern, AI may generate significant variance: + +``` +Skill Document: "Use Transducer pattern with State, Event, transduce function" + ↓ +AI generates 10 variations: + - Some use enum Products: Transducer, some use struct + - Some put State inside enum, some outside as separate type + - Some name function transduce, some use update or reduce + - Some include initialState as property, some as static var + - Some use different effect types (.task vs .action) + - Some omit Response type entirely + - Some add helper methods to State +``` + +Skills documents aren't enough because: + +1. **Natural language is ambiguous** — "use Transducer pattern" has infinite interpretations +2. **AI fills gaps with its own understanding** — details not specified in the skill may vary +3. **No enforcement** — Skills are guidelines, not constraints +4. **Context drift** — AI forgets or misinterprets over time + +## From Specification to Code + +The library solves this by **encoding the pattern** into primitives that the compiler enforces: + +``` +Gherkin Spec (natural language) + ↓ +LLM generates transducer code + ↓ +Swift compiler validates conformance + ↓ +Generated code conforming to the Transducer architecture +``` + +The protocol defines the contract: + +```swift +enum Products: Transducer { + struct State { ... } // Single source of truth + enum Event { ... } // Domain actions + typealias Env = ... // Dependencies + typealias Response = ... // Return value for request() + + static func transduce(&state, event) -> Effect { ... } +} +``` + +The protocol constrains the architectural search space so AI generates consistent code. + +Traditional architecture documents describe a pattern. Transduce embeds the pattern into the type system, allowing the compiler—not code review—to reject architectural deviations. + +**Key insight: AI generates only the finite state machine.** Scheduling, synchronization, cancellation, task lifetime, dependency flow, and effect execution are already implemented by the runtime. + +### Example: Product Loading + +#### Without Transduce + +Prompt: "Implement product loading feature" + +AI might produce: + +```swift +class ProductViewModel: ObservableObject { + @Published var products: [Product] = [] + @Published var isLoading = false + @Published var error: Error? + + private let api: ProductAPI + + init(api: ProductAPI) { + self.api = api + } + + func loadProducts() { + isLoading = true + Task { + do { + let result = try await api.fetchProducts() + self.products = result + } catch { + self.error = error + } + isLoading = false + } + } +} +``` + +#### With Transduce + +Prompt: "Generate a transducer for product loading" + +```swift +enum Products: Transducer { + struct State { + enum Mode { case idle, loading, loaded([Product]), failed(Error) } + var mode: Mode + } + + enum Event { case load, loaded([Product]), loadFailed(Error) } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.idle, .load): + state.mode = .loading + return .task(id: "fetch-products") { input, env in ... } + + case (.loading, .loaded(let products)): + state.mode = .loaded(products) + return .none + + case (_, .loadFailed(let error)): + state.mode = .failed(error) + return .none + } + } +} +``` + +## Benefits + +### For AI Coding + +| Aspect | Without Transduce | With Transduce | +|--------|-------------------|----------------| +| Structure | Dozens of valid variations | Single pattern enforced by compiler | +| Reviewability | Hard (each different) | Easy (same structure) | +| Onboarding | Learn each variation | Learn one pattern | +| Consistency | Developer-dependent | Enforced by protocol | + +### For Developers + +1. **Fast iteration** — Feature in one file (~350 lines) +2. **Testable** — Pure `transduce` function requires no async, mocking, or UI infrastructure +3. **Type-safe** — Compile-time guarantees via Swift enum states +4. **Async-first** — Native Swift Concurrency with managed tasks +5. **DI-ready** — `Env` type for dependencies + +## Real-World Impact + +### Small App (1-5 features) +``` +App/ +├── Products.swift # Feature + mock +├── Users.swift # Feature + mock +└── Orders.swift # Feature + mock +``` + +### Large App (100+ features) +``` +App/ +├── Products.swift # Feature only +├── Users.swift # Feature only +└── Orders.swift # Feature only + +DataLayer/ # Shared data sources +├── ProductDataSource.swift # Transducer +├── UserDataSource.swift # Transducer +└── CacheDataSource.swift # Reusable transducer + +Features/ +├── ProductsFeature/ # Self-contained +├── UsersFeature/ # Self-contained +└── OrdersFeature/ # Self-contained +``` + +**Same pattern at every scale.** + +## The Spec-Driven Cycle + +``` +1. Write Gherkin spec (natural language) + ↓ +2. AI generates transducer code + ↓ +3. Tests pass (spec verified) + ↓ +4. Done + +Repeat for each feature. +``` + +**Architectural discussions shift from "How should this feature be structured?" to "Does this business logic correctly implement the specification?".** + +## Trade-offs + +### Advantages +- **Predictability**: Same pattern everywhere +- **Maintainability**: Consistent structure across features +- **AI compatibility**: Reliable generation and review +- **Testability**: Pure `transduce` function runs synchronously + +### Disadvantages +- **Learning curve**: MVI/FSM pattern requires mental shift from imperative code +- **Boilerplate**: Enum definitions and switch statements can feel verbose for simple features + +### When Not to Use +- Simple one-off async operations with no state transitions +- Features where the UI state is trivial (single boolean flag) + +## Summary + +EffectComponents enforces conventions through the `Transducer` protocol, constraining the architectural space: + +- **Before:** "How should I structure this feature?" (discussion) +- **After:** "Here's the spec, generate the code." (constrained by protocol) + +The library encodes the pattern into the type system, allowing the compiler—not code review—to reject architectural deviations. diff --git a/Documentation/ArchitecturalComparison.md b/Documentation/ArchitecturalComparison.md index 6255a0c..21728c4 100644 --- a/Documentation/ArchitecturalComparison.md +++ b/Documentation/ArchitecturalComparison.md @@ -1,13 +1,13 @@ # Architectural Comparison -EffectComponents is not a new idea. It translates a family of well-established patterns — Elm, Redux, Elixir/GenServer — into idiomatic SwiftUI, using Swift's own concurrency model rather than fighting it. +What the package provides is not a new idea. It translates a family of well-established patterns — Elm, Redux, Elixir/GenServer — into idiomatic SwiftUI, using Swift's own concurrency model rather than fighting it. -This document maps EffectComponents against the patterns iOS developers are most likely to know, across five dimensions that matter in practice: +This document maps this Swift package against the patterns iOS developers are most likely to know, across five dimensions that matter in practice: 1. **State model** — what kinds of state exist, who owns each kind, and who can mutate it 2. **Effect/side-effect model** — how async work is described and executed 3. **Task lifecycle** — who creates tasks, who cancels them, and what scopes their lifetime -4. **Dispatch semantics** — what it means to "send" an event or action +4. **Dispatch semantics** — what it means to "send" an event into the transducer 5. **Testability** — what you need to construct to exercise the logic ## Motivation @@ -16,7 +16,7 @@ Many SwiftUI bugs are implicit state-machine bugs. Loading flags, two-way bindin That can feel normal because the first implementation often works. A ViewModel with mutable properties, Combine handlers, and task-launching methods may appear shorter than an explicit transition function. But the missing code has not disappeared; it has moved into timing assumptions, property observer order, task interleavings, and QA-discovered edge cases. -EffectComponents is a response to that accidental temporal complexity. It puts the workflow back into one transition function: events enter, state changes synchronously, effects are returned as values, and follow-up work re-enters as events. The result is not always fewer lines. The result is that the lines describe the machine the feature actually runs. +This Swift package is a response to that accidental temporal complexity. It puts the workflow back into one transition function: events enter, state changes synchronously, effects are returned as values, and follow-up work re-enters as events. The result is not always fewer lines. The result is that the lines describe the machine the feature actually runs. --- @@ -108,31 +108,31 @@ Phoenix LiveView's `handle_event` maps almost directly to a transducer's `update --- -## EffectComponents +## Transduce -EffectComponents translates the Elm/GenServer model into idiomatic SwiftUI — using `@State`, structured concurrency, and `@MainActor` as the runtime rather than a custom one. +In the Transduce package the `EffectView` translates the Elm/GenServer model into idiomatic SwiftUI — using `@State`, structured concurrency, and a system actor as the runtime rather than a custom one. **State model:** Three kinds of state are structurally distinct: -- **Ephemeral state** (`ViewState`) — a plain value type owned by `@State`. Lives and dies with the view's identity. Nothing outside the view can read or write it. -- **Shared state** — an `@Observable` object passed via `Env` or the SwiftUI environment. A view *observes a specific slice* of shared state via `.observe(\.store, keyPath: \.count)` and receives changes as events into its own `update` loop. The view never writes shared state directly — it sends events to the store's own mutation API. -- **Persistent state** — always external. Effects read from or write to persistence, then translate results back into events. `update` never sees storage directly. +- **Ephemeral state** (`State`) — a plain value type owned by `@State`. Lives and dies with the view's identity. Nothing outside the view can read or write it. +- **Shared state** — an `@Observable` object passed via `Env` or the SwiftUI environment. A view *observes a specific slice* of shared state via `.observe(\.store, keyPath: \.count)` and receives changes as events into its own `transduce` loop. The view never writes shared state directly — it sends events to the store's own mutation API. +- **Persistent state** — always external. Effects read from or write to persistence, then translate results back into events. `transduce` never sees storage directly. The read/write relationship with shared state is asymmetric by construction: ``` -Store ──(observed slice)──▶ events ──▶ update ──▶ ViewState (read) -ViewState ──(user action)──▶ update ──▶ effect ──▶ store.send (write) +Store ──(observed slice)──▶ events ──▶ transduce ──▶ State (read) +State ──(user action)──▶ transduce ──▶ effect ──▶ store.send (write) ``` -**Effect model:** `update` returns an `Effect` value — a description, never an execution. The library executes it. `update` is synchronous, has no `async` annotation, and cannot perform work directly. The same event on the same state always produces the same `Effect` description. +**Effect model:** `transduce` returns an `Effect` value — a description, never an execution. The library executes it. `transduce` is synchronous, has no `async` annotation, and cannot perform work directly. The same event on the same state always produces the same `Effect` description. -**Task lifecycle:** Tasks are created by returning `run(id:)`, `request(id:)`, or `task(id:)` from `update`. They are cancelled by returning `cancel(...)` from `update`, or automatically when the view's identity is torn down via `.id(...)`. Both creation and cancellation are outputs of the transition function — they live alongside state mutations in the same `switch`, subject to the same compiler exhaustiveness checks. +**Task lifecycle:** Tasks are created by returning `.task(id:)`, `.action`, or `.event` from `transduce`. They are cancelled by returning `.cancel(...)` from `transduce`, or automatically when the view's identity is torn down via `.id(...)`. Both creation and cancellation are outputs of the transition function — they live alongside state mutations in the same `switch`, subject to the same compiler exhaustiveness checks. -A task is automatically cancelled and replaced if `update` returns new work with the same identifier before the previous one finishes. This makes cancel-and-restart a one-liner with no explicit handle management: +A task is automatically cancelled and replaced if `transduce` returns new work with the same identifier before the previous one finishes. This makes cancel-and-restart a one-liner with no explicit handle management: ```swift -// update: +// transduce: case .queryChanged(let q): state.query = q return .task(id: "search") { input, env in @@ -143,13 +143,14 @@ case .queryChanged(let q): } ``` -**Dispatch semantics:** `Input` exposes three levels, chosen at the call site: +**Dispatch semantics:** `Input` exposes four methods, chosen at the call site: | Method | Semantics | Use when | |---|---|---| -| `try input.post(.event)` | Fire-and-forget. Schedules on `@MainActor`, returns immediately. | Button handlers, `onChange`, observation fire-and-forget | -| `try await input.send(.event)` | Waits until `update` has processed the event. State is settled; effects are only *started*. | Caller needs to read resulting state, doesn't care about downstream work | -| `try await input.request(.event)` | Suspends until the full effect chain — `update`, the returned effect, and any effects it triggers recursively — has settled. | Pull-to-refresh spinners, sequential task steps, observation loop backpressure | +| `try input.post(.event)` | Fire-and-forget. Schedules on the system actor, returns immediately. | Button handlers, `onChange`, observation fire-and-forget | +| `try await input.send(.event)` | Waits until `transduce` has processed the event. State is settled; effects are only *started*. | Caller needs to read resulting state, doesn't care about downstream work | +| `try await input.request(.event)` | Suspends until the full effect chain — `transduce`, the returned effect, and any effects it triggers recursively — has settled. | Pull-to-refresh spinners, sequential task steps, observation loop backpressure | +| `try await input.uniqueRequest(.event)` | Exclusive request — cancels all prior in-flight work before dispatching. | When only the latest request matters, strict one-to-one correspondence needed | `request` is the mechanism that makes SwiftUI's `.refreshable` work naturally: @@ -171,11 +172,11 @@ try await input.request(.storeChanged(newCount: count)) No rate-limiting code, no semaphores. The dispatch semantic *is* the backpressure mechanism. -**Testability:** `update` is a static function — `(inout ViewState, Event) -> Effect?`. No framework, no `@MainActor`, no mocking. The full transition logic is exercisable from a plain `XCTest`: +**Testability:** `transduce` is a static function — `(inout State, Event) -> Effect`. No framework, no `@MainActor`, no mocking. The full transition logic is exercisable from a plain `XCTest`: ```swift -var state = MyView.ViewState() -let effect = MyView.update(&state, event: .loadMovies) +var state = MyView.State() +let effect = MyView.transduce(&state, event: .loadMovies) XCTAssertEqual(state.isLoading, true) // inspect the returned Effect description if needed ``` @@ -185,24 +186,24 @@ XCTAssertEqual(state.isLoading, true) | Concept | Role | |---|---| | `Event` | What you can *send* to it | -| `Output` | What you can *receive* from it (via `try await input.request(_:)`) | +| `Response` | What you can *receive* from it (via `try await input.request(_:)`) | | `State` | What it *renders* (optionally exposed) | -Everything else — running tasks, `Env`, intermediate async types, internal models — is private to the component. The `Output?` value is not the return value of an async operation; it is what the transition function decides to hand back after the machine has processed the effect chain. The async work inside a task is an implementation detail the caller never sees. +Everything else — running tasks, `Env`, intermediate async types, internal models — is private to the component. The `Response?` value is not the return value of an async operation; it is what the transition function decides to hand back after the machine has processed the effect chain. The async work inside a task is an implementation detail the caller never sees. -`State` has a natural second layer of privacy: the `Binding` is held by a `private @State` in the enclosing SwiftUI view. Ancestor views see only what is rendered — they never hold the binding. In practice, the visible API of a component from the outside is just `Event` and `Output`: +`State` has a natural second layer of privacy: the `Binding` is held by a `private @State` in the enclosing SwiftUI view. Ancestor views see only what is rendered — they never hold the binding. In practice, the visible API of a component from the outside is just `Event` and `Response`: ``` Ancestor view │ SwiftUI view (owner) │ EffectView internals ───────────────────────┼──────────────────────────┼───────────────────────── sees rendered UI only │ State (private @State) │ tasks (private) can send Event │ Event │ Env (private) -awaits Output? │ Output │ intermediate types +awaits Response? │ Response │ intermediate types ``` This is a stronger encapsulation boundary than TCA's store, which is deliberately transparent: any code holding a `Store` reference can observe the entire state tree. EffectView components behave more like actors — they receive messages, produce typed responses, and keep everything else behind a wall. -**Code volume vs explicitness:** A real transition function can be longer than the equivalent naive ViewModel. A product list with initial loading, pull-to-refresh, infinite scrolling, search, filtering, empty content, stale content, cancellation, and error dismissal may have an `update` function around 150 lines. A ViewModel version of the same feature might look like 80 lines of imperative logic. +**Code volume vs explicitness:** A real transition function can be longer than the equivalent naive ViewModel. A product list with initial loading, pull-to-refresh, infinite scrolling, search, filtering, empty content, stale content, cancellation, and error dismissal may have a `transduce` function around 150 lines. A ViewModel version of the same feature might look like 80 lines of imperative logic. That difference is not automatically boilerplate. In the transducer version, those lines name the workflow's invariants: @@ -224,14 +225,14 @@ Making the workflow explicit also reveals what is generic. In larger projects, p | | MVVM | Redux | TCA | Elm | Elixir/GenServer | EffectComponents | |---|---|---|---|---|---|---| -| **Ephemeral state owner** | ViewModel class | Global store | Feature store | Model value | Process-local | `ViewState` value | +| **Ephemeral state owner** | ViewModel class | Global store | Feature store | Model value | Process-local | `State` value | | **Shared state access** | Direct reference | Global selector | `@Shared` wrapper | Message-passing only | Explicit IPC | Read-only slice via `.observe` | -| **Mutation authority** | Anyone with a reference | Reducer only | Reducer only | `update` only | `handle_*` only | `update` only | -| **Effect description** | Imperative `Task { }` | Middleware value | `Effect` | `Cmd Msg` | Return tuple | `Effect` | -| **Task creation** | Anywhere | Middleware | `Effect.run` | Runtime | Spawn | `update` return value | -| **Task cancellation** | Manual handle | Dispatch cancel action | `CancelID` action | Runtime subscription diff | Process termination | `update` return value | +| **Mutation authority** | Anyone with a reference | Reducer only | Reducer only | `update` only | `handle_*` only | `transduce` only | +| **Effect description** | Imperative `Task { }` | Middleware value | `Effect` | `Cmd Msg` | Return tuple | `TransducerEffect` | +| **Task creation** | Anywhere | Middleware | `Effect.run` | Runtime | Spawn | `transduce` return value | +| **Task cancellation** | Manual handle | Dispatch cancel action | `CancelID` action | Runtime subscription diff | Process termination | `transduce` return value | | **Task scope** | ViewModel lifetime | App lifetime | Store lifetime | Runtime | Process tree | View identity | -| **Dispatch levels** | Synchronous call | Fire-and-forget | Fire-and-forget (+ async `send` inside effects) | Fire-and-forget | `call` (sync) / `cast` (async) | `post` / `send` / `request` | +| **Dispatch levels** | Synchronous call | Fire-and-forget | Fire-and-forget (+ async `send` inside effects) | Fire-and-forget | `call` (sync) / `cast` (async) | `post` / `send` / `request` / `uniqueRequest` | | **Test surface** | Full class construction | Reducer pure function | `TestStore` harness | Pure `update` function | Process message passing | Static pure function | The common thread in the well-designed patterns (Elm, GenServer, TCA, EffectComponents) is the same: a single authoritative transition function that owns all state mutations and returns effect descriptions. The differences are in scope (global vs. local), dispatch semantics, task lifecycle management, and how much framework ceremony is required to express the pattern. diff --git a/Documentation/BridgingEventDrivenAndImperative.md b/Documentation/BridgingEventDrivenAndImperative.md index f921dce..f38d5b5 100644 --- a/Documentation/BridgingEventDrivenAndImperative.md +++ b/Documentation/BridgingEventDrivenAndImperative.md @@ -1,6 +1,6 @@ # Bridging Event-Driven and Imperative Code -EffectView is event-driven: views fire events, `update` mutates state, effects +EffectView is event-driven: views fire events, `transduce` mutates state, effects run as a consequence. This model is clean, testable, and predictable — but it has one widely-cited pain point: @@ -10,7 +10,7 @@ This is true for naive event dispatch. It is not true for EffectView. --- -## The problem +## Motivation SwiftUI's `.refreshable` modifier expects the supplied `async` closure to stay suspended for as long as the refresh is in progress. The moment the closure @@ -24,9 +24,6 @@ spinner disappears before the data arrives: } ``` -Note: in this case it is safe to write `try?` since we can ignore the error when -attempting to dispatch an event when it happens within the `refresh` modifier. - The same issue arises for any SwiftUI feature that awaits an async closure: `task(id:)`, `searchable` with an async suggestions closure, button actions in @@ -34,10 +31,10 @@ The same issue arises for any SwiftUI feature that awaits an async closure: --- -## The solution: `request(_:)` +## Solution: `request(_:)` `Input.request(_:)` suspends the caller until the entire resulting effect chain -has settled and returns an optional `Output` value, or throws if the runtime +has settled and returns an optional `Response` value, or throws if the runtime cannot accept or complete the request: ```swift @@ -54,27 +51,27 @@ no extra state flag, no manual `Task` management. --- -## How it works +## Design -When `request` is called, a `CheckedContinuation` is created and threaded -through the effect chain alongside the event. The continuation is not resumed -until the chain reaches a terminal point: +`request(_:)` guarantees the caller suspends until the entire effect chain +settles. The continuation is threaded through the effect graph and only +resumes when a terminal effect is reached: ``` event → [.action chain] → terminal effect - ├─ .task → Output? + ├─ .task → Response? ├─ .cancel → nil └─ nil → nil ``` -Critically, the continuation travels *inside* the effect graph. The `.task` -closure does not need to know a caller is waiting — it just returns a value, -and the engine forwards it to the suspended caller automatically. +This mechanism ensures two key properties: -This is what distinguishes `request` from external state-polling approaches -like XState's `waitFor`: the caller does not observe state changes from -outside; it dispatches an event and awaits the FSM settling as a direct -consequence of that event. +- **No state polling required.** The caller doesn't observe state changes + externally; it dispatches an event and awaits the FSM settling as a direct + consequence. +- **No implementation leakage.** The `.task` closure doesn't need to know a + caller is waiting — it returns a value, and the engine forwards it + automatically. --- @@ -89,10 +86,7 @@ ContentView() } ``` -Note: in this case it is safe to write `try?` since we can ignore the error when -attempting to dispatch an event when it happens within the `refresh` modifier. - -`update` handles `.refresh` by returning a `.task` that fetches data and +`transduce` handles `.refresh` by returning a `.task` that fetches data and sends `.loaded(data)`. `request` resumes when the task closure returns. ### Navigation confirmation @@ -109,10 +103,6 @@ Button("Save") { } ``` -Note: in this case it is safe to write `try?` since we can ignore the error when -attempting to dispatch an event when it happens within the button action. - - ### Async `task(id:)` When the app regains foreground, re-fetch only if the previous task has @@ -134,7 +124,7 @@ or polling required: ```swift let result = try await input.request(.load) -XCTAssertEqual(state.items.count, 3) +#expect(state.items.count == 3) ``` --- @@ -149,7 +139,7 @@ XCTAssertEqual(state.items.count, 3) | TCA `store.send(.refresh).finish()` | Yes | No | No | | ImmutableData `dispatcher.dispatch` | No | — | — | | Akka `actor ? message` | Yes | Yes (explicit reply) | No | -| `try await input.request(.refresh)` | Yes | Yes (`Output?`) | Yes | +| `try await input.request(.refresh)` | Yes | Yes (`Response?`) | Yes | ### TCA: `StoreTask.finish()` diff --git a/Documentation/Component-Oriented Observable Architecture.md b/Documentation/Component-Oriented Observable Architecture.md new file mode 100644 index 0000000..3fcded7 --- /dev/null +++ b/Documentation/Component-Oriented Observable Architecture.md @@ -0,0 +1,615 @@ +# Component-Oriented Observable Architecture + +## Two-Dimensional Composability + +Large SwiftUI applications scale because they combine **two independent compositional dimensions**: + +``` +Dimension 1: Ephemeral View Hierarchy (SwiftUI) + └─ Views compose via view composition + └─ State flows down via @State/@Binding + +Dimension 2: Persistent Domain Components (Observables) + └─ Observables compose via dependency injection + └─ State lives in long-lived objects +``` + +These dimensions are **orthogonal** - they work together without coupling. + +--- + +### Architecture Diagram + +``` +┌────────────────────────────────────────────────────────────┐ +│ SwiftUI View Hierarchy │ +│ (Ephemeral - Created/Destroyed as user navigates) │ +├────────────────────────────────────────────────────────────┤ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ HomeView │ │ SettingsView │ │ ProfileView │ │ +│ │ │ │ │ │ │ │ +│ │ @State: local│ │ @State: local│ │ @State: local│ │ +│ │ @Environment:│ │ @Environment:│ │ @Environment:│ │ +│ └───────┬──────┘ └───────┬──────┘ └───────┬──────┘ │ +│ │ │ │ │ +└──────────┼─────────────────┼─────────────────┼─────────────┘ + │ │ │ + └─────────────────┴─────────────────┘ + │ + ┌────────┼────────┐ + ▼ ▼ ▼ + ┌─────────────────────────────────────┐ + │ Persistent Domain Components │ + │ (Long-lived, shared across views) │ + ├─────────────────────────────────────┤ + │ ┌──────────┐ ┌──────────┐ │ + │ │ Session │ │ Downloads│ │ + │ │ Manager │ │ Manager │ │ + │ └──────────┘ └──────────┘ │ + │ ┌──────────┐ │ + │ │ Settings │ │ + │ │ Store │ │ + │ └──────────┘ │ + └─────────────────────────────────────┘ +``` + +**Key observations:** +- Views are ephemeral UI components with their own state and logic, composed hierarchically +- Components are persistent domain objects shared across views +- Views selectively observe only the components they need +- Components can be observed by multiple views simultaneously + +--- + +## Dimension 1: Ephemeral View Composition + +SwiftUI views are **ephemeral** - they're created and destroyed as the user navigates. + +```swift +struct ContentView: View { + var body: some View { + NavigationStack { + HomeView() // Created → Destroyed + SettingsView() // Created → Destroyed + } + } +} +``` + +**Role of views:** +- Views have their own local state (`@State`) +- Views observe shared state from domain components +- Views react to change notifications from observed components +- Views combine user intents with shared state to produce UI + +--- + +## Dimension 2: Persistent Domain Components + +Domain components are **long-lived** - they outlive any particular view and may be observed by multiple views simultaneously. + +```swift +@Observable +class SessionManager { + var user: User? + + func login() async { ... } + func logout() { ... } +} + +@Observable +class DownloadManager { + var downloads: [Download] = [] + + func start(...) { ... } +} +``` + +**Key properties:** +- Components are classes (reference types) +- Lifecycle independent of views +- State is persistent and shared + +--- + +## How They Work Together + +### Composition Pattern + +Views observe state automatically through SwiftUI Observation. `onChange` can be used for explicit reactions to changes. + +```swift +// Root level: Create and wire observables +@main +struct MyApp: App { + @State private var session = SessionManager() + @State private var downloads = DownloadManager() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(\.session, session) + .environment(\.downloads, downloads) + } + } +} + +// View level: Observe only what you need +struct DashboardView: View { + @Environment(\.session) private var session + @Environment(\.downloads) private var downloads + + var body: some View { + // Uses BOTH observables independently + Text("User: \(session.user?.name ?? "Guest")") + ProgressView(downloads.progress) + } +} +``` + +Views compose observables like Lego blocks - each view picks exactly what it needs. + +**Observation patterns:** +- `@Environment` — static observation via dependency injection +- `onChange(of:perform:)` — reactive response to specific changes + +--- + +### Challenges of Shared Domain Components + +Testing and maintaining applications built solely from SwiftUI Views and @Observable components becomes increasingly difficult when a single business operation is distributed across multiple layers. + +Typical symptoms include: + +- Business invariants are enforced partly by Views and partly by Observable components. +- UI behavior (.disabled, onChange, Task, etc.) becomes required for correctness rather than presentation. +- Observable methods assume callers satisfy preconditions instead of enforcing them themselves. +- Business operations become difficult to unit test because correctness depends on View behavior. +- Reusing the same Observable from another View, background task, or service can violate hidden assumptions. + +For example, a View may disable a Sign Up button while a request is running, but unless the component itself prevents concurrent requests, another caller can still trigger multiple sign-up operations. + +```swift +@Observable +@MainActor +final class SignUpManager { + var isSigningUp = false + + func signUp() async { + guard !isSigningUp else { return } // Component enforces invariant + + isSigningUp = true + defer { isSigningUp = false } + + ... + } +} +``` + +The challenge is not that business logic resides inside an Observable, but that a single logical operation can become fragmented across Views and domain components. Business invariants should be enforced by the component itself, not emerge from cooperation between the UI and the component. + + +### Responsibilities of Shared Components + +Unlike SwiftUI Views, domain components are long-lived and shared. They may be accessed by multiple Views, background tasks, deep links, timers, or other components throughout the application's lifetime. + +Therefore, a shared component should: + +- Preserve its own business invariants regardless of who invokes its API. +- Remain correct without relying on UI behavior (.disabled, navigation state, etc.). +- Coordinate concurrent operations when requests originate from multiple independent sources. +- Expose a consistent API that is safe to reuse from Views, services, background tasks, and tests. +- Remain independent of the lifecycle of any particular View. + + +### Key Observation + +SwiftUI's `@Observable` macro makes this pattern natural. You get observation for free without needing a separate ViewModel layer. Observables encapsulate persistent application components, and the View is purely declarative. + +And we can alleviate the core issues: + +--- + +## Where EffectView Fits In + +EffectView and TransducerObservable are not designed specifically for the Component-Oriented Observable Architecture. They are general-purpose tools that fit particularly well. + +**Transduce's design goals** (from RuntimeDesign.md): +1. Preserve a single ordered mutation path for domain state +2. Support async effects and async actions without pushing buffering logic into transducer state +3. Allow callers to use ordinary suspending functions as the back pressure mechanism +4. Permit immediate runtime interruption while a regular event chain is suspended +5. Keep the implementation small enough that the invariants are locally understandable + +**Why it fits:** +The Component-Oriented Observable Architecture benefits from these capabilities: +- Testable business logic (pure `transduce` function) +- Structured effects with automatic task lifecycle management +- Shareable tasks that handle concurrent operations gracefully + +**Example: Sign-up flow** + +A `SignUpManager` as a `TransducerObservable` demonstrates how Transduce fits naturally: + +```swift +enum SignUp: Transducer { + enum State { + case start, signedUp, error(Error) + } + + enum Event { + case signUp + case signUpCompleted + case signUpFailed(Error) + case confirmError + } + + struct Env { + let api: SignUpAPI + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.start, .signUp): + return .task(id: "signUp", .shareable) { input, env in + // `.shareable`: only _one_ sign-up task will be active at any time! + // A shareable task can have multible subscribers that wait for the + // result. + do { + try await env.api.signUp() + return .send(.signUpCompleted) + } catch { + return .send(.signUpFailed(error)) + } + } + case (.start, .signUpCompleted): + state = .signedUp + return .none + case (.start, .signUpFailed(let error)): + state = .error(error) + return .none + case (.error, .confirmError): + state = .start + return .none + default: return .none + } + } + + enum Response { + case notSignedUp, signedUp, error(Error) + } + static func repsonse(state: State, event: Event) -> Response { + switch state { + case .start: return .notSignedUp + case .signedUp: return .signedUp + case .error(let error): return .error(error) + } + } +} + +typealias SignUpManager = TransducerObservable +``` + +**Key insight:** The `shareable` task policy handles concurrent sign-up requests gracefully - multiple callers get the same in-flight operation, avoiding duplicate requests. This is exactly what shared domain components need. + +**The complete picture:** + +1. **Environment** - Observable injected via environment: +```swift +struct Env { + let settings: SettingsStore // Regular @Observable +} +``` + +2. **Transducer** - ALL logic lives here (pure state machine): +```swift +enum SettingsFeature: Transducer { + struct State { var darkMode: Bool = false } + + enum Event { + case setDarkMode(Bool) + case storeChanged(Bool) // From observing Observable + } + + struct Env { + let settings: SettingsStore + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.idle, .start): + // Start observing the Observable + return .task(id: "observe-settings") { @MainActor input, env in + try await observe { + let darkMode = env.settings.darkMode // Read from Observable + try? input.post(.storeChanged(darkMode)) // Send event to transducer + } + } + + case (.idle, .storeChanged(let darkMode)): + // Mirror Observable state into transducer state + state.darkMode = darkMode + return .none + + case (.idle, .setDarkMode(let dark)): + // Send event to Observable + state.darkMode = dark + return .task { _, env in env.settings.setDarkMode(dark) } + } + } +} +``` + +3. "**Dumb" View** - Only renders state and sends user intents: +```swift +struct SettingsView: View { + @Environment(\.settingsEnv) private var env + @State private var state = SettingsFeature.State() + + var body: some View { + EffectView( + of: SettingsFeature.self, + state: $state, + initialEnv: env + ) { state, input in + // The dumb "SettingsContentView" renders state and sends user intents + Toggle("Dark Mode", isOn: Binding( + get: { state.darkMode }, // Read from transducer state + set: { try? input.post(.setDarkMode($0)) } // Send event to transducer + )) + } + } +} +``` + +**What EffectView provides:** +- Task lifecycle management (auto-cancellation) +- Request buffering during initialization +- Debounce/retry via task overlap policies +- Testable business logic (pure `transduce` function) + +**What remains unchanged:** +- Observables still drive domain state +- Views still compose via environment injection +- No central state tree needed + +**System testing:** The entire system (View + Transducer + Observable) can be tested as a unit. The `transduce` function is pure and synchronous, making it easy to test state transitions. The Observable can be mocked via the `Env`, allowing full system testing without SwiftUI. + +The View is "dumb" - it only renders state and sends user intents. All logic lives in the `transduce` function, which also observes the Observable and sends events to it. The system, i.e. the View's logic including the Observable logic can be tested as a system - how it should be tested. + +--- + +## Observing Observables with EffectView + +EffectView works with **any Observable** from the Observation framework. The transducer observes the Observable via `observe()` in a task, reading values from `env` and sending events to the transducer: + +```swift +case .start: + return .task(id: "observe") { @MainActor input, env in + try await observe { + let value = env.timer.value // Read from Observable + try? input.post(.tick(value)) // Send event to transducer + } + } +``` + +The Observable is injected via `Env` and accessed in the action closure. The transducer can: +- Read values from the Observable via `observe()` +- Send events to the Observable (e.g., `env.timer.start()`) +- Mirror Observable state into transducer state + +--- + +## TransducerObservable: A Transducer that is also an Observable + +`TransducerObservable` is a generic class that combines both: +- **Observable semantics** - SwiftUI observation via `@Observable` +- **Transducer semantics** - structured effects via `transduce()` + +Since `TransducerObservable` itself is already an `@Observable` class, you don't need to wrap it. A store is simply a typealias: + +```swift +enum SettingsTransducer: Transducer { + struct State { + var darkMode = false + var fontSize = 14 + } + + enum Event { + case setDarkMode(Bool) + case setFontSize(Int) + } + + struct Env { + let userDefaults: UserDefaults + } + + static let initialState: State = .init() + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .setDarkMode(let dark): + state.darkMode = dark + return .none + case .setFontSize(let size): + state.fontSize = size + return .none + } + } +} + +typealias SettingsStore = TransducerObservable +``` + +That's it - no wrapper class needed. Use it like any observable: + +```swift +@State private var settings = SettingsStore( + initialState: .init(), + initialEnv: .init(userDefaults: .standard) +) + +// Read state +Toggle("Dark Mode", isOn: Binding( + get: { settings.state.darkMode }, + set: { try? settings.post(.setDarkMode($0)) } +)) + +// Send events +Button("Reset Font Size") { + try? settings.post(.setFontSize(14)) +} +``` + +**Recursive capability:** Since `TransducerObservable` is itself a Transducer, it can observe other Observables. This creates a chain: +- Observable → can be → Transducer → can observe → Observable +- The Observable is the Model; EffectView manages the View layer. + +--- + +## Scalability + +### Localized Reasoning + +```swift +// Settings feature developer only thinks about: +// - SettingsFeature (transducer) +// - SettingsManager (observable) +// - NOT the entire app state + +enum SettingsFeature: Transducer { + struct State { var darkMode: Bool; var fontSize: Double } + enum Event { case setDarkMode(Bool); case setFontSize(Double) } + + static func transduce(_ state: inout State, event: Event) -> Effect { + // Only handles settings-related events + } +} +``` + +**Cognitive load is bounded by feature scope, not app size.** + +### Independent Development + +```swift +// Team A works on Session feature +class SessionManager: Observable { + var user: User? +} + +// Team B works on Downloads feature +class DownloadManager: Observable { + var downloads: [Download] = [] +} + +// No coordination needed - they're independent +``` + +**Teams work in isolation, no state tree conflicts.** + +### Selective Observation + +```swift +// HomeView only observes what it needs +struct HomeView: View { + @Environment(\.session) private var session // ✓ + @Environment(\.downloads) private var downloads // ✓ + // NOT observing: Settings, Location, etc. +} +``` + +**Views are decoupled from unrelated domains.** + +### Testability + +```swift +// Transducer is pure and synchronous +var state = SettingsFeature.State() +_ = SettingsFeature.transduce(&state, event: .setDarkMode(true)) +XCTAssertEqual(state.darkMode, true) + +// Observable can be tested with mock dependencies +class MockUserDefaults: UserDefaultsStore { ... } +let settings = SettingsStore(env: MockEnv(userDefaults: MockUserDefaults())) +``` + +**Business logic is testable without UI.** + +--- + +## Trade-offs + +### Centralized state vs. component-oriented + +Centralized state management (like Redux) has different trade-offs: + +- **Centralized state**: Single source of truth; predictable state transitions; but cognitive load grows with app size +- **Component-oriented**: Localized reasoning; independent development; but requires discipline to avoid state duplication + +### SwiftUI-only vs. Transduce-enhanced + +Using `@Observable` alone vs. combining with Transduce: + +- **SwiftUI-only**: Simple for small apps; but business logic becomes hard to test +- **Transduce-enhanced**: Testable business logic; structured effects; but adds complexity + +### When to use Component-Oriented Observable Architecture + +This pattern works well when: + +- You have multiple independent domains (e.g., Session, Downloads, Settings) +- Teams work on different features independently +- You need testable business logic without UI dependencies + +This pattern may not be ideal when: + +- Your app has tightly coupled state across all features +- You prefer a single global state tree +- You need complex cross-feature state relationships + +--- + +## Summary + +**Component-Oriented Observable Architecture scales because:** + +1. **Two independent dimensions** (view hierarchy + domain observables) +2. **Orthogonal composition** (views pick what they need) +3. **Localized reasoning** (each feature is self-contained) +4. **Independent development** (teams work in isolation) +5. **Testable business logic** (pure transducers + mockable observables) + +**EffectView's role:** Adds structured effect management to the view composition dimension without disrupting the observable domain architecture. + +You can have **both**: +- SwiftUI's native observation system (for UI state) +- Transduce's structured effects (for business logic) + +...and they work together seamlessly because they operate on different dimensions. + +--- + +## What This Library Provides + +This library provides state management primitives for building large SwiftUI apps. It is not a complete application framework. + +### What it provides +- Structured effect management (tasks, actions, cancellation) +- Pure `transduce` functions for testable business logic +- Dependency injection via `Env` +- Task lifecycle management (auto-cancellation, overlap policies) + +### What it doesn't provide +- Network clients or database access +- Testing infrastructure (mocking frameworks, test doubles) +- App architecture decisions (feature boundaries, module organization) + +You still need to provide implementation details and build your app architecture around this library. + +**Trade-offs:** +- No vendor lock-in (small, focused library) +- Fast build times (single ~5,000 line module) +- No unnecessary dependencies +- You control the app architecture; this library just helps with state management + +**Limitations:** This library provides state management primitives, not a complete solution. You still need to build your app architecture around it (network layer, database, testing infrastructure, etc.). diff --git a/Documentation/CorrectByConstruction.md b/Documentation/CorrectByConstruction.md index fccc107..84e0203 100644 --- a/Documentation/CorrectByConstruction.md +++ b/Documentation/CorrectByConstruction.md @@ -1,4 +1,4 @@ -# Correct by Construction: State Machines and MVI with EffectComponents +# Correct by Construction: State Machines The [previous article](TamingAsyncTasksInSwiftUIViews.md) solved a mechanical problem: `.task` doesn't give you the tools to manage task lifetimes properly. This article addresses a deeper one. @@ -15,124 +15,367 @@ EffectComponents addresses this by pulling all logic into a single, pure functio --- -## The update function +## Motivation -The heart of EffectComponents is the update function: +The problems that lead to unmanageable async UI code don't appear out of nowhere. They accumulate as features grow: + +### Scattered State + +Without a formal state model, views accumulate independent boolean flags: ```swift -(inout State, Event) -> Effect? +@State private var isLoading = false +@State private var hasError = false +@State private var isEmpty = false +@State private var isRefreshing = false ``` -Given the current state and an event, it: +These flags can combine in ways that make no sense: loading *and* showing an error, or empty *and* not loading. The compiler cannot help you — only tests (if you're lucky) will catch these impossible states. -1. Mutates state synchronously. -2. Optionally returns an `Effect` — a *description* of work to do next, not the work itself. +### Imperative Cancellation -That's all it does. It never touches the network, never reads a database, never calls `await`. It is a pure state transition function. +With SwiftUI's `.task`, cancellation is implicit: tasks start when a view appears and cancel when it disappears. But real requirements are more nuanced: -This has a name: it's a **finite state machine**. +- **Cancel-only** — a Stop button should halt work without immediately restarting +- **Cancel-and-restart** — typing in a search box should cancel the previous query and start a new one +- **Dynamic concurrency** — one task per selected file, not one per view render ---- +Managing this requires storing `Task` handles, checking `Task.isCancelled`, and manually coordinating lifecycles. The code becomes a web of stored properties and cleanup logic. -## What is a finite state machine? +### Layering Complexity -A finite state machine (FSM) is a model where: +To "manage complexity", developers introduce layers: -- The system is always in exactly one **state**. -- **Events** cause transitions to a new state, optionally triggering side effects. -- The full set of states and transitions is *finite* and *explicit*. +- **View** → **ViewModel** → **Service** → **API** -FSMs are everywhere in UI logic, even when we don't acknowledge them. A loading screen is either waiting, loading, showing results, or showing an error. Pretending otherwise — using three boolean flags — is where bugs are born. +Each layer adds indirection. The view calls a method on the ViewModel, which calls a service, which calls an API. Error handling, cancellation, and state transitions are scattered across layers. Testing requires mocking multiple layers. ---- +### Race Conditions -## Modelling state as an enum - -Without an FSM, a search screen typically accumulates state like this: +When multiple async operations write to the same `@State`, race conditions appear: ```swift -@State private var isLoading = false -@State private var results: [Movie] = [] -@State private var errorMessage: String? = nil -@State private var currentQuery = "" +// View 1 +Task { + let data = await api.fetch() + self.data = data // Could be stale if View 2 also fetched +} + +// View 2 (same state) +Task { + let data = await api.fetch() + self.data = data // Overwrites View 1's write +} ``` -There are immediately several illegal combinations: `isLoading == true && errorMessage != nil`. `results.isEmpty && !isLoading && errorMessage == nil` — is that idle, or empty results? Tests have to enumerate these combinations and hope they've covered the right ones. +The last task to complete wins, regardless of whether its data is still relevant. This is the "async mutation race" — multiple writers to shared state without coordination. + +### The "Edge Case" Trap + +What looks like an "edge case" is usually an unhandled transition: + +- User taps "Refresh" while loading → what happens? +- Server returns error during refresh → does the spinner stay visible? +- User navigates away while loading → is the task cancelled? Does state clean up? + +Without an explicit state machine, these questions don't have clear answers. The code either handles them inconsistently or ignores them entirely. + +### Lifecycle Management + +Objects that represent async work (tasks, cancellable operations) require lifecycle management: + +- When do they start? +- When do they stop? +- Who owns them? +- How do you test them without the view lifecycle? -With EffectComponents you model state as a Swift enum instead: +This adds a layer of complexity that has nothing to do with business logic. + +### Complex Async Flows + +Some problems are inherently complex because they involve multiple concurrent operations with nuanced lifecycle requirements. Consider a **URLSession-like component**: + +- Starts up and handles several requests simultaneously +- Needs graceful shutdown: running requests complete, new requests rejected +- Must track each request by ID and manage cancellation +- Eventually reaches a terminal "terminated" state + +Implementing this correctly requires: +- Manual task handle management for each request +- Race-prone state updates across delegate callbacks +- Complex cancellation logic with proper cleanup +- State flags for "active" vs "shutting down" vs "terminated" + +Without a formal model, this becomes a tangle of conditionals and stored tasks. With transducers, the entire flow is encoded in the state machine — the runtime handles task management, cancellation, and serialization automatically. ```swift -enum SearchState { - case idle - case loading(query: String) - case loaded(query: String, results: [Movie]) - case failed(query: String, message: String) +enum URLSessionFeature: Transducer { + enum State { + case idle + case active(requests: [RequestID: RequestState]) + case shuttingDown(requests: [RequestID: RequestState]) + case terminated + } + + enum Event { + case startRequest(RequestID, URLRequest) + case requestCompleted(RequestID, Data) + case requestFailed(RequestID, Error) + case shutdown + case shutdownComplete + } + + struct Env { + let session: URLSession + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.idle, .startRequest(let id, let request)): + state = .active(requests: [id: .init(request)]) + return .task(id: "request-\(id)") { input, env in + let (data, _) = try await env.session.data(from: request.url!) + return .response(.requestCompleted(id, data)) + } + + case (.active(var requests), .startRequest(let id, let request)): + requests[id] = .init(request) + state = .active(requests: requests) + return .task(id: "request-\(id)") { input, env in + let (data, _) = try await env.session.data(from: request.url!) + return .response(.requestCompleted(id, data)) + } + + case (.active(let requests), .requestCompleted(let id, let data)): + var newRequests = requests + newRequests.removeValue(forKey: id) + state = newRequests.isEmpty ? .idle : .active(requests: newRequests) + return .none + + case (.active(let requests), .requestFailed(let id, let error)): + var newRequests = requests + newRequests.removeValue(forKey: id) + state = newRequests.isEmpty ? .idle : .active(requests: newRequests) + return .none + + case (.active(let requests), .shutdown): + state = .shuttingDown(requests: requests) + return .none + + case (.shuttingDown(let requests), .requestCompleted(let id, _)): + var remaining = requests + remaining.removeValue(forKey: id) + state = remaining.isEmpty ? .terminated : .shuttingDown(requests: remaining) + return .none + + case (.shuttingDown(let requests), .requestFailed(let id, _)): + var remaining = requests + remaining.removeValue(forKey: id) + state = remaining.isEmpty ? .terminated : .shuttingDown(requests: remaining) + return .none + + case (.idle, .shutdown): + state = .terminated + return .none + + case (.terminated, _): + return .none + + case (.active, .shutdownComplete): + return .none + + case (.shuttingDown, .shutdownComplete): + return .none + } + } } ``` -Illegal combinations don't exist. The compiler enforces it. +--- + +## Core Concepts + +Before diving into implementation, understand these core concepts: + +### Transducer + +A **Transducer** is a protocol that defines the contract for a feature: + +- `State` — The single source of truth (modeled as a Swift enum) +- `Event` — Domain actions that drive state transitions +- `Effect` — Declarative descriptions of work (not execution) +- `Env` — Dependencies captured at runtime initialization +- `Response` — Value returned to callers using `request()` + +The runtime treats `transduce(&state, event)` as the **single mutation point**. All state changes flow through this function. + +### State + +State is modeled as a Swift enum where each case represents a distinct state of the feature. The compiler enforces that all possible states are represented, and impossible states cannot be created. + +### Event + +Events are domain actions that drive state transitions. They are dispatched from the UI or system callbacks and processed by the transducer. + +### Effect + +Effects describe work to be performed. They come in two forms: +- **Actions** — Inline work that remains part of the current computation cycle +- **Tasks** — Managed async operations that run concurrently and may return events later + +### Host + +A host owns the transducer state and provides an `Input` interface for dispatching events. Available hosts: +- `EffectView` — SwiftUI view hosting +- `TransducerObservable` — Integration with `@Observable` +- `GlobalActorRuntime` — Custom actor isolation +- `BaseRuntime` — Base class for custom hosts + +### Env + +Env is an immutable dependency container captured at host initialization. It's forwarded to every effect and enables testability through dependency injection. --- -## Events and transitions +## Simple Example + +A simple search feature illustrates the pattern: ```swift -enum SearchEvent { - case searchTapped(query: String) - case resultsReceived([Movie]) - case requestFailed(String) - case cancelTapped +enum SearchFeature: Transducer { + enum State { + case idle + case loading(query: String) + case loaded(query: String, results: [Movie]) + case failed(query: String, message: String) + } + + enum Event { + case searchTapped(query: String) + case resultsReceived([Movie]) + case requestFailed(String) + case cancelTapped + } + + struct Env { + let search: (String) async throws -> [Movie] + } + + static var initialState: State { .idle } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (_, .searchTapped(let query)): + state = .loading(query: query) + return .task(id: "search", .switchToLatest) { input, env in + do { + let movies = try await env.search(query: query) + return .response(.resultsReceived(movies)) + } catch { + return .response(.requestFailed(error.localizedDescription)) + } + } + + case (.loading(let query), .resultsReceived(let movies)): + state = .loaded(query: query, results: movies) + return .none + + case (.loading(let query), .requestFailed(let message)): + state = .failed(query: query, message: message) + return .none + + case (.loading, .cancelTapped): + state = .idle + return .cancel("search") + + case (.loaded, _): + return .none + + case (.failed, _): + return .none + } + } + + static func response(state: State, event: Event) -> Response { + switch state { + case .idle: return [] + case .loading(let query): return [] + case .loaded(_, let results): return results + case .failed: return [] + } + } } ``` -The update function is a `switch` over state and event: +--- + +## Events and Transitions + +The `transduce` function is a `switch` over state and event combinations: ```swift -static func update( - _ state: inout SearchState, - event: SearchEvent -) -> Effect? { +static func transduce(_ state: inout State, event: Event) -> Effect { switch (state, event) { case (_, .searchTapped(let query)): state = .loading(query: query) - return .sequence([ - cancel("search"), - run(id: "search") { input, env in - do { - let movies = try await env.search(query: query) - try input.post(.resultsReceived(movies)) - } catch { - try input.post(.requestFailed(error.localizedDescription)) - } + return .task(id: "search", .switchToLatest) { input, env in + do { + let movies = try await env.search(query: query) + return .response(.resultsReceived(movies)) + } catch { + return .response(.requestFailed(error.localizedDescription)) } - ]) + } case (.loading(let query), .resultsReceived(let movies)): state = .loaded(query: query, results: movies) - return nil + return .none case (.loading(let query), .requestFailed(let message)): state = .failed(query: query, message: message) - return nil + return .none case (.loading, .cancelTapped): state = .idle return .cancel("search") - default: - return nil // event not valid in current state — ignore it + case (.loaded, _): + return .none + + case (.failed, _): + return .none } } ``` Every reachable behaviour is visible in one place. There is no flag to check somewhere else, no hidden early-return in an async closure, no `guard self != nil` buried in a completion handler. +### Exhaustive Pattern Matching + +The `switch` statement should enumerate all valid state-event combinations. Avoid using `default` as a catch-all unless it throws or logs an error: + +```swift +// ❌ Anti-pattern: silent ignore with default +default: + return .none + +// ✅ Correct: explicit cases or error handling +case (.idle, .cancelTapped): + return .none // explicitly handled + +// Or, if you want to catch unhandled cases: +default: + fatalError("Unhandled state-event combination: \(state), \(event)") +``` + +The compiler enforces exhaustiveness for `enum` states, so missing cases will cause a compile error. This is the "correct by construction" guarantee — you cannot accidentally forget to handle a transition. + --- ## From Gherkin to Swift -Requirements written in Gherkin map almost directly to cases in the update function. +Requirements written in Gherkin map almost directly to cases in the transduce function. **Requirement:** @@ -168,7 +411,7 @@ case (_, .searchTapped(let query)): // Scenario: Search returns results case (.loading(let query), .resultsReceived(let movies)): state = .loaded(query: query, results: movies) - return nil + return .none // Scenario: User cancels while loading case (.loading, .cancelTapped): @@ -178,45 +421,45 @@ case (.loading, .cancelTapped): The scenarios *are* the implementation. The mapping is near 1:1. -This also works in reverse: given an update function, you can read off the Gherkin scenarios directly. The function is the specification. +This also works in reverse: given an transduce function, you can read off the Gherkin scenarios directly. The function is the specification. --- -## Testing: zero mocking, zero async +## Testing: Zero Mocking, Zero Async -Because the update function is pure and synchronous, testing requires no mocking, no `XCTestExpectation`, no `async`/`await`, and no UI infrastructure. You call it like any other function and assert on the output. +Because the `transduce` function is pure and synchronous, testing requires no mocking, no `XCTestExpectation`, no `async`/`await`, and no UI infrastructure. You call it like any other function and assert on the output. ```swift @Suite("SearchState transitions") struct SearchStateTests { @Test func searchTappedTransitionsToLoading() { - var state = SearchState.idle - let effect = update(state: &state, event: .searchTapped(query: "inception")) + var state = SearchFeature.State.idle + let effect = SearchFeature.transduce(&state, event: .searchTapped(query: "inception")) #expect(state == .loading(query: "inception")) #expect(effect != nil) } @Test func resultsArrivedTransitionsToLoaded() { - var state = SearchState.loading(query: "inception") - let effect = update(state: &state, event: .resultsReceived([.init(title: "Inception")])) + var state = SearchFeature.State.loading(query: "inception") + let effect = SearchFeature.transduce(&state, event: .resultsReceived([.init(title: "Inception")])) #expect(state == .loaded(query: "inception", results: [.init(title: "Inception")])) #expect(effect == nil) } @Test func cancelWhileLoadingGoesIdle() { - var state = SearchState.loading(query: "inception") - let effect = update(state: &state, event: .cancelTapped) + var state = SearchFeature.State.loading(query: "inception") + let effect = SearchFeature.transduce(&state, event: .cancelTapped) #expect(state == .idle) // effect is cancel("search") } @Test func cancelInIdleStateIsIgnored() { - var state = SearchState.idle - _ = update(state: &state, event: .cancelTapped) + var state = SearchFeature.State.idle + _ = SearchFeature.transduce(&state, event: .cancelTapped) #expect(state == .idle) } @@ -225,11 +468,19 @@ struct SearchStateTests { You can exhaustively test every row in your transition table. The tests run in milliseconds and never flake, because there is no async work, no main-actor scheduling, and no shared mutable state to race against. +For full lifecycle testing (including effects), drive the transducer via `request()`: + +```swift +let runtime = SearchRuntime() +try await runtime.input(env: env).request(.searchTapped(query: "inception")) +// Assert on final state +``` + You can also derive a transition table from the test suite and verify it matches the Gherkin document directly — the connection is direct enough to automate. --- -## No edge cases +## No Edge Cases "Edge cases" in async UI code are usually one of two things: @@ -238,25 +489,118 @@ You can also derive a transition table from the test suite and verify it matches The enum state model eliminates category 1 entirely: the Swift type system won't let you represent `isLoading == true && errorMessage != nil` if your states are an enum. -Category 2 is handled by the update function being processed serially on `@MainActor`. Two events never execute concurrently. State is never observed mid-mutation. The "what if the user taps twice?" scenario is just two calls to `update` in sequence: the second `searchTapped` hits the `sequence([cancel("search"), run(id: "search") { ... }])` branch and replaces the in-flight task cleanly. +Category 2 is handled by the transduce function being processed serially on the system actor. Two events never execute concurrently. State is never observed mid-mutation. The "what if the user taps twice?" scenario is just two calls to `transduce` in sequence: the second `searchTapped` hits the `sequence([cancel("search"), task(id: "search") { ... }])` branch and replaces the in-flight task cleanly. -Concurrency exists — tasks genuinely run in the background — but concurrency never *touches* state directly. It only delivers events. The update function remains a simple, synchronous function. +Concurrency exists — tasks genuinely run in the background — but concurrency never *touches* state directly. It only delivers events. The transduce function remains a simple, synchronous function. --- -## MVI in practice +## MVI Pattern -EffectComponents implements the **Model–View–Intent** (MVI) pattern: +EffectComponents implements **Model–View–Intent** (MVI): -| MVI concept | EffectComponents equivalent | -|---|---| -| **Model** | `State` — the single source of truth | -| **Intent** | `Event` — user actions and system callbacks | -| **View** | SwiftUI `Content` closure — reads state, fires events | -| **Reducer** | `update` function — the only place state changes | -| **Side effects** | `Effect` — declarative descriptions returned from `update` | +| Concept | EffectComponents | +|---------|-----------------| +| Model | `State` — single source of truth | +| Intent | `Event` — user actions and system callbacks | +| View | SwiftUI `Content` closure — reads state, fires events | +| Reducer | `transduce` — only place state changes | +| Side effects | `Effect` — declarative descriptions returned from reducer | -The key MVI property is **unidirectional data flow**: state flows down into the view, events flow up into `update`, and `update` produces the next state. There is no path for the view to mutate state directly, no path for an async task to mutate state directly, and no path for two parts of the view to disagree about the current state. +The key property is **unidirectional data flow**: state flows down into the view, events flow up into `transduce`, and `transduce` produces the next state. There is no path for the view to mutate state directly. + +--- + +## Transducer Hosts + +The `Transduce` library provides several ways to integrate transducers into your application, depending on your platform and needs: + +### EffectView (SwiftUI) + +`EffectView` is a SwiftUI view that hosts a transducer and provides the `Input` interface for dispatching events. Use this when building SwiftUI applications. + +```swift +EffectView( + of: MyFeature.self, + state: $state, + initialEnv: env +) { state, input in + Button("Increment") { try? input(.increment) } + Text("Count: \(state.count)") +} +``` + +### TransducerObservable (SwiftUI Observation) + +`TransducerObservable` integrates with Swift's `@Observable` macro, allowing you to use transducers with the newer observation system. + +```swift +@Observable +class MyFeatureHost { + var state: MyFeature.State + + private let runtime = TransducerObservable(MyFeature.self, initialState: .init()) + + init() { + $state = runtime.state + } + + func input(_ event: MyFeature.Event) { + try? runtime.input.post(event) + } +} +``` + +### GlobalActorRuntime (Custom Isolation) + +`GlobalActorRuntime` provides a transducer host bound to a specific global actor. Use this when you need strict isolation guarantees or are building non-SwiftUI applications. + +```swift +actor MyFeatureActor { + let runtime: GlobalActorRuntime + + init() { + runtime = .init(on: MainActor.self, initialState: .init()) + } + + func input(_ event: MyFeature.Event) async throws { + try await runtime.send(event) + } + + var state: MyFeature.State { + get async throws { try await runtime.state } + } +} +``` + +### Custom Hosts + +You can implement your own transducer host by building on `BaseRuntime`. This gives you full control over the integration point while still leveraging the transducer's effect model. + +--- + +## Trade-offs + +This approach trades conceptual simplicity for runtime flexibility and correctness guarantees. + +### Advantages +- **Impossible states unrepresentable** — Swift's type system enforces valid state combinations +- **Testable in isolation** — pure `transduce` requires no async, mocking, or UI infrastructure +- **Traceable to requirements** — Gherkin scenarios map 1:1 to `switch` cases +- **No race conditions** — state mutations are serialized on the system actor +- **Clear lifecycle** — tasks are identified and cancellable + +### Disadvantages +- **Learning curve** — MVI/FSM pattern requires mental shift from imperative code +- **Boilerplate** — enum definitions and switch statements can feel verbose for simple features +- **Runtime overhead** — task management adds indirection compared to direct `.task` usage + +### When Not to Use +- Simple one-off async operations with no state transitions +- Features where the UI state is trivial (single boolean flag) +- Migration scenarios where incremental adoption isn't feasible + +For simple cases, SwiftUI's `.task` is sufficient. The FSM pattern shines when requirements grow and state complexity increases. --- @@ -265,11 +609,13 @@ The key MVI property is **unidirectional data flow**: state flows down into the | Concern | Where it lives | Characteristic | |---|---|---| | State | Swift enum | Impossible states unrepresentable | -| Transitions | `update` function | Pure, synchronous, compiler-verified | +| Transitions | `transduce` function | Pure, synchronous, compiler-verified | | Side effects | `Effect` return values | Declarative descriptions, not execution | | Async work | Task closures in `Effect` | Isolated, identified, cancellable | | Rendering | SwiftUI `Content` closure | Reads state only, fires events only | -The update function does one thing: given a state and an event, decide what the next state is and what work to trigger. Because it is pure and synchronous, it is trivially testable, straightforwardly readable, and directly traceable to requirements. Concurrency is real, but it is confined to the edges — it delivers events, it doesn't own state. +The transduce function does one thing: given a state and an event, decide what the next state is and what work to trigger. Because it is pure and synchronous, it is trivially testable, straightforwardly readable, and directly traceable to requirements. Concurrency is real, but it is confined to the edges — it delivers events, it doesn't own state. + +--- -*Next: [Using Env for dependency injection](UsingEnvForDependencyInjection.md)* +*Previous: [Taming async tasks in SwiftUI views](TamingAsyncTasksInSwiftUIViews.md) | Next: [Using Env for dependency injection](UsingEnvForDependencyInjection.md)* diff --git a/Documentation/EffectsReference.md b/Documentation/EffectsReference.md new file mode 100644 index 0000000..141b3ee --- /dev/null +++ b/Documentation/EffectsReference.md @@ -0,0 +1,503 @@ +## Effect variants + +Transduce defines a small set of effect variants. Each variant is declarative — you describe what should happen; the runtime performs it. + +Effects come in two forms: *actions* and *tasks*. + +#### Actions +Actions keep control within the current *computation cycle*. The cycle does not complete until the action finishes. Actions may be synchronous or asynchronous, but they never escape the cycle — they run as part of the current `transduce` pass. + +#### Tasks +Tasks hand work to Swift concurrency and return immediately. They escape the current *computation cycle* by starting a `Task`; follow‑up events will arrive later. + +Both forms can be *partial* or *terminal*: +- *Partial*: the closure returns an `Event`. That event is dispatched immediately and processed synchronously within the same *computation cycle*, continuing the chain. +- *Terminal*: the closure returns no event (i.e. `Void` or `.none`). No follow‑up event is produced, so the current cycle can end (unless additional effects are queued by a surrounding `.sequence`). + +#### The *computation cycle* +A *computation cycle* begins when an event is dispatched and ends when the transducer has finished processing a terminal effect. A single cycle may process more than one internal event (from partial actions). While the cycle is running, external events are queued and processed after the cycle finishes. + +In practice, the computation continues chaining partial effects and their resulting events until a terminal effect is reached (for example, `.none`, a terminal action, or a `task`). Only a terminal effect ends the current cycle. + +### Event Chain Behavior + +When an **action** returns an `Event` (partial effect), that event is immediately dispatched to the transducer's `transduce` function within the same compute cycle. This creates a chain: + +``` +Event → transduce(&state, event) → Action returns Event → +transduce(&state, newEvent) → Effect → ... → Terminal Effect +``` + +**Important behaviors:** + +- **Synchronous chaining**: Each partial event is processed immediately without yielding to other events. External dispatches (`post`, `send`, `request`) are queued until the current cycle completes. + +- **Stack depth limit**: The runtime enforces a maximum stack depth of 10 for effect execution. Exceeding this throws `ExecuteEffectStackDepthExceededError`. This prevents infinite recursion from event chains. + +- **No recursion cap on partial events**: While the effect execution stack has a depth limit, there is no explicit cap on how many times `transduce` can be called in a chain. Design your event chains to be bounded. + +- **Task events arrive later**: When a task returns an `Event`, that event enters the system as a *new* dispatch, not as part of the original cycle. The task outlives the compute cycle that started it. This is different from actions, which chain events synchronously within the same cycle. + +- **Terminal vs. partial**: + - Returning `Void` or `.none` from an action is *terminal* — the current effect chain settles. + - Returning `Event` (or `Event?` with a non-nil value) from an action is *partial* — the event re-enters `transduce` immediately within the same cycle. + - Tasks never chain events synchronously. When a task returns an `Event`, it enters the system as a new dispatch after the task completes. + +**Example: Partial action chain** + +```swift +case .start: + return .action { env -> Event in + // Returns an event, so the chain continues + return .stepOne + } + +case .stepOne: + return .action { env -> Event? in + // Returns another event, chain continues + return .stepTwo + } + +case .stepTwo: + return .action { _ in + // Returns Void, chain settles + state.completed = true + } +``` + +In this example, all three `transduce` calls happen in the same compute cycle, one after another, before any external event can be processed. + +--- + +### `.none` + +Do nothing. Use when no follow-up work is needed. + +```swift +case .loaded(let items): + state.items = items + return .none +``` + +--- + +### `.event(_:)` — inject an effect value + +Creates a `.event` effect that instructs the runtime to dispatch a domain event for immediate processing within the current cycle. This is useful when you want to continue the chain without triggering another dispatch from outside the transducer. + +```swift +case .refreshing: + state.status = .loading + return .event(.requestCurrentTime) +``` + +**Pitfalls:** +- `.event` produces *partial* work — the returned event is processed in-place. If that handler returns `.none`, the cycle ends. +- Infinite chains are possible if `.event` produces an event whose handler `.event`s the same event back. Use with care. +- Partial actions (`.action { _ in return .someEvent }`) and partial async actions also chain events in-place with no explicit recursion cap. A cycle that loops through `a → send(.a) → a → send(.a)…` will recurse until the call stack overflows. Keep event chains bounded by design. + +--- + +### `cancel(_:)` — cancel a managed task + +Cancels the task with the given identifier, if one is in flight. A no-op if no such task exists. + +```swift +case .refreshPressed: + return .sequence([ + .cancel("load"), // discard stale work + .task(id: "load") { /* ... */ } // start fresh + ]) +``` + +**Guidance:** +- Always cancel before re-starting a deduplicated task when you use `.shareable` option. +- For the common "cancellable, replace in-flight" pattern, prefer `.switchToLatest` — it cancels automatically. + +--- + +### `sequence(_)` — run effects left-to-right + +Executes each effect in order (left to right). All effects are executed within the same computation cycle. Task effects are launched but not awaited; their completion is observed by any subscribers. + +```swift +case .refreshPressed: + state.status = .loading + return .sequence([ + .cancel("load"), // discard stale work first + .task(id: "load") { input, env in + // Task returns Event? — this event is processed via request() + // when the task completes, not immediately in the sequence + let movies = try await env.api.load() + return .loaded(movies) + } + ]) +``` + +**Key behaviors:** + +- **All effects execute**: Every effect in the sequence runs from left to right. The sequence does not short-circuit on intermediate results. +- **Task effects launch but don't await**: When a task effect is encountered, it starts the managed task and immediately continues to the next effect. The caller of `request(_:)` or `uniqueRequest(_:)` waits for the task to complete, not for the sequence itself. +- **Intermediate events are ignored**: If an effect in the middle of the sequence returns an event (e.g., a partial action), that event is processed synchronously but its result is not propagated to the sequence's caller. Only the terminal effect of the *entire* sequence determines when the chain settles. +- **Response value**: When a sequence is returned from `transduce` and dispatched via `request(_:)` or `uniqueRequest(_:)`, the response value is computed from the **original event** (the one that returned the sequence) via `response(state:event:)` after the entire sequence completes. +- **Task ownership**: Tasks in a sequence run as runtime-owned work. The caller's continuation is transferred to the task manager when a task effect is encountered, and the continuation waits for that task's completion. + +**Common patterns:** + +- **Cancel then start fresh**: Cancel any existing work before starting new work with the same identifier. + ```swift + return .sequence([ + .cancel("load"), + .task(id: "load") { ... } + ]) + ``` + +- **Side effect then task**: Perform a synchronous side effect, then start an async task. + ```swift + return .sequence([ + .action { env in env.cache.clear() }, + .task(id: "load") { ... } + ]) + ``` + +**Constraints:** +- The sequence depth is limited to 10 levels (a sequence may contain another sequence, but the nesting cannot exceed 10). +- Intermediate effects should typically be terminal (side-effect only) or `.cancel`. Partial events from intermediate steps are processed but their results don't affect the sequence's terminal behavior. +- If you need event-producing intermediate states that chain within the same cycle, use partial actions or `.event` effects *outside* of a sequence. + +--- + +### Action effects + +Actions create immediate, non-escaping work that runs inside the current `compute` invocation. A single `.transduce` (or `compute`) call may chain many actions through partial return values until a terminal effect ends the cycle. + +Actions never escape `compute` — you cannot return a managed task ID or await across cycles from within them. For long-running, cancellable work, use task effects instead. + +#### Synchronous actions (terminal — returns `Void`) + +```swift +case .resetConfirmed: + state.items = [] + return .action { _ in + state.cache.clear() + } +``` + +The cycle settles after the action completes (unless composed in a surrounding sequence). + +#### Synchronous actions (partial — returns `Event`) + +```swift +case .loaded(let response): + guard !response.data.isEmpty else { + return .action { _ in + state.cache.invalidate() + return .emptyResultReceived + } + } + state.items = response.data + return .none +``` + +The returned event is processed immediately in the same cycle, chaining further handlers until a terminal effect is reached. + +#### Synchronous actions (maybe-partial — returns `Event?`) + +Return an event if some conditional work should continue, or `nil` to terminate: + +```swift +case .dataReady: + return .action { env -> Event? in + guard env.config.enableFeature else { return nil } + return .featureEnabled + } +``` + +#### Asynchronous actions (nonsending variant — terminal) + +The closure is `nonisolated(nonsending)` and may suspend but still runs inside the current cycle: + +```swift +case .loadConfig: + state.status = .loadingConfig + return .action(nonsendingOperation: { _ in + let config = try await env.configService.load() + // You can throw here; errors halt execution. + state.config = config + }) +``` + +#### Asynchronous actions (nonsending variant — partial) + +Returns the next domain event after awaiting: + +```swift +case .requestTimestamp: + return .action(nonsendingOperation: { _ in + let now = await dateProvider.now() + return .currentTimestamp(now) + }) +``` + +--- + +### Isolation of action closures + +Action closures execute on the **system actor** by default. If an asynchronous closure is annotated with a global literal actor, it runs on that actor: + +```swift +// Runs synchronously on the system actor +return .action { _ in + let value = env.nonisolatedCounter.value + return .counterValue(value) +} +``` + +```swift +// Runs asynchronously on the MainActor +return .action { @MainActor _ in + let value = env.observableCounter.value + return .counterValue(value) +} +``` + +--- + +### Task effects + +Task effects start managed, cancellable tasks that outlive the current `compute` cycle. The closure receives `(Input, Env)` and can return one of three types: + +1. **`Void`** — terminal task, no follow-up event +2. **`TaskReturn`** — explicit control over how the returned event is dispatched +3. **`Event?`** — optional event return (processed as `.request(event)` if non-nil, response value if `nil`) + +#### Task with Void return — terminal task + +Runs async work that produces no follow-up event. Useful for logging, metrics, or fire-and-forget operations: + +```swift +case .buttonTapped(let action): + state.pendingActions.append(action) + return .sequence([ + .task(id: "analytics", priority: .utility) { _, env in + try await env.metrics.track(button: action) + }, + .action { _env in + state.status = .processing + } + ]) +``` + +The task completes without producing a follow-up event. A response value is computed from the current state and the original event via `response(state:event:)`, which resumes any waiting request callers. + +#### Task with `TaskReturn` return — explicit dispatch control + +The most flexible form: runs async work and returns a `TaskReturn` value that explicitly controls how the resulting event is dispatched: + +```swift +case .refreshPressed: + state.status = .loading + return .task(id: "fetch") { input, env -> TaskReturn in + let data = try await env.api.fetch() + return .response(.didLoad(data)) + } +``` + +The `TaskReturn` enum provides several options: + +- **`.response(event)`** — completes the request with `response(state:event:)` using the given event (no additional dispatch) +- **`.send(event)`** — calls `input.send(event)`, then completes with response value +- **`.post(event)`** — calls `input.post(event)`, then completes with response value +- **`.request(event)`** — awaits `input.request(event)`, prolonging waiter suspension (full effect chain) +- **`.uniqueRequest(event)`** — awaits `input.uniqueRequest(event)`, prolonging waiter suspension (exclusive semantics) + +For most cases where you need to return a response value, use `.response(event)`. + +**Parameters:** +| Parameter | Purpose | +|-----------|---------| +| `id` | Optional identifier for deduplication / cancellation. Pass `nil` for one-shot tasks with no identity management. | +| `priority` | Optional `TaskPriority` for scheduling priority (e.g., `.userInitiated`, `.low`). | +| `option` | Policy for overlapping tasks sharing the same `id`. Defaults to `.switchToLatest`. | + +**Common options:** +- **`.switchToLatest` (default)**: cancels any in-flight task with the same identifier and starts a fresh one (for shareable IDs), or creates an independent unique task that ignores existing work. Best for "last write wins" semantics where stale results are undesirable. +- **`.shareable`**: attaches a new caller as a waiter to any existing task sharing the same identifier, or creates a new task only if none exists. Useful when multiple callers want to share the same running work and all receive its result. + +#### Task with `Event?` return — optional event + +The closure returns an optional event. This is the legacy form; prefer `TaskReturn` for explicit control: + +```swift +case .loadData: + return .task(id: "load") { input, env -> Event? in + let data = try await env.api.fetch() + return .didLoad(data) + } +``` + +**Behavior:** +- **Non-nil `Event`**: The event is dispatched via `input.request(event)` and the full effect chain is awaited. This is equivalent to returning `.request(event)` from a `TaskReturn`. +- **`nil`**: The task completes without producing a follow-up event. A response value is computed from the current state and the original event via `response(state:event:)`. + +**Note**: For tasks that need to return a response value, prefer returning `TaskReturn` instead of `Event?`. Use `.response(event)` when you want the response function to compute a value from the event: + +```swift +.task(id: "fetch") { input, env -> TaskReturn in + let data = try await env.api.fetch(input) + return .response(.didLoad(data)) +} +``` + +#### Task isolation + +Although the parameter is declared `nonisolated(nonsending)`, you can pass a closure isolated to a global actor: + +```swift +// Runs on the system actor by default (no explicit global actor) +.task(id: "fetch") { input, env -> TaskReturn in + let value = try await env.api.fetch(input) + return .response(.didLoad(value)) +} + +// Runs isolated to the MainActor +.task(id: "ui-side-effect") { @MainActor input, env -> TaskReturn in + try await env.uiCoordinator.presentToast(for: input.label) + return .response(.toastCompleted) +} +``` + +#### Cancellation model + +Tasks are automatically cancelled when their identifier is replaced (with `.switchToLatest`) or explicitly via `.cancel(id)`. A cancelled task's closure will observe `Task.isCancelled` or `ThrowCancellationError`. Ensure your async operations check for cancellation promptly: + +```swift +.task(id: "longLoad") { input, env -> Event in + guard !Task.isCancelled else { throw CancellationError() } + let data = try await env.network.fetchLong(input.url) + guard !Task.isCancelled else { throw CancellationError() } + return .didLoad(data) +} +``` + +#### Subscriber Lifecycle on Shared Tasks + +**When does subscriber behavior apply?** Only when all of these are true: + +1. The transducer returns a task effect (`.task(...)`, `.action(...)`, etc.) with a **shareable** ownership policy in its `Effect` identifier — tasks with `.createUnique` ownership never collect waiters. +2. The task's addition strategy is `.subscribeOrCreateShared` (not `.replaceAndCreateShared` / switchToLatest). Only the former accumulates subscribers on a single task. +3. A dispatcher method (`send`, `post`, `request`, or `uniqueRequest`) routes an event through that effect, and the continuation passed to task creation carries subscriber semantics. + +**V1 default: subscribing does not equal controlling.** When the sole subscriber drops from a shared waiter list (waiter count → 0), the underlying SwiftTask **keeps running**. Subscribers observe — they do not control — the work's lifecycle. The task continues until: +- The transducer sends a `CancelEffect` for that task ID, or +- An external caller invokes `control(.cancel)` + +**Why this default.** A subscriber may unsubscribe while another component still needs the result. Automatically cancelling would silently abort work owned by someone else. See `SubscriberPolicy.keepRunning` in the runtime source code. + +**Later: policy-at-effect-creation.** The upcoming v1.x API will allow setting a policy at effect-creation time via a new `TaskAdditionOption`. This will let you say "cancel this task when the last subscriber leaves" — currently unavailable, but tracked as a future feature. + +#### Compose with External Sub-systems + +The runtime is a **pure value-transfer engine**. To leverage more complex external systems — stores, coordinators, data providers — introduce them as peer sub-systems by passing them into `Env`. Once injected, any transducer action or task may query their state, send events through their channels, or invoke their methods. + +**The Observer sub-system contract.** Any external system that fills these three roles serves as an observer: + +1. **Observable properties** — readable at any point via snapshotting +2. **Event channel** — accepts events from the transducer via `send(_:)` or method calls +3. **Callable methods** — stateful or computed behavior invoked as normal actor dispatches + +There is no generic Observer protocol in Transduce; the contract describes what your external sub-system *does*, not a required import. This keeps the runtime decoupled from whatever technology drives observation in your surrounding architecture. **The Observation framework is one concrete form of this pattern.** Later, you may add other implementations — for example, combining an Observable-backed store with Swift Combine pipelines, or bridging to UIKit's target-action system. + +```swift +// The Observation framework gives us @Observable + actor-driven isolation. +// Any other pub-sub system or coordinator can replace it. +struct AppEnv { + let counterStore: CounterStore // @Observable + @MainActor — one concrete form +} + +static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .pollCounter: + return .task(id: "snapshot-counter") { _, env in + // Snapshot one observable property at this instant + let count = await env.counterStore.observe { $0.value } + return .counterValue(count) + } + case .incrementConfirmed: + return .action(nonsendingOperation: { _ in + // Call a method — the sub-system may also emit events internally + try await env.counterStore.increment(by: 1) + return nil // terminal — no follow-up event + }) + } +} +``` + +**Env is the injection point.** The transducer knows nothing about how your observer is implemented. It receives a typed reference via `Env` and interacts through its public facade using whatever transducer primitives you need (actions, tasks, `send`, etc.). If an external state change should trigger transducer work, the sub-system emits events that re-enter the compute cycle — or a bridging component does so on your behalf. + +**Backpressure warning.** Snapshotting observable properties from tightly-looped actions or tasks creates cross-isolation checkpoints that serialize on the sub-system's execution queue and may stall its other callers. When the sub-system can emit events in response to state changes itself (via `send`), prefer that push path over transducer-side polling. + +**Platform constraints.** The `observe(isolated:)` method requires Observation framework v2+ (iOS 17+, macOS 14+). For earlier platforms, use explicit actor entry (`await store.property`) or a bridge layer translating Observable subscriptions into transducer events. + +**Observation Caveat — Event Coalescing.** When you rely on the Observation framework for reactive integration (e.g., `onReceive` on `ObservableObject`, observing publishers, or SwiftUI's `.onChange(of:)`), intermediate mutations may be coalesced or dropped between your polling intervals. Observable only fires change notifications at the end of an @MainActor runloop cycle; transient state changes that do not persist when the loop yields will not reach your subscriber. If you need every mutation delivered, use a custom event channel (e.g., `AsyncStream`, `AsyncChannel`, or your own pub-sub) rather than Observable's change coalescing semantics. + + +#### Environment capture + +Tasks capture `Env` at the moment they are produced. For value types, this is a fixed snapshot — it prevents mid-flight race conditions where other code could mutate shared state while a task runs. If `Env` contains reference types (classes), their mutable properties may change during the task's lifetime; Sendable governs what crosses that boundary, and whether mutation is safe is the user's responsibility. This distinction enables test-friendly patterns where class-based `Env` instances share state across transducers. Any feature requiring an updated value type `Env` should start a fresh effect via dispatch. + +```swift +// Env struct is captured at production time — its value is fixed +return .task(id: "load") { _, env -> Event in + // 'env' holds the exact snapshot from when this effect was returned, + // not any updates that occurred after + let config = env.config + return .loaded(config) +} +``` + +--- + +## Dispatch semantics: `post`, `send`, `request`, and `uniqueRequest` + +| Dispatch method | Where | When to use | Semantics | +|-----------------|-------|-------------|-----------| +| **`send(_:)`** (dispatch) | `TransducerInput` / host's input handle | Send an event from external code for immediate re-entry into the compute cycle. Synchronous, no external actor needed. | +| **`post(_:)`** (fire-and-forget) | `TransducerInput` / host's input handle | External work that should not block a dispatcher; queues the event for later processing on the system actor. | +| **`request(_:)`** (awaitable) | `TransducerInput` / host's input handle | Code needs to wait for a transducer response; awaits the result from `response(state:event:)` after the event chain settles. | +| **`uniqueRequest(_:)`** (exclusive awaitable) | `TransducerInput` / host's input handle | Strictly exclusive request: cancellation stops all work immediately. Use when you need to prevent work from starting if the caller cancels early. | + +### Guidance and pitfalls + +- **Inside `transduce`**: always use effects (`.event`, `.action`, `.task`). Never call dispatch methods directly — they belong in hosts or outside the reducer. +- **From actions**: use `.event` to continue the chain; it runs synchronously without escaping the cycle. +- **From external code**: a host layer calls `post` for non-blocking events, `request` when a result is needed, or `uniqueRequest` for strictly exclusive semantics where cancellation should stop all work immediately. + +--- + +## Frequently misunderstood + +| Misconception | Reality | +|--------------|---------| +| "Actions can start tasks" | Actions run inside `compute`; they never create task IDs. Use task effects for cancellable, long-lived work. | +| "I can call dispatch methods (post/send/request) from inside `transduce`" | `transduce` should only return effects. Dispatch belongs in host/layer code outside the reducer contract. | +| "A cancelled task's closure still runs to completion" | When a task is cancelled, Swift checks for cancellation at each suspension point. Your closure must handle or throw to exit promptly. | +| "`sequence([...])` chains partial events through intermediate steps" | **Partially incorrect**: Partial events from intermediate effects *are* processed synchronously within the sequence, but their results don't affect the terminal behavior of the sequence. The response value for a request is computed from the original event that returned the sequence, not from intermediate events. | +| "Task `Env` captures live references" | `Env` is captured by value at production time. Use reference types if you need live access. | + +--- + +## Error handling + +Errors thrown from action closures or task operations are treated as system errors and halt execution. To recover, catch errors inside the closure before returning: + +```swift +return .action(nonsendingOperation: { _ in + do { + let data = try await env.api.fetch() + state.data = data + } catch { + state.status = .error(error.localizedDescription) + } +}) +``` diff --git a/Documentation/GitWorkflow.md b/Documentation/GitWorkflow.md index 19dc089..ed81b67 100644 --- a/Documentation/GitWorkflow.md +++ b/Documentation/GitWorkflow.md @@ -145,7 +145,7 @@ Summary: - Hide effect implementation details behind TransducerEffect - Rename runtime boundary error type to RuntimeError - Comment out unfinished low-level Transducer.run API -- Clarify post, send, and request dispatch semantics +- Clarify post, send, request, and uniqueRequest dispatch semantics - Add README framing for Transducer as reducer-like but effect-emitting - Expand docs around dispatch failure and try? usage - Fix typos and stale terminology across docs, tests, and examples diff --git a/Documentation/Recipes.md b/Documentation/Recipes.md index 59b927e..1f696e7 100644 --- a/Documentation/Recipes.md +++ b/Documentation/Recipes.md @@ -27,12 +27,7 @@ Button("Retry") { } ``` - -Note: in this case it is safe to write `try?` since we can ignore the error when -attempting to dispatch an event when it happens within the button actions or -within the onChange closure. - - +Note: in this case it is safe to write `try?` since we can ignore the error when attempting to dispatch an event when it happens within the button actions or within the onChange closure. `post` is fire-and-forget. It schedules the event and returns immediately. @@ -69,9 +64,7 @@ struct ProductsListView: View { ``` This is the right choice for `.refreshable`, because SwiftUI keeps the spinner visible while the request is still in flight. - It is also a good shape for diffing: let the child view that owns the query state perform the refresh itself, instead of receiving an ad-hoc `refresh` closure from the parent. - Passing `input` down is stable for the lifetime of the runtime, so SwiftUI can treat the child view's action handle as unchanged across parent reevaluations. ## Prefer stable `input` over ad-hoc action closures @@ -96,15 +89,17 @@ case .queryChanged(let query): state.query = query state.isLoading = true - return .task(id: "search") { input, env in + return .task(id: "search") { input, env -> TaskReturn in try? await Task.sleep(for: .milliseconds(300)) - guard !Task.isCancelled else { return } - + guard !Task.isCancelled else { return .response(.none) } do { let results = try await env.search(query) - try input(.resultsLoaded(results)) + return .response(.resultsLoaded(results)) + } catch is CancellationError { + // search cancelled + return .response(.none) } catch { - try input(.searchFailed(error.localizedDescription)) + return .response(.searchFailed(error.localizedDescription)) } } ``` @@ -120,12 +115,12 @@ case .refresh: state.isRefreshing = true return .sequence([ .cancel("load"), - .task(id: "refresh") { input, env in + .task(id: "refresh") { input, env -> TaskReturn in do { let items = try await env.loadItems() - try input(.loaded(items)) + return .response(.loaded(items)) } catch { - try input(.loadFailed(error.localizedDescription)) + return .response(.loadFailed(error.localizedDescription)) } } ]) @@ -149,7 +144,7 @@ case .startObserving: case .countChanged(let count): state.count = count - return nil + return .none ``` The observation task emits the initial value immediately, then sends updates as the observed value changes. @@ -157,11 +152,8 @@ The observation task emits the initial value immediately, then sends updates as ## Share one asynchronously started `EffectActor` between multiple consumers Use this pattern when the actor cannot be fully initialized in `init`. - An actor's initializer is non-isolated, so any setup that must run with isolated access to actor state has to happen later. Once that setup becomes asynchronous, a naive "just start it on first use" approach is easy to get wrong under reentrancy: multiple callers can all observe "not started yet" and race into startup. - The safer shape is: - - create the `EffectActor` with a lightweight initial state - model the real async setup as transducer logic triggered by a `.start` event - gate external access through one shared startup task @@ -219,7 +211,7 @@ enum SearchFeature: Transducer { let makeClient: @Sendable () async throws -> Client } - static func update(_ state: inout State, event: Event) -> Effect? { + static func transduce(_ state: inout State, event: Event) -> Effect { switch (state, event) { case (.start, .start): state = .initializing @@ -229,7 +221,7 @@ enum SearchFeature: Transducer { case (.initializing, .didInitialize(let client)): state = .idle(client) - return nil + return .none case (.idle(let client), .refresh(let query)): return .task { _, _ in @@ -237,7 +229,7 @@ enum SearchFeature: Transducer { } default: - return nil + return .none } } } @@ -262,7 +254,6 @@ _ = try await (a, b) ``` Why this shape works: - - The first caller creates the shared startup task and triggers `.start` exactly once. - Concurrent callers await that same task instead of racing `start(...)`. - The transducer, not the wrapper, owns the async initialization steps and state transitions. @@ -273,12 +264,12 @@ Use this pattern when the runtime has app- or feature-lifetime ownership and mul ## Keep logic easy to test -Because the decision-making stays in `update`, you can test the feature by driving state and events directly. +Because the decision-making stays in `transduce`, you can test the feature by driving state and events directly. ```swift var state = SearchFeature.State() -let effect = SearchFeature.update(&state, event: .queryChanged("milk")) +let effect = SearchFeature.transduce(&state, event: .queryChanged("milk")) XCTAssertEqual(state.query, "milk") XCTAssertTrue(state.isLoading) @@ -286,3 +277,68 @@ XCTAssertNotNil(effect) ``` The test checks what changed immediately. If needed, separate tests can exercise the returned effect path. + +## Connect an `AsyncSequence` to a transducer + +Use an `AsyncStream` as a conduit between external async sources and your transducer. The stream's continuation acts as a sender that automatically knows when the consumer (the transducer) is cancelled. + +```swift +enum WeatherFeature: Transducer { + struct State { + var temperature: Double = 0 + var isUpdating = false + } + + enum Event { + case startUpdates + case temperatureUpdated(Double) + case stopUpdates + } + + struct Env: Sendable { + let sensorStream: AsyncStream + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .startUpdates: + state.isUpdating = true + return .task(id: "sensor-stream") { input, env in + for await value in env.sensorStream { + try? input.post(.temperatureUpdated(value)) + } + } + + case .temperatureUpdated(let temp): + state.temperature = temp + return .none + + case .stopUpdates: + state.isUpdating = false + return .cancel("sensor-stream") + } + } +} +``` + +The `AsyncStream` pattern is powerful because: +- **Automatic cancellation awareness**: When the transducer is cancelled (via `.cancel(id)` or view dismissal), the stream's continuation detects `continuation.isCancelled` and stops producing values +- **Backpressure handling**: The stream's buffer automatically handles bursts of data +- **No manual task management**: The transducer runtime manages the task lifecycle + +To use it, create the stream in your `Env`: + +```swift +let sensorStream = AsyncStream { continuation in + Task { + while !continuation.isCancelled { + let value = await sensor.readTemperature() + continuation.yield(value) + } + } +} + +let env = WeatherFeature.Env(sensorStream: sensorStream) +``` + +The stream sender automatically stops when the transducer cancels — no manual cleanup needed. diff --git a/Documentation/RuntimeDesign.md b/Documentation/RuntimeDesign.md index 52a4a7b..58ceefd 100644 --- a/Documentation/RuntimeDesign.md +++ b/Documentation/RuntimeDesign.md @@ -1,18 +1,14 @@ # Runtime Design -This note describes the runtime model behind `EffectView`. - -It is not an API tutorial. The goal is to make the runtime's invariants explicit, explain why the implementation looks the way it does, and preserve the design intent as the library evolves. - -In short, `EffectView` is an event-driven runtime built around a finite state machine model of computation. Events drive state transitions, effects describe operations to perform, and the runtime executes and manages those operations while preserving ordered state reduction. - +This note describes the runtime model. +This is not an API tutorial. The goal is to make the runtime's invariants explicit, explain why the implementation looks the way it does, and preserve the design intent as the library evolves. +It describes the runtime from the perspective of `TransducerRuntime` so it applies equally to all hosts, not just SwiftUI views. +In short, `TransducerRuntime` is an event-driven runtime built around a finite state machine model of computation. Events drive state transitions, effects describe operations to perform, and the runtime executes and manages those operations while preserving ordered state reduction. At a high level, effects come in two forms: - - Actions are inline effect steps in the current computation cycle. Unlike tasks, they remain part of the current event chain even when they suspend. - Tasks are managed asynchronous operations; they run outside the current reduction step, may be tracked by logical identifier, and can feed events back into the system later. Two earlier articles describe adjacent concerns from the public API side: - - [Taming async tasks in SwiftUI views](TamingAsyncTasksInSwiftUIViews.md) explains why the library needs runtime-managed effects instead of relying on SwiftUI's `.task` modifier. - [Using Env for dependency injection](UsingEnvForDependencyInjection.md) explains how dependencies are captured and forwarded into effects. @@ -20,14 +16,28 @@ This document focuses on the runtime itself: event processing, back pressure, ca --- -## The problem this runtime solves +## Core terminology + +This document uses the following terms: -At the feature level, `EffectView` wants a simple contract: +- **Transducer**: A pure, synchronous state machine function `(inout State, Event) -> Effect` that transforms state and declares work. The transducer is the domain logic boundary. +- **Host**: A runtime bridge (such as `EffectView` or `BaseRuntime`) that owns the transducer state and provides an `Input` handle for dispatching events. +- **Effect**: A declarative description of work to perform. Effects come in two forms: actions (inline, part of current computation) and tasks (managed async operations). +- **State**: A value type (struct or enum) owned by the host via `Binding`. State changes only occur in `transduce`. +- **Event**: A domain message that drives state transitions. Events are dispatched via `Input` and processed by `transduce`. +- **Env**: An immutable dependency container captured at host initialization and forwarded to effects. -- state changes only in `update` +--- + +## Motivation + +At the feature level, `TransducerRuntime` provides a simple contract to its hosts (any "Transducer Host" such as `EffectView`): + +- state changes only in `transduce` - effects are declared, not performed inline - async work can send events back into the system -- callers can choose how to dispatch an event: post it and return immediately, send it and wait for the current computation cycle, or request it and suspend until the terminal result is available +- callers can choose how to dispatch an event: post it and return immediately, send it and wait for the current computation cycle, request it and suspend until the terminal result is available, or use `uniqueRequest` for strictly exclusive semantics with task ownership +- `Input` is not an external concept; it is the caller-facing API owned by the `TransducerRuntime` and handed to hosts as a handle for dispatching events (`post`, `send`, `request`, `uniqueRequest`). That contract becomes significantly harder to uphold once async actions and runtime-managed tasks exist. @@ -46,7 +56,6 @@ The current design answers those questions explicitly rather than implicitly. ## Design goals The runtime is intentionally designed around the following goals: - 1. Preserve a single ordered mutation path for domain state. 2. Support async effects and async actions without pushing buffering logic into transducer state. 3. Allow callers to use ordinary suspending functions as the back pressure mechanism. @@ -60,7 +69,6 @@ These goals are Swift-shaped. They rely on actor isolation, structured concurren ## The two-path model The runtime has two distinct execution paths: - - `compute(...)` processes regular domain events. - `control(...)` processes runtime control events. @@ -71,7 +79,7 @@ This split is fundamental. `compute(...)` is the domain path. It: - reads and mutates transducer state -- calls `update` +- calls `transduce` - executes returned effects - may suspend while awaiting async actions - may transfer a request continuation into a managed task @@ -82,90 +90,86 @@ Because `compute(...)` is the path that can mutate state, it must not be re-ente `control(...)` is the runtime path. It: -- does not call `update` -- must not mutate transducer storage +- does not call `transduce` +- must not mutate transducer state - may cancel the runtime or latch a system failure - may run while a regular `compute(...)` invocation is suspended -This is not merely acceptable. Once async actions exist, it is the correct model. A hard-stop control path must be able to intercept a suspended event chain immediately. Otherwise shutdown would be delayed until the action returns, which would weaken runtime cancellation semantics substantially. +The benefit of a control path is that it provides an independent way to control the associated state of a transducer - while still using an event-driven semantic. Control has the authority to cut in and stop the system even while a regular event chain is paused awaiting an async step. If control had to wait for that action to finish, shutdown would lag behind and the runtime’s cancellation guarantees would be significantly weakened. --- ## Core invariants The runtime is designed around these invariants: - 1. `compute(...)` is never re-entered. 2. `control(...)` may interleave with a suspended `compute(...)`. -3. `control(...)` must never mutate transducer storage. +3. `control(...)` must never mutate transducer state. 4. All regular event entry points pass through the same gate before entering `compute(...)`. 5. After every suspension point inside `compute(...)`, runtime cancellation must be re-checked before more work is committed. 6. A thrown `compute(...)` does not consume the request continuation; the caller remains responsible for resuming or failing it. -7. Shutdown and latched failure state remain centralized in one runtime authority. +7. The compute function has a catch clause which catches all errors and resumes the continuation with this error. +8. Shutdown and latched failure state remain centralized in one runtime authority. +9. Effect interpretation does not resume request continuations directly; `compute(...)` resumes on terminal paths or transfers ownership to `TaskManager` for managed work. If future changes violate one of these rules, they should be treated as a design change, not as an incidental refactor. --- -## Why `compute(...)` is gated +## Gate mechanism Without a gate, an awaited async action yields the system actor, allowing another task to enter the runtime and call `compute(...)` again. That would break the single ordered mutation model. The runtime therefore uses a gate in front of `compute(...)`. - Conceptually, the gate means: - - if no regular event chain is active, the caller enters immediately - if a regular event chain is already active, the next caller suspends until admission This is a strict back pressure model. - The runtime does not primarily buffer events. Instead, callers wait. -### Why not an event buffer? +### Buffering vs. suspension An event buffer is viable, but it encodes a different policy: - - the runtime owns queued events - overflow policy becomes part of the design - buffering and scheduling concerns become more prominent than caller suspension The current design chooses the opposite center of gravity: - - the caller owns its event until admitted - suspension is the back pressure mechanism - structured concurrency expresses the waiting relationship directly In practice, this lets call sites remain simple: they use suspending functions, and back pressure emerges from the language's existing async model rather than from a separate buffering abstraction. -### Why the gate can stay simple +### Gate implementation The gate itself can stay small because it lives on the system actor. It does not need mutexes or cross-thread synchronization primitives. Its job is only to serialize admission to `compute(...)`. - Implementation details may evolve, but conceptually the gate is a FIFO of waiting senders or continuations, not a mailbox of events. --- -## Event entry semantics +## Event dispatch methods -The runtime exposes three relevant caller-facing modes: +The runtime exposes four caller-facing dispatch methods: +`Input` is the integral interface to these modes. Hosts obtain an `Input` handle from the `TransducerRuntime` and use it to dispatch events into the runtime; the runtime owns the lifecycle and semantics of that handle. -- fire-and-forget event submission -- synchronous event submission -- request-style submission with a result continuation +- `post(_:)` — fire-and-forget event submission +- `send(_:)` — synchronous event submission (wait for immediate reduction) +- `request(_:)` — request-style submission with result continuation +- `uniqueRequest(_:)` — request-style with **task ownership** (exclusive, invisible to subscribers) -These modes differ in how much of the event chain the caller waits for, but they all rely on the same underlying serialization rules for regular events. +These methods differ in how much of the event chain the caller waits for (`post` returns immediately, `send` waits for reduction, `request`/`uniqueRequest` wait for full settlement), and whether the launched task is shareable or uniquely owned. ### Fire-and-forget dispatch (`post`) -A fire-and-forget call schedules work and returns immediately. The caller does not wait for `compute(...)` to run. - +A fire-and-forget call schedules work and returns immediately. The caller does not wait for `compute(...)` to run. `post` may throw synchronously if the runtime cannot accept the event for immediate scheduling (for example, after cancellation). Once scheduled, later failures occur on the runtime’s own execution path and are not reported back to the caller of `post`. This is intentionally the weakest back pressure mode. It is useful, but it is also the deliberate escape hatch: callers can create pending work without themselves awaiting admission. +Implementations should perform a preflight readiness check (for example, via the central cancellation authority) before scheduling work. This ensures `post` fails fast when the runtime is already unavailable, while preserving fire-and-forget semantics for work that successfully schedules. ### Synchronous dispatch (`send`) A synchronous `send` means: - - the caller waits for the event chain it started - the caller does not directly await unrelated managed tasks unless the chain reaches a request-style effect - the system guarantees that regular event reduction remains serialized @@ -173,58 +177,97 @@ A synchronous `send` means: The important nuance is that "synchronous" here is semantic, not literally non-suspending. If an async action runs inline, `send` may suspend while still preserving single-entry regular event reduction. ### Request/response dispatch (`request`) - A request carries a continuation through the event chain until the chain terminates, transfers the continuation into a managed task, or throws out of `compute(...)`. - This is the runtime's bridge between event-driven logic and ordinary async/await callers. -### When dispatch fails +#### Cancellation semantics +A `request` call establishes a cancellation handler that allows the caller to detach early from the result. When the caller's task is cancelled: +- The continuation is immediately resumed with `CancellationError`. +- The caller stops awaiting the response. +- The underlying event chain continues executing independently. -Dispatching an event into the runtime can fail for various reasons. For example: an internal event buffer may be full, the actor may have been cancelled, the actor may already be deinitialized, or the transducer may have been cancelled. +This design separates **caller lifetime** from **runtime work**: +- If an action has not yet started, it may or may not execute (scheduling is non-deterministic). +- If an action is already executing, it runs to completion. +- Any events produced by that action feed back into the state machine normally. +- Managed tasks continue their normal execution. + +**Key principle**: Once work begins, it is unstoppable by caller cancellation. The caller can only choose whether to wait for the result; it cannot stop the computation itself. +This preserves event-driven semantics: effects should run to completion and emit their results into the system, even when the caller who initiated them has moved on. It also prevents silent errors where a caller detaches and side effects partially execute. + +### Request/response dispatch with ownership (`uniqueRequest`) +`uniqueRequest(_:)` is a variant of `request(_:)` that adds **task ownership** to the request semantics. Like `request`, it suspends until the transducer's response is available, but in addition it establishes exclusive ownership of any managed task that gets launched. + +#### Task ownership semantics +When `uniqueRequest` dispatches an event that triggers a managed task: +- The task is created with a **caller-owned identifier** (internal to the runtime) +- This makes the task "invisible" to other potential subscribers +- Other callers using `request` or `shareable` tasks cannot subscribe to this task +- The transducer's cancellation effects (`.cancel(id:)`) cannot cancel this task, since its ID is unknown to the transducer +- The task only gets cancelled when the **runtime itself** is cancelled (e.g., host deinitialization) + +#### When to use `uniqueRequest` +Use `uniqueRequest` when you need: +- Strict one-to-one correspondence between caller and task +- No risk of task sharing or interference from other subscribers +- Exclusive access to a resource or operation +- Cancellation of the caller should propagate to stopping all work (unlike `request`) + +#### Relationship to dispatch methods +`uniqueRequest` is not a separate dispatch method like `post`, `send`, or `request`. Instead, it adds another **dimension** to the dispatch model: task ownership. The dispatch method (`post`, `send`, or `request`-style) determines how long the caller waits, while the ownership mode (`shareable` vs `unique`) determines who can see and control the launched task. + +### When dispatch fails +Dispatching an event into the runtime can fail for various reasons. For example: an internal event buffer may be full, the actor may have been cancelled, the actor may already be deinitialized, or the transducer may have been cancelled. Depending on the context, some of these failures are benign. For example, in a SwiftUI Button action: ```swift +import Transduce + Button("Start") { - send(.start) + try? input(.start) } ``` + In this case, when sending the "start" event fails, it is often not a critical error. A user may just try again. However, there are other cases where a failure means a critical error. For example, in an operation when it finishes and the transducer logic awaits and requires a completion event: - ```swift - static func refreshMovies() -> Effect { - run(id: "refresh") { input, env in - let result = await env.movieFetch() - try? input(.fetchMoviesCompletion(result)) // Do not use `try?` when dispatching completion events - } +```swift +import Transduce + +static func refreshMovies() -> Effect { + .task(id: "refresh") { input, env in + let result = await env.movieFetch() + try? input(.fetchMoviesCompletion(result)) // Do not use `try?` when dispatching completion events + } } ``` + In the case above, if event dispatch fails and the error is ignored (`try?`), the transducer will never receive a completion event. This might mean it stays in "loading" mode indefinitely and silently ignores any other event unless it sees the completion event. Thus, when the event cannot be dispatched, it is better to forward the failure into the system, that is, letting it throw the error: - ```swift - static func refreshMovies() -> Effect { - run(id: "refresh") { input, env in - let result = await env.movieFetch() - try input(.fetchMoviesCompletion(result)) - } + +```swift +import Transduce + +static func refreshMovies() -> Effect { + .task(id: "refresh") { input, env in + let result = await env.movieFetch() + try input(.fetchMoviesCompletion(result)) + } } ``` -The runtime now detects the error, treats it as a critical failure, and cancels the transducer. Now, the transducer "knows" it is in a failure mode, and any attempt to send events into it will fail early at the call site. +The runtime now detects the error, treats it as a critical failure, and cancels the transducer. Now, the transducer "knows" it is in a failure mode, and any attempt to send events into it will fail early at the call site. --- ## Async actions Async actions exist to model a bounded awaited step that must remain logically inside the current event chain. - -They are intentionally different from managed tasks. - +They are intentionally different from managed tasks An async action: - - runs inline as part of the current `compute(...)` chain - does not create its own task identity - does not participate in task overlap policies like `subscribe` or `switchToLatest` @@ -232,9 +275,8 @@ An async action: - must be followed by a cancellation re-check before its result is trusted This makes async actions suitable for short prerequisite steps such as: - - actor bootstrap before later events are allowed to proceed -- establishing an authorization token or capability handle +- executing the "initialisation pattern" - awaiting a bounded dependency precondition before the next event can be interpreted correctly Async actions are not the right tool for long-running background work, subscriptions, retry loops, or overlapping work that needs runtime-managed identity. Those belong to managed tasks. @@ -244,12 +286,10 @@ Async actions are not the right tool for long-running background work, subscript ## Post-suspension cancellation checks Because `control(...)` may intercept a suspended `compute(...)`, an awaited async action cannot simply resume and continue as if nothing happened. - After an async suspension point, `compute(...)` must re-check runtime cancellation through the runtime's central cancellation state before it: - - resumes a request continuation - feeds the returned event back into the loop -- mutates more state indirectly through another call to `update` +- mutates more state indirectly through another call to `transduce` The important detail is that this re-check should use the runtime's central cancellation mechanism rather than ad-hoc boolean tests. @@ -260,11 +300,8 @@ That preserves the latched shutdown reason and keeps the thrown error consistent ## Shutdown and failure authority The runtime needs one authoritative place for shutdown and latched failure state. - In the current implementation, much of that responsibility lives in `TaskManager`. A future runtime `Context` could own that state more directly while still preserving the same semantic contract. - Conceptually, this authority owns: - - managed task tracking - overlap policy for logically identified tasks - task waiter sets @@ -272,59 +309,53 @@ Conceptually, this authority owns: - the distinction between normal managed cancellation and fatal runtime failure This is why `checkCancellation()` is such a central primitive. It turns internal runtime state into a public execution rule: if the runtime is no longer accepting work, the current path must stop. - Keeping this authority centralized prevents the runtime from drifting into multiple slightly different notions of cancellation. --- ## Continuation ownership -Request continuations intentionally have asymmetric ownership. - -During regular synchronous computation, the continuation is owned by the current `compute(...)` frame. - -If the chain reaches a managed task, ownership transfers to `TaskManager`, which resumes the waiting caller when that task completes, fails, or is cancelled. +Request continuations intentionally have asymmetric ownership. During regular synchronous computation, the continuation is owned by the current compute(...) frame and is only resumed there when the chain settles. The effect interpreter (executeEffect) never resumes a continuation directly; it either returns it unchanged to compute or transfers it to TaskManager when scheduling managed work. +If the chain reaches a managed task, ownership transfers to TaskManager, which resumes the waiting caller when that task completes, fails, or is cancelled. +If compute(...) throws, the continuation is resumed by compute(...) with the thrown error; the caller does not need to handle it. +This rule is especially important for interrupted async actions: +• the async action resumes +• compute(...) re-checks cancellation +• compute(...) throws because the runtime was invalidated mid-flight +• compute(...) resumes the continuation with the error -If `compute(...)` throws, the continuation is not consumed by `compute(...)`. The caller that entered `compute(...)` remains responsible for resuming or failing it. +That ownership discipline avoids double-resume bugs and keeps failure propagation localized. -This rule is especially important for interrupted async actions: +## Effect interpretation and continuation passing -- the async action resumes -- `compute(...)` re-checks cancellation -- `compute(...)` throws because the runtime was invalidated mid-flight -- the outer caller maps or forwards that failure and resumes the continuation exactly once +• .event (a pure next-event effect): preserve the continuation and return the event to compute; compute will loop. +• .none (no-op): preserve the continuation and return no next event; compute decides whether the chain is terminal and resumes accordingly. +• Terminal actions (void): do not resume in executeEffect; return to compute with no next event so compute can settle and resume the continuation exactly once. +• Partial actions (event-returning): preserve the continuation and return the next event to compute. +• Managed tasks: transfer continuation ownership to TaskManager when a task is scheduled; compute no longer owns the continuation. -That ownership discipline avoids double-resume bugs and keeps failure propagation localized. --- ## Managed tasks and overlap semantics Managed tasks solve a different problem from async actions. - They represent asynchronous work that should be tracked by logical identifier and governed by overlap policy. - The runtime currently supports at least two overlap behaviors: - - `switchToLatest`: cancel the current physical task instance, keep the waiter set, and move those waiters to the replacement task -- `subscribe`: keep the running task and attach the new waiter to the existing logical work +- `shareable`: keep the running task and attach the new waiter to the existing logical work This model gives the runtime a principled answer to overlapping requests without requiring feature code to store task handles manually. - It also means the runtime can express request/response style behavior without forcing every transducer to implement its own queue or subscription bookkeeping in domain state. --- -## Why `control(...)` is a promising extension point +## Control plane extensibility The `compute(...)` / `control(...)` split does more than enable interruption. - It also creates a real control plane. - Because `control(...)` is runtime-facing and storage-safe, it can host future runtime features without polluting the domain event model. - Plausible extensions include: - - diagnostics and runtime introspection - fault injection in tests - tracing and instrumentation @@ -332,15 +363,13 @@ Plausible extensions include: - reporting current gate or task-manager state For example, a diagnostic control event could print or export current runtime context, including task-manager state, without pretending that diagnostics are part of the feature's domain event vocabulary. - This is one of the design's strongest architectural consequences: operational concerns get a dedicated channel with dedicated rules. --- -## Why this is a Swift-native design +## Swift language features This runtime model leans heavily on Swift's specific features: - - actor isolation provides serialization boundaries - structured concurrency expresses back pressure naturally as suspension - continuations bridge event-driven logic to async/await callers @@ -348,21 +377,85 @@ This runtime model leans heavily on Swift's specific features: - first-class closures make dependency injection through `Env` lightweight That combination makes it realistic to implement what is effectively an FSM effect actor without introducing a large framework or an elaborate supervisory architecture. - Other languages may need different primitives, especially if they lack actor isolation or typed continuation-style suspension. In Swift, this design maps naturally onto the language rather than fighting it. +--- + +## Architecture +This section addresses the question of how the Transduce library can support building and architecture. + +### Component-Oriented Observable Architecture in SwiftUI +In this pattern, one or more root SwiftUI views hold `@Observable` (or `@StateObject`) structs that act as **shared** repositories — alongside each view's own transient `@State` properties and the logic that mutates them. The observable objects do not merely carry data; they live across renders and are mutated imperatively by any view in the subtree that has been given a reference or injected through Env. This turns them into persistent state hubs at the top of a component tree whose individual components each carry their own ephemeral state on top. + +### The split-state pattern (observable-backed) — overview +A SwiftUI app built with `@Observable` classes distributes state across an **object graph** of independently evolving domain components rather than a single composed `AppState` tree. Each observable owns its own lifecycle, business logic, and side effects; root views wire a selection into `Env`, and descendant screens read snapshots and emit events imperatively. Views carry their own transient `@State` for ephemeral concerns, and composability flows through view composition rather than through a global reducer. + +A detailed treatment — including Redux/TCA comparison, naming considerations, state topology, and testability tradeoffs — is in [Component-Oriented Observable Architecture](Component-Oriented%20Observable%20Architecture.md). + +The runtime doesn't special-case this architecture: observers are injected through `Env`, accessed via actions and tasks, subject to actor isolation like any other subsystem. The developer must track **who owns final truth** about a shared observable root when more than one view writes to it, because the runtime has no mechanism to serialize writes or mediate contention between transducers on the same root. + +--- + +## Trade-offs + +### Event buffering vs. caller suspension + +The runtime chooses caller suspension over event buffering for back pressure. This means: + +- **Advantages**: Simpler mental model; callers use ordinary async/await; no buffer overflow policy to define +- **Disadvantages**: Callers may block while waiting for admission; less tolerance for bursty workloads +- **Limitations**: Not suitable for scenarios requiring decoupled producers and consumers + +### Async actions vs. managed tasks + +Async actions run inline with the current computation cycle, while managed tasks run concurrently: + +- **Async actions**: Good for short, bounded steps that must remain logically inside the current event chain; no task identity; cannot be cancelled independently +- **Managed tasks**: Good for long-running work, subscriptions, retry loops; have logical identifiers; support overlap policies; can be cancelled independently + +### Request vs. uniqueRequest + +`request` allows task sharing, while `uniqueRequest` establishes exclusive ownership: + +- **request**: Multiple callers can subscribe to the same in-flight task; cancellation of one caller doesn't stop the task +- **uniqueRequest**: Exclusive task ownership; caller cancellation stops all work; no interference from other subscribers + +### Compute gate serialization + +The compute gate prevents re-entry but adds latency: + +- **Advantages**: Single ordered mutation path for domain state; no race conditions on state +- **Disadvantages**: Blocked callers suspend; throughput limited by gate serialization +- **Limitations**: Not suitable for high-throughput scenarios where parallel state mutations are acceptable + + +--- + +## Response patterns + +The `response(state:event:)` function computes the terminal value for a settled request. There are two common patterns: + +| Pattern | Description | Example | +|---------|-------------|---------| +| **State-based** | Response depends only on current state | `return state.count` | +| **Event-based** | Response depends only on the terminal event | `return items` (where `items` comes from the event) | + +Less commonly, response may depend on both state and event (e.g., combining computed values with event payload). + +This gives the transducer hosts components a runtime that stays small in code size while still supporting async actions, request/response bridging with four dispatch methods (`post`, `send`, `request`, `uniqueRequest`), runtime-managed tasks with ownership semantics, immediate interruption, and future runtime control features. + + --- ## Testable consequences The following behaviors should remain pinned down by tests: - 1. `compute(...)` is not re-entered while another regular event chain is active. 2. A control event may cancel the runtime while `compute(...)` is suspended in an async action. 3. After that interruption, the suspended `compute(...)` frame does not continue processing returned events. 4. A request interrupted during an async action completes with the correct runtime failure semantics. 5. Managed task overlap policies continue to preserve waiter ownership correctly. -6. Control events never mutate transducer storage. +6. Control events never mutate transducer state. These tests are not implementation details. They are executable statements of the design. @@ -371,12 +464,8 @@ These tests are not implementation details. They are executable statements of th ## Summary The runtime is intentionally built around four ideas: - 1. regular event reduction is serialized through gated `compute(...)` 2. runtime control is separated into ungated `control(...)` 3. caller suspension provides the primary back pressure mechanism 4. `TaskManager` centralizes shutdown and task-lifecycle semantics -This gives EffectComponents a runtime that stays small in code size while still supporting async actions, request/response bridging, runtime-managed tasks, immediate interruption, and future runtime control features. - -The design is intentional, not accidental. diff --git a/Documentation/SwiftUIFirst.md b/Documentation/SwiftUIFirst.md index 430ff42..aae24f8 100644 --- a/Documentation/SwiftUIFirst.md +++ b/Documentation/SwiftUIFirst.md @@ -37,10 +37,10 @@ With EffectView, state is a Swift value type (`struct` or `enum`) owned by the c Logic lives in the transducer's transition function: ```swift -static func update( +static func transduce( _ state: inout State, event: Event -) -> Effect? +) -> Effect ``` This function is not an object. It has no stored properties, no lifecycle, and no hidden shared state. It is easier to read, easier to test, and easier to trace than a ViewModel — and it does more, because it explicitly models every side effect as a return value rather than as a fire-and-forget call inside an async method. @@ -53,7 +53,7 @@ This function is not an object. It has no stored properties, no lifecycle, and n Dependency injection frameworks exist to solve one problem: getting concrete implementations of services into the code that needs them, without coupling the two directly. SwiftUI's `@Environment` already does this. It is hierarchical, it propagates automatically, and it can be overridden at any level of the view tree. -EffectComponents connects to it through `EnvReader` — a four-line wrapper around `@Environment`. No registration, no container, no reflection, no macros. +EffectComponents connects to it through `EnvReader` — a thin wrapper around `@Environment` provided in the example app. No registration, no container, no reflection, no macros. Dependencies are declared as structs of closures in the feature module. Concrete implementations are assigned in a single `EnvironmentValues` extension in the glue layer. Test doubles are struct literals. @@ -67,8 +67,8 @@ Combine was the bridge between async work and `@Published` properties on the mai | Concern | Solution | Article | |---|---|---| -| Async task management | Identified, cancellable tasks via `Effect.run` | [Taming async tasks in SwiftUI views](TamingAsyncTasksInSwiftUIViews.md) | -| Correctness and logic | FSM update function, impossible states unrepresentable | [Correct by Construction](CorrectByConstruction.md) | +| Async task management | Identified, cancellable tasks via `.task` | [Taming async tasks in SwiftUI views](TamingAsyncTasksInSwiftUIViews.md) | +| Correctness and logic | FSM transduce function, impossible states unrepresentable | [Correct by Construction](CorrectByConstruction.md) | | Dependency injection | Struct of closures + SwiftUI environment + `EnvReader` | [Using Env for Dependency Injection](UsingEnvForDependencyInjection.md) | --- @@ -93,7 +93,7 @@ Combine was the bridge between async work and `@Published` properties on the mai │ State (value type) ──────────────────────► Content │ │ Events ◄──────────────────────────────────── Content │ │ │ -│ event ──► update(state:event:) ──► Effect ──► Task │ +│ event ──► transduce(state:event:) ──► Effect ──► Task │ │ │ │ │ │ └── mutates state │ │ │ │ │ @@ -109,7 +109,7 @@ Combine was the bridge between async work and `@Published` properties on the mai A single feature follows the same pattern at any size: -- **Small screen** (a toggle that triggers a task): one `@State`, a two-case enum, a five-line `update` function. +- **Small screen** (a toggle that triggers a task): one `@State`, a two-case enum, a five-line `transduce` function. - **Large screen** (a feed with pagination, search, filters, and pull-to-refresh): the same pattern with more states and events. The shape doesn't change. Features compose by nesting `EffectView`s inside each other, with each one owning its slice of state and its slice of the environment. There is no shared mutable class to coordinate, no global event bus, and no parent ViewModel that aggregates child state. @@ -118,7 +118,7 @@ Features compose by nesting `EffectView`s inside each other, with each one ownin ## The trade-off -EffectView asks you to think in terms of states and events rather than imperative sequences. For developers used to writing `await someMethod()` directly in a button action, the indirection through events and an update function can feel unfamiliar at first. +EffectView asks you to think in terms of states and events rather than imperative sequences. For developers used to writing `await someMethod()` directly in a button action, the indirection through events and an transduce function can feel unfamiliar at first. The payoff is that the question "what happens when the user taps this button while something is already loading?" always has an explicit, readable answer — it's a case in the `switch`. There are no implicit races, no unintended concurrency, and no hidden shared mutable state. The behaviour of the whole screen is the content of one function. @@ -126,10 +126,3 @@ That function requires no framework to test, no mocking library, and no async te --- -## Getting started - -```swift -.package(url: "https://github.com/couchdeveloper/EffectComponents.git", from: "0.1.0") -``` - -Start with the simplest case — one state enum, one event enum, one `update` function — and expand from there. The pattern is the same at every scale. diff --git a/Documentation/TamingAsyncTasksInSwiftUIViews.md b/Documentation/TamingAsyncTasksInSwiftUIViews.md index 9a85b96..32bb715 100644 --- a/Documentation/TamingAsyncTasksInSwiftUIViews.md +++ b/Documentation/TamingAsyncTasksInSwiftUIViews.md @@ -2,122 +2,65 @@ SwiftUI's `.task` modifier is a convenient way to start async work when a view appears. For simple cases — fire a fetch on load, cancel it when the view disappears — it works well. But as soon as your requirements grow slightly more complex, you start running into walls. -This article walks through those walls one by one, and shows how `EffectView` addresses each of them. +This article walks through those limitations and shows how `EffectView` addresses each of them. --- -## The fundamental tension: event-driven systems and async/await +## Motivation -`EffectView` is an event-driven system. State only changes in response to an event processed by `update`. This is the property that makes state transitions auditable, testable, and race-free. +SwiftUI's `.task` modifier is built around rendering semantics: tasks start when a view appears and cancel when it disappears. The `id:` parameter controls restarts, but only by cancelling and immediately restarting — there is no way to cancel without restarting. -But event-driven systems have a structural limitation: **you cannot directly `await` a logical operation**. You can only fire an event and move on: +These rendering-driven semantics don't align with application logic. Real-world requirements include: -```swift -try input.post(.fetch) -// ... that's it. The event is scheduled, but there is still -// no completion signal for the logical operation. -``` - -The result of the fetch arrives later, indirectly, as a state change triggered by a `.loaded` or `.loadFailed` event. If dispatch itself fails, you learn that immediately. But to know when the operation is complete, you still have to observe state — which is cumbersome every time you need to bridge between the event-driven world and a caller that expects `async`/`await` semantics. - -This friction shows up acutely with SwiftUI's `.refreshable` modifier. It needs something to `await` — a suspension that holds the system refresh spinner until the work is genuinely done. An event-driven system has no natural answer for this. Sending `.refresh` returns immediately; the spinner would dismiss before the data has arrived. - -The same problem appears anywhere a caller needs to know when an event's consequences have fully settled: orchestrating multi-step flows in tests, chaining operations in response to user gestures, or coordinating with any async API that expects a completion signal. - -### Bridging the gap: three dispatch strategies - -`Input` provides three methods that give you precise control over how much of the event chain you wait for: - -```swift -try input.post(.loaded(items)) // fire-and-forget: schedules event, returns immediately -try await input.send(.loaded(items)) -// wait until update() has run -try await input.request(.loaded(items)) -// wait until update() *and all resulting effects* settle -``` - -`request` is the full bridge. It threads a continuation through the entire effect chain — if `.loaded` returns another effect, and that effect eventually completes, `request` resumes only after all of it has settled. The call site reads like ordinary async/await code while the FSM continues to own all state mutations: - -```swift -// In a .refreshable block — the spinner holds until the full load cycle is complete: -try? await input.request(.refresh) - -// In a test — assert state only after the operation has fully settled: -try await input.request(.load) -#expect(state.items.count == 20) -``` - -This is what makes the three strategies genuinely powerful — not the methods themselves, but that they let you **express synchronisation intent explicitly at the call site**, selecting exactly how much of the event-driven world you need to wait for, without changing anything else about the system. +- **Cancel-only operations** — a Stop button should halt work without triggering a new run +- **Dynamic concurrency** — one upload per selected file, one prefetch per visible row +- **Logical identity** — task lifetime tied to the view's logical identity, not individual renders +- **Automatic cancellation** — starting new work with an existing identifier should cancel the previous run +- **Full settlement** — `refreshable` spinner should stay visible until the entire effect chain completes --- -## The `.task` modifier and its limits - -### Task lifetime is tied to rendering, not logic - -The `.task` modifier cancels and restarts based on two things: the view appearing/disappearing, and changes to the `id:` parameter. Both are driven by SwiftUI's rendering engine — not by your application logic. +## Requirements -This means a task can be cancelled because a parent view re-rendered and changed the view's identity, even if you didn't intend any logical restart. Conversely, there is no way to keep a task running across a navigation push and pop, because the view disappears. +The requirements that fall out of real apps are: -### The `id:` parameter re-cancels *and* restarts — there is no cancel-only - -A common pattern for debounced search is: - -```swift -ContentView() -.task(id: query) { - try? await Task.sleep(for: .milliseconds(300)) - guard !Task.isCancelled else { return } - results = await search(query) -} -``` - -This works as long as you understand that changing `query` *always* restarts the task — including immediately after the delay fires. There is no way to say "cancel the running task but don't start a new one." That asymmetry makes some patterns — like a Stop button that simply halts work without triggering a new run — awkward to express. - -### The number of concurrent tasks is fixed at compile time - -Each `.task` modifier owns exactly one task slot. If you need to run a variable number of concurrent operations — one upload per selected file, one prefetch per visible row, one sync per connected device — you cannot express that with modifiers alone. - -The usual workaround is to reach for a ViewModel that holds an array of `Task` handles and manages them imperatively. That's already a signal that you've outgrown the primitive. - -### Explicit cancellation on user intent is not straightforward - -If the user taps a Cancel button, you want to stop the running task immediately. With `.task`, there is no handle to call `cancel()` on. The modifier owns the task and exposes no cancellation API. The workarounds involve either changing the `id:` value (which also restarts), or storing a `Task` handle externally — at which point you're managing task lifetime manually, outside of SwiftUI's model. - -### Coordination between tasks is manual - -Two `.task` modifiers on the same view run independently. If one depends on the result of the other, or if they must not run simultaneously, you need to coordinate them yourself — through shared state, flags, or actor isolation. There is no built-in sequencing. +| # | Requirement | Description | +|---|-------------|-------------| +| 1 | Logical identity | Task lifetime tied to the view's logical identity, not to individual renders | +| 2 | Explicit cancellation | Tasks can be cancelled by identifier from any event — a button tap, a timeout, a competing task starting | +| 3 | Dynamic concurrency | A variable number of identified tasks can run concurrently | +| 4 | Automatic cancel-and-restart | Starting new work with an identifier that's already running automatically cancels the previous one — no manual bookkeeping | +| 5 | Ordered mutations | Results feed back into the view through a single, ordered mutation point — no scattered `@State` writes racing each other | +| 6 | Full settlement | The `refreshable` spinner stays visible until the full effect chain completes — not just until the first `await` | --- -## What's actually needed +## Core concepts -Stepping back, the requirements that fall out of real apps are: +**Transducer** — A protocol defining `State`, `Event`, and `Effect` types. The `transduce(&state, event)` function is a pure reducer that mutates state synchronously and returns an optional effect describing follow-up work. -1. Task lifetime is tied to the **view's logical identity**, not to individual renders. -2. Tasks can be **cancelled by identifier** from any event — a button tap, a timeout, a competing task starting. -3. A **dynamic number** of identified tasks can run concurrently. -4. Starting new work with an identifier that's already running **automatically cancels the previous one** — no manual bookkeeping. -5. Results feed back into the view through a **single, ordered mutation point** — no scattered `@State` writes racing each other. -6. The `refreshable` spinner stays visible until the **full effect chain completes** — not just until the first `await`. +**Effect** — A declarative description of work: `.task` for async operations, `.cancel` to stop tracked tasks, `.action` for synchronous work, or `.none` to settle. -`EffectView` is a small SwiftUI wrapper that provides exactly these primitives. +**TaskManager** — Runtime component that manages identified tasks with cancellation and overlap policies (`.switchToLatest`, `.shareable`). ---- +**Dispatch strategies** — Four methods on `Input` that control how much of the event chain you wait for: -## The solution +| Method | Waits for | Use case | +|--------|-----------|----------| +| `post(_:)` | Event scheduling only | Fire-and-forget signals where ordering doesn't matter | +| `send(_:)` | `transduce()` completion | Synchronous state updates, no nested effects | +| `request(_:)` | Full effect chain settlement | When caller needs guaranteed completion (e.g., `refreshable`) | +| `uniqueRequest(_:)` | Exclusive execution with cancellation | When only the latest request matters | -`EffectView` separates concerns cleanly: +See [EffectsReference.md](EffectsReference.md) for complete effect types, and [Recipes.md](Recipes.md) for common patterns. -- **`update`** — a pure function `(inout State, Event) -> Effect?`. All state mutations happen here. No async, no throwing, just a switch. -- **`Effect`** — what the view asks the runtime to do next: start tracked work, cancel tracked work, fire a synchronous action chain, or a sequence of the above. -- **`Input`** — how async work sends events back into the update loop. +--- -Tasks are identified by logical identifiers at runtime, owned by the `EffectView` for its identity lifetime, and cancelled automatically when the view disappears. +## Design ### `refreshable` that actually waits -`request(_:)` suspends the caller until the full effect chain — including any task that runs and sends events back — has completed. This makes it a natural fit for `refreshable`: +The most common pain point with SwiftUI's `.task` is that the `refreshable` spinner dismisses before async work completes. `request(_:)` solves this by suspending until the entire effect chain settles: ```swift List(state.items, id: \.self) { Text($0) } @@ -127,42 +70,51 @@ List(state.items, id: \.self) { Text($0) } } ``` -`.refresh` is a plain event. The work is a task returned from `update`: +The `.refresh` event triggers a task that fetches data and returns it via the `response(state:event:)` function: ```swift -case .refresh: - return .task(id: "refresh") { input, env in - do { - let items = try await env.fetch() - return try await input.request(.loaded(items)) - } catch { - return try await input.request( - .loadFailed(error.localizedDescription) - ) +enum Feature: Transducer { + struct State { var items: [String] = [] } + enum Event { case refresh; case loaded([String]) } + typealias Response = [String] + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .refresh: + return .task(id: "refresh", .switchToLatest) { input, env -> TaskReturn in + let items = try await env.fetchItems() + return .response(.loaded(items)) + } + + case .loaded(let items): + state.items = items + return .none } } -``` -The three dispatch strategies on `Input` differ in what they wait for: - -- **`try input.post(.loaded(items))`** — schedules the event on the `@MainActor` and returns immediately. The task closure exits before `update` processes `.loaded(items)`, so the outer `request(.refresh)` continuation resumes before the state is updated. The spinner disappears too early. - -- **`try await input.send(.loaded(items))`** — hops to the `@MainActor` and runs `update(.loaded(items))` synchronously before returning. The state is updated before the closure exits. However, `send` passes a `nil` continuation, so if `.loaded` itself returns an effect — another task, an action chain — that effect's completion is not awaited. The closure exits as soon as `update` returns, regardless of what the effect does next. - -- **`try await input.request(.loaded(items))`** — threads the outer continuation through the entire effect chain triggered by `.loaded(items)`. The closure only exits once `update` has run *and* any effect it returned has fully settled. This is the correct choice here: it handles the simple case identically to `send`, and correctly extends the wait if `.loaded` ever grows to return an effect of its own. + static func response(state: State, event: Event) -> Response { + switch event { + case .refresh: + return state.items + case .loaded(let items): + return items + } + } +} +``` -The rule of thumb: use `request` when the result needs to be complete before the caller resumes; use `post` for fire-and-forget signals where ordering doesn't matter. +The task returns `.response(.loaded(items))`, which the TaskManager handles by calling `runtime.response(event: .loaded(items))`. This directly invokes `Transducer.response(state:event:)` (a pure function) and resumes the waiting `request()` caller with the result. ### Cancel on user intent -Cancellation is a first-class event returned from `update`: +Cancellation is a first-class effect returned from `transduce`: ```swift case .cancelTapped: - return cancel("fetch") + return .cancel("fetch") ``` -That's it. No stored `Task` handle, no flag, no `id:` dance. +No stored `Task` handle, no flag, no `id:` dance. ### Dynamic number of tasks @@ -170,19 +122,13 @@ Because task identifiers can be created at runtime, you can start as many tasks ```swift case .startDownload(let id): - return .task(id: "download-\(id)") { input, env in - do { - let data = try await env.download(id) - try input.post(.downloaded(id, data)) - } catch { - try input.post( - .downloadFailed(id, error.localizedDescription) - ) - } + return .task(id: "download-\(id)") { input, env -> TaskReturn in + let data = try await env.download(id) + return .response(.downloaded(id, data)) } case .cancelDownload(let id): - return cancel("download-\(id)") + return .cancel("download-\(id)") ``` No ViewModel, no array of handles, no manual lifecycle. @@ -194,24 +140,23 @@ Starting a task whose identifier is already running cancels the previous run fir ```swift case .queryChanged(let q): state.query = q - return .task(id: "search") { input, env in + return .task(id: "search") { input, env -> TaskReturn in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } let results = await env.search(q) - try input.post(.resultsLoaded(results)) + return .response(.resultsLoaded(results)) } ``` --- -## Adding EffectComponents to your project +## Trade-offs -```swift -// Package.swift -.package(url: "https://github.com/couchdeveloper/EffectComponents.git", from: "0.1.0") -``` +This approach trades conceptual simplicity for runtime flexibility. SwiftUI's `.task` is easier to understand for simple cases, but breaks down as requirements grow. `EffectView`'s runtime-managed tasks add a layer of indirection but provide precise control over task lifetime and cancellation. + +The main limitation is that tasks are managed by the runtime — you cannot directly access a `Task` handle. This is intentional: it prevents external code from bypassing the event-driven model by mutating state outside of `transduce`. -The library is around 200 lines of source — a focused primitive, not a framework. +For a deeper dive into the runtime design, including how `response(state:event:)` works with the TaskManager and request continuations, see [RuntimeDesign.md](RuntimeDesign.md). --- diff --git a/Documentation/UsingEnvForDependencyInjection.md b/Documentation/UsingEnvForDependencyInjection.md index a10053c..d9208db 100644 --- a/Documentation/UsingEnvForDependencyInjection.md +++ b/Documentation/UsingEnvForDependencyInjection.md @@ -4,24 +4,101 @@ The [previous article](CorrectByConstruction.md) showed how to model state and l --- -## The problem +## Motivation -Tasks in the update function need to call services. The obvious approaches all have friction: +When a component needs to perform side effects—network requests, database access, analytics tracking—it must call external services. To keep the core logic pure and testable, you need a way to provide these dependencies from outside the component. -- **Capture from the outer scope** — works for simple cases, but the captured value is fixed at view creation; and `EffectView` explicitly ignores `Env` changes after the first appearance to avoid mid-flight races. You need a principled way to inject dependencies, not accidental captures. -- **Protocol-based injection** — requires existentials or generics that propagate upward through every layer, making the call site and the `EffectView` signature more complex than necessary. -- **Singleton / static access** — untestable; you can't swap a live service for a test double without global mutable state. +The `Env` pattern solves this by acting as an **abstraction layer** that connects your component logic to any existing dependency container. It doesn't impose a specific DI framework; instead, it works with whatever mechanism you already use—SwiftUI's environment, constructor injection, service locator, etc. -EffectView's `Env` type parameter solves this cleanly: the value is captured once at view creation, forwarded to every effect, and can be swapped wholesale for testing. +For example, SwiftUI's environment works like this: + +1. **SwiftUI provides the environment** with values (like `httpClient`) +2. **Views declare what they need** using `@Environment` properties +3. **The view body reads the value** through the environment property + +```swift +struct MyView: View { + @Environment(\.httpClient) var httpClient + + var body: some View { + Button("Load") { + Task { + let data = try await httpClient.get("/api/items") + } + } + } +} +``` + +The `httpClient` value comes from SwiftUI's environment, but the view doesn't know—or care—where it was provided from. It simply accesses it through the environment abstraction. + +With EffectView, you use the same pattern: **Env is how you inject environment values from outside into your transducer**. When using SwiftUI, you don't invent a different DI mechanism—you use the existing SwiftUI environment, populate the `Env` struct from environment values, and pass it to EffectView. + +The flow is: +1. SwiftUI provides dependencies through its environment (via `@Entry` or custom `EnvironmentKey`) +2. Your view reads those values using `@Environment` or `EnvReader` +3. You populate an `Env` struct with those values and pass it to EffectView via `initialEnv:` +4. The transducer accesses dependencies through the `Env` struct without knowing their source + +This way, your transducer logic stays pure and testable, while still being able to access real services from any DI mechanism. + +### Example: Using Env with EffectView and EnvReader view: + +```swift +struct MovieSearchView: View { + @State private var state = MovieSearchState.idle + + var body: some View { + EnvReader(\.movieSearchEnv) { env in + EffectView( + of: MovieSearchLogic.self, + state: $state, + initialEnv: env + ) { state, input in + MovieSearchContent(state: state, send: input) + } + } + } +} +``` + +--- + +## Core Concepts + +### Env + +`Env` is a struct of closures or static properties that the transducer uses to access services. It's captured once at host initialization and forwarded to every effect. + +**Env is not a DI framework.** It's the abstraction layer that lets you use *whatever DI mechanism you choose* — SwiftUI's environment, constructor injection, service locator, etc. The transducer only knows about the `Env` struct; it doesn't care how that struct was populated. + +### EnvReader + +`EnvReader` is an optional convenience utility that reads a value from the SwiftUI environment and passes it to `EffectView` via `initialEnv:`. It's a thin wrapper around `@Environment` for ergonomics at the `EffectView` call site, but you can also populate the `Env` struct manually without using `EnvReader`. + +### Architectural Layers + +| Layer | Responsibility | Knows about | +|-------|----------------|-------------| +| **Feature** | State, events, transitions, action closure types | Own types only | +| **View** | SwiftUI layout, `EffectView` wiring, `EnvReader` | Feature types | +| **Glue** | `EnvironmentValues` extension, concrete service instances | Feature + infrastructure | +| **Infrastructure** | Network clients, databases, analytics SDKs | Own types only | + +The feature module declares *what* it needs (closure types). The glue layer decides *what provides it* (concrete instances). The two never meet directly. --- -## Step 1: Declare the service API in the View layer +## Instance Env: Closures Captured at Runtime + +This is the default pattern. `Env` is a struct of instance properties (closures) that are captured at runtime. + +### Step 1: Declare the service API in the Feature layer The service API lives with the feature, not with the infrastructure. You declare it as a struct of closures — no protocol, no generic parameter. ```swift -// MovieSearch/MovieSearchActions.swift (View layer) +// MovieSearch/MovieSearchActions.swift (Feature layer) import Foundation @@ -39,14 +116,12 @@ Using a struct of closures instead of a protocol has several advantages: - **Test doubles are a literal.** Replacing a closure value requires a single-line assignment; no mock class needed. - **The caller decides the contract.** The feature defines precisely what it needs — not what the service is capable of. ---- - -## Step 2: Build the Env struct +### Step 2: Build the Env struct -`Env` is the container EffectView captures and forwards to every task and action: +`Env` is the container that `EffectView` captures and forwards to every task and action: ```swift -// MovieSearch/MovieSearchEnv.swift (View layer) +// MovieSearch/MovieSearchEnv.swift (Feature layer) struct MovieSearchEnv: Sendable { var actions: MovieSearchActions @@ -64,39 +139,35 @@ struct MovieSearchEnv: Sendable { Either layout works. The struct-of-structs pattern scales better when several features share a dependency group. -The transducer's `update` requirement now becomes: - -```swift -static func update( - _ state: inout MovieSearchState, - event: MovieSearchEvent -) -> Effect? -``` +### Step 3: Use Env in the transducer -And inside a task, `env` is simply the injected value: +Inside a task, `env` is simply the injected value: ```swift -case (_, .searchTapped(let query)): - state = .loading(query: query) - return .sequence([ - .cancel("search"), - .task(id: "search") { input, env in - env.trackQuery(query) - do { - let movies = try await env.search(query) - try input.post(.resultsReceived(movies)) - } catch { - try input.post(.requestFailed(error.localizedDescription)) +static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (_, .searchTapped(let query)): + state = .loading(query: query) + return .sequence([ + .cancel("search"), + .task(id: "search") { input, env -> TaskReturn in + env.trackQuery(query) + do { + let movies = try await env.search(query) + return .response(.resultsReceived(movies)) + } catch { + return .response(.requestFailed(error.localizedDescription)) + } } - } - ]) + ]) + // ... + } +} ``` -The update function itself never imports the network module. It references `env.search` — a closure — whose concrete implementation is provided from outside. +The transducer function itself never imports the network module. It references `env.search` — a closure — whose concrete implementation is provided from outside. ---- - -## Step 3: Inject via the SwiftUI environment +### Step 4: Inject via the SwiftUI environment SwiftUI's environment is the right place to propagate dependencies: it's already hierarchical, it reaches every view without threading values through every init, and it integrates naturally with `EnvReader`. @@ -146,9 +217,7 @@ MovieSearchView() )) ``` ---- - -## Step 4: Read the environment and wire up EffectView +### Step 5: Wire up EffectView `EnvReader` reads a value from the SwiftUI environment and makes it available as a closure parameter. Use it to bridge the environment into `EffectView`: @@ -172,13 +241,11 @@ struct MovieSearchView: View { } ``` -`EnvReader` is a thin wrapper around `@Environment`; it exists purely for ergonomics at the `EffectView` call site. The value it captures is passed to `initialEnv:`, and `EffectView` takes ownership from there — forwarding it to every `.run`, `.request`, and `.action` for the lifetime of the view. +`EnvReader` is a thin wrapper around `@Environment`; it exists purely for ergonomics at the `EffectView` call site. The value it captures is passed to `initialEnv:`, and `EffectView` takes ownership from there — forwarding it to every `.task`, `.request`, and `.action` for the lifetime of the view. Note that the transducer type (`MovieSearchLogic.self`) is passed directly rather than constructing an inline closure. This keeps the view body free of logic and makes the transition function easily findable and independently testable. ---- - -## Step 5: Testing with no mocking framework +### Step 6: Testing with no mocking framework Because `Env` is a struct of closures, constructing a test double is constructing a value: @@ -194,7 +261,7 @@ struct MovieSearchTransitionTests { @Test func searchTappedTransitionsToLoading() { var state = MovieSearchState.idle - let effect = MovieSearchLogic.update(&state, event: .searchTapped(query: "inception")) + let effect = MovieSearchLogic.transduce(&state, event: .searchTapped(query: "inception")) #expect(state == .loading(query: "inception")) #expect(effect != nil) @@ -204,28 +271,190 @@ struct MovieSearchTransitionTests { var state = MovieSearchState.loading(query: "inception") let movies = try await testEnv.search("inception") - _ = MovieSearchLogic.update(&state, event: .resultsReceived(movies)) + _ = MovieSearchLogic.transduce(&state, event: .resultsReceived(movies)) #expect(state == .loaded(query: "inception", results: movies)) } } ``` -The state-transition tests don't involve `Env` at all — they call `update` directly and check the resulting state. The `Env` is only needed in the integration tests that exercise a complete event-effect-event cycle, and there it is a plain struct literal with no framework overhead. +The state-transition tests don't involve `Env` at all — they call `transduce` directly and check the resulting state. The `Env` is only needed in the integration tests that exercise a complete event-effect-event cycle, and there it is a plain struct literal with no framework overhead. --- -## Layering summary +## Static Env: Properties Resolved at Compile Time -| Layer | Responsibility | Knows about | -|---|---|---| -| **Feature** | State, events, transitions, action closure types | Own types only | -| **View** | SwiftUI layout, `EffectView` wiring, `EnvReader` | Feature types | -| **Glue** | `EnvironmentValues` extension, concrete service instances | Feature + infrastructure | -| **Infrastructure** | Network clients, databases, analytics SDKs | Own types only | +This is an optimization pattern. `Env` is a struct of **static properties** that conform to protocols. The compiler resolves these at build time, eliminating existential containers and enabling aggressive optimization. -The feature module declares *what* it needs (closure types). The glue layer decides *what provides it* (concrete instances). The two never meet directly. +### Example: Static Env pattern -This is dependency injection without a framework, without reflection, and without protocols. The only mechanism is function values — which Swift has had since day one. +```swift +// Define a protocol for the service interface +protocol SearchService { + associatedtype Output + static func search(_ query: String) async throws -> [Output] +} + +// Static Env with static properties (no existential) +struct MovieSearchEnv: Sendable { + static var search: some SearchService { MovieService.live } + static var trackQuery: (String) -> Void { Analytics.live.track } +} +``` + +### Usage in the transducer + +```swift +static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (_, .searchTapped(let query)): + state = .loading(query: query) + return .sequence([ + .cancel("search"), + .task(id: "search") { input, env -> TaskReturn in + env.trackQuery(query) + do { + // Static dispatch: no existential, direct call + let movies = try await env.search.search(query) + return .response(.resultsReceived(movies)) + } catch { + return .response(.requestFailed(error.localizedDescription)) + } + } + ]) + // ... + } +} +``` + +Note the extra `.search` in `env.search.search(query)` — the first accesses the static property, the second calls the protocol method. + +### Comparison: Instance vs Static Env + +| Aspect | Instance Env (closures) | Static Env (static properties) | +|--------|-------------------------|--------------------------------| +| **Runtime overhead** | Small (existential container) | Zero (direct static dispatch) | +| **Flexibility** | High (swap at runtime) | Low (fixed at compile time) | +| **Testability** | Easy (swap closures in tests) | Requires build configuration or conditional compilation | +| **Use case** | Most features, runtime swapping needed | Core infrastructure, performance-critical paths | +| **Implementation** | `var search: (String) async throws -> [Movie]` | `static var search: some SearchService` | + +### When to use Static Env + +Choose the static pattern when: + +1. **Performance is critical** — eliminate existential overhead in hot paths +2. **Dependencies are truly global** — no need to swap implementations at runtime +3. **Build-time configuration is sufficient** — different builds (debug/release) can use different implementations +4. **You want maximum compiler optimization** — static dispatch enables inlining and devirtualization + +### Testing with Static Env + +For testing, you can use Swift's build configuration: + +```swift +#if DEBUG +extension MovieSearchEnv { + static var search: some SearchService { MockMovieService } +} +#else +extension MovieSearchEnv { + static var search: some SearchService { MovieService.live } +} +#endif +``` + +Or use a test-specific build setting to swap the implementation: + +```swift +// In your test target's build settings: +// SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST_ENV + +extension MovieSearchEnv { + #if TEST_ENV + static var search: some SearchService { MockMovieService } + #else + static var search: some SearchService { MovieService.live } + #endif +} +``` + +### Layering with Static Env + +The static pattern works best when your infrastructure layer is organized around protocols with associated types or concrete implementations: + +```swift +// Infrastructure/MovieService.swift +struct MovieService: SearchService { + static func search(_ query: String) async throws -> [Movie] { + // Real network call + } +} + +struct MockMovieService: SearchService { + static func search(_ query: String) async throws -> [Movie] { + // Test data + } +} +``` + +--- + +## Trade-offs + +### Instance Env (closures) + +**Advantages:** +- Runtime flexibility — swap implementations at any time +- Easy testing — test doubles are struct literals +- No protocol conformance required — just closures + +**Disadvantages:** +- Small runtime overhead — existential containers for closures +- Closure capture semantics — reference types in Env may mutate + +**Limitations:** +- Not suitable for performance-critical hot paths + +### Static Env (static properties) + +**Advantages:** +- Zero runtime overhead — direct static dispatch +- Maximum compiler optimization — enables inlining and devirtualization +- No existential containers + +**Disadvantages:** +- Compile-time fixed — no runtime swapping +- Testing requires build configuration +- More complex setup — requires protocols with static requirements + +**Limitations:** +- Only suitable when dependencies are truly global +- Not suitable when you need to swap implementations at runtime + +--- + +## Summary + +| Concern | Where it lives | Characteristic | +|---------|----------------|----------------| +| **Env definition** | Feature layer | Struct of closures or static properties | +| **Injection point** | Glue layer | `EnvironmentValues` extension | +| **View wiring** | View layer | `EnvReader` → `EffectView(initialEnv:)` | +| **Service access** | Transducer | `env.search(query)` or `env.search.search(query)` | + +**Choose Instance Env when:** +- You need runtime flexibility +- Testing is a priority +- Performance is not critical + +**Choose Static Env when:** +- Performance is critical +- Dependencies are truly global +- Build-time configuration is acceptable + +Both patterns integrate seamlessly with EffectView's `Env` mechanism. Choose based on your performance requirements and swapping needs. + +--- -*Next: [Bridging event-driven and imperative code](BridgingEventDrivenAndImperative.md)* +*Previous: [Correct by construction](CorrectByConstruction.md) | Next: [Bridging event-driven and imperative code](BridgingEventDrivenAndImperative.md)* diff --git a/EffectView.code-workspace b/EffectView.code-workspace deleted file mode 100644 index 876a149..0000000 --- a/EffectView.code-workspace +++ /dev/null @@ -1,8 +0,0 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": {} -} \ No newline at end of file diff --git a/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj index 7277d50..fad0491 100644 --- a/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj +++ b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ A1C326A62FD21F7C00393BD8 /* EffectComponents in Frameworks */ = {isa = PBXBuildFile; productRef = A1C326A52FD21F7C00393BD8 /* EffectComponents */; }; + A1EC99A02FEB35CD005FA9C0 /* Transduce in Frameworks */ = {isa = PBXBuildFile; productRef = A1EC999F2FEB35CD005FA9C0 /* Transduce */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -46,6 +47,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A1EC99A02FEB35CD005FA9C0 /* Transduce in Frameworks */, A1C326A62FD21F7C00393BD8 /* EffectComponents in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -114,6 +116,7 @@ name = EffectViewExample; packageProductDependencies = ( A1C326A52FD21F7C00393BD8 /* EffectComponents */, + A1EC999F2FEB35CD005FA9C0 /* Transduce */, ); productName = SimpleEffectView; productReference = A13E260F2FA6014E00C324E5 /* EffectViewExample.app */; @@ -192,7 +195,7 @@ mainGroup = A13E26062FA6014E00C324E5; minimizedProjectReferenceProxies = 1; packageReferences = ( - A1C326A42FD21F7C00393BD8 /* XCLocalSwiftPackageReference "../../../EffectComponents" */, + A1EC999E2FEB35CD005FA9C0 /* XCLocalSwiftPackageReference "../../../EffectComponents" */, ); preferredProjectObjectVersion = 77; productRefGroup = A13E26102FA6014E00C324E5 /* Products */; @@ -581,7 +584,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - A1C326A42FD21F7C00393BD8 /* XCLocalSwiftPackageReference "../../../EffectComponents" */ = { + A1EC999E2FEB35CD005FA9C0 /* XCLocalSwiftPackageReference "../../../EffectComponents" */ = { isa = XCLocalSwiftPackageReference; relativePath = ../../../EffectComponents; }; @@ -592,6 +595,10 @@ isa = XCSwiftPackageProductDependency; productName = EffectComponents; }; + A1EC999F2FEB35CD005FA9C0 /* Transduce */ = { + isa = XCSwiftPackageProductDependency; + productName = Transduce; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A13E26072FA6014E00C324E5 /* Project object */; diff --git a/Examples/EffectViewExample/EffectViewExample/App.swift b/Examples/EffectViewExample/EffectViewExample/App.swift index e0e18cf..e12e1e4 100644 --- a/Examples/EffectViewExample/EffectViewExample/App.swift +++ b/Examples/EffectViewExample/EffectViewExample/App.swift @@ -10,6 +10,10 @@ struct EffectViewExampleApp: App { .tabItem { Label("Counter", systemImage: "plus.forwardslash.minus") } + Counters.Views.ContentView() + .tabItem { + Label("Counters", systemImage: "plus.forwardslash.minus") + } Movies.Views.ContentView() .tabItem { diff --git a/Examples/EffectViewExample/EffectViewExample/Counter.swift b/Examples/EffectViewExample/EffectViewExample/Counter.swift index 9346f81..1a6bef0 100644 --- a/Examples/EffectViewExample/EffectViewExample/Counter.swift +++ b/Examples/EffectViewExample/EffectViewExample/Counter.swift @@ -1,10 +1,10 @@ import SwiftUI import Foundation -import EffectComponents +import Transduce enum Counter { enum Views {} - enum Transducer {} + nonisolated enum Transducer {} } // MARK: - Environment @@ -14,13 +14,13 @@ extension EnvironmentValues { // MARK: - Transducer extension Counter.Transducer: Transducer { - struct State { var counter = 0 init() { self.counter = 0 } } + static let initialState: State = .init() enum Event { case start @@ -33,24 +33,22 @@ extension Counter.Transducer: Transducer { init() {} } - static func update( + static func transduce( _ state: inout State, event: Event - ) -> Self.Effect? { + ) -> Self.Effect { switch event { case .start: state.counter = 0 - return .task(id: "Counter") { input, env in + return .task(id: "Counter") { input, env -> Void in while true { - do { - try await Task.sleep(nanoseconds: 1_000_000_000) // 1 sec - print("tick") - try? input(.tick) - } catch {} // most likely, the counter task has been cancelled; ignore it. + try await Task.sleep(nanoseconds: 1_000_000_000) // 1 sec + print("tick") + try input(.tick) } } case .tick: - state.counter += 1; return nil + state.counter += 1; return .none case .stop: return .cancel("Counter") } @@ -88,8 +86,12 @@ extension Counter.Views { VStack { Text("\(state.counter)") .font(Font.largeTitle.monospacedDigit()) - Button("Start") { try? input(.start) } - Button("Stop") { try? input(.stop) } + Button("Start") { + try? input(.start) + } + Button("Stop") { + try? input(.stop) + } } } .id(env.id) // restart the EffectView when the env changes diff --git a/Sources/EffectComponents/Utilities/EnvReader.swift b/Examples/EffectViewExample/EffectViewExample/EnvReader.swift similarity index 100% rename from Sources/EffectComponents/Utilities/EnvReader.swift rename to Examples/EffectViewExample/EffectViewExample/EnvReader.swift diff --git a/Examples/EffectViewExample/EffectViewExample/Movies.swift b/Examples/EffectViewExample/EffectViewExample/Movies.swift index 4816ab1..87926a3 100644 --- a/Examples/EffectViewExample/EffectViewExample/Movies.swift +++ b/Examples/EffectViewExample/EffectViewExample/Movies.swift @@ -1,10 +1,10 @@ import SwiftUI -import EffectComponents +import Transduce import Foundation enum Movies { enum Views {} - enum Transducer {} + nonisolated enum Transducer {} } // MARK: - Model @@ -84,6 +84,8 @@ extension Movies.Transducer: Transducer { } } + static let initialState: State = .init() + public enum Event { case load case refresh @@ -97,13 +99,13 @@ extension Movies.Transducer: Transducer { public var movieFetch: Movies.MovieFetch } - static func update(_ state: inout State, event: Event) -> Effect? { + static func transduce(_ state: inout State, event: Event) -> Effect { switch event { case .load: // Guard against refresh: can only race with programmatic load triggers // (e.g. .onAppear, timers). UI pull-to-refresh is serialised by SwiftUI. - guard !state.isRefreshing else { return nil } - guard !state.isLoading else { return nil } + guard !state.isRefreshing else { return .none } + guard !state.isLoading else { return .none } state.mode = .loading return loadMovies() @@ -115,11 +117,11 @@ extension Movies.Transducer: Transducer { case .loaded(let movies): state.mode = .idle state.content = .content(movies) - return nil + return .none case .loadFailed(let error): state.mode = .failed(error) - return nil + return .none case .cancel: state.mode = .idle @@ -127,13 +129,13 @@ extension Movies.Transducer: Transducer { case .dismiss: state.mode = .idle - return nil + return .none } } static func loadMovies() -> Effect { - Effect.task(id: "load") { input, env in + .task(id: "load") { input, env -> Void in do { let movies = try await env.movieFetch() try input(.loaded(movies)) @@ -145,7 +147,7 @@ extension Movies.Transducer: Transducer { static func refreshMovies() -> Effect { // Note: a refresh action - Effect.task(id: "refresh") { input, env in + .task(id: "refresh") { input, env -> Void in do { let movies = try await env.movieFetch() try input(.loaded(movies)) diff --git a/Examples/EffectViewExample/EffectViewExample/NewObservation.swift b/Examples/EffectViewExample/EffectViewExample/NewObservation.swift new file mode 100644 index 0000000..39354a7 --- /dev/null +++ b/Examples/EffectViewExample/EffectViewExample/NewObservation.swift @@ -0,0 +1,174 @@ +import Transduce +import SwiftUI + +// MARK: - Example + +enum Counters { + nonisolated enum Transducer {} + enum Views {} +} + +extension Counters { + + @Observable + final class Counter { + + @ObservationIgnored + private var task: Task? + + private(set) var value = 0 + + nonisolated + init() { + print("*** timer initialized") + } + + deinit { + print("*** timer deinitialized") + task?.cancel() + } + + func start() { + print("*** timer start") + self.task?.cancel() + value = 0 + self.task = Task { + while true { + try await Task.sleep(for: .seconds(1)) + self.value += 1 + print("*** timer tick(\(self.value))") + } + } + } + + func cancel() { + if let task { + print("*** timer cancelled") + task.cancel() + self.task = nil + } else { + print("*** timer already cancelled") + } + } + } + +} + +// Note: All types are MainActor isolated +// When we observe in a transducer, the Observable must be +// either nonisolated or the Transducer must be isolated to +// the isolation of the Observable. Otherwisxer we get errors +// like +// Cannot form key path to global actor 'SomeGlobalActor'-isolated property 'value' +// where Observabe is isolated to SomeGlobalActor. +extension Counters.Transducer: Transducer { + + typealias Counter = Counters.Counter + + enum State { + case start + case idle(Int) + + init () { self = .start } + + var value: String { + if case .idle(let value) = self { + "\(value)" + } else { "" } + } + } + + static let initialState: State = .idle(0) + + enum Event { + case start + case tick(Int) + case cancel + } + + nonisolated + struct Env { + let counter = Counter() + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + + /// Pattern note: + /// You can “import” external observable state into a transducer via `observe`, as shown in `update`. + /// The transducer reacts to changes (sending events or invoking observable methods) without coupling to a View. + /// Compared to using SwiftUI environment + `onChange` in a View, this keeps logic and state composition inside + /// the transducer, making it separable from UI and straightforward to test with different external observables. + /// Views can then focus on lightweight presentation and ephemeral UI state. + case (.start, .start): + state = .idle(0) + return .sequence([ + .task(id: "observe") { @MainActor input, env in + try await observe() { + let value = env.counter.value + try? input(.tick(value)) + } + }, + .send(.start) + ]) + + case (.idle, .tick(let value)): + state = .idle(value) + return .none + + case (.idle, .start): + return .action { env in + env.counter.start() + } + + case (_, .cancel): + return .action { env in + env.counter.cancel() + } + + case (.start, .tick): + return .none + } + } +} + + +extension EnvironmentValues { + @Entry var countersEnv: Counters.Transducer.Env = .init() +} + +extension Counters.Views { + + typealias T = Counters.Transducer + + @MainActor + struct ContentView: View { + @Environment(\.countersEnv) private var env + @State private var state: T.State = .init() + + var body: some View { + EffectView(of: T.self, state: $state, initialEnv: env) { state, input in + VStack { + Text(state.value) + .font(.largeTitle) + Button("Start") { + try? input(.start) + } + .buttonStyle(.bordered) + Button("Cancel") { + try? input(.cancel) + } + .buttonStyle(.bordered) + } + } + } + } + +} + +#Preview { + NavigationStack { + // Text("wait ...") + Counters.Views.ContentView() + } +} diff --git a/Examples/EffectViewExample/EffectViewExample/Products.swift b/Examples/EffectViewExample/EffectViewExample/Products.swift new file mode 100644 index 0000000..e3eccd2 --- /dev/null +++ b/Examples/EffectViewExample/EffectViewExample/Products.swift @@ -0,0 +1,331 @@ +import SwiftUI +import Transduce +import Foundation + +/// Products feature - declares all public contracts. +/// This is the single source of truth for what this feature needs and provides. +enum Products { + /// Transducer implementation - the core business logic. + nonisolated enum Transducer {} + + /// View layer - SwiftUI views that consume the transducer. + enum Views {} +} + +// MARK: - Public API (Feature Contracts) + +/// Product model - declared by feature, not data layer. +public struct Product: Codable, Identifiable, Sendable, Equatable { + public let id: Int + public let title: String + public let description: String + public let category: String + public let price: Double + public let discountPercentage: Double + public let rating: Double + public let stock: Int + public let brand: String? + public let thumbnail: String + public let images: [String] +} + +/// Response wrapper - declared by feature. +public struct ProductsResponse: Codable, Sendable { + public let products: [Product] + public let total: Int + public let skip: Int + public let limit: Int + + public init(products: [Product], total: Int, skip: Int, limit: Int) { + self.products = products + self.total = total + self.skip = skip + self.limit = limit + } +} + +/// Service API - declared by feature, implemented by data layer. +public struct ProductFetch: Sendable { + public let fetch: @Sendable (Int, Int) async throws -> ProductsResponse + + public init(fetch: @escaping @Sendable (Int, Int) async throws -> ProductsResponse) { + self.fetch = fetch + } + + public func callAsFunction(_ skip: Int, _ limit: Int) async throws -> ProductsResponse { + try await fetch(skip, limit) + } +} + +// MARK: - Transducer Implementation + +extension Products.Transducer: Transducer { + + public struct State { + public enum Mode { + case idle + case loading + case failed(Error) + } + + public var mode: Mode + public var products: [Product] + public var skip: Int + public var limit: Int + + public init() { + mode = .idle + products = [] + skip = 0 + limit = 10 + } + + public var isLoading: Bool { + if case .loading = mode { return true } + return false + } + + public var error: Error? { + if case .failed(let error) = mode { return error } + return nil + } + } + + public static let initialState: State = .init() + + public enum Event { + case load + case loaded(ProductsResponse) + case loadFailed(Error) + } + + public struct Env: Sendable { + public var productFetch: ProductFetch + } + + public static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .load: + guard !state.isLoading else { return .none } + state.mode = .loading + return loadProducts(skip: state.skip, limit: state.limit) + + case .loaded(let response): + state.mode = .idle + state.products = response.products + state.skip = response.skip + state.limit = response.limit + return .none + + case .loadFailed(let error): + state.mode = .failed(error) + return .none + } + } + + private static func loadProducts(skip: Int, limit: Int) -> Effect { + .task(id: "fetch-products") { input, env in + do { + let response = try await env.productFetch(skip, limit) + try input(.loaded(response)) + } catch { + try input(.loadFailed(error)) + } + } + } +} + +// MARK: - Views + +extension Products.Views { + public struct ContentView: View { + public init() {} + + public var body: some View { + ProductsListView() + } + } + + public struct ProductsListView: View { + @State private var state = Products.Transducer.State() + + public init() {} + + public var body: some View { + EffectView( + of: Products.Transducer.self, + state: $state, + initialEvent: .load, + initialEnv: Products.Transducer.Env( + productFetch: ProductFetch(fetch: { _, _ in + // Default mock implementation + try await Task.sleep(nanoseconds: 1_000_000_000) + let mockProducts: [Product] = [ + .init( + id: 1, + title: "iPhone 9", + description: "An apple mobile which is nothing like apple", + category: "smartphones", + price: 549.0, + discountPercentage: 12.96, + rating: 4.69, + stock: 94, + brand: "Apple", + thumbnail: "https://i.dummyjson.com/data/products/1/thumbnail.jpg", + images: ["https://i.dummyjson.com/data/products/1/1.jpg"] + ), + .init( + id: 2, + title: "iPhone X", + description: "SIM-Free, Model A19211 6.5-inch Super Retina HD display with OLED technology A12 Bionic chip with ...", + category: "smartphones", + price: 899.0, + discountPercentage: 17.94, + rating: 4.44, + stock: 34, + brand: "Apple", + thumbnail: "https://i.dummyjson.com/data/products/2/thumbnail.jpg", + images: ["https://i.dummyjson.com/data/products/2/1.jpg"] + ), + .init( + id: 3, + title: "Samsung Universe 9", + description: "Samsung's new variant which goes beyond galaxy to deliver extraordinary photography", + category: "smartphones", + price: 1249.0, + discountPercentage: 15.46, + rating: 4.09, + stock: 36, + brand: "Samsung", + thumbnail: "https://i.dummyjson.com/data/products/3/thumbnail.jpg", + images: ["https://i.dummyjson.com/data/products/3/1.jpg"] + ), + .init( + id: 4, + title: "OPPOF19", + description: "OPPO F19 is officially announced on April 2021.", + category: "smartphones", + price: 280.0, + discountPercentage: 17.91, + rating: 4.3, + stock: 123, + brand: "OPPO", + thumbnail: "https://i.dummyjson.com/data/products/4/thumbnail.jpg", + images: ["https://i.dummyjson.com/data/products/4/1.jpg"] + ), + .init( + id: 5, + title: "Huawei P30", + description: "Huawei's re-badged P30 Pro New Edition was officially unveiled yesterday in Germany and unfortunately the company's so far lightweightest de...", + category: "smartphones", + price: 499.0, + discountPercentage: 10.58, + rating: 4.06, + stock: 32, + brand: "Huawei", + thumbnail: "https://i.dummyjson.com/data/products/5/thumbnail.jpg", + images: ["https://i.dummyjson.com/data/products/5/1.jpg"] + ) + ] + return .init(products: mockProducts, total: mockProducts.count, skip: 0, limit: 10) + }) + ) + ) { state, input in + VStack { + if state.isLoading { + ProgressView("Loading products...") + .padding() + } + + List(state.products) { product in + ProductRow(product: product) + } + .listStyle(.plain) + + if let error = state.error { + Text("Error: \(error.localizedDescription)") + .foregroundColor(.red) + .padding() + Button("Retry") { + try? input(.load) + } + .padding() + } + } + .navigationTitle("Products") + .alert( + "Error", + isPresented: .constant(state.error != nil), + presenting: state.error + ) { _ in + Button("OK") { + state.error = nil + try? input(.load) + } + } message: { error in + Text(error.localizedDescription) + } + } + } + } +} + +extension Products.Views.ProductsListView { + public struct ProductRow: View { + let product: Product + + public init(product: Product) { + self.product = product + } + + public var body: some View { + HStack { + if let thumbnailURL = URL(string: product.thumbnail) { + AsyncImage(url: thumbnailURL) { phase in + switch phase { + case .empty: + Color.gray.frame(width: 60, height: 60) + case .success(let image): + image.resizable().frame(width: 60, height: 60) + case .failure: + Color.red.frame(width: 60, height: 60) + @unknown default: + Color.gray.frame(width: 60, height: 60) + } + } + } else { + Color.gray.frame(width: 60, height: 60) + } + + VStack(alignment: .leading) { + Text(product.title) + .font(.headline) + + Text("$\(product.price, specifier: "%.2f")") + .font(.subheadline) + .foregroundColor(.green) + + if let brand = product.brand { + Text(brand) + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + Text("\(product.rating, specifier: "%.1f") ⭐") + .font(.caption) + } + .padding(.vertical, 4) + } + } +} + +// MARK: - Previews + +#Preview { + NavigationView { + Products.Views.ProductsListView() + } +} diff --git a/Examples/EffectViewExample/EffectViewExample/ProductsExample.md b/Examples/EffectViewExample/EffectViewExample/ProductsExample.md new file mode 100644 index 0000000..d6a23eb --- /dev/null +++ b/Examples/EffectViewExample/EffectViewExample/ProductsExample.md @@ -0,0 +1,96 @@ +# Products Example + +This example demonstrates the Transducer pattern with a products listing feature that fetches data from an API. + +## Architecture + +### Model (`Products.Product`) +- `Product`: Codable struct representing a product with id, title, description, price, rating, etc. +- `ProductsResponse`: Response wrapper with pagination info + +### Environment (`Products.Transducer.Env`) +- `productFetch: ProductFetch` - Abstraction over the data source + - Uses closure: `(Int, Int) async throws -> ProductsResponse` + - Allows easy mocking for testing + - Injected via `EnvironmentValues` + +### Transducer (`Products.Transducer`) + +**State:** +- `mode`: `.idle`, `.loading`, or `.failed(Error)` +- `products`: Array of fetched products +- `skip`/`limit`: Pagination parameters + +**Events:** +- `.load` - Trigger initial fetch +- `.loaded(ProductsResponse)` - Success with data +- `.loadFailed(Error)` - Error state + +**Effect Flow:** +``` +.load → .task(id: "fetch-products") → .loaded()/.loadFailed() +``` + +### View (`Products.Views.ProductsListView`) + +Uses `EffectView` to manage the transducer lifecycle: +- `initialEvent: .load` - Auto-fetch on appear +- `initialEnv: env` - Dependency injection +- State bound via `@State private var state` + +## Key Patterns + +### 1. Environment Abstraction +```swift +public struct Env: Sendable { + public var productFetch: Products.ProductFetch +} +``` +- Decouples data source from business logic +- Enables easy testing with mock implementations + +### 2. Structured Effects +```swift +.task(id: "fetch-products") { input, env in + let response = try await env.productFetch(skip, limit) + try input(.loaded(response)) +} +``` +- Named tasks for deduplication/cancellation +- Declarative effect description + +### 3. State Machine +```swift +enum Mode { + case idle + case loading + case failed(Error) +} +``` +- Clear state transitions +- Compile-time exhaustiveness + +## Testing + +The mock service in `EnvironmentValues` simulates network delay: +```swift +try await Task.sleep(nanoseconds: 1_000_000_000) +``` + +For unit tests, inject a different `ProductFetch` implementation. + +## Usage + +```swift +Products.Views.ContentView() +``` + +Or with custom environment: +```swift +let customEnv = Products.Transducer.Env( + productFetch: .init(fetch: { skip, limit in + // Custom implementation + }) +) +Products.Views.ProductsListView(env: customEnv) +``` diff --git a/Examples/EffectViewExample/EffectViewExample/ProductsFeature-ModuleStructure.md b/Examples/EffectViewExample/EffectViewExample/ProductsFeature-ModuleStructure.md new file mode 100644 index 0000000..7e9bb24 --- /dev/null +++ b/Examples/EffectViewExample/EffectViewExample/ProductsFeature-ModuleStructure.md @@ -0,0 +1,167 @@ +# ProductsFeature - Module Structure + +## Package Layout + +``` +ProductsFeature/ +├── Sources/ +│ └── ProductsFeature/ +│ ├── Product.swift # Public model +│ ├── ProductsResponse.swift # Public response type +│ ├── ProductFetch.swift # Public service API (DIP) +│ └── Products.swift # Transducer + Views +├── Tests/ +│ └── ProductsFeatureTests/ +│ └── ProductsTests.swift +└── Package.swift +``` + +## Public API (ProductsFeature) + +### 1. Product Model +```swift +public struct Product: Codable, Identifiable, Sendable, Equatable { + public let id: Int + public let title: String + public let description: String + public let category: String + public let price: Double + public let discountPercentage: Double + public let rating: Double + public let stock: Int + public let brand: String? + public let thumbnail: String + public let images: [String] +} +``` + +### 2. Response Type +```swift +public struct ProductsResponse: Codable, Sendable { + public let products: [Product] + public let total: Int + public let skip: Int + public let limit: Int +} +``` + +### 3. Service API (Dependency Inversion) +```swift +public struct ProductFetch: Sendable { + public let fetch: @Sendable (Int, Int) async throws -> ProductsResponse + + public init(fetch: @escaping @Sendable (Int, Int) async throws -> ProductsResponse) + + public func callAsFunction(_ skip: Int, _ limit: Int) async throws -> ProductsResponse +} +``` + +### 4. Transducer Typealias +```swift +public typealias ProductsFeature = Products.Transducer +``` + +### 5. View Typealias +```swift +public typealias ProductsListView = Products.Views.ProductsListView +``` + +## Usage in App + +### App Package (Consumer) + +```swift +import ProductsFeature +import Transduce +import SwiftUI + +// 1. Implement the service API (concrete dependency) +struct DummyJSONProductFetch: Sendable { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func fetch(_ skip: Int, _ limit: Int) async throws -> ProductsResponse { + let url = URL(string: "https://dummyjson.com/products?skip=\(skip)&limit=\(limit)")! + let (data, _) = try await session.data(from: url) + return try JSONDecoder().decode(ProductsResponse.self, from: data) + } +} + +// 2. Provide via EnvironmentValues +extension EnvironmentValues { + @Entry var productsEnv: ProductsFeature.Env = .init( + productFetch: DummyJSONProductFetch() + ) +} + +// 3. Use in views +struct AppView: View { + var body: some View { + ProductsListView(env: env) // Uses typealias + } +} +``` + +## Testing + +### Unit Test (No Network) +```swift +import Testing +import ProductsFeature + +@Test func testLoadSuccess() async throws { + // Mock service + let fetch = ProductFetch { _, _ in + .init( + products: [.init(...)], + total: 1, + skip: 0, + limit: 10 + ) + } + let env = ProductsFeature.Env(productFetch: fetch) + + // Test transducer + var state = ProductsFeature.State() + try await runTransducer(env: env, event: .load) { newState in + #expect(newState.mode == .idle) + #expect(newState.products.count == 1) + } +} +``` + +## Module Boundaries + +### What the Package Exposes +- ✅ `Product` - Data model +- ✅ `ProductsResponse` - API response +- ✅ `ProductFetch` - Service interface (DIP) +- ✅ `ProductsFeature` - Transducer typealias +- ✅ `ProductsListView` - View typealias + +### What the Package Hides (Internal) +- ❌ `Products.Transducer` - Implementation details +- ❌ `Products.Views` - View implementation +- ❌ `Products.Transducer.Env` - Use typealias instead + +### Dependency Flow +``` +App Package (Consumer) + ↓ provides +DummyJSONProductFetch (concrete implementation) + ↓ injected via +ProductsFeature.Env + ↓ used by +ProductsFeature.Transducer (package) +``` + +## Benefits of This Structure + +1. **Testability**: Service API is a value type (struct with closure) +2. **Decoupling**: Package doesn't know about network layer +3. **Reusability**: Same feature, different implementations (mock, real, cache) +4. **Type Safety**: Compile-time guarantees via `ProductFetch` interface +5. **DI Ready**: Environment injection pattern built-in diff --git a/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift b/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift index e5f3498..f25f81d 100644 --- a/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift +++ b/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift @@ -1,11 +1,11 @@ import SwiftUI import Foundation -import EffectComponents +import Transduce @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) enum RemoteCounter { enum Views {} - enum Transducer {} + nonisolated enum Transducer {} } // MARK: - Remote Store @@ -48,11 +48,12 @@ extension EnvironmentValues { // MARK: - Transducer @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) extension RemoteCounter.Transducer: Transducer { - struct State { var count: Int = 0 var lastDelta: Int = 0 } + + static let initialState: State = .init() enum Event { case start @@ -67,45 +68,44 @@ extension RemoteCounter.Transducer: Transducer { let store: RemoteCounter.CounterStore } - static func update( + static func transduce( _ state: inout State, event: Event - ) -> Effect? { + ) -> Effect { switch event { case .start: - return .observe( - \.store, keyPath: \.count, - id: "observe-store-count" - ) { input, value, env in - print("observation-handler store.count: ", value) - try? await input.request(.storeChanged(newCount: value)) + return .task(id: "observe-store-count") { @MainActor input, env in + try await observe { + let value = env.store.count + print("observation-handler store.count: ", value) + try? input.post(.storeChanged(newCount: value)) + } } - case .storeChanged(let newCount): // The only path that writes the mirrored value. print("received event: \(event), state: \(state)") state.lastDelta = newCount - state.count state.count = newCount - return nil + return .none case .incrementTapped: print("incrementTapped") return .task { input, env in - await env.store.send(.increment) + env.store.send(.increment) } case .decrementTapped: print("decrementTapped") return .task { _, env in - await env.store.send(.decrement) + env.store.send(.decrement) } case .resetTapped: print("resetTapped") return .task { _, env in - await env.store.send(.reset) + env.store.send(.reset) } } } diff --git a/Examples/HTTPClient/HTTPCLIENT_IMPLEMENTATION.md b/Examples/HTTPClient/HTTPCLIENT_IMPLEMENTATION.md new file mode 100644 index 0000000..ad84300 --- /dev/null +++ b/Examples/HTTPClient/HTTPCLIENT_IMPLEMENTATION.md @@ -0,0 +1,165 @@ +# HTTP Client Implementation Plan + +## Overview + +This document outlines the implementation plan for a production-ready `HttpClient` component that works with Transduce. The component will be a separate package (not part of core Transduce) that provides: + +1. **`HttpClient`** - A concrete implementation of `HTTPClientProtocol` +2. **`URLLoader`** - A concrete implementation of `URLLoaderProtocol` using URLSession +3. **`AuthStorage`** - A secure credential storage implementation +4. **`AuthorizationServer`** - Token refresh and authentication flow + +## Implementation Steps + +### Phase 1: Core Types (Foundation) + +Create the core types in `Sources/HttpClient/`: + +``` +Sources/HttpClient/ +├── HttpClient.swift # Main HttpClient type +├── URLLoader.swift # URLSession-based loader +├── AuthStorage.swift # Secure credential storage +├── AuthorizationServer.swift # Token management +└── Support/ + ├── Errors.swift # Custom error types + └── Types.swift # Supporting types (ProtectionSpace, etc.) +``` + +### Phase 2: Protocol Conformance + +Implement all required protocol methods: + +#### HttpClient +- `enqueue(_:)` - Queue requests +- `executeRequest(_:)` - Execute with auth handling +- `authenticationChallenge(for:)` - Parse 401 responses +- `authDisposition(for:challenge:)` - Determine auth flow +- `authenticateRequest(_:for:with:)` - Add auth headers +- `authenticatePlatformRequest(_:for:with:)` - Platform-specific auth +- `refreshCredential(for:)` - Token refresh +- `authenticate(for:)` - User authentication flow +- `credential(for:)` - Get stored credential +- `invalidateCredential(for:)` - Clear credentials +- `parseAuthChallenge(from:at:)` - Parse auth headers +- `parseAuthentication(from:)` - Parse request auth +- `supportedAuthenticationSchemes` - Supported schemes + +#### URLLoader +- `data(for:)` - URLSession wrapper + +#### AuthStorage +- `read(key:)` - Get credential +- `write(key:value:)` - Store credential + +#### AuthorizationServer +- `authenticate(for:)` - Initiate auth flow +- `refreshCredential(for:using:)` - Refresh token + +### Phase 3: Transducer Integration + +Create example transducers in `Examples/HttpClientExample/`: + +``` +Examples/HttpClientExample/ +├── Transducers/ +│ ├── NetworkRequest.swift # Basic request transducer +│ └── AuthenticatedRequest.swift # Auth flow transducer +├── Models/ +│ ├── Request.swift +│ └── Response.swift +└── App.swift +``` + +### Phase 4: Documentation + +- Add `Documentation/HttpClient.md` - Usage guide +- Update `MOCKHTTPCLIENT.md` with production implementation details +- Add examples to `Documentation/Recipes.md` + +### Phase 5: Testing + +- Create unit tests for each component +- Add integration tests for auth flows +- Performance benchmarks comparing URLSession vs custom implementation + +## Key Design Decisions + +### 1. Use URLSession as Base + +- Proven, well-tested networking stack +- Handles edge cases (redirects, cookies, etc.) +- Can be wrapped in `URLLoader` for testability + +### 2. Separate AuthStorage from HttpClient + +- Allows different storage backends (Keychain, UserDefaults, etc.) +- Easier to test +- Follows single responsibility principle + +### 3. State Machine in Transducer + +- Auth flow state managed by transducer +- HttpClient only provides protocol conformance +- Clear separation of concerns + +### 4. Policy-Based Design + +- `HttpClient` +- Each policy is a type +- Compile-time polymorphism + +## Performance Considerations + +### Benchmark Scenarios + +1. **Basic request** - Compare with URLSession +2. **Concurrent requests** - Test task deduplication +3. **Auth flow** - Measure auth overhead +4. **Token refresh** - Compare with manual handling + +### Optimization Targets + +- Minimize allocations in hot path +- Use value types where possible +- Avoid unnecessary async/await transitions +- Leverage Swift Concurrency optimizations + +## Testing Strategy + +### Unit Tests +- Each protocol method +- Error handling paths +- Edge cases (expired tokens, invalid responses) + +### Integration Tests +- Full auth flow +- Concurrent requests +- Network failures and retries + +### Performance Tests +- Request latency +- Memory usage +- Task coalescing efficiency + +## Future Enhancements + +1. **Request Caching** - Cache successful responses +2. **Retry Policy** - Configurable retry strategy +3. **Rate Limiting** - Throttle requests +4. **Progress Tracking** - Upload/download progress +5. **Request Interceptors** - Middleware pattern + +## Timeline + +- **Week 1**: Core types and protocol conformance +- **Week 2**: Transducer integration and examples +- **Week 3**: Documentation and testing +- **Week 4**: Performance optimization and benchmarks + +## Notes + +- Keep implementation simple and focused +- Follow Swift Concurrency best practices +- Use Swift 6 language features where appropriate +- Ensure thread safety with proper synchronization diff --git a/Examples/HTTPClient/HTTPClient.swift b/Examples/HTTPClient/HTTPClient.swift new file mode 100644 index 0000000..65e98b3 --- /dev/null +++ b/Examples/HTTPClient/HTTPClient.swift @@ -0,0 +1,1447 @@ +import Foundation +import Mutex + +/// A protocol that defines a URL loading mechanism for making network requests. +/// +/// This protocol abstracts the platform-specific networking layer, allowing +/// transducers to work with different networking implementations while maintaining +/// type safety through associated types. +/// +/// - Note: This protocol is designed to work with the Transduce architecture's +/// effect-driven approach to asynchronous operations. +/// +/// - Parameters: +/// - Request: The native platform type representing an HTTP request (e.g., `URLRequest`). +/// - Response: The native platform type representing an HTTP response (e.g., `(data: Data, response: URLResponse)`). +/// +/// - Example: +/// ```swift +/// enum MyURLLoader: URLLoaderProtocol { +/// static func data( +/// for request: URLRequest +/// ) async throws -> (Data, URLResponse) { +/// return try await URLSession.shared.data( +/// for: request +/// ) +/// } +/// } +/// ``` +public protocol URLLoaderProtocol { + /// The native platform type for an HTTP request. + /// + /// This associated type allows the protocol to work with different + /// networking frameworks' request types (e.g., `URLRequest` from Foundation). + associatedtype PlatformRequest + + /// The native platform type for an HTTP response. + /// + /// This associated type allows the protocol to return responses in the + /// format expected by the underlying networking framework. + associatedtype PlatformResponse + + /// Loads data for the given request and returns the response. + /// + /// - Parameter request: The request to load data for. + /// - Returns: A tuple containing the response data and metadata. + /// - Throws: An error if the request fails or the response is invalid. + static func data(for: PlatformRequest) async throws -> PlatformResponse +} + +/// A type that contans the properties of an authentication error response from +/// a request that failed authentication. +/// +/// The value has basically two life-cycle states: the state before an authentication +/// attempt and the state after an authentication attempt with this challenge. +public protocol AuthenticationChallengeProtocol< + ProtectionSpace, PlatformResponse +>: Sendable { + associatedtype ProtectionSpace: ProtectionSpaceProtocol + associatedtype PlatformResponse: Sendable + + /// The protection space of this challenge. + var protectionSpace: ProtectionSpace { get } + + /// The URL response object representing the last authentication failure. + /// + /// The initial value will be set to the failureResponse of the previous challenge if any - + /// otherwise it will be set to `nil`. If this challenge fails, `failureResponse` will be + /// set to the corresponding response. If no response has been received, it will be set to `nil`.- + var failureResponse: PlatformResponse? { get set } + + /// The request's total failed authentication attempts. + /// + /// The initial value will set to the failureCount of the previous challenge, if any - otherwise + /// it's set to zero. When this challenge fails, the failureCount will be incremented by one. + var failureCount: Int { get set } + + /// The error object representing the last authentication failure. + /// + /// The initial value will be set to the `error` of the previous challenge if any - + /// otherwise it will be set to `nil`. If this challenge fails with an error - i.e. no response, + /// `error` will be set to the thrown error. If a response has been received, it will be set + /// to `nil`.- + var error: Error? { get set } +} + +/// A protocol that defines a protection space for authentication. +/// +/// A protection space represents a logical security domain that requires +/// authentication. Each protection space has its own rules for applying +/// credentials to requests. +/// +/// - Example: An HTTP protection space might use Bearer tokens, while +/// a WebDAV protection space might use Basic authentication. +public protocol ProtectionSpaceProtocol: Hashable, Sendable { + /// Authentication scheme, such as Basic, Bearer, Digest, etc. + associatedtype AuthenticationScheme: Equatable + + /// The platform-specific request type that will be authenticated. + /// + /// This allows the protocol to work with different networking frameworks + /// (e.g., `URLRequest` from Foundation, or custom request types). + associatedtype PlatformRequest + + /// The associated type of credential this specific space requires. + /// + /// The credential must conform to `Equatable` to enable comparison + /// and caching of credentials in secure storage. + associatedtype Credential: Equatable + + var scheme: AuthenticationScheme { get } + + /// Applies the credential to the request based on this space's unique rules. + /// + /// This method modifies the request by adding authentication information + /// according to the specific requirements of this protection space. For + /// example, an HTTP Bearer token space might add an `Authorization` header, + /// while a different space might use query parameters or request body. + /// + /// - Parameters: + /// - request: The request to authenticate. + /// - credential: The credential to apply to the request. + /// - Returns: A new request instance with authentication applied, or + /// the same request if it's mutable. + /// - Throws: An error if authentication cannot be applied (e.g., invalid + /// credential format, missing required fields). + func authenticateRequest( + _ request: PlatformRequest, + credential: Credential + ) async throws -> PlatformRequest +} + +/// A protocol that defines a secure storage mechanism for storing sensitive data. +/// +/// This protocol abstracts the platform-specific secure storage implementation, +/// allowing transducers to work with different storage systems while maintaining +/// type safety through associated types. +/// +/// - Note: This protocol is designed to work with the Transduce architecture's +/// effect-driven approach to asynchronous operations. +/// +/// - Parameters: +/// - Key: The type used as a unique identifier for stored values. Must conform to `Hashable`. +/// - Value: The type of value to store in secure storage. +/// +/// - Example: +/// ```swift +/// enum KeychainStorage: SecureStorageProtocol { +/// static func read(key: String) async throws -> String? { +/// // Read from Keychain +/// } +/// +/// static func write(key: String, value: String) async throws { +/// // Write to Keychain +/// } +/// } +/// ``` +public protocol SecureStorageProtocol { + /// The type used as a unique identifier for stored values. + /// + /// This key is used to locate and retrieve values from secure storage. + /// It must conform to `Hashable` to enable efficient storage and lookup. + associatedtype Key: Hashable + + /// The type of value stored in secure storage. + /// + /// This can be any type that needs to be securely persisted, such as + /// credentials, tokens, or other sensitive data. + associatedtype Value + + /// Reads a value from secure storage for the given key. + /// + /// - Parameter key: The unique identifier for the value to read. + /// - Returns: The stored value, or `nil` if no value exists for the key. + /// - Throws: An error if the storage system cannot be accessed or the value cannot be retrieved. + static func read(key: Key) async throws -> Value? + + /// Writes a value to secure storage for the given key. + /// + /// - Parameters: + /// - key: The unique identifier for the value being written. + /// - value: The value to store in secure storage. + /// - Throws: An error if the storage system cannot be accessed or the value cannot be stored. + static func write(key: Key, value: Value) async throws +} + +/// A protocol that defines an authorization server for handling authentication flows. +/// +/// This protocol abstracts the authorization server's authentication and credential +/// refresh mechanisms, allowing transducers to work with different authentication +/// systems while maintaining type safety through associated types. +/// +/// - Note: This protocol is designed to work with the Transduce architecture's +/// effect-driven approach to asynchronous operations. +/// +/// - Parameters: +/// - ProtectionSpace: The protection space protocol that defines the authentication domain. +/// - AuthenticationRecord: The authentication record protocol that contains credentials and proof of authentication. +/// +/// - Example: +/// ```swift +/// enum OAuthAuthorizationServer: AuthorizationServerProtocol { +/// static func authenticate( +/// for protectionSpace: ProtectionSpace +/// ) async throws -> AuthenticationRecord { +/// // Perform OAuth flow +/// } +/// +/// static func refreshCredential( +/// for protectionSpace: ProtectionSpace, +/// using authentication: AuthenticationRecord +/// ) async throws -> Credential { +/// // Refresh the credential using refresh token +/// } +/// } +/// ``` +public protocol AuthorizationServerProtocol { + /// The protection space protocol that defines the authentication domain. + associatedtype ProtectionSpace: ProtectionSpaceProtocol + + /// The authentication record protocol that contains credentials and proof of authentication. + associatedtype AuthenticationRecord: AuthenticationRecordProtocol + + /// The credential type used for authentication. + typealias Credential = AuthenticationRecord.Credential + + /// Performs an authentication flow for the specified protection space. + /// + /// This method may require user interaction (e.g., opening a web browser for OAuth). + /// The authentication process may return secure items other than just credentials, + /// such as refresh tokens or ID tokens. + /// + /// - Parameter protectionSpace: The protection space for which an authentication should be performed. + /// - Returns: The authentication record containing credentials and proof of authentication. + /// - Throws: A network error, user cancellation, or authentication failure. + static func authenticate(for protectionSpace: ProtectionSpace) async throws -> AuthenticationRecord + + /// Returns a new valid credential for the specified protection space using existing authentication proof. + /// + /// This method is called when a request returns an authentication error indicating that the + /// current credential has expired. It should not invoke a full authentication flow or throw an error. + /// If the credential refresh fails, it must return `nil`. + /// + /// The HTTP client typically provides a proof of authentication (such as a refresh token) to + /// obtain a new short-lived credential from the authorization server. + /// + /// - Parameters: + /// - protectionSpace: The protection space for which to retrieve a fresh credential. + /// - authentication: The authentication record containing the proof of authentication (e.g., refresh token). + /// - Returns: A new valid credential for this realm, or `nil` if refresh failed. + /// - Throws: An authentication error or network error (only if the refresh mechanism itself fails). + static func refreshCredential(for protectionSpace: ProtectionSpace, using authentication: AuthenticationRecord) async throws -> Credential? +} + +/// A type that contains authentication properties that will be stored in a +/// secure store for subsequent use. In contrast to an `Authentication` +/// value – which contains sufficient information to authenticate a request – +/// it may store additional secure items, such as a refresh token. +/// +/// This protocol defines the minimal interface for storing authentication +/// credentials and related secure data in a secure storage system. It is +/// typically used by HTTP clients to persist authentication state across +/// application sessions. +/// +/// - Note: This protocol is designed to work with the Transduce architecture's +/// effect-driven approach to asynchronous operations. +/// +/// - Parameters: +/// - ProtectionSpace: The protection space protocol that defines the authentication domain. +/// +/// - Example: +/// ```swift +/// struct OAuthAuthenticationRecord: AuthenticationRecordProtocol { +/// let protectionSpace: HTTPProtectionSpace +/// var credential: String // Access token +/// +/// init(protectionSpace: HTTPProtectionSpace, accessToken: String) { +/// self.protectionSpace = protectionSpace +/// self.credential = accessToken +/// } +/// } +/// ``` +public protocol AuthenticationRecordProtocol: Equatable, Sendable { + /// The protection space protocol that defines the authentication domain. + associatedtype ProtectionSpace: ProtectionSpaceProtocol + + /// The credential type used for authentication, derived from the protection space. + typealias Credential = ProtectionSpace.Credential + + /// The protection space for which this record contains authentication data. + var protectionSpace: ProtectionSpace { get } + + /// The credential used for authentication in this protection space. + /// + /// This property is mutable to allow credential updates, such as when + /// refreshing an expired token. + var credential: Credential? { get set } +} + +/// The return value of `authDisposition(for:with:)` which analyses the request and +/// the response and then determins the action the caller has to take. +public enum AuthDisposition { + /// No authentication is required. + case none + case authenticateRequestAndRetry(with: ProtectionSpace, credential: ProtectionSpace.Credential) + case refreshCredentialAndRetry(for: ProtectionSpace) + case authenticateSessionAndRetry(for: ProtectionSpace) +} + +public protocol RequestProtocol: Hashable, Sendable { + associatedtype PlatformRequest: Sendable + associatedtype AuthenticationChallenge: AuthenticationChallengeProtocol + associatedtype Continuation: Sendable + + init(platformRequest: PlatformRequest) + + var platformRequest: PlatformRequest { get set } + var challenge: AuthenticationChallenge? { get set } + var continuation: Continuation { get } +} + +public protocol ResponseProtocol: Sendable { + associatedtype PlatformResponse: Sendable + associatedtype Request: RequestProtocol + + init(platformResponse: PlatformResponse, for request: Request) + + var platformResponse: PlatformResponse { get set } + var request: Request { get } +} + +/// A protocol that defines the authentication information contained in a request. +/// +/// This protocol provides a standardized interface for extracting authentication +/// metadata from platform-specific request types. It allows HTTP clients to inspect +/// the authentication scheme and associated parameters without needing to know the +/// concrete request type. +/// +/// - Parameters: +/// - AuthenticationScheme: The type representing the authentication scheme (e.g., Bearer, Basic). +/// - Parameters: An optional dictionary of authentication parameters. +/// +/// - Example: +/// ```swift +/// struct BearerAuthentication: RequestAuthenticationProtocol { +/// let scheme: AuthenticationScheme +/// let parameters: [String: String]? +/// +/// init(token: String) { +/// self.scheme = .bearer +/// self.parameters = ["token": token] +/// } +/// } +/// ``` +public protocol RequestAuthenticationProtocol { + /// The authentication scheme used (e.g., Bearer, Basic, Digest). + /// + /// This value identifies the type of authentication credentials required + /// or provided by this request. + associatedtype AuthenticationScheme: Equatable + + /// Optional parameters associated with the authentication. + /// + /// This dictionary may contain additional metadata such as token values, + /// realm specifications, or other scheme-specific parameters. + associatedtype Parameters: Equatable + + /// The authentication scheme employed by this request. + var scheme: AuthenticationScheme { get } + + /// Additional parameters for the authentication, if any. + /// + /// Some authentication schemes require or support additional parameters + /// beyond the core credential. For example, a Bearer token might include + /// token type information or scope parameters. + var parameters: Parameters? { get } +} + +public protocol RequestSchedulerProtocol { + associatedtype PlatformRequest + associatedtype PlatformResponse + static func executeRequest(_ request: PlatformRequest) async throws -> PlatformResponse +} + +// Stateless +public protocol HTTPClientProtocol: SendableMetatype { + typealias Parameters = [String: String] + + associatedtype PlatformRequest: Sendable + associatedtype PlatformResponse: Sendable + associatedtype ProtectionSpace: ProtectionSpaceProtocol + associatedtype AuthenticationChallenge: AuthenticationChallengeProtocol + associatedtype Request: RequestProtocol + associatedtype Response: ResponseProtocol + associatedtype Authentication: RequestAuthenticationProtocol + + /// An opaque secret which is used to prove an authentication. + associatedtype AuthenticationRecord: AuthenticationRecordProtocol + + associatedtype RequestScheduler: RequestSchedulerProtocol + associatedtype URLLoader: URLLoaderProtocol + associatedtype AuthStorage: SecureStorageProtocol + associatedtype AuthorizationServer: AuthorizationServerProtocol + + + typealias Credential = ProtectionSpace.Credential + typealias AuthenticationScheme = ProtectionSpace.AuthenticationScheme + + // Called from a call site + static func enqueue(_ request: Request) async throws + + /// Called by the engine to executes the request. + /// + /// Returns the response obtained from the service. If no response was received, the + /// function should throw an error. + /// + /// > Implemented by the default function. + static func executeRequest(_ request: Request) async throws -> Response + + /// Returns the authentication challenge for this response if an authentication is required for the + /// corresponding request. + /// + /// - Parameter response: The response + /// - Returns: An `AuthenticationChallenge` value if anf authentication is required for + /// this request - otherwise `nil`. + /// - Throws:An error if the response could not be parsed - or when it has an invalid + /// authentication challenge. + /// + /// > Implemented by the default function. + static func authenticationChallenge( + for response: Response + ) throws -> AuthenticationChallenge? + + + /// Returns a disposition for the given request based on its response and the authentication + /// challege. + /// + /// The function returns a `AuthDisposition` value if the request was declined due to an + /// auth error. + /// + /// - Returns: An AuthDisposition if authentication is required for this request - otherwise `nil`. + /// + /// > Implemented by the default function. + static func authDisposition( + for response: Response, + challenge: AuthenticationChallenge + ) async throws -> AuthDisposition? + + /// Modify the request and setup authentication for it according the given challenge + /// and the credential. + /// + /// - Returns: The authenticated request. + /// - Parameters: + /// - request: The initial request which needs to be authenticated. + /// - challenge: The authentication challenge for which the request should be authenticated + /// - credential:The credential for this protection space. + /// - Throws: An error when the system cannot authenticate the request, for example due to an invalid credential, or retry attempts have been exceeded. + /// + /// > Implemented by the default function - which calls ``authenticatePlatformRequest(_:for:with)``. + static func authenticateRequest( + _ request: Request, + for challenge: AuthenticationChallenge, + with credential: Credential + ) throws -> Request + + /// Modify the platform equest and setup authentication for it according the given protection space + /// and the credential. + /// + /// - Returns: The authenticated request. + /// - Parameters: + /// - platformRequest: The initial platform request which needs to be authenticated. + /// - protectionSpace: The protection space for which the request should be authenticated. + /// - credential:The credential for this protection space. + /// - Throws: An error when the system cannot authenticate the request, for example due to an invalid credential, or retry attempts have been exceeded. + /// + /// > Must be implemented by the conforming type. + static func authenticatePlatformRequest( + _ platformRequest: PlatformRequest, + for protectionSpace: ProtectionSpace, + with credential: Credential + ) throws -> PlatformRequest + + /// Requests a new valid credential for the specified protection space. + /// + /// This method attempts to refresh an existing credential by contacting the authorization server + /// using the stored authentication record. It is called when a request fails with an authentication + /// error indicating that the current credential has expired. + /// + /// If the refresh fails, the client must return an error to the caller instead invoking an + /// authentication flow. + /// + /// - Parameter protectionSpace: The protection space for which an authentication should + /// be performed. + /// - Returns: A new valid credential for this realm. + /// + /// > Implemented by the default function. + /// + /// > Important: The Transducer ensures that only one function is active at a time. + static func refreshCredential(for protectionSpace: ProtectionSpace) async throws -> Credential? + + /// Performs an authentication for this protection space. + /// + /// Authentication may require user interaction. + /// + /// This process may also return other credential artifecats such as a refresh-token - but + /// this is an implemenation detail. + /// + /// A conforming type is expected to store the credential and other secrets obtained by + /// the authentication flow in a secure local storage. The function `credential(for:)` will + /// read the credential from the secure store. + /// + /// - Parameter protectionSpace: The protection space for which an authentication should be performed. + /// - Returns: The credential which was obtained in the authentication flow. + /// - Throws: The authentication could not be completed, or the user cancelled the authentication. + /// + /// > Implemented by the default function. + /// + /// > Important: The Transducer ensures that only one function is active at a time. + static func authenticate(for protectionSpace: ProtectionSpace) async throws -> Credential + + /// Returns the current credential for this protection space from the local secure store. + /// + /// If there's no AuthenticationRecod or if the credential is `nil` the function returns `nil` + /// + /// > Important: A credential may be invalid or expired. + /// + /// - Parameter protectionSpace: The proteczion space for which a credential is needed. + /// - Returns: The credential, or `nil` if none exists. + /// - Throws: An error when the secure store could not be accessed. + /// + /// > Implemented by the default function. + /// + /// > Important: The Transducer ensures that only one function is active at a time. + static func credential(for protectionSpace: ProtectionSpace) async throws -> Credential? + + /// Invalidates the current credential for this protection space if an authenticate record exists. + /// + /// If the credential is not found, the functions does nothing (idempotent). + /// - Throws: An error when the secure store could not be accessed. + /// + /// > Implemented by the default function. + /// + /// > Important: The Transducer ensures that only one function is active at a time. + static func invalidateCredential(for protectionSpace: ProtectionSpace) async throws + + /// Returns the the authentication challenge from this response if it has any. + /// + /// A response may have more than one authentication challenges. + /// + /// - Parameters: + /// - response: The response + /// - at: The authentication challenge at position `at`. + /// - Returns: The authentication challenge at postition `at` from the + /// response or `nil` if none exists. + /// + /// > Must be implemented by the conforming type. + static func parseAuthChallenge(from response: PlatformResponse, at: Int) -> AuthenticationChallenge? + + /// Returns the authentication from this request if it has any. + /// + /// A request may contain authentication information that needs to be parsed + /// and processed by the client. + /// + /// - Parameter request: The platform request to inspect for authentication. + /// - Returns: An `Authentication` value if the request contains authentication + /// information, or `nil` if no authentication is present. + /// + /// > Must be implemented by the conforming type. + static func parseAuthentication(from request: PlatformRequest) throws -> Authentication? + + /// Defines the authentication challenges that are supported by the client. + /// + /// > Must be implemented by the conforming type. + static var supportedAuthenticationSchemes: [AuthenticationScheme] { get } +} + + +// Default Implementations +extension HTTPClientProtocol { + + /// Executes a network request using the configured URL loader. + /// + /// This default implementation loads data for the platform-specific request + /// and wraps the result in a response object that conforms to the + /// ``ResponseProtocol``. + /// + /// - Parameter request: The request containing the platform-specific request + /// to execute. + /// - Returns: A response object containing the platform response and a + /// reference to the original request. + /// - Throws: An error if the URL loader fails to execute the request. + /// + /// ## Overview + /// + /// This method serves as the default implementation for executing requests + /// in HTTP clients. It delegates the actual network operation to the + /// ``URLLoader`` type, which abstracts the underlying networking framework. + /// + /// After the platform response is received, it's wrapped in a ``Response`` + /// instance that includes both the raw platform data and metadata from the + /// original request. + /// + /// - SeeAlso: ``URLLoaderProtocol`` + public static func executeRequest(_ request: Request) async throws -> Response { + let platformResponse = try await URLLoader.data(for: request.platformRequest) + return Response(platformResponse: platformResponse, for: request) + } + + /// Returns the authentication challenge for this response if an authentication is required for the + /// corresponding request. + /// + /// If an authentication is required, a response may suggest more than one authentication + /// challenges. The function interates over the suggested challenges in the response and returns + /// the first challenge which is supported by the HTTPClient. If none is supported – and an + /// authentication is requried – it throws an error. + /// + /// - Parameters: + /// - response: The response + /// - Returns: If an authentication is required, returns the supported authentication + /// challenge – otherwise `nil`. + /// - Throws: An error when an authentication is required but none of the suggested authentication + /// challenges are supported by the HTTPClient. + public static func authenticationChallenge( + for response: Response + ) throws -> AuthenticationChallenge? { + // Note: the default + guard let _challenge = parseAuthChallenge(from: response.platformResponse, at: 0) else { + return nil + } + var challenge = _challenge + var index = 0 + loop: while true { + if supportedAuthenticationSchemes.contains(challenge.protectionSpace.scheme) { + break loop + } else { + index += 1 + } + guard let _challenge = parseAuthChallenge(from: response.platformResponse, at: index) else { + throw AuthenticationChallengesNotSupportedError() + } + challenge = _challenge + } + if let previousChallenge = response.request.challenge { + challenge.failureCount = previousChallenge.failureCount + 1 + challenge.error = previousChallenge.error + challenge.failureResponse = response.platformResponse + } + return challenge + } + + public static func authDisposition( + for response: Response, + challenge: AuthenticationChallenge + ) async throws -> AuthDisposition? { + let requestAuthentication = try parseAuthentication(from: response.request.platformRequest) + switch requestAuthentication { + case .some(let authentication) where authentication.scheme == challenge.protectionSpace.scheme: + // assuming the credential was expired. + return .refreshCredentialAndRetry(for: challenge.protectionSpace) + default: + // request is not authenticated, or the protection space doesn't match + if let authenticationRecord = try await AuthStorage.read(key: challenge.protectionSpace), + let credential = authenticationRecord.credential + { + return .authenticateRequestAndRetry(with: challenge.protectionSpace, credential: credential) + } else { + return .authenticateSessionAndRetry(for: challenge.protectionSpace) + } + } + } + + public static func authenticateRequest( + _ request: Request, + for challenge: AuthenticationChallenge, + with credential: Credential + ) throws -> Request { + let platformRequest = try authenticatePlatformRequest( + request.platformRequest, + for: challenge.protectionSpace, + with: credential + ) + var request = request + request.platformRequest = platformRequest + request.challenge = challenge + return request + } + + public static func refreshCredential(for protectionSpace: ProtectionSpace) async throws -> Credential? { + guard let authenticationRecord = try await AuthStorage.read(key: protectionSpace) else { + throw NoAuthenticationRecordError() + } + return try await AuthorizationServer.refreshCredential(for: protectionSpace, using: authenticationRecord) + } + + + /// Performs an authentication flow for the specified protection space and stores the result. + /// + /// This method initiates an authentication process with the authorization server, + /// retrieves the authentication record containing credentials and proof of authentication, + /// and stores it in the secure storage for future use. + /// + /// - Parameter protectionSpace: The protecton space for which authentication should be performed. + /// - Returns: The credential obtained from the authentication flow. + /// - Throws: ``InvalidAuthenticationRecordError`` if the authentication record + /// does not contain a valid credential. + /// + /// ## Overview + /// + /// This function is called when the HTTP client needs to authenticate a request + /// but no valid credential is available in storage, or the existing credential + /// has expired. The authentication process may require user interaction (e.g., + /// opening a web browser for OAuth flows). + /// + /// After successful authentication, the resulting authentication record is + /// persisted in secure storage, allowing subsequent requests to reuse the + /// credential without requiring re-authentication. + /// + /// - Note: This function should not be called when a valid credential exists + /// in storage. Use ``credential(for:)`` to check for existing credentials + /// before invoking this method. + /// + /// - SeeAlso: ``AuthorizationServerProtocol/authenticate(for:)`` + public static func authenticate(for protectionSpace: ProtectionSpace) async throws -> Credential { + let authenticationRecord = try await AuthorizationServer.authenticate(for: protectionSpace) + guard let credential = authenticationRecord.credential else { + throw InvalidAuthenticationRecordError() + } + try await AuthStorage.write(key: protectionSpace, value: authenticationRecord) + return credential + } + + public static func credential(for protectionSpace: ProtectionSpace) async throws -> Credential? { + guard let authenticationRecord = try await AuthStorage.read(key: protectionSpace) else { + return nil + } + return authenticationRecord.credential + } + + public static func invalidateCredential(for protectionSpace: ProtectionSpace) async throws { + guard var authRecord = try await AuthStorage.read(key: protectionSpace) else { + return + } + authRecord.credential = nil + try await AuthStorage.write(key: protectionSpace, value: authRecord) + } +} + + +// MARK: - Errors +struct InvalidAuthenticationError: Error {} +struct InvalidAuthenticationRecordError: Error {} +struct NoAuthenticationRecordError: Error {} +struct AuthenticationChallengesNotSupportedError: Error {} + + +// MARK: - Mock +public enum Mock {} + +// MARK: - Mock ULRLoader +extension Mock { + public enum URLLoader: URLLoaderProtocol { + public static func data(for request: URLRequest) async throws -> HTTPURLResponse { + // TODO: implement + guard let url = request.url else { + throw URLError(.badURL) + } + guard let response = HTTPURLResponse.init( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + ) else { + throw URLError(.cannotParseResponse) + } + return response + } + } +} + +// MARK: - Mock Credential Store +extension Mock { + + final class KeyValueStore: Sendable { + + public struct Key: @unchecked Sendable, Hashable { + private let wrapped: AnyHashable + + /// Creates an identifier from any hashable, sendable value. + /// + /// - Parameter wrapped: The logical identifier value to wrap. + public init(_ wrapped: some Hashable & Sendable) { + self.wrapped = .init(wrapped) + } + } + + typealias Storage = [Key: Any & Sendable] + + let state: Mutex = .init(.init()) + + func read(key: Key) async throws -> Value? { + state.withLock { storage in + storage[key] as? Value + } + } + + func write(key: Key, value: Value) async throws { + state.withLock { storage in + storage[key] = value + } + } + } + + private static let credentialStore: KeyValueStore = .init() + + public enum CredentialStore: SecureStorageProtocol { + public static func read(key: Key) async throws -> Value? { + try await credentialStore.read(key: KeyValueStore.Key(key)) + } + public static func write(key: Key, value: Value) async throws { + try await credentialStore.write(key: KeyValueStore.Key(key), value: value) + } + } + +} + +enum MockURLLoader: URLLoaderProtocol { + public static func data(for request: URLRequest) async throws -> HTTPURLResponse { + // TODO: implement + guard let url = request.url else { + throw URLError(.badURL) + } + guard let response = HTTPURLResponse.init( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + ) else { + throw URLError(.cannotParseResponse) + } + return response + } +} + +#if false +/// Implements a Mock HttpClient for testing. +/// +/// A concrete HttpClient is also a URLLoader. That is, it exposes the *exact same* API than the +/// underlying lower level URLLoader which objectivity is to execute the raw request without handling +/// any responses. That also means, whatever API the underlying URLLoader is implementing, it +/// needs to be implemented in the HttpClient as well. In other words, an HttpClient provides the +/// same capabilities as the underlying URLLoader plus authentication, and other features which are +/// implemented in the transducer and the HTTPClientProtocol. +/// +/// +/// +public enum MockHttpClient< + RequestScheduler: RequestSchedulerProtocol, + URLLoader: URLLoaderProtocol +>: HTTPClientProtocol, URLLoaderProtocol { + public typealias Credential = Date + public typealias Realm = String + public typealias Parameters = [String: String] + public typealias PlatformRequest = URLRequest + public typealias PlatformResponse = HTTPURLResponse + public typealias AuthStorage = Mock.CredentialStore + + + // MARK: URLoader + + /// Loads data for the given request and returns the response. + /// + /// - Parameter request: The request to load data for. + /// - Returns: A tuple containing the response data and metadata. + /// - Throws: An error if the request fails or the response is invalid. + public static func data(for: URLRequest) async throws -> HTTPURLResponse { + // TODO: implement + fatalError() + } + + + // The Authentication schemes the client supports + public enum AuthenticationScheme: String, Sendable { + case bearer = "Bearer" + case basic = "Basic" + } + + // TODO: This struct looks final to me + public struct ProtectionSpace: ProtectionSpaceProtocol, Hashable, Sendable { + typealias Key = String + + init(from platfomResponse: HTTPURLResponse) { + self.scheme = .bearer + self.host = platfomResponse.url?.host ?? "" + self.port = platfomResponse.url?.port ?? 80 + self.protocol = nil + self.realm = nil + } + + public let scheme: AuthenticationScheme + public let host: String + public let port: Int + public let `protocol`: String? + public let realm: String? + + public func authenticateRequest( + _ request: URLRequest, + credential: Credential + ) async throws -> URLRequest { + var request = request + let encodedCredential = "\(credential)".data(using: .utf8)!.base64EncodedString() + let authorizationHeaderValue = "\(scheme.rawValue) \(encodedCredential)" + request.setValue(authorizationHeaderValue, forHTTPHeaderField: "Authorization") + return request + } + } + + // TODO: This struct looks final to me + public struct AuthenticationChallenge: AuthenticationChallengeProtocol & Sendable { + init(protectionSpace: ProtectionSpace) { + self.protectionSpace = protectionSpace + self.failureResponse = nil + self.failureCount = 0 + self.error = nil + } + + public var protectionSpace: ProtectionSpace + public var failureResponse: PlatformResponse? + public var failureCount: Int + public var error: Error? + } + + // TODO: This struct looks final to me + public struct Authentication: RequestAuthenticationProtocol { + public var scheme: AuthenticationScheme + public var parameters: Parameters? + } + + public struct AuthenticationRecord: AuthenticationRecordProtocol, Sendable { + init(protectionSpace: ProtectionSpace, credential: Credential?) { + self.protectionSpace = protectionSpace + self.credential = credential + } + public var protectionSpace: ProtectionSpace + public var credential: Credential? + } + + public struct Request: Hashable, RequestProtocol { + public typealias Continuation = Void + + public init(platformRequest: PlatformRequest) { + self.platformRequest = platformRequest + self.challenge = nil + self.continuation = Void() + } + + public var platformRequest: PlatformRequest + public var challenge: AuthenticationChallenge? + public var continuation: Continuation + + public func hash(into hasher: inout Hasher) { + hasher.combine(platformRequest) + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.platformRequest == rhs.platformRequest + } + } + + public struct Response: ResponseProtocol { + public init(platformResponse: PlatformResponse, for request: Request) { + self.platformResponse = platformResponse + self.request = request + } + + public var platformResponse: PlatformResponse + public var request: Request + public var body: Data = Data() + } + + public enum AuthorizationServer: AuthorizationServerProtocol { + public static func authenticate( + for protectionSpace: ProtectionSpace + ) async throws -> AuthenticationRecord { + return AuthenticationRecord( + protectionSpace: protectionSpace, + credential: Date() + ) + } + public static func refreshCredential( + for protectionSpace: ProtectionSpace, + using authentication: AuthenticationRecord + ) async throws -> Credential? { + return Date() + } + } + + // Called from a call site + public static func enqueue(_ request: Request) async throws { + // TODO: implement + } + + public static func authenticatePlatformRequest( + _ platformRequest: PlatformRequest, + for protectionSpace: ProtectionSpace, + with credential: Credential + ) throws -> PlatformRequest { + var request = platformRequest + let scheme = protectionSpace.scheme + switch scheme { + case .bearer, .basic: + let b64token = "\(credential)" + .data(using: .utf8)! + .base64EncodedString() + request.setValue("\(scheme.rawValue) \(b64token)", forHTTPHeaderField: "Authorization") + + return request + } + } + + /// Returns the authentication from this request if it has any. + /// + /// A request may contain authentication information that needs to be parsed + /// and processed by the client. + /// + /// - Parameter request: The platform request to inspect for authentication. + /// - Returns: An `Authentication` value if the request contains authentication + /// information – otherwise `nil` + /// - Throws: `InvalidAuthenticationError` if the Authorization header contains + /// an unsupported scheme or is malformed. + /// + /// > Must be implemented by the conforming type. + public static func parseAuthentication(from request: URLRequest) throws -> Authentication? { + guard let authHeader = request.value(forHTTPHeaderField: "Authorization") else { + return nil + } + + let components = authHeader.split(separator: " ", maxSplits: 1) + + let schemeString = String(components[0]) + let token = String(components[1]) + guard !token.isEmpty else { + throw InvalidAuthenticationError() + } + + switch AuthenticationScheme(rawValue: schemeString) { + case .some(let scheme): + guard components.count == 2 else { + throw InvalidAuthenticationError() + } + return Authentication(scheme: scheme, parameters: ["token": token]) + case .none: + guard components.count == 2 else { + throw InvalidAuthenticationError() + } + throw InvalidAuthenticationError() + } + } + + /// Returns the the authentication challenge from this response if it has any. + /// + /// A response may have more than one authentication challenges. + /// + /// - Parameters: + /// - response: The response + /// - at: The authentication challenge at position `at`. + /// - Returns: The authentication challenge at postition `at` from the + /// response or `nil` if none exists. + /// + /// > Must be implemented by the conforming type. + public static func parseAuthChallenge(from response: HTTPURLResponse, at: Int) -> AuthenticationChallenge? { + fatalError("not implemented") + } + + + /// Defines the authentication challenges that are supported by the client. + /// + /// > Must be implemented by the conforming type. + public static var supportedAuthenticationSchemes: [AuthenticationScheme] { + [.bearer, .basic] + } + +} + +#endif + + +// MARK: - Concrete test HTTP client + +/// A concrete HTTP client for testing the RequestScheduler transducer. +/// +/// This client uses simple in-memory types and configurable behavior to test +/// the authentication flow. The `executeRequestHandler` closure controls what +/// responses the mock server returns. +/// +/// The default `authDisposition` from `HTTPClientProtocol` is used, which: +/// 1. Checks if the request already has auth matching the challenge scheme → refresh +/// 2. Looks up stored credential → use it +/// 3. Otherwise → full auth flow +public enum TestHttpClient: HTTPClientProtocol { + + // MARK: - Configurable behavior + + /// Handler that controls the mock server's response for each request. + /// Set this in tests to simulate different server behaviors (401, 200, etc.). + public nonisolated(unsafe) static var executeRequestHandler: ((URLRequest) async throws -> HTTPURLResponse)? + + // MARK: - Associated types + + public typealias PlatformRequest = URLRequest + public typealias PlatformResponse = HTTPURLResponse + public typealias Credential = String + public typealias Parameters = [String: String] + public typealias AuthStorage = Mock.CredentialStore + + public enum AuthenticationScheme: String, Equatable, Sendable { + case bearer = "Bearer" + } + public struct ProtectionSpace: ProtectionSpaceProtocol, Hashable, Sendable { + public let scheme: AuthenticationScheme + public let host: String + + public func authenticateRequest( + _ request: URLRequest, + credential: Credential + ) async throws -> URLRequest { + var request = request + request.setValue( + "\(scheme.rawValue) \(credential)", + forHTTPHeaderField: "Authorization" + ) + return request + } + } + + public struct AuthenticationChallenge: AuthenticationChallengeProtocol, Sendable { + init(protectionSpace: ProtectionSpace) { + self.protectionSpace = protectionSpace + self.failureResponse = nil + self.failureCount = 0 + self.error = nil + } + + public var protectionSpace: ProtectionSpace + public var failureResponse: HTTPURLResponse? + public var failureCount: Int + public var error: Error? + } + + public struct Authentication: RequestAuthenticationProtocol { + public var scheme: AuthenticationScheme + public var parameters: Parameters? + } + + public struct AuthenticationRecord: AuthenticationRecordProtocol, Sendable { + init(protectionSpace: ProtectionSpace, credential: Credential?) { + self.protectionSpace = protectionSpace + self.credential = credential + } + + public var protectionSpace: ProtectionSpace + public var credential: Credential? + } + + public struct Request: RequestProtocol { + public typealias Continuation = Void + + public init(platformRequest: URLRequest) { + self.platformRequest = platformRequest + self.challenge = nil + self.continuation = () + } + + public var platformRequest: URLRequest + public var challenge: AuthenticationChallenge? + public var continuation: Continuation + + public func hash(into hasher: inout Hasher) { + hasher.combine(platformRequest) + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.platformRequest == rhs.platformRequest + } + } + + public struct Response: ResponseProtocol { + public init(platformResponse: HTTPURLResponse, for request: Request) { + self.platformResponse = platformResponse + self.request = request + } + + public var platformResponse: HTTPURLResponse + public var request: Request + } + + public enum AuthorizationServer: AuthorizationServerProtocol { + public static func authenticate( + for protectionSpace: ProtectionSpace + ) async throws -> AuthenticationRecord { + // Simulate: return a fresh credential (current timestamp as string). + return AuthenticationRecord( + protectionSpace: protectionSpace, + credential: "fresh-token-\(Date().timeIntervalSince1970)" + ) + } + + public static func refreshCredential( + for protectionSpace: ProtectionSpace, + using authentication: AuthenticationRecord + ) async throws -> Credential? { + // Simulate: return a refreshed token. + return "refreshed-token-\(Date().timeIntervalSince1970)" + } + } + + // MARK: - SecureStorage + + // Uses the existing Mock.CredentialStore from above. + // (Already defined in the file as Mock.CredentialStore) + + // MARK: - URLLoaderProtocol + public enum URLLoader: URLLoaderProtocol { + public static func data(for request: URLRequest) async throws -> HTTPURLResponse { + guard let handler = executeRequestHandler else { + throw URLError(.badServerResponse) + } + return try await handler(request) + } + } + + // MARK: - HTTPClientProtocol required implementations + + public static func enqueue(_ request: Request) async throws { + // For testing, directly execute the request. + let _ = try await executeRequest(request) + } + + public static func authenticatePlatformRequest( + _ platformRequest: URLRequest, + for protectionSpace: ProtectionSpace, + with credential: Credential + ) throws -> URLRequest { + var request = platformRequest + request.setValue( + "\(protectionSpace.scheme.rawValue) \(credential)", + forHTTPHeaderField: "Authorization" + ) + return request + } + + public static func parseAuthChallenge( + from response: HTTPURLResponse, + at: Int + ) -> AuthenticationChallenge? { + guard at == 0 else { return nil } + guard let authHeader = response.value(forHTTPHeaderField: "WWW-Authenticate") else { + return nil + } + // Parse "Bearer realm=..." or just "Bearer" + let schemeString = authHeader.split(separator: " ").first.map(String.init) ?? "" + guard let scheme = AuthenticationScheme(rawValue: schemeString) else { + return nil + } + let protectionSpace = ProtectionSpace( + scheme: scheme, + host: response.url?.host ?? "localhost" + ) + return AuthenticationChallenge(protectionSpace: protectionSpace) + } + + public static func parseAuthentication(from request: URLRequest) throws -> Authentication? { + guard let authHeader = request.value(forHTTPHeaderField: "Authorization") else { + return nil + } + let parts = authHeader.split(separator: " ", maxSplits: 1) + guard parts.count == 2 else { throw InvalidAuthenticationError() } + let schemeString = String(parts[0]) + guard let scheme = AuthenticationScheme(rawValue: schemeString) else { + throw InvalidAuthenticationError() + } + return Authentication(scheme: scheme, parameters: ["token": String(parts[1])]) + } + + public static var supportedAuthenticationSchemes: [AuthenticationScheme] { + [.bearer] + } + + // MARK: - RequestScheduler (conforms to RequestSchedulerProtocol) + + public enum TestScheduler: RequestSchedulerProtocol { + public typealias PlatformRequest = URLRequest + public typealias PlatformResponse = HTTPURLResponse + + public static func executeRequest(_ request: URLRequest) async throws -> HTTPURLResponse { + guard let handler = executeRequestHandler else { + throw URLError(.badServerResponse) + } + return try await handler(request) + } + } + + public typealias RequestScheduler = TestScheduler +} + + +// MARK: - RequestScheduler Transducer + +/// A transducer that orchestrates HTTP request execution with automatic authentication handling. +/// +/// The HTTP client is **stateless** — all request lifecycle, credential caching, and auth-flow +/// state lives inside the transducer's `State`. +/// +/// **Auth serialization:** All auth operations (`credential(for:)`, `authenticate(for:)`, +/// `refreshCredential(for:)`) are called inside a single task effect. The runtime gates +/// `compute()` so only one event chain runs at a time — auth tasks dispatched from different +/// `receivedResponse` events are serialized by the gate. +/// +/// ## Flow +/// +/// 1. Call site dispatches `.request(req)` → transducer executes the request via a task. +/// 2. Task completes → dispatches `.receivedResponse(resp)`. +/// 3. Transducer spawns an auth-check task that: +/// a. Parses the auth challenge (synchronously via `authenticationChallenge`). +/// b. If no challenge → dispatches `.requestCompleted(resp)` (terminal). +/// c. If challenge → calls `authDisposition`, performs auth, retries, dispatches `.requestCompleted(retryResp)`. +/// 4. `.requestCompleted` settles the state back to idle. +public enum RequestScheduler: Transducer, Sendable { + + // MARK: - Env + public typealias Env = Void + + // MARK: - State + public enum State: Sendable { + case start + case idle + case shuttingDown + case terminated + } + + // MARK: - Event + public enum Event: Sendable { + /// A request dispatched from a call site. + case request(Client.Request) + /// HTTP client returned a platform response, paired with the original request. + case receivedResponse(Client.Request, Client.PlatformResponse) + /// Auth check completed with a (possibly retried) response. Terminal for this request. + case requestCompleted(Client.PlatformResponse) + /// Shut down the transducer. + case shutdown + } + + // MARK: - Transducer + + public static var initialState: State { .start } + + public static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + + // ── Bootstrap ────────────────────────────────────────────────────── + case (.start, _): + state = .idle + return .event(event) + + // ── Idle ─────────────────────────────────────────────────────────── + case (.idle, .request(let request)): + return executeRequestEffect(request) + + case (.idle, .receivedResponse(let request, let platformResponse)): + return authCheckTask(request: request, platformResponse: platformResponse) + + case (.idle, .requestCompleted): + return .none + + case (.idle, .shutdown): + state = .terminated + return .none + + // ── Shutting down / terminated ───────────────────────────────────── + case (.shuttingDown, _), (.terminated, _): + return .none + } + } + + public static func response(state: State, event: Event) -> Client.PlatformResponse? { + switch event { + case .requestCompleted(let platformResponse): + return platformResponse + default: + return nil + } + } + + // MARK: - Private: Effect builders + + /// Task that executes a single request through the HTTP client. + /// Each request gets a unique task identifier derived from the request's Hashable conformance. + private static func executeRequestEffect(_ request: Client.Request) -> Effect { + .task(id: TaskIdentifier(request), .switchToLatest) { input, env in + let response = try await Client.executeRequest(request) + return .receivedResponse(request, response.platformResponse) + } + } + + /// Per-request task that checks for an auth challenge and either settles or performs the auth flow. + /// Uses a unique task identifier so multiple concurrent auth checks don't interfere. + private static func authCheckTask(request: Client.Request, platformResponse: Client.PlatformResponse) -> Effect { + .task(id: TaskIdentifier("auth-\(request.hashValue)"), .switchToLatest, nonsendingOperation: { input, env async throws -> TaskReturn in + let response = Client.Response(platformResponse: platformResponse, for: request) + + // No challenge or no auth action needed → settle immediately. + guard let challenge = try? Client.authenticationChallenge(for: response), + let disposition = try await Client.authDisposition(for: response, challenge: challenge) + else { + return .response(.requestCompleted(platformResponse)) + } + + switch disposition { + case .none: + return .response(.requestCompleted(platformResponse)) + + case .authenticateRequestAndRetry(_, let credential): + let authenticatedRequest = try Client.authenticateRequest( + response.request, for: challenge, with: credential + ) + let retryResponse = try await Client.executeRequest(authenticatedRequest) + return .response(.requestCompleted(retryResponse.platformResponse)) + + case .refreshCredentialAndRetry(let protectionSpace): + guard let newCredential = try await Client.refreshCredential(for: protectionSpace) else { + return .response(.requestCompleted(platformResponse)) + } + let authenticatedRequest = try Client.authenticateRequest( + response.request, for: challenge, with: newCredential + ) + let retryResponse = try await Client.executeRequest(authenticatedRequest) + return .response(.requestCompleted(retryResponse.platformResponse)) + + case .authenticateSessionAndRetry(let protectionSpace): + let credential = try await Client.authenticate(for: protectionSpace) + let authenticatedRequest = try Client.authenticateRequest( + response.request, for: challenge, with: credential + ) + let retryResponse = try await Client.executeRequest(authenticatedRequest) + return .response(.requestCompleted(retryResponse.platformResponse)) + } + }) + } +} diff --git a/Examples/HTTPClient/HttpClientTests.swift b/Examples/HTTPClient/HttpClientTests.swift new file mode 100644 index 0000000..7fd96ef --- /dev/null +++ b/Examples/HTTPClient/HttpClientTests.swift @@ -0,0 +1,260 @@ +import Testing +import Foundation +import Transduce +import Mutex + +@Suite(.serialized) +struct HttpClientTests { + + @Suite(.serialized) + struct NetworkRequestWithAuthentication { + + @Test + func canAuthenticate() async throws { + // Arrange: server returns 401 on first request, 200 after auth. + var requestCount = 0 + + TestHttpClient.executeRequestHandler = { request in + requestCount += 1 + if requestCount == 1 { + // First request: no auth → 401 + return HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: "HTTP/1.1", + headerFields: ["WWW-Authenticate": "Bearer realm=test"] + )! + } else { + // After auth: 200 + return HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + } + } + + // Act: create a host and dispatch a request. + let host = try TransducerHost, MainActor>( + initialState: .start + ) + + let urlRequest = URLRequest(url: URL(string: "https://api.example.com/data")!) + let result = try await host.input.request( + .request(TestHttpClient.Request(platformRequest: urlRequest)) + ) + let httpResonse = try #require(result) + + print("Response: \(httpResonse)") + + // Assert: the request was authenticated and retried. + #expect(requestCount == 2) + #expect(result != nil) + #expect(result!.statusCode == 200) + } + + @Test + func multipleOverlappingAuthenticateReceiveTheSameCredential() async throws { + // Arrange: server returns 401 on first 2 requests, 200 after. + // This verifies that multiple overlapping requests during auth + // share the same authentication result. + let mutex: Mutex = .init(0) + + TestHttpClient.executeRequestHandler = { request in + let count = mutex.withLock { count -> Int in + let current = count + count += 1 + return current + } + if count < 2 { + return HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: "HTTP/1.1", + headerFields: ["WWW-Authenticate": "Bearer realm=test"] + )! + } else { + return HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + } + } + + let host = try TransducerHost, MainActor>( + initialState: .start + ) + + let url = URL(string: "https://api.example.com/data")! + + // Fire two overlapping requests. + async let r1: HTTPURLResponse? = host.input.request( + .request(TestHttpClient.Request(platformRequest: URLRequest(url: url))) + ) + async let r2: HTTPURLResponse? = host.input.request( + .request(TestHttpClient.Request(platformRequest: URLRequest(url: URL(string: "https://api.example.com/data2")!))) + ) + + let (result1, result2) = try await (r1, r2) + + // Both should succeed with 200. + #expect(result1?.statusCode == 200) + #expect(result2?.statusCode == 200) + } + + @Test + func requestWhenNotAuthenticated() async throws { + // Arrange: server always returns 401 (auth will fail). + TestHttpClient.executeRequestHandler = { request in + HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: "HTTP/1.1", + headerFields: ["WWW-Authenticate": "Bearer realm=test"] + )! + } + + let host = try TransducerHost, MainActor>( + initialState: .start + ) + + let urlRequest = URLRequest(url: URL(string: "https://api.example.com/data")!) + + // The auth flow will call TestHttpClient.authenticate which returns + // a fresh credential, then retry. But the server always returns 401, + // so the retry also gets 401. + let result = try await host.input.request( + .request(TestHttpClient.Request(platformRequest: urlRequest)) + ) + + // Result is the 401 response from the retry. + #expect(result?.statusCode == 401) + } + + @Test + func requestWhenAuthenticated() async throws { + // Arrange: server returns 200 when Authorization header is present, + // 401 otherwise. + TestHttpClient.executeRequestHandler = { request in + let hasAuth = request.value(forHTTPHeaderField: "Authorization") != nil + let status = hasAuth ? 200 : 401 + return HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: hasAuth ? nil : ["WWW-Authenticate": "Bearer realm=test"] + )! + } + + let host = try TransducerHost, MainActor>( + initialState: .start + ) + + let urlRequest = URLRequest(url: URL(string: "https://api.example.com/data")!) + + // First request: no auth → 401 → authenticate → retry with auth → 200. + let result = try await host.input.request( + .request(TestHttpClient.Request(platformRequest: urlRequest)) + ) + #expect(result?.statusCode == 200) + + // Second request: credential is now stored → authenticate with stored cred → 200. + let result2 = try await host.input.request( + .request(TestHttpClient.Request(platformRequest: urlRequest)) + ) + #expect(result2?.statusCode == 200) + } + + @Test + func requestWhenNotAuthenticatedAndAuthenticationFails() async throws { + // Arrange: always returns 401. Auth server throws on authenticate. + TestHttpClient.executeRequestHandler = { request in + HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: "HTTP/1.1", + headerFields: ["WWW-Authenticate": "Bearer realm=test"] + )! + } + + // Override the authorization server to simulate failure. + // NOTE: This test verifies that when the full auth flow fails, + // the error propagates correctly. + + let host = try TransducerHost, MainActor>( + initialState: .start + ) + + let urlRequest = URLRequest(url: URL(string: "https://api.example.com/data")!) + + // The authenticate() call will succeed (mock returns a credential), + // but the server still returns 401 on retry. Result is the 401. + let result = try await host.input.request( + .request(TestHttpClient.Request(platformRequest: urlRequest)) + ) + #expect(result?.statusCode == 401) + } + + @Test + func requestWhenAuthenticatedButCredentialsAreExpired() async throws { + // Arrange: first request → 200 (with existing stored credential). + // Second request → 401 (expired) → refresh → retry → 200. + let mutex: Mutex = .init(0) + + TestHttpClient.executeRequestHandler = { request in + let count = mutex.withLock { count -> Int in + let current = count + count += 1 + return current + } + let hasAuth = request.value(forHTTPHeaderField: "Authorization") != nil + if count == 1 && hasAuth { + // First request with auth → 200. + return HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + } else if count == 2 { + // Second request → 401 (expired). + return HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: "HTTP/1.1", + headerFields: ["WWW-Authenticate": "Bearer realm=test"] + )! + } else { + // After refresh → 200. + return HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + } + } + + let host = try TransducerHost, MainActor>( + initialState: .start + ) + + let urlRequest = URLRequest(url: URL(string: "https://api.example.com/data")!) + + // First request: auth succeeds → 200. + let r1 = try await host.input.request( + .request(TestHttpClient.Request(platformRequest: urlRequest)) + ) + #expect(r1?.statusCode == 200) + + // Second request: credential expired → 401 → refresh → retry → 200. + let r2 = try await host.input.request( + .request(TestHttpClient.Request(platformRequest: urlRequest)) + ) + #expect(r2?.statusCode == 200) + } + } +} diff --git a/Examples/HTTPClient/MOCKHTTPCLIENT.md b/Examples/HTTPClient/MOCKHTTPCLIENT.md new file mode 100644 index 0000000..5019fc4 --- /dev/null +++ b/Examples/HTTPClient/MOCKHTTPCLIENT.md @@ -0,0 +1,186 @@ +# MockHttpClient - Compile-Time Policy-Based HTTP Client + +## Overview + +`MockHttpClient` is a **component** (not part of the core Transduce library) that demonstrates how to build a policy-based HTTP client using Swift's type system. It serves as both: + +1. A **testable HTTP client** for Transduce-based applications +2. A **demonstration** of compile-time policy-based design in Swift + +## Design Philosophy + +### The Problem with Traditional HTTP Clients + +Traditional HTTP clients (like `URLSession`) use: +- **Delegates** - scattered state and logic +- **Callbacks** - complex async flow +- **Runtime polymorphism** - `any Protocol` with existential overhead + +### The Transduce Approach + +`MockHttpClient` uses **compile-time policies**: + +```swift +enum MockHttpClient< + RequestScheduler: RequestSchedulerProtocol, + URLLoader: URLLoaderProtocol +>: HTTPClientProtocol, URLLoaderProtocol { + // Static methods as protocol witnesses +} +``` + +## Key Concepts + +### 1. Protocol-Oriented with Static Dispatch + +- **No instances** - the type itself is the witness +- **Static methods** - protocol conformance via static dispatch +- **Zero runtime overhead** - compiler monomorphizes each generic specialization + +### 2. Layered Composition (Lego-Style) + +``` +MockHttpClient + ↓ + HTTPClientProtocol (adds authentication) + ↓ + URLLoaderProtocol (raw request/response) +``` + +Each layer is a **policy** that adds behavior while maintaining the same interface. + +### 3. Compile-Time Dependency Injection + +Instead of: +```swift +class HttpClient { let urlLoader: URLLoader } +``` + +We use: +```swift +enum HttpClient { ... } +``` + +The **type** is the dependency, injected at compile time. + +## Transducer Features + +The Transduce runtime provides several built-in capabilities that work with `MockHttpClient`: + +### 1. Pure Logic & State Machine + +- **State is explicit** - `State` enum with `.start`, `.idle(credential:)`, `.unauthorized` +- **Effects are declarative** - `.task`, `.cancel`, `.action`, `.sequence` +- **No side effects** - all state transitions are pure functions + +### 2. Authorization Server Integration + +Effects call the authorization server: +- `Env.authenticate(for:)` - User authentication flow +- `Env.refreshCredential(for:)` - Token refresh +- `Env.credential(for:)` - Retrieve stored credentials + +### 3. HTTP Client Integration + +Effects call the HTTP client: +- `Env.executeRequest(_:)` - Execute authenticated requests +- `Env.authenticatePlatformRequest(_:for:with:)` - Add auth headers +- `Env.parseAuthChallenge(from:at:)` - Parse 401 responses + +### 4. Automatic Request Enqueuing + +When a request needs authentication but an auth flow is in progress: +- **Requests are queued** - Transducer buffers them +- **Single auth flow** - Only one authentication at a time +- **Automatic retry** - Queued requests replay after auth completes + +### 5. Subscribing Feature (Task Deduplication) + +- **`.shareable`** - Multiple waiters share the same task +- **`.switchToLatest`** - Cancel old, start fresh +- **No duplicate work** - Runtime handles task coalescing + +### 6. Task Lifecycle Management + +- **Automatic cancellation** - Tasks cancel when host deinitializes +- **No dangling closures** - Runtime tracks task lifetime +- **Clean teardown** - Effects can `.cancel` tasks on state changes + +## Usage + +### Basic Setup + +```swift +// 1. Create the host (user only needs to know this) +let host = TransducerHost, MainActor>( + initialState: .start, + env: MockHttpClient() +) + +// 2. Use the host's domain API +let response = try await host.request(.someRequest) +``` + +### Customizing Behavior + +To add features (progress tracking, retry, logging): + +1. Extend the `URLLoaderProtocol` with new methods +2. Implement those methods in your loader type +3. Update the transducer to use the new functionality + +No changes to the engine needed! + +## Protocol Hierarchy + +### Core Protocols + +- **`URLLoaderProtocol`** - Raw request/response +- **`HTTPClientProtocol`** - Adds authentication layer +- **`RequestSchedulerProtocol`** - Scheduling strategy +- **`SecureStorageProtocol`** - Credential storage + +### Supporting Types + +- **`ProtectionSpaceProtocol`** - Auth domain definition +- **`AuthenticationChallengeProtocol`** - Auth challenges +- **`RequestAuthenticationProtocol`** - Request-level auth +- **`AuthorizationServerProtocol`** - Token refresh + +## Why This Pattern? + +### Advantages + +- **Composability** - Layer behaviors like Lego blocks +- **Testability** - Swap implementations via generics +- **Type safety** - Compile-time verification +- **Performance** - Zero-runtime-overhead static dispatch + +### Tradeoffs + +- **Complexity** - More protocols to understand +- **Compile time** - Generic monomorphization +- **Learning curve** - Unusual compared to OOP patterns + +## Comparison: URLSession vs MockHttpClient + +| Aspect | URLSession | MockHttpClient | +|--------|-----------|----------------| +| **Polymorphism** | Runtime (`any Protocol`) | Compile-time (generics) | +| **State** | Scattered across delegates | Centralized in transducer | +| **Async** | Callbacks | `async/await` | +| **Complexity** | 50+ delegate methods | 10-15 protocol requirements | +| **Testability** | Hard (requires mock session) | Easy (swap generic parameter) | + +## Future Work + +- Implement `MockHttpClient.data(for:)` - the core URL loading +- Add concrete implementations for common use cases +- Create example transducers that use the client +- Document the policy-based design pattern + +## Related Documentation + +- `Documentation/RuntimeDesign.md` - Effect runtime model +- `Documentation/UsingEnvForDependencyInjection.md` - Env pattern +- `Documentation/Recipes.md` - Common patterns diff --git a/Package.resolved b/Package.resolved index 6c83d55..778c981 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6552e058d505046c858cb5668cadd1f52f912dcd6c20ec9baf5aa17fee6f9846", + "originHash" : "d51b24f0aeaec8a689201e2284303ffdae656b70080843eae7000e687b812c89", "pins" : [ { "identity" : "swift-mutex", diff --git a/Package.swift b/Package.swift index eb71082..34c68a5 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.2 +// swift-tools-version: 6.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -8,38 +8,43 @@ let package = Package( platforms: [ .iOS(.v15), .macOS(.v12), - .watchOS(.v8), + .watchOS(.v9), .macCatalyst(.v15), - .tvOS(.v12) + .tvOS(.v15) ], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. .library( - name: "EffectComponents", - targets: ["EffectComponents"] + name: "Transduce", + targets: ["Transduce"] ), ], dependencies: [ - .package(url: "https://github.com/swhitty/swift-mutex.git", from: "0.0.1"), + .package(url: "https://github.com/swhitty/swift-mutex.git", from: "0.0.6"), ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. .target( - name: "EffectComponents", + name: "Transduce", dependencies: [ - .product(name: "Mutex", package: "swift-mutex"), + .product( + name: "Mutex", + package: "swift-mutex" + ), ], + path: "Sources/Transduce", swiftSettings: [ + // This forces the compiler to turn on the Swift 6 language mode features + .enableUpcomingFeature("Swift6"), ] ), .testTarget( - name: "EffectComponentsTests", + name: "TransduceTests", dependencies: [ - "EffectComponents", - .product(name: "Mutex", package: "swift-mutex"), + "Transduce", ], + path: "Tests/Transduce", swiftSettings: [ + // This forces the compiler to turn on the Swift 6 language mode features + .enableUpcomingFeature("Swift6"), ] ), ], diff --git a/README.md b/README.md index 237569c..aaf3f3e 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,242 @@ -# EffectComponents +# Transduce [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fcouchdeveloper%2FEffectComponents%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/couchdeveloper/EffectComponents) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fcouchdeveloper%2FEffectComponents%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/couchdeveloper/EffectComponents) -EffectComponents is a SwiftUI-first library for building composable finite-state components that emit and manage effects. +> **Status:** Beta (v1.0 Release Candidate) — Production-ready for iOS, macos, SwiftUI. -Here, an *effect* means follow-up work caused by a state transition: starting a task, calling a service, waiting, cancelling, observing, or sending the next event back into the system. +**Transduce** is a deterministic reactive runtime with a pure state machine at its core. The runtime owns all concurrency, task management, synchronization, and effect execution, leaving the developer—or an LLM—to specify only state transitions and business rules. -EffectComponents contains a SwiftUI view `EffectView` that is specifically useful for SwiftUI developers who are tired of ViewModels that keep absorbing async methods, loading flags, `Task` handles, cancellation logic, and UI glue. It gives your view one event-driven place where state changes are decided. +## What This Library Is (and Isn't) -You can think of `EffectView` as SwiftUI's `task` modifier taken further. Instead of attaching async work ad hoc to views, you return effects from `update`, and the runtime tracks, replaces, cancels, and routes that work by event. +**What it is:** +- A **library**, not a full application framework +- The essential components for building large SwiftUI apps with structured state management +- A pattern-based approach (FSM/MVI) that works with SwiftUI's native observation system +**What it isn't:** +- ❌ Not a complete application framework (you still need network code, database, etc.) +- ❌ Not a testing framework (you still need mocking frameworks, test doubles, etc.) +- ❌ Not a vendor lock-in solution (no large codebase, no slow build times) -## The problem +**The library provides:** +- ✅ Structured effect management (tasks, actions, cancellation) +- ✅ Pure `transduce` functions for testable business logic +- ✅ Dependency injection via `Env` +- ✅ Task lifecycle management (auto-cancellation, overlap policies) -Most ViewModels start small and quickly turn into this: +**You still need to provide:** +- Implementation details (network clients, database access, etc.) +- Testing infrastructure (mocking frameworks, test doubles, etc.) +- App architecture decisions (feature boundaries, module organization) -- button actions and `task` modifiers mutate state -- view-scoped tasks are cancelled when the view disappears, while deliberate cancellation stays awkward -- ViewModel logic starts fighting race conditions -- logic gets split across view, ViewModel, and model -- two-way bindings further complicate the code and edge cases get missed -- tests get harder to write, and mocks replace logic instead of verifying it +## Guardrails & Conventions -The SwiftUI `task` modifier behavior is often surprising in practice. A timer started from a tab's root view is cancelled when the user switches tabs, then restarted when the view appears again. Work you expected to keep running gets torn down and started again just because the view went off-screen. +This library enforces **conventions over creativity** — not to restrict developers, but to enable reliable AI-assisted development: -## The solution +- **What it enforces**: State, Event, Env, Response, transduce function structure +- **What it enables**: AI can reliably generate and review code because patterns are consistent +- **Why this is valuable**: Focus on business logic, not boilerplate; everyone knows where to find what -With `EffectView`, you move a feature's logic into a small stand-alone enum that declares `State`, `Event`, one `update` function, and optionally an `output` function for request-style calls that return a result. +This is not about limiting creativity - it's about **focusing creativity** on what matters: business logic, not architecture. -`update` is a plain synchronous function: it receives the current state and an event, changes state, and decides what should happen next. It does not call services, start tasks, or cause side effects itself. - -If more work is needed, `update` returns an effect: a value that describes the next operation for the runtime. Some effects start asynchronous work, while others synchronously feed another event back into `update` in the same dispatch. That means one external event can unfold through an immediate chain of internal events before the computation settles. The split keeps the logic easy to read and easy to test. - -For flows that need a value back, callers use `try await input.request(...)`. If the transducer defines `output(state:event:)`, the request returns the output produced for the last event in the settled computation chain. - -If you know Redux or TCA, a `Transducer` plays a similar role to what those architectures often call a reducer. `EffectComponents` uses "transducer" because `update` does more than reduce state from an event: it can drive an immediate effect/event chain, start managed async work, and produce an output for request-style dispatch. - -The example below is a small debounced search feature. Read it as a transition table: query changes put the feature into a loading state and start a named search task; response events then settle the state back into either results or an error. +## Quick Start ```swift -import EffectComponents import SwiftUI - -enum SearchFeature: Transducer { - struct State { - var query = "" - var isLoading = false - var results: [String] = [] - var errorMessage: String? - } - - enum Event { - case queryChanged(String) - case searchResponse([String]) - case searchFailed(String) +import Transduce + +enum Counter: Transducer { + struct State: Equatable { var count = 0 } + enum Event: Equatable { case increment, decrement } + + static let initialState: State = .init() + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .increment: state.count += 1 + case .decrement: state.count -= 1 + } + return .none } +} - struct Env: Sendable { - var search: @Sendable (String) async throws -> [String] - } +struct ContentView: View { + @State private var state = Counter.State() - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .queryChanged(let query): - state.query = query - state.isLoading = true - state.errorMessage = nil - - return .task(id: "search") { input, env in - try? await Task.sleep(for: .milliseconds(300)) - guard !Task.isCancelled else { return } - - do { - let results = try await env.search(query) - try input.post(.searchResponse(results)) - } catch { - try input.post(.searchFailed(error.localizedDescription)) - } + var body: some View { + EffectView(of: Counter.self, state: $state) { state, input in + HStack { + Button("-") { try? input(.decrement) } + Text("\(state.count)") + Button("+") { try? input(.increment) } } - - case .searchResponse(let results): - state.results = results - state.isLoading = false - return nil - - case .searchFailed(let message): - state.results = [] - state.errorMessage = message - state.isLoading = false - return nil } } } ``` -`update` is the only place that decides how the feature changes. -When `update` returns `nil`, processing stops there. When `update` returns `.run(id: "search")`, the runtime starts that async job, tracks it by identifier, and routes follow-up events back through `update`. That task can also be cancelled in the update function by its identifier. +## What is a Reactive Transducer? + +A reactive transducer reduces events into state transitions and side effects. In short: -That means no `Task?` stored in a ViewModel, no ad-hoc mutation from random callbacks, and no guessing where the last state change came from. + δ : S × E → F // State × Event → Effect (describe work to do) + λ : S × E × S' → O // Updated State, Event → Response (produce output) -## Use it from SwiftUI +- A pure `transduce` function decides all state changes — it is the sole mutation point. +- Structured effects: start/cancel tasks, run action chains, or sequence operations—declaratively. +- Managed lifetime via hosts: SwiftUI `EffectView` and other hosts like Observables or Actors. +- Response is derived purely from State and Event. +- Clear async semantics at the call site with `post`, `send`, and `request`. -Use the generic `EffectView`, which implements the "FSM Effect Actor". This is a standard SwiftUI view that retrieves its state from a parent view, in this case the `SearchView`. The `EffectView` contains a `Content` view defined as a `@ViewBuilder` closure with two parameters: the state and the input value. The state dictates what to render while the input allows you to send user intents (or `Events`) into the `EffectView` – or more precisely, into its underlying state machine – which is the `update` function. +Transduce is SwiftUI-first but not SwiftUI-only—the core model is entirely UI-agnostic. +## Installation ```swift -struct SearchView: View { - @State private var state = SearchFeature.State() +.package(url: "https://github.com/couchdeveloper/EffectComponents.git", from: "0.10.0") +``` - let env: SearchFeature.Env +Add Transduce to your target dependencies. - var body: some View { - EffectView( - of: SearchFeature.self, - state: $state, - initialEnv: env - ) { state, input in - VStack { - TextField( - "Search", - text: Binding( - get: { state.query }, - set: { try? input.post(.queryChanged($0)) } - ) - ) - - if state.isLoading { - ProgressView() - } +```swift +// Target +.target( + name: "YourApp", + dependencies: [ + .product(name: "Transduce", package: "EffectComponents") + ] +) +``` - List(state.results, id: \.self, rowContent: Text.init) - } - .padding() + +## Key ideas + +| Concept | What it does | +|---------|-------------| +| **Transducer** | `(inout State, Event) → Effect` — the single place that mutates state and describes follow-up work. | +| **Effect** | A declarative description of what to do next (`.task`, `.cancel`, `.action`, `.sequence`, `.none`). The runtime performs it; your code only describes intent. | +| **Host** | Owns task lifetime and routes events (`EffectView` for SwiftUI, `BaseRuntime` as base class). | +| **Env** | Immutable dependency capture at host initialization — forwarded to every effect invocation. | + +**Dispatch styles:** `post` (fire-and-forget), `send` (await processing), `request` (await result + return a value). + +## Package Modules + +| Module | Status | Purpose | +|--------|--------|---------| +| **Transduce** | Public (v1.0) | Effect runtime — transducer types, effect system, event dispatch, task lifecycle, non-Combine observation layer. | +| *Expect* | Internal only | Lightweight expectation testing (`.expect()` on `Result`). Only used internally by this package's tests. Not published as a standalone API at this time. | + +**Supported platforms:** iOS 15+, macOS 12+, watchOS v9, tvOS 15+, macCatalyst 15+. + +## Environments (Env) + +Pass dependencies — clocks, API clients, schedulers — via `Env`. The value is captured once when the host initializes and forwarded to every effect. No singletons: + +```swift +struct Dependencies { var api: APIClient; var clock: AnyClock } + +enum MoviesFeature: Transducer { + enum State { case idle, loading, loaded([Movie]), error(Error) } + enum Event { case loadRequested, moviesLoaded([Movie]), loadFailed(Error) } + + typealias Env = Dependencies + static let initialState: State = .idle + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.idle, .loadRequested): + state = .loading + return .task(id: "load") { input, env in + let movies = try await env.api.fetch() + try? input(.moviesLoaded(movies)) + } + + case (_, .moviesLoaded(let movies)): + state = .loaded(movies) + return .none + + default: + return .none } } } ``` -The view renders state and posts events. The feature logic stays in `update`. +Env changes are handled via `.id(envId)` at the call site — this destroys the old view (cancelling all tasks) and creates a fresh instance with updated dependencies. -When familiar with ViewModels, EffectView essentially implements the view model without requiring an Observable class instance. It also facilitates testing of the logic as the Transducer is a plain synchronous pure function. Furthermore, it enables the easy mocking of services because the infrastructure is already in place: effects receive a parameter `env` which is a struct or class containing dependencies provided from the SwiftUI environment. -## Why is an EffectView useful +## Real-world Architecture Patterns -- state changes stay local and explicit -- async work is started from one place -- repeated work can be replaced by identifier -- tests can drive `update` with plain values +**Transduce** excels at solving difficult dependency management problems where standard singletons typically fail — for example, network requests racing against async database initialization. -## Installation +### 1. The Initialization Pattern (Request Buffering) +When managing **CoreData migrations** or **heavy network managers**, you usually have to build custom `DispatchGroup`s, semaphores, or `NSNotifications` just to block early requests until the manager is ready. + +Because **Transduce** processes events through a strict queue, if multiple clients call `input.send(.fetch)` while the manager is initializing, those requests simply "hang" in the event backlog. They resume only after your initialization task emits a `.didInit` event — serializing access across components without global locks or race conditions. ```swift -.package(url: "https://github.com/couchdeveloper/EffectComponents.git", from: "0.8.0") +// ─── Database Repository Transducer ─── +enum DatabaseManager: Transducer { + enum State { case loading, ready } + enum Event { case startMigration, didInit, fetchData } + + static let initialState: State = .loading + + // Note: We use `task` internally to bootstrap the manager + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.loading, .startMigration): + // Performs async setup. Until this emits `.didInit`, + // ALL subsequent requests buffer automatically in the queue! + return .task(id: "init") { [weak input] _, _ in + try await performHeavyCoreDataMigration() + if let i = input { try await i.post(.didInit) } + } + + case (.ready, .fetchData): + // This only fires once the `.managerReady` event is received + return .none + } + } +} ``` -Add `EffectComponents` to your target dependencies. + +### 2. Call-Site Owned Tasks & Complex Setups +Use Transduce's call-site (caller-owned) tasks to manage HTTP requests exactly like URLSession, but with deterministic lifecycle control. If the user navigates away during a request, the task is automatically cancelled and unmounted—preventing dangling closures or crashed view states that standard singletons struggle to track. + +This pattern scales beautifully to: + - Multi-server clients with strict state up/down sequences (e.g., handshake → auth → sync) + - Downloader managers where requests can hang in the queue waiting for bandwidth or credentials + - Authenticators that tear down stale sessions and enforce clean re-initialization without race conditions + +Because every effect is a pure, serializable description of work, you get predictable teardown sequences across complex dependency graphs without manual cleanup boilerplate. + +## Core Principles + +A computation is initiated by an external event. +During a computation: + - At most one event is processed at a time. + - External events are suspended. + - Effects may suspend. + - Effects may emit internal events. + - Internal events are processed immediately. + - Tasks are owned by the transducer. + - Tasks may be cancelled. + - A computation terminates when no internal event and no immediate effect remains. + - Responses are produced only when a request computation terminates. ## Learn more -- [Recipes](Documentation/Recipes.md) -- [SwiftUI first](Documentation/SwiftUIFirst.md) +- [Getting started with SwiftUI](Documentation/SwiftUIFirst.md) - [Taming async tasks in SwiftUI views](Documentation/TamingAsyncTasksInSwiftUIViews.md) +- [Correct by construction (FSM/MVI)](Documentation/CorrectByConstruction.md) +- [Effect hosts and dependency env](Documentation/UsingEnvForDependencyInjection.md) - [Bridging event-driven and imperative code](Documentation/BridgingEventDrivenAndImperative.md) +- [Recipes](Documentation/Recipes.md) ## License diff --git a/Sources/EffectComponents/EffectActor/ActorStateBinding.swift b/Sources/EffectComponents/EffectActor/ActorStateBinding.swift deleted file mode 100644 index 0dbd4d6..0000000 --- a/Sources/EffectComponents/EffectActor/ActorStateBinding.swift +++ /dev/null @@ -1,85 +0,0 @@ - -/// A lightweight helper that provides safe, isolated access to mutable state owned by an actor. -/// -/// ActorStateBinding encapsulates a pair of closures that read and write a value -/// from within a specific actor’s isolation domain. It allows callers that are -/// already isolated to the host actor to: -/// - Read the current state value synchronously. -/// - Perform an in-place mutation of the value via an async closure, ensuring the -/// updated value is written back to the actor when the operation completes. -/// -/// This type is useful when you want to expose a specific piece of actor state -/// to internal utilities or helpers without revealing the entire actor, while still -/// respecting Swift’s actor isolation guarantees. -/// -/// Type Parameters: -/// - Host: The actor type that owns the state. -/// - Value: The type of the state value being accessed and mutated. -/// -/// Initialization: -/// - get: A closure that reads the value from the isolated actor. -/// - set: A closure that writes a new value to the isolated actor. -/// -/// Thread-safety and Isolation: -/// - Calls to `state(systemActor:)` and `withValue(systemActor:body:)` must be made -/// from within the host actor’s isolation (e.g., methods on the actor or `await` -/// calls that hop into the actor). This preserves data-race freedom. -/// -/// Performance: -/// - `withValue` copies the value out and writes it back once. For large Value types, -/// consider using reference semantics or splitting state to reduce copying. -/// -/// See Also: -/// - Swift Concurrency, Actor isolation, `isolated` parameters, and `nonisolated(nonsending)` closures. -public struct ActorStateBinding { - private let _get: (isolated Host) -> Value - private let _set: (isolated Host, Value) -> Void - - /// Initializes an ActorStateBinding with custom get and set closures that operate - /// under the host actor’s isolation. - /// - /// - Parameters: - /// - get: A closure that reads and returns the state value from an isolated host. - /// - set: A closure that writes a new state value to an isolated host. - /// - /// - /// Returns the current value of the bound state while the caller is isolated to the host actor. - /// - /// - Parameter systemActor: The isolated host actor, usually passed implicitly as `#isolation` - /// from within actor-isolated contexts. Defaults to `#isolation`. - /// - Returns: The current state value. - /// - /// - /// Reads the current value, allows in-place mutation in the provided body, and writes - /// the updated value back to the host actor when the body completes. - /// - /// The body runs nonisolated with an inout copy of the value. Upon return (including - /// when throwing), the possibly-mutated value is written back to the actor. - /// - /// - Parameters: - /// - systemActor: The isolated host actor. - /// - body: An async throwing closure that receives an inout Value to mutate. - /// - Returns: The value returned by `body`. - /// - Throws: Rethrows any error thrown by `body`. - /// - Note: Use this for compound updates to keep get/set paired and scoped to the operation. - public init( - get: @escaping (isolated Host) -> Value, - set: @escaping (isolated Host, Value) -> Void - ) { - self._get = get - self._set = set - } - - public func state(systemActor: isolated Host = #isolation) -> Value { - _get(systemActor) - } - - // Require the host to be isolated at the call site - public func withValue( - systemActor: isolated Host, body: nonisolated(nonsending) (inout Value) async throws -> R - ) async rethrows -> R where R: Sendable { - var s = _get(systemActor) - defer { _set(systemActor, s) } - return try await body(&s) - } -} diff --git a/Sources/EffectComponents/EffectActor/EffectActor.Input.swift b/Sources/EffectComponents/EffectActor/EffectActor.Input.swift deleted file mode 100644 index 5d2f95b..0000000 --- a/Sources/EffectComponents/EffectActor/EffectActor.Input.swift +++ /dev/null @@ -1,142 +0,0 @@ -@available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) -extension EffectActor { - - /// `Sendable` input handle for dispatching events into an ``EffectActor``. - /// - /// `EffectActor.Input` exposes the same runtime entry points as the host - /// actor while keeping only a weak reference to that actor: - /// - /// - ``send(_:)`` dispatches immediately and awaits the synchronous - /// reduction path. - /// - ``post(_:)`` schedules fire-and-forget dispatch. - /// - ``request(_:)`` suspends until the triggered effect chain settles. - /// - /// ### Isolation and lifetime safety - /// - /// All state mutation still runs on the owning ``EffectActor``. The input - /// handle itself is safe to move across isolation domains because it only - /// stores a weak actor reference. If that actor has already been released, - /// operations fail with ``RuntimeError/actorDeallocated``. - /// - /// If the calling `Task` is cancelled while awaiting ``request(_:)``, the - /// suspension continues until the accepted effect chain reaches a terminal - /// outcome. - /// - /// ### Generic parameters - /// - /// - `Event`: The event type dispatched into the state machine. - /// - `Output`: The value returned by ``request(_:)``. - /// Use `Void` when no return value is needed. - public struct Input: TransducerInput, Sendable { - - init(_ actor: EffectActor) { - self.actor = actor - } - - private weak let actor: EffectActor? - - /// Dispatches `event` synchronously on the Actor. - /// - /// Use `send` when you are already running on the Actor and want the event to be - /// processed immediately, in the same synchronous turn. A typical example is a SwiftUI - /// button action: - /// - /// ```swift - /// Button("Increment") { - /// // Processed before the next await point: - /// input.send(.increment) - /// } - /// ``` - /// - /// "Synchronous" here means that `update` is called inline, any `.action` chain is - /// unwound, and the resulting state change is applied — all before `send` returns. - /// If `update` returns a `.task`, that task is *launched* synchronously but runs - /// concurrently; `send` does not wait for it to finish. Use ``request(_:)`` if you - /// need to await the task's completion. - /// - /// If you want to fire-and-forget the event — scheduling it without waiting for even - /// the synchronous `update` pass to complete — use ``post(_:)`` instead. - /// - /// - Warning: Because `send` unwinds `.action` chains synchronously on the Actor, - /// a cycle in your `update` function — e.g. `.ping` → `.action { .pong }` → `.action { .ping }` → … — - /// will loop forever and hang the main thread. ``post(_:)`` and ``request(_:)`` are - /// immune because each re-entry is scheduled as a new task, yielding control between iterations. - /// - Parameter event: The event to send into the actor-hosted runtime. - /// - Throws: ``RuntimeError/actorDeallocated`` if the host actor has - /// already been released, plus any error that ``EffectActor/send(_:)`` - /// would throw for the same event. - public func send(_ event: sending Event) async throws { - guard let actor else { - throw RuntimeError.actorDeallocated - } - try await actor.send(event) - } - - /// Schedules `event` on the Actor without awaiting it. - /// - /// Safe to call from any actor isolation or non-isolated context. - /// Use this to fire-and-forget an event from a background task or a - /// non-isolated callback without waiting for `update` to run. - /// - /// - Parameter event: The event to enqueue into the actor-hosted runtime. - /// - Throws: ``RuntimeError/actorDeallocated`` if the host actor has - /// already been released. - @inline(__always) - public func post(_ event: sending Event) throws { - guard let actor else { - throw RuntimeError.actorDeallocated - } - // TODO: try actor.checkRuntimeAvailability() - Task { - try? await actor.send(event) - } - } - - /// Sends `event` and suspends until the entire resulting effect chain has completed, - /// returning the `Output?` value produced by the terminal `.task` closure. - /// - /// A single event can trigger a cascade: an `.action` may return the next event to - /// process immediately, which in turn may return another, and so on. The continuation - /// is threaded through the whole chain and only resumed when the chain reaches a - /// terminal effect — typically a `.task`, whose async operation runs to completion - /// before `request` returns. - /// - /// ``` - /// event → [.action chain] → terminal effect - /// ├─ .task → Output? - /// ├─ .cancel → nil - /// └─ nil → nil - /// ``` - /// - /// The caller hops to the Actor for the duration of the call. - /// - /// - Note: If the calling `Task` is cancelled while suspended, - /// `request` continues to wait until the effect chain settles. - /// - Note: If the actor-hosted runtime has already shut down, `request` - /// throws ``RuntimeError`` immediately instead of entering the runtime. - /// - Parameter event: The event to send into the actor-hosted runtime. - /// - Throws: ``RuntimeError/actorDeallocated`` if the host actor has - /// already been released, plus any error that ``EffectActor/request(_:)`` - /// would throw for the same event. - /// - Returns: The terminal `Output?` value produced by the settled effect chain. - /// - /// For usage patterns including `.refreshable`, `task(id:)`, and testing, - /// see . - @discardableResult - public func request(_ event: Event) async throws -> Output? { - guard let actor else { - throw RuntimeError.actorDeallocated - } - return try await actor.request(event) - } - - /// Convenience call-as-function syntax for ``post(_:)``. - /// - /// - Parameter event: The event to enqueue into the observable runtime. - /// - Throws: Any error that ``post(_:)`` would throw for the same event. - @inline(__always) - public func callAsFunction(_ event: sending Event) throws { - try post(event) - } - } -} diff --git a/Sources/EffectComponents/EffectActor/EffectActor.swift b/Sources/EffectComponents/EffectActor/EffectActor.swift deleted file mode 100644 index 7c92013..0000000 --- a/Sources/EffectComponents/EffectActor/EffectActor.swift +++ /dev/null @@ -1,389 +0,0 @@ -/// A generic, observable effect runtime actor that hosts a Transducer-driven state machine. -/// -/// EffectActor coordinates event-driven state updates and effect execution for a given -/// Transducer. It maintains the current state, provides an input handle for dispatching -/// events, and manages lifecycle concerns such as startup, cancellation, and error -/// propagation across the runtime boundary. -/// -/// Key responsibilities: -/// - Owns and publishes the transducer’s State for observation. -/// - Accepts Events and synchronously reduces them via the transducer’s update logic, -/// possibly spawning asynchronous effect work. -/// - Supports request-style interactions that await terminal effect outputs. -/// - Manages runtime availability, cancellation, and system error latching to ensure -/// consistent boundary semantics. -/// -/// Concurrency: -/// - Implemented as a Swift actor to serialize access to internal state and runtime -/// machinery. -/// - Uses async/await to drive effect execution and request handling. -/// -/// Availability: -/// - iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4. -/// -/// Type parameters: -/// - T: A Transducer that defines State, Event, Env, Output, and Effect semantics. -/// -/// Constraints: -/// - T.Output: Sendable -/// - T.Env: Sendable -/// - T.Effect == TransducerEffect -/// - T.Event: Sendable -/// -/// See also: -/// - start(initialEvent:env:) -/// - send(_:) -/// - request(_:) -/// - cancel() -/// - cancel(with:) -/// -/// ## Deinitialization of the effect actor -/// -/// When the actor is torn down by ARC, the deinitialiser attempts to cancel the -/// runtime immediately with a standard `actorCancelled` error. This ensures -/// that any pending operations are notified of shutdown and that the runtime -/// latches a terminal system error, preventing further event processing. -/// -/// Behavior: -/// - Sends a control event to broadcast cancellation to observers and -/// in-flight work, if the runtime is still active. -/// - Swallows any errors that might occur during teardown to guarantee that -/// deinitialization cannot throw or trap. -/// -/// Concurrency: -/// - Isolated to the actor to safely access internal state without additional -/// synchronization. -/// -/// Notes: -/// - This is a host-level teardown. If you need a graceful shutdown sequence, -/// model it as an event handled by the transducer before the actor is -/// released. -@available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) -public final actor EffectActor< - T: Transducer -> where - T.Event: Sendable, - T.Output: Sendable, - T.Env: Sendable, - T.Effect == TransducerEffect -{ - public typealias State = T.State - public typealias Event = T.Event - public typealias Output = T.Output - public typealias Env = T.Env - public typealias Effect = T.Effect - - typealias StateAccessor = ActorStateBinding - - typealias Send = EffectComponents::Send - - - /// Current transducer state published through Swift Observation. - internal(set) public var state: State - - private var stateAccessor: StateAccessor - private var _input: Input! - private var runtimeSend: Send! - private var runtimeUnavailable: RuntimeError? - - - /// Creates an observable runtime with a captured dependency environment. - /// - /// Use `initialEvent` to kick off startup work after construction. The - /// event is scheduled asynchronously; the initializer does not wait for - /// that work to finish before returning. - /// - /// - Parameters: - /// - of: The transducer type. - /// - initialState: The initial value for ``state``. - /// - initialEvent: An optional event sent when the view first appears. - /// - env: Dependencies captured for the runtime lifetime. - public init( - of: T.Type = T.self, - initialState: State - ) { - self.state = initialState - self.stateAccessor = .init(get: { this in - this.state - }, set: { this, state in - this.state = state - }) - } - - - /// Starts the effect actor’s runtime with an optional bootstrap event and a provided environment. - /// - /// Call this method once to initialize the internal runtime machinery, bind state access, - /// and make the actor ready to process events. If `initialEvent` is provided, it is sent - /// immediately after initialization and fully reduced before the call returns. - /// - /// Behavior: - /// - Initializes the runtime exactly once. Subsequent calls throw `RuntimeError.actorAlreadyInitialised`. - /// - Captures the provided environment for the lifetime of the runtime. - /// - Creates and stores the actor’s `Input` handle and the internal send function. - /// - If `initialEvent` is non-nil, synchronously reduces it (and any resulting event chain), - /// potentially spawning effect work as defined by the transducer. - /// - /// Concurrency: - /// - Must be called on the actor (isolated). - /// - May suspend while reducing `initialEvent` if effect actions perform awaits. - /// - /// Errors: - /// - Throws `RuntimeError.actorAlreadyInitialised` if called more than once. - /// - Propagates any error thrown by reducing `initialEvent`. - /// - /// - Parameters: - /// - initialEvent: An optional event dispatched immediately after startup to kick off work. - /// - env: The dependency environment captured by the runtime for effect execution. - /// - /// - Important: You must call this method before using `send(_:)`, `request(_:)`, or `input`. - /// - SeeAlso: `send(_:)`, `request(_:)`, `cancel()`, `cancel(with:)` - public func start( - initialEvent: Event? = nil, - env: Env - ) async throws { - guard (self.runtimeSend == nil && self._input == nil) else { - throw RuntimeError.actorAlreadyInitialised - } - - let send = T.makeSend( - with: Input.self, - actorStateAcessor: stateAccessor, - env: env - ) - self._input = Input(self) - self.runtimeSend = send - if let event = initialEvent { - try await send(systemActor: self, event, input: _input) - } - } - - - isolated deinit { - try? cancelRuntime() - } - - - private func cancelRuntime(with systemError: (any Swift.Error)? = nil) throws { - guard runtimeUnavailable == nil else { - return - } - runtimeUnavailable = systemError == nil ? .actorCancelled : .systemError - guard let send = runtimeSend else { - return - } - if let systemError { - try send.control( - systemActor: self, - ControlEvent.systemError(systemError) - ) - } else { - try send.control( - systemActor: self, - .cancel - ) - } - } - - // MARK - - // TODO: make this nonisolated and atomic - func checkRuntimeAvailability() throws { - guard runtimeSend != nil, _input != nil else { - throw RuntimeError.actorNotInitialised - } - if let runtimeUnavailable { - throw runtimeUnavailable - } - } -} - -@available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) -extension EffectActor: TransducerHost { - - /// Sends the given event into the transducer. - /// - /// The event will be processed by the transducer's update function, which may - /// return an effect which may itself return an event. This event is synchronously - /// processed by the update function. The chain of events is processed until no - /// further events are returned. - /// - /// If the update function returns a task effect, this task will be started. This - /// also terminates the event processing chain and `send` returns. - /// - /// When `send` returns the transducer has fully processed the event, that is it has - /// updated its state accordingly, and started all effect tasks returned by the - /// update function during processing of the event. However, any async operations - /// in those tasks may continue to run. - /// - /// - Caution: `send` preserves ordered inline reduction. If the current event chain - /// reaches a long-running awaited step, the caller remains suspended until that - /// step yields control back to the runtime. - /// - /// - seealso: **Effect Operations and Actions** - /// - /// - Note: `send` may suspend only when it executes suspending effect actions. - /// - /// - Parameter event: The event that is sent into the system. - /// - Throws: ``RuntimeUnavailable/actorCancelled`` if the runtime has already been - /// cancelled, ``RuntimeUnavailable/systemError`` if the runtime has latched a - /// critical failure, or `CancellationError` if accepted work is later cancelled. - public func send(_ event: sending T.Event) async throws { - try checkRuntimeAvailability() - do { - try await runtimeSend(event, input: _input) - } catch { - let boundaryError = runtimeBoundaryError(for: error) - if let runtimeUnavailable = boundaryError as? RuntimeError { - self.runtimeUnavailable = runtimeUnavailable - } - throw boundaryError - } - } - - /// Sends `event` and suspends until operations of the resulting effect chain complete. - /// - /// The event will be processed by the transducer's update function, which may - /// return an effect which may itself return an event. This event is synchronously - /// processed by the update function. The chain of events is processed until no - /// further events are returned. - /// - /// If the update function returns a task, this task will be executed and `request` will - /// suspend until the task's operation completes, returning the task's output. - /// This also terminates the event processing chain. - /// - /// When `request` returns the transducer has fully processed the event, that is it has - /// updated its state accordingly, and started and awaited the effect task returned - /// by the update function during processing of the event. In the mean time, the - /// transducer can receive and process other events, but the caller is suspended until - /// the effect task triggered by this event has completed. - /// - /// - seealso: **Effect Operations and Actions** - /// - /// > Important: For the duration of the call, the effect actor will be captured strongly. - /// - /// ## Effect Operations and Actions - /// - /// If the chain reaches a named task, overlapping waiters for the same task - /// identifier are coalesced according to that task's ``TaskExecutionOption``. - /// The caller is waiting for the current active task for that identifier, not - /// necessarily for the first physical task instance that was started. - /// - /// A single event can trigger a cascade: an `.action` may return the next event to - /// process immediately, which in turn may return another, and so on. The continuation - /// is threaded through the whole chain and only resumed when the chain reaches a - /// terminal effect — typically a `.task`, whose async operation runs to completion - /// before `request` returns. - /// - /// ``` - /// event → [.action chain] → terminal effect - /// ├─ .task → Output? - /// ├─ .cancel → nil - /// └─ nil → nil - /// ``` - /// - /// - Caution: Cancelling the caller does not immediately tear down an accepted request. - /// The runtime resumes the continuation only after the in-flight chain reaches a - /// terminal outcome or the runtime reports cancellation. - /// - /// - Parameter event: The event that is sent into the system. - /// - Throws: ``RuntimeUnavailable/actorCancelled`` if the runtime has already been - /// cancelled, ``RuntimeUnavailable/systemError`` if the runtime has latched a - /// critical failure before the request can enter or while it is running, or - /// `CancellationError` if accepted work is later cancelled. - /// - Returns: the `Output?` value produced by the terminal `.task` closure. - @discardableResult - public func request(_ event: Event) async throws -> Output? { - try checkRuntimeAvailability() - guard let send = self.runtimeSend else { - throw RuntimeError.actorCancelled - } - return try await withCheckedThrowingContinuation { (continuation: Continuation) in - Task { - do { - // Note: captures `self` strongly for the duration of the send function. - try await send.send(self, event, input, continuation) - } catch { - let boundaryError = runtimeBoundaryError(for: error) - if let runtimeUnavailable = boundaryError as? RuntimeError { - self.runtimeUnavailable = runtimeUnavailable - } - continuation.resume(throwing: boundaryError) - } - } - } - } - - /// Dispatch handle for sending events into this runtime. - public var input: Input { - Input(self) - } - - /// Immediately cancels the observable runtime. - /// - /// After cancellation, newly created ``input`` handles will no longer - /// deliver events, and pending ``Input/request(_:)`` calls resolve with - /// `nil` if they reach the cancelled runtime after teardown. - /// - /// This is host-level disposal, not graceful transducer shutdown. Model a - /// gentle teardown as an event handled by the transducer itself, then call - /// `cancel()` when the host is ready to discard the runtime. - nonisolated - public func cancel() { - Task { - try await cancelRuntime() - } - } - - /// Cancels the observable runtime with a caller-provided system error. - /// - /// Use this when the host needs pending work to observe a specific - /// failure at the runtime boundary rather than a generic cancellation. - /// - /// - Parameter error: The system-level failure to latch and broadcast. - nonisolated - public func cancel(with error: any Swift.Error) { - Task { - try? await cancelRuntime(with: error) - } - } - -} - - -@available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) -extension EffectActor where Env == Void { - - /// Starts the effect actor’s runtime with an optional bootstrap event. - /// - /// Call this method once to initialize the internal runtime machinery, bind state access, - /// and make the actor ready to process events. If `initialEvent` is provided, it is sent - /// immediately after initialization and fully reduced before the call returns. - /// - /// Behavior: - /// - Initializes the runtime exactly once. Subsequent calls throw `RuntimeError.actorAlreadyInitialised`. - /// - Creates and stores the actor’s `Input` handle and the internal send function. - /// - If `initialEvent` is non-nil, synchronously reduces it (and any resulting event chain), - /// potentially spawning effect work as defined by the transducer. - /// - /// Concurrency: - /// - Must be called on the actor (isolated). - /// - May suspend while reducing `initialEvent` if effect actions perform awaits. - /// - /// Errors: - /// - Throws `RuntimeError.actorAlreadyInitialised` if called more than once. - /// - Propagates any error thrown by reducing `initialEvent`. - /// - /// - Parameters: - /// - initialEvent: An optional event dispatched immediately after startup to kick off work. - /// - /// - Important: You must call this method before using `send(_:)`, `request(_:)`, or `input`. - /// - SeeAlso: `send(_:)`, `request(_:)`, `cancel()`, `cancel(with:)` - public func start( - initialEvent: Event? = nil - ) async throws { - try await self.start( - initialEvent: initialEvent, - env: () - ) - } -} diff --git a/Sources/EffectComponents/EffectObservable/EffectObservable.Input.swift b/Sources/EffectComponents/EffectObservable/EffectObservable.Input.swift deleted file mode 100644 index bf1a0a8..0000000 --- a/Sources/EffectComponents/EffectObservable/EffectObservable.Input.swift +++ /dev/null @@ -1,146 +0,0 @@ -/// A `Sendable` handle for dispatching events into the effect engine. -/// -/// `EffectObservableInput` provides three dispatch strategies with different semantics: -/// - ``send(_:)`` — synchronous; must be called from the `@MainActor`. -/// - ``post(_:)`` — fire-and-forget; safe from any isolation. -/// - ``request(_:)`` — suspends the caller, returning `Output?`. -/// -/// ### Isolation and lifetime safety -/// -/// All state mutations run on the `@MainActor`, a global, app-lifetime -/// executor. Because the `@MainActor` is never destroyed, ``request(_:)`` -/// is guaranteed to resume its continuation on every code path — no -/// `withTaskCancellationHandler` bookkeeping is required. -/// -/// If the calling `Task` is cancelled while awaiting ``request(_:)``, -/// the suspension continues until the event is processed. Swift does not -/// automatically resume continuations on cancellation; this is safe -/// because the `@MainActor` always completes its work. -/// -/// ### Generic parameters -/// -/// - `Event`: The event type dispatched into the state machine. -/// - `Output`: The value returned by ``request(_:)``. -/// Use `Void` when no return value is needed. -@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) -extension EffectObservable where T.Event: Sendable, T.Output: Sendable { - - public struct Input: TransducerInput, Sendable { - - init(_ actor: EffectObservable) { - self.actor = actor - } - - private weak let actor: EffectObservable? - - /// Dispatches `event` synchronously on the `@MainActor`. - /// - /// Use `send` when you are already running on the `@MainActor` and want the event to be - /// processed immediately, in the same synchronous turn. A typical example is a SwiftUI - /// button action: - /// - /// ```swift - /// Button("Increment") { - /// // Processed before the next await point: - /// input.send(.increment) - /// } - /// ``` - /// - /// "Synchronous" here means that `update` is called inline, any `.action` chain is - /// unwound, and the resulting state change is applied — all before `send` returns. - /// If `update` returns a `.task`, that task is *launched* synchronously but runs - /// concurrently; `send` does not wait for it to finish. Use ``request(_:)`` if you - /// need to await the task's completion. - /// - /// If you want to fire-and-forget the event — scheduling it without waiting for even - /// the synchronous `update` pass to complete — use ``post(_:)`` instead. - /// - /// - Warning: Because `send` unwinds `.action` chains synchronously on the `@MainActor`, - /// a cycle in your `update` function — e.g. `.ping` → `.action { .pong }` → `.action { .ping }` → … — - /// will loop forever and hang the main thread. ``post(_:)`` and ``request(_:)`` are - /// immune because each re-entry is scheduled as a new task, yielding control between iterations. - /// - Parameter event: The event to send into the observable runtime. - /// - Throws: ``RuntimeUnavailable/actorDeallocated`` if the host observable has - /// already been released, plus any error that ``EffectObservable/send(_:)`` - /// would throw for the same event. - @MainActor - public func send(_ event: sending Event) async throws { - guard let actor else { - throw RuntimeError.actorDeallocated - } - try await actor.send(event) - } - - /// Schedules `event` on the `@MainActor` without awaiting it. - /// - /// Safe to call from any actor isolation or non-isolated context. - /// Use this to fire-and-forget an event from a background task or a - /// non-isolated callback without waiting for `update` to run. - /// - /// - Parameter event: The event to enqueue into the observable runtime. - /// - Throws: ``RuntimeUnavailable/actorDeallocated`` if the host observable has - /// already been released, or the current latched runtime failure if the runtime - /// is no longer accepting work. - @inline(__always) - public func post(_ event: sending Event) throws { - guard let actor else { - throw RuntimeError.actorDeallocated - } - try actor.checkRuntimeAvailability() - Task { @MainActor in - try? await actor.send(event) - } - } - - /// Sends `event` and suspends until the entire resulting effect chain has completed, - /// returning the `Output?` value produced by the terminal `.task` closure. - /// - /// A single event can trigger a cascade: an `.action` may return the next event to - /// process immediately, which in turn may return another, and so on. The continuation - /// is threaded through the whole chain and only resumed when the chain reaches a - /// terminal effect — typically a `.task`, whose async operation runs to completion - /// before `request` returns. - /// - /// ``` - /// event → [.action chain] → terminal effect - /// ├─ .task → Output? - /// ├─ .cancel → nil - /// └─ nil → nil - /// ``` - /// - /// The caller hops to the `@MainActor` for the duration of the call. Because the - /// `@MainActor` is a global, app-lifetime executor, the continuation is always - /// resumed — no cancellation handler is needed. - /// - /// - Note: If the calling `Task` is cancelled while suspended, - /// `request` continues to wait until the effect chain settles. - /// - Note: If the observable runtime has already shut down, `request` - /// throws ``RuntimeUnavailable`` immediately instead of entering the runtime. - /// - Parameter event: The event to send into the observable runtime. - /// - Throws: ``RuntimeUnavailable/actorDeallocated`` if the host observable has - /// already been released, plus any error that ``EffectObservable/request(_:)`` - /// would throw for the same event. - /// - Returns: The terminal `Output?` value produced by the settled effect chain. - /// - /// For usage patterns including `.refreshable`, `task(id:)`, and testing, - /// see . - @discardableResult - public func request( - _ event: Event - ) async throws -> Output? where Output: Sendable, Event: Sendable { - guard let actor else { - throw RuntimeError.actorDeallocated - } - return try await actor.request(event) - } - - /// Convenience call-as-function syntax for ``post(_:)``. - /// - /// - Parameter event: The event to enqueue into the observable runtime. - /// - Throws: Any error that ``post(_:)`` would throw for the same event. - @inline(__always) - public func callAsFunction(_ event: sending Event) throws { - try post(event) - } - } -} diff --git a/Sources/EffectComponents/EffectObservable/EffectObservable.swift b/Sources/EffectComponents/EffectObservable/EffectObservable.swift deleted file mode 100644 index d78d2ad..0000000 --- a/Sources/EffectComponents/EffectObservable/EffectObservable.swift +++ /dev/null @@ -1,291 +0,0 @@ -import Observation - -@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) -@MainActor -@Observable -/// Observable host for a transducer-driven runtime. -/// -/// `EffectObservable` stores the current transducer `State`, exposes it -/// through Swift Observation, and provides an ``Input`` handle for sending -/// events back into the runtime from views, tasks, and callbacks. -/// -/// Construct the observable once for the host view's lifetime. The runtime -/// captures `Env` at initialization, updates ``state`` only through the -/// transducer, and keeps event processing serialized on the `@MainActor`. -public final class EffectObservable< - T: Transducer, ->: @MainActor TransducerHost where - T.Event: Sendable, - T.Output: Sendable, - T.Env: Sendable, - T.Effect == TransducerEffect -{ - public typealias State = T.State - public typealias Event = T.Event - public typealias Output = T.Output - public typealias Env = T.Env - public typealias Effect = T.Effect - - typealias Storage = UnownedReferenceKeyPathStorage - typealias Send = EffectComponents::Send - - - /// Current transducer state published through Swift Observation. - internal(set) public var state: State - - @ObservationIgnored - private var runtimeSend: Send? - @ObservationIgnored - nonisolated(unsafe) private var runtimeUnavailable: RuntimeError? - @ObservationIgnored - private var initialEvent: Event? - @ObservationIgnored - private var storage: Storage! - @ObservationIgnored - private var _input: Input! - - - /// Creates an observable runtime with a captured dependency environment. - /// - /// Use `initialEvent` to kick off startup work after construction. The - /// event is scheduled asynchronously; the initializer does not wait for - /// that work to finish before returning. - /// - /// - Parameters: - /// - of: The transducer type. - /// - initialState: The initial value for ``state``. - /// - initialEvent: An optional event sent when the view first appears. - /// - env: Dependencies captured for the runtime lifetime. - public init( - of: T.Type = T.self, - initialState: State, - initialEvent: Event? = nil, - env: Env - ) { - self.state = initialState - self.initialEvent = initialEvent - self.storage = .init(host: self, keyPath: \.state) - let send = T.makeSend( - systemActor: MainActor.shared, - with: Input.self, - storage: storage, - env: env - ) - self._input = Input(self) - self.runtimeSend = send - if let event = initialEvent { - Task { - do { - try await send(event, input: _input) - } catch { - try? send.control(.systemError(error)) - } - } - } - } - - @_optimize(none) // https://github.com/swiftlang/swift/issues/82523 - isolated deinit { - cancel() - } - - /// Sends the given event into the transducer. - /// - /// The event will be processed by the transducer's update function, which may - /// return an effect which may itself return an event. This event is synchronously - /// processed by the update function. The chain of events is processed until no - /// further events are returned. - /// - /// If the update function returns a task effect, this task will be started. This - /// also terminates the event processing chain and `send` returns. - /// - /// When `send` returns the transducer has fully processed the event, that is it has - /// updated its state accordingly, and started all effect tasks returned by the - /// update function during processing of the event. However, any async operations - /// in those tasks may continue to run. - /// - /// - Caution: `send` preserves ordered inline reduction. If the current event chain - /// reaches a long-running awaited step, the caller remains suspended until that - /// step yields control back to the runtime. - /// - /// - seealso: **Effect Operations and Actions** - /// - /// - Note: `send` may suspend only when it executes suspending effect actions. - /// - /// - Parameter event: The event that is sent into the system. - /// - Throws: ``RuntimeUnavailable/actorCancelled`` if the runtime has already been - /// cancelled, ``RuntimeUnavailable/systemError`` if the runtime has latched a - /// critical failure, or `CancellationError` if accepted work is later cancelled. - public func send(_ event: sending Event) async throws { - try checkRuntimeAvailability() - guard let send = runtimeSend else { - throw RuntimeError.actorCancelled - } - do { - try await send(event, input: _input) - } catch { - let boundaryError = runtimeBoundaryError(for: error) - if let runtimeUnavailable = boundaryError as? RuntimeError { - self.runtimeUnavailable = runtimeUnavailable - } - throw boundaryError - } - } - - /// Sends `event` and suspends until operations of the resulting effect chain complete. - /// - /// The event will be processed by the transducer's update function, which may - /// return an effect which may itself return an event. This event is synchronously - /// processed by the update function. The chain of events is processed until no - /// further events are returned. - /// - /// If the update function returns a task, this task will be executed and `request` will - /// suspend until the task's operation completes, returning the task's output. - /// This also terminates the event processing chain. - /// - /// When `request` returns the transducer has fully processed the event, that is it has - /// updated its state accordingly, and started and awaited the effect task returned - /// by the update function during processing of the event. In the mean time, the - /// transducer can receive and process other events, but the caller is suspended until - /// the effect task triggered by this event has completed. - /// - /// - seealso: **Effect Operations and Actions** - /// - /// ## Effect Operations and Actions - /// - /// If the chain reaches a named task, overlapping waiters for the same task - /// identifier are coalesced according to that task's ``TaskExecutionOption``. - /// The caller is waiting for the current active task for that identifier, not - /// necessarily for the first physical task instance that was started. - /// - /// A single event can trigger a cascade: an `.action` may return the next event to - /// process immediately, which in turn may return another, and so on. The continuation - /// is threaded through the whole chain and only resumed when the chain reaches a - /// terminal effect — typically a `.task`, whose async operation runs to completion - /// before `request` returns. - /// - /// ``` - /// event → [.action chain] → terminal effect - /// ├─ .task → Output? - /// ├─ .cancel → nil - /// └─ nil → nil - /// ``` - /// - /// - Caution: Cancelling the caller does not immediately tear down an accepted request. - /// The runtime resumes the continuation only after the in-flight chain reaches a - /// terminal outcome or the runtime reports cancellation. - /// - /// - Parameter event: The event that is sent into the system. - /// - Throws: ``RuntimeUnavailable/actorCancelled`` if the runtime has already been - /// cancelled, ``RuntimeUnavailable/systemError`` if the runtime has latched a - /// critical failure before the request can enter or while it is running, or - /// `CancellationError` if accepted work is later cancelled. - /// - Returns: the `Output?` value produced by the terminal `.task` closure. - @discardableResult - public func request(_ event: Event) async throws -> Output? { - try checkRuntimeAvailability() - guard let send = self.runtimeSend, let input = _input else { - throw RuntimeError.actorCancelled - } - return try await withCheckedThrowingContinuation { (continuation: Continuation) in - Task { [weak self] in - guard let self else { return } - do { - try await send.send(MainActor.shared, event, input, continuation) - } catch { - let boundaryError = runtimeBoundaryError(for: error) - if let runtimeUnavailable = boundaryError as? RuntimeError { - self.runtimeUnavailable = runtimeUnavailable - } - continuation.resume(throwing: boundaryError) - } - } - } - } - - /// Dispatch handle for sending events into this runtime. - public var input: Input { - _input - } - - /// Immediately cancels the observable runtime. - /// - /// After cancellation, future entry points fail with - /// ``RuntimeUnavailable/actorCancelled`` and accepted requests are resumed - /// with `CancellationError` once the runtime observes the control event. - /// - /// This is host-level disposal, not graceful transducer shutdown. Model a - /// gentle teardown as an event handled by the transducer itself, then call - /// `cancel()` when the host is ready to discard the runtime. - public func cancel() { - cancelRuntime() - } - - /// Cancels the observable runtime with a caller-provided system error. - /// - /// Use this when the host needs pending work to observe a specific - /// failure at the runtime boundary rather than a generic cancellation. - /// Accepted requests observe `error`, and later entry points fail with - /// ``RuntimeUnavailable/systemError``. - /// - /// - Parameter error: The system-level failure to latch and broadcast. - public func cancel(with error: any Swift.Error) { - cancelRuntime(with: error) - } - - // MARK - - - nonisolated - func checkRuntimeAvailability() throws { - if let runtimeUnavailable { - throw runtimeUnavailable - } - } - - private func cancelRuntime(with systemError: (any Swift.Error)? = nil) { - guard runtimeUnavailable == nil else { - return - } - - runtimeUnavailable = systemError == nil ? .actorCancelled : .systemError - - guard let send = runtimeSend else { - return - } - - Task { @MainActor [send, weak self] in - guard let self else { return } - if let systemError { - try? send.control(ControlEvent.systemError(systemError)) - } else { - try? send.control(.cancel) - } - _ = self - } - } - -} - -@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) -extension EffectObservable where Env == Void { - - /// Creates an observable runtime with no external dependencies. - /// - /// - Parameters: - /// - of: The transducer type. - /// - initialState: The initial value for ``state``. - /// - initialEvent: An optional event sent when the view first appears. - convenience public init( - of transducer: T.Type = T.self, - initialState: State, - initialEvent: Event? = nil - ) { - self.init( - of: transducer, - initialState: initialState, - initialEvent: initialEvent, - env: () - ) - } -} - diff --git a/Sources/EffectComponents/EffectObservable/TransducerEffect.observe.swift b/Sources/EffectComponents/EffectObservable/TransducerEffect.observe.swift deleted file mode 100644 index bbac4cc..0000000 --- a/Sources/EffectComponents/EffectObservable/TransducerEffect.observe.swift +++ /dev/null @@ -1,417 +0,0 @@ -import Foundation -import Mutex -import Observation - -// MARK: - TransducerEffect.observe - -extension TransducerEffect { - - /// Observes a key path on an `@Observable` object resolved from the environment. - /// - /// The handler is invoked with the **initial value** immediately, then again on every - /// subsequent change, until the task is cancelled or the object is deallocated. - /// - /// The object is resolved from the environment inside the task, so the effect captures - /// only a key path rather than the object itself. Use ``Input/request(_:)`` in the - /// handler so the loop waits for the view to settle before advancing: - /// - /// ```swift - /// // update: - /// case .start: - /// return .observe( - /// \.store, keyPath: \.count - /// ) { input, count in - /// await input.request(.countChanged(count)) - /// } - /// ``` - /// - /// The named task (`"observe"` by default) is cancelled automatically when the view - /// disappears, or immediately when `update` returns `cancel(name)`. - /// - /// - Parameters: - /// - envKeyPath: Key path from `Env` to the `@Observable` object. The object is held - /// weakly inside the task; the loop exits when it is deallocated. - /// - keyPath: The property on the object to observe. - /// - id: Optional name for the underlying task. Defaults to `"observe"`. - /// - priority: Optional `TaskPriority` for the underlying task. - /// - handler: Called with `input` and the current value on the initial read and on - /// every subsequent change. `async` — use `await input.request(…)` to wait for the - /// view to settle before the next observation cycle. - @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) - public static func observe( - _ envKeyPath: KeyPath, - keyPath: KeyPath, - id: TaskIdentifier? = "observe", - priority: TaskPriority? = nil, - handler: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Value, Env) async -> Void - ) -> Self - where Object: Observable & AnyObject & Sendable, Value: Sendable, Env: Sendable - { - let box = SendableKeyPath(keyPath: keyPath) - let envKeyPathBox = SendableKeyPath(keyPath: envKeyPath) - return .task(id: id, priority: priority) { input, env in - do { - let weakObject = WeakObject(object: env[keyPath: envKeyPathBox.keyPath]) - await handler(input, try observedValue(weakObject, keyPath: box), env) - while true { - try await _waitForObservationChange(weakObject, keyPath: box) - await handler(input, try observedValue(weakObject, keyPath: box), env) - } - } catch is CancellationError { - // Transducer logic has cancelled. Do not rethrow. - // Expected termination path for explicit cancel(name) or view teardown. - } catch is ObservationTerminationError { - // IFF we would rethrow the error, the update function is responsible - // to catch and handle it, or otherwise it becomes a critical - // system error. - - // For now we end the observation quietly. - } catch { - // IFF we would rethrow the error, the update function is responsible - // to catch and handle it, or otherwise it becomes a critical - // system error. - assertionFailure("Unexpected observation failure: \(error)") - } - return nil - } - } - - @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) - /// Observes an environment-resolved key path and runs the callback on the host actor. - /// - /// Semantics match the `handler`-based overload, but `isolatedHandler` receives the - /// current host actor isolation explicitly. - /// - /// - Parameters: - /// - systemActor: The host actor isolation forwarded into `isolatedHandler`. - /// - envKeyPath: Key path from `Env` to the `@Observable` object. - /// - keyPath: The property on the object to observe. - /// - id: Optional name for the underlying task. Defaults to `"observe"`. - /// - priority: Optional `TaskPriority` for the underlying task. - /// - isolatedHandler: Called with `input`, the current value, and the host actor. - public static func observe( - systemActor: isolated (any Actor)? = #isolation, - _ envKeyPath: KeyPath, - keyPath: KeyPath, - id: TaskIdentifier? = "observe", - priority: TaskPriority? = nil, - isolatedHandler: @escaping (any TransducerInput, Value, Env, isolated any Actor) async -> Void - ) -> Self - where Object: Observable & AnyObject & Sendable, Value: Sendable - { - let box = SendableKeyPath(keyPath: keyPath) - let envKeyPathBox = SendableKeyPath(keyPath: envKeyPath) - return .task(id: id, priority: priority) { input, env, isolation in - do { - precondition( - systemActor != nil && systemActor === isolation, - "observe(isolatedOperation:) requires a non-nil matching system actor. Actor hosts must provide isolation. Expected \(String(describing: systemActor)), got \(isolation)." - ) - let weakObject = WeakObject(object: env[keyPath: envKeyPathBox.keyPath]) - await isolatedHandler(input, try observedValue(weakObject, keyPath: box), env, isolation) - while true { - try await _waitForObservationChange(weakObject, keyPath: box) - await isolatedHandler(input, try observedValue(weakObject, keyPath: box), env, isolation) - } - } - catch is CancellationError { - // Expected termination path for explicit cancel(name) or view teardown. - } catch is ObservationTerminationError { - // Observed object deallocated; end the observation quietly. - } catch { - assertionFailure("Unexpected observation failure: \(error)") - } - return nil - } - } - - /// Observes a key path on a directly provided `@Observable` object. - /// - /// The handler is invoked with the **initial value** immediately, then again on every - /// subsequent change, until the task is cancelled or the object is deallocated. - /// - /// The `input` parameter gives the handler the same three dispatch strategies - /// (``Input/post(_:)``, ``Input/send(_:)``, ``Input/request(_:)``) available in any - /// other effect. For observation you will typically want ``Input/request(_:)`` so the loop - /// waits for the EffectView to process each change before advancing to the next one: - /// - /// ```swift - /// // update: - /// case .storeReceived(let store): - /// return .observe( - /// store, keyPath: \.count - /// ) { input, count in - /// await input.request(.countChanged(count)) - /// } - /// ``` - /// - /// The named task (`"observe"` by default) is cancelled automatically when the view - /// disappears, or immediately when `update` returns `cancel(name)`. - /// - /// - Parameters: - /// - object: The `@Observable` object to watch. Held weakly inside the task so the - /// effect does not extend the object's lifetime. The loop exits when `object` is - /// deallocated. - /// - keyPath: The property to observe. - /// - id: Optional name for the underlying task. Defaults to `"observe"`. - /// - priority: Optional `TaskPriority` for the underlying task. - /// - operation: Called with `input` and the current value on the initial read and on - /// every subsequent change. `async` — use `await input.request(…)` to wait for the - /// view to settle before the next observation cycle. - @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) - public static func observe( - _ object: Object, - keyPath: KeyPath, - id: TaskIdentifier? = "observe", - priority: TaskPriority? = nil, - operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Value, Env) async -> Void - ) -> Self - where Object: Observable & AnyObject & Sendable, Value: Sendable, Env: Sendable - { - let box = SendableKeyPath(keyPath: keyPath) - let weakObject = WeakObject(object: object) - return .task(id: id, priority: priority) { input, env in - do { - await operation(input, try observedValue(weakObject, keyPath: box), env) - while true { - try await _waitForObservationChange(weakObject, keyPath: box) - await operation(input, try observedValue(weakObject, keyPath: box), env) - } - } catch is CancellationError { - // Expected termination path for explicit cancel(name) or view teardown. - } catch is ObservationTerminationError { - // Observed object deallocated; end the observation quietly. - } catch { - assertionFailure("Unexpected observation failure: \(error)") - } - return nil - } - } - - @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) - /// Observes a directly provided key path and runs the callback on the host actor. - /// - /// Semantics match the `operation`-based overload, but `isolatedOperation` receives the - /// current host actor isolation explicitly. - /// - /// - Parameters: - /// - systemActor: The host actor isolation forwarded into `isolatedOperation`. - /// - object: The `@Observable` object to watch. - /// - keyPath: The property to observe. - /// - id: Optional name for the underlying task. Defaults to `"observe"`. - /// - priority: Optional `TaskPriority` for the underlying task. - /// - isolatedOperation: Called with `input`, the current value, and the host actor. - public static func observe( - systemActor: isolated (any Actor)? = #isolation, - _ object: Object, - keyPath: KeyPath, - id: TaskIdentifier? = "observe", - priority: TaskPriority? = nil, - isolatedOperation: @escaping (any TransducerInput, Value, Env, isolated any Actor) async -> Void - ) -> Self - where Object: Observable & AnyObject & Sendable, Value: Sendable - { - let box = SendableKeyPath(keyPath: keyPath) - let weakObject = WeakObject(object: object) - return .task(id: id, priority: priority) { input, env, isolation in - do { - precondition( - systemActor != nil && systemActor === isolation, - "observe(isolatedOperation:) requires a non-nil matching system actor. Actor hosts must provide isolation. Expected \(String(describing: systemActor)), got \(isolation)." - ) - await isolatedOperation(input, try observedValue(weakObject, keyPath: box), env, isolation) - while true { - try await _waitForObservationChange(weakObject, keyPath: box) - await isolatedOperation(input, try observedValue(weakObject, keyPath: box), env, isolation) - } - } catch is CancellationError { - // Expected termination path for explicit cancel(name) or view teardown. - } catch is ObservationTerminationError { - // Observed object deallocated; end the observation quietly. - } catch { - assertionFailure("Unexpected observation failure: \(error)") - } - return nil - } - } - -} - -// MARK: - Internal helpers - -/// A minimal `@unchecked Sendable` box for `KeyPath`. -/// -/// `KeyPath` is a value type with no mutable state — it is intrinsically safe to share -/// across concurrency domains. This wrapper makes that explicit so key path values can be -/// captured in `@Sendable` closures without requiring `SE-0418` (`InferSendableFromCaptures`) -/// at every call site. -private struct SendableKeyPath: @unchecked Sendable { - let keyPath: KeyPath -} - -private final class ObservationContinuationBox: @unchecked Sendable { - private enum State { - case pending(CheckedContinuation?) - case resolved(Result) - } - - private let state = Mutex(State.pending(nil)) - - init() {} - - func install(_ continuation: CheckedContinuation) { - let resultToResume: Result? = state.withLock { state in - switch state { - case .pending(nil): - state = .pending(continuation) - return nil - case .pending(.some): - fatalError("Observation continuation installed more than once") - case .resolved(let result): - return result - } - } - - if let resultToResume { - resumeContinuation(continuation, with: resultToResume) - } - } - - func resume() { - resolve(with: .success(())) - } - - func resume(throwing error: Error) { - resolve(with: .failure(error)) - } - - private func resolve(with result: Result) { - let continuationToResume = state.withLock { state in - switch state { - case .pending(let continuation): - state = .resolved(result) - return continuation - case .resolved: - return nil - } - } - - if let continuationToResume { - resumeContinuation(continuationToResume, with: result) - } - } -} - -private struct WeakObject: @unchecked Sendable where Object: Sendable { - weak var object: Object? -} - -private enum ObservationTerminationError: Error { - case deallocated -} - -@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) -@inline(__always) -private func observedValue( - _ weakObject: WeakObject, - keyPath box: SendableKeyPath -) throws -> Value where Object: Observable & AnyObject & Sendable, Value: Sendable { - guard let object = weakObject.object else { - throw ObservationTerminationError.deallocated - } - return object[keyPath: box.keyPath] -} - -@inline(__always) -private func resumeContinuation( - _ continuation: CheckedContinuation, - with result: Result -) { - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } -} - - -/// Observes a key path on an `@Observable` object, calling `handler` -/// with each new value until the task is cancelled or `object` is -/// deallocated. -/// -/// - Parameters: -/// - systemActor: The actor isolation used to deliver observed values to `handler`. -/// - object: The observable object to watch. -/// - keyPath: The property to observe on `object`. -/// - handler: The async callback invoked with each observed value. -/// - Throws: If observation is cancelled or the object becomes unavailable before -/// the next value can be delivered. -@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) -public func observeKeyPath( - systemActor: isolated any Actor = #isolation, - _ object: Object, - keyPath: KeyPath, - handler: @escaping (isolated any Actor, Value) async -> Void -) async throws where Object: Observable & AnyObject & Sendable, Value: Sendable { - let box = SendableKeyPath(keyPath: keyPath) - let weakObject = WeakObject(object: object) - - try await observeWeakKeyPath(systemActor: systemActor, weakObject, keyPath: box, isolatedHandler: handler) -} - -@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) -private func observeWeakKeyPath( - systemActor: isolated any Actor = #isolation, - _ weakObject: WeakObject, - keyPath box: SendableKeyPath, - isolatedHandler: @escaping (isolated any Actor, Value) async -> Void -) async throws where Object: Observable & AnyObject & Sendable, Value: Sendable { - let initialValue = try observedValue(weakObject, keyPath: box) - - // Seed the initial value — withObservationTracking only fires on *changes*. - await isolatedHandler(systemActor, initialValue) - - while true { - try await _waitForObservationChange(weakObject, keyPath: box) - await isolatedHandler(systemActor, try observedValue(weakObject, keyPath: box)) - } -} - -/// Legacy one-shot waiter for `observeKeyPath` on macOS < 26. -/// -/// `onChange` fires *before* the new value is committed and on an arbitrary thread, so a -/// child `Task` hops back onto `systemActor` before resuming the suspended observer. -@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) -private func _waitForObservationChange( - _ weakObject: WeakObject, - keyPath box: SendableKeyPath, -) async throws where Object: Observable & AnyObject & Sendable, Value: Sendable { - let continuationBox = ObservationContinuationBox() - - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - continuationBox.install(continuation) - - func installObservation() { - guard weakObject.object != nil else { - continuationBox.resume(throwing: ObservationTerminationError.deallocated) - return - } - withObservationTracking( - { _ = weakObject.object?[keyPath: box.keyPath] }, - onChange: { - Task { - continuationBox.resume() - } - } - ) - } - - installObservation() - } - } onCancel: { - continuationBox.resume(throwing: CancellationError()) - } -} - diff --git a/Sources/EffectComponents/EffectView/EffectView.swift b/Sources/EffectComponents/EffectView/EffectView.swift deleted file mode 100644 index c63fd98..0000000 --- a/Sources/EffectComponents/EffectView/EffectView.swift +++ /dev/null @@ -1,239 +0,0 @@ -import SwiftUI - -/// A SwiftUI view that manages structured side effects via an Elm-style update loop. -/// -/// `EffectView` owns the task scheduler for the duration of its view identity. -/// State is held by the caller via `Binding` so ancestor views can observe changes. -/// The supplied transducer type is the single mutation authority: it receives events, -/// mutates state, and optionally returns an ``Effect`` to run or cancel. -/// -/// ### Basic usage -/// -/// ```swift -/// typealias Counter = CounterFeature.Transducer -/// -/// @State private var state = Counter.State() -/// -/// EffectView( -/// of: Counter.self, -/// state: $state, -/// ) { state, input in -/// Button("\(state.count)") { -/// try? input.post(.increment) -/// } -/// } -/// ``` -/// > Note: In this case it is safe to write `try?` since we can ignore the error when -/// attempting to dispatch an event when it happens within the Button action. -/// -/// ### Using `Env` for dependencies -/// -/// Pass dependencies (clocks, API clients, etc.) via `Env`. The value is captured -/// once when the view appears and forwarded to every effect. -/// -/// ```swift -/// typealias Feature = MoviesFeature.Transducer -/// @State private var state = Feature.State() -/// struct Env { let api: any APIClient } -/// -/// EffectView( -/// of: Feature.self, -/// state: $state, -/// initialEnv: Env(api: liveAPI), -/// ) { state, input in -/// Button("Load") { -/// try? input.post(.load) -/// } -/// } -/// ``` -/// -/// ### Env changes -/// -/// If `Env` changes during the view's lifetime, running effects keep the original -/// captured value. To restart with new dependencies, apply `.id(env)` at the call -/// site (requires `Env: Hashable`). This destroys the old view — cancelling all -/// tasks — and creates a fresh instance with the updated `Env`. -/// -/// ### Generic parameters -/// -/// - `State`: The type of the view's mutable state. -/// - `Event`: The event type driving state transitions. -/// - `Env`: The dependency environment. Use `Void` for no dependencies. -/// - `Output`: The value returned to callers of ``Input/request(_:)``. -/// Use `Void` when no return value is needed. -/// - `Content`: The view builder output type. -@MainActor -public struct EffectView< - T: Transducer, - Content: View ->: View, @MainActor TransducerHost where - T.Output: Sendable, - T.Env: Sendable, - T.Effect == TransducerEffect, - T.Event: Sendable -{ - public typealias State = T.State - public typealias Event = T.Event - public typealias Output = T.Output - public typealias Env = T.Env - public typealias Effect = T.Effect - - public typealias Input = EffectViewInput - - @SwiftUI.State private var send: Send? - - private var state: Binding - private var initialEvent: Event? - private let env: Env - private let content: (State, Input) -> Content - - /// Creates an effect-managed view with a captured dependency environment. - /// - /// The transducer type, `initialEvent`, and `initialEnv` are captured once when - /// the view appears for the first time. Later changes are intentionally ignored - /// to avoid mid-flight dependency swaps during running effects. To restart with - /// new dependencies, use `.id(env)` at the call site when that identity model - /// makes sense for your feature. - /// - /// ```swift - /// EffectView( - /// of: Feature.self, - /// state: $state, - /// initialEnv: env, - /// ) { state, input in - /// Button("Start") { - /// try? input.post(.start) - /// } - /// } - /// .id(env.id) - /// ``` - /// - /// - Parameters: - /// - of: The transducer type. - /// - state: A `Binding` to the view's state, owned by the caller. - /// - initialEvent: An optional event sent when the view first appears. - /// - initialEnv: The environment captured for this view's lifetime. - /// - content: Builds the view from current state and an ``Input`` handle. - public init( - of: T.Type = T.self, - state: Binding, - initialEvent: Event? = nil, - initialEnv: Env, - @ViewBuilder content: @escaping (State, Input) -> Content - ) { - self.state = state - self.initialEvent = initialEvent - self.env = initialEnv - self.content = content - } - - public var body: some View { - HStack { - if let send { - content(self.state.wrappedValue, Input(send)) - } else { - // transparent placeholder; holds layout until effectManager is ready - Color.clear - .frame(maxWidth: 1, maxHeight: 1) - } - } - .task { - guard self.send == nil else { - return - } - self.send = T.makeSend( - with: Input.self, - storage: self.state, - env: self.env - ) - if let event = initialEvent { - do { - try await Input(send!).send(event) - } catch { - try? self.send?.control(.systemError(error)) - } - } - } - } - - public func send(_ event: sending T.Event) async throws { - guard let send = self.send else { - throw RuntimeError.actorNotInitialised - } - try await Input(send).send(event) - } - - public func request(_ event: T.Event) async throws -> T.Output? { - try await input.request(event) - } - - public var input: Input { - guard let send = self.send else { - fatalError(RuntimeError.actorNotInitialised.localizedDescription) - } - return Input(send) - } - - public func cancel() { - guard let send = self.send else { - fatalError(RuntimeError.actorNotInitialised.localizedDescription) - } - try? send.control(.cancel) - } - - public func cancel(with error: any Error) { - guard let send = self.send else { - fatalError(RuntimeError.actorNotInitialised.localizedDescription) - } - try? send.control(.systemError(error)) - } -} - - -extension EffectView where Env == Void { - - /// Creates an effect-managed view with no external dependencies. - /// - /// The transducer type and `initialEvent` are captured once when the view - /// appears for the first time. To reset the runtime, recreate the view's - /// identity with `.id(...)`. - /// - /// ```swift - /// EffectView( - /// of: Feature.self, - /// state: $state, - /// ) { state, input in - /// Button("Start") { - /// try? input.post(.start) - /// } - /// } - /// ``` - /// - /// - Parameters: - /// - of: The transducer type. - /// - state: A `Binding` to the view's state, owned by the caller. - /// - initialEvent: An optional event sent when the view first appears. - /// - content: Builds the view from current state and an ``Input`` handle. - public init( - of: T.Type = T.self, - state: Binding, - initialEvent: Event? = nil, - @ViewBuilder content: @escaping (State, Input) -> Content - ) { - self.state = state - self.initialEvent = initialEvent - self.env = () - self.content = content - } -} - -extension SwiftUI.Binding: Storage { - public var value: Value { - get { - self.wrappedValue - } - nonmutating set { - self.wrappedValue = newValue - } - } -} diff --git a/Sources/EffectComponents/EffectView/EffectViewInput.swift b/Sources/EffectComponents/EffectView/EffectViewInput.swift deleted file mode 100644 index 520f606..0000000 --- a/Sources/EffectComponents/EffectView/EffectViewInput.swift +++ /dev/null @@ -1,173 +0,0 @@ -import Foundation - -/// A `Sendable` handle for dispatching events into the effect engine. -/// -/// `EffectViewInput` provides three dispatch strategies with different semantics: -/// - ``send(_:)`` — synchronous; must be called from the `@MainActor`. -/// - ``post(_:)`` — fire-and-forget; safe from any isolation. -/// - ``request(_:)`` — suspends the caller, returning `Output?`. -/// -/// ### Isolation and lifetime safety -/// -/// All state mutations run on the `@MainActor`, a global, app-lifetime -/// executor. Because the `@MainActor` is never destroyed, ``request(_:)`` -/// is guaranteed to resume its continuation on every code path — no -/// `withTaskCancellationHandler` bookkeeping is required. -/// -/// If the calling `Task` is cancelled while awaiting ``request(_:)``, -/// the suspension continues until the event is processed. Swift does not -/// automatically resume continuations on cancellation; this is safe -/// because the `@MainActor` always completes its work. -/// -/// ### Generic parameters -/// -/// - `Event`: The event type dispatched into the state machine. -/// - `Output`: The value returned by ``request(_:)``. -/// Use `Void` when no return value is needed. -public struct EffectViewInput: TransducerInput, Identifiable, Sendable { - - @MainActor - init(_ send: Send) { - self._send = { @MainActor (event, input, continuation) async throws -> Void in - try await send(event, input: input, continuation: continuation) - } - id = send.id - } - - let _send: @MainActor (Event, EffectViewInput, Continuation?) async throws -> Void - - public let id: UUID - - - /// Sends the given event into the transducer. - /// - /// The event will be processed by the transducer's update function, which may - /// return an effect which may itself return an event. This event is synchronously - /// processed by the update function. The chain of events is processed until no - /// further events are returned. - /// - /// If the update function returns a task effect, this task will be started. This - /// also terminates the event processing chain and `send` returns. - /// - /// When `send` returns the transducer has fully processed the event, that is it has - /// updated its state accordingly, and started all effect tasks returned by the - /// update function during processing of the event. However, any async operations - /// in those tasks may continue to run. - /// - /// - seealso: **Effect Operations and Actions** - /// - /// > Note: The send function may suspend only when it executes suspending effect - /// actions. - /// - /// - Parameter event: The event that is sent into the system. - /// - Throws: ``RuntimeUnavailable`` if the runtime cannot accept the event, - /// or `CancellationError` if accepted work is later cancelled. - /// - /// ## Example - /// - /// Use `send` when you are already running on the `@MainActor` and want the event to be - /// processed immediately, in the same synchronous turn. A typical example is a SwiftUI - /// button action: - /// - /// ```swift - /// Button("Increment") { - /// // Processed before the next await point: - /// input.send(.increment) - /// } - /// ``` - /// - /// "Synchronous" here means that `update` is called inline, any `.action` chain is - /// unwound, and the resulting state change is applied — all before `send` returns. - /// If `update` returns a `.task`, that task is *launched* synchronously but runs - /// concurrently; `send` does not wait for it to finish. Use ``request(_:)`` if you - /// need to await the task's completion. - /// - /// If you want to fire-and-forget the event — scheduling it without waiting for even - /// the synchronous `update` pass to complete — use ``post(_:)`` instead, - /// for example, `input.post(.increment)` — or use the shorthand to post - /// an event: `input(.increment)`. - /// - /// - Warning: Because `send` unwinds `.action` chains synchronously on the `@MainActor`, - /// a cycle in your `update` function — e.g. `.ping` → `.action { .pong }` → `.action { .ping }` → … — - /// will loop forever and hang the main thread. ``post(_:)`` and ``request(_:)`` are - /// immune because each re-entry is scheduled as a new task, yielding control between iterations. - @MainActor - public func send(_ event: Event) async throws { - try await _send(event, self, nil) - } - - /// Schedules `event` on the `@MainActor` without awaiting it. - /// - /// Safe to call from any actor isolation or non-isolated context. - /// Use this to fire-and-forget an event from a background task or a - /// non-isolated callback without waiting for `update` to run. - /// - /// - Parameter event: The event to enqueue into the runtime. - /// - Note: This implementation does not throw synchronously. Runtime failures - /// surface only inside the scheduled task that later processes the event. - @inline(__always) - public func post(_ event: sending Event) { - Task { @MainActor in - try await _send(event, self, nil) - } - } - - /// Sends `event` and suspends until operations of the resulting effect chain complete. - /// - /// The event will be processed by the transducer's update function, which may - /// return an effect which may itself return an event. This event is synchronously - /// processed by the update function. The chain of events is processed until no - /// further events are returned. - /// - /// If the update function returns a task, this task will be executed and `request` will - /// suspend until the task's operation completes, returning the task's output. - /// This also terminates the event processing chain. - /// - /// When `request` returns the transducer has fully processed the event, that is it has - /// updated its state accordingly, and started and awaited the effect task returned - /// by the update function during processing of the event. In the mean time, the - /// transducer can receive and process other events, but the caller is suspended until - /// the effect task triggered by this event has completed. - /// - /// The caller hops to the `@MainActor` for the duration of the call. Because the - /// `@MainActor` is a global, app-lifetime executor, the continuation is always - /// resumed — no cancellation handler is needed. - /// - /// - Note: If the calling `Task` is cancelled while suspended, - /// `request` continues to wait until the effect chain settles. - /// - Parameter event: The event to send into the runtime. - /// - Throws: ``RuntimeUnavailable`` when the runtime cannot accept the request, - /// or `CancellationError` if accepted work is later cancelled. - /// - Returns: The terminal `Output?` value produced by the settled effect chain. - /// - /// For usage patterns including `.refreshable`, `task(id:)`, and testing, - /// see . - @discardableResult - public func request(_ event: Event) async throws-> Output? where Event: Sendable { - try await withCheckedThrowingContinuation { continuation in - Task { @MainActor in - do { - try await _send(event, self, continuation) - } catch { - continuation.resume(throwing: runtimeBoundaryError(for: error)) - } - } - } - } - - /// Convenience call-as-function syntax for ``post(_:)``. - /// - /// - Parameter event: The event to enqueue into the runtime. - /// - Throws: Any error that ``post(_:)`` would throw for the same event. - @inline(__always) - public func callAsFunction(_ event: sending Event) throws { - post(event) - } -} - - -extension EffectViewInput: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.id == rhs.id - } -} diff --git a/Sources/EffectComponents/Storage/ReferenceKeyPathStorage.swift b/Sources/EffectComponents/Storage/ReferenceKeyPathStorage.swift deleted file mode 100644 index c5a4b30..0000000 --- a/Sources/EffectComponents/Storage/ReferenceKeyPathStorage.swift +++ /dev/null @@ -1,72 +0,0 @@ -internal struct ReferenceKeyPathStorage: Storage { - - init(host: Host, keyPath: ReferenceWritableKeyPath) { - self.host = host - self.keyPath = keyPath - } - - private let host: Host - private let keyPath: ReferenceWritableKeyPath - - var value: Value { - get { - host[keyPath: keyPath] - } - nonmutating set { - host[keyPath: keyPath] = newValue - } - } -} - -internal struct WeakReferenceKeyPathStorage: Storage { - - init(host: Host, keyPath: ReferenceWritableKeyPath) { - self.host = host - self.keyPath = keyPath - } - - private weak var host: Host? - private let keyPath: ReferenceWritableKeyPath - - var value: Value { - get { - guard let host = host else { - fatalError( - "Value accessed through weak ReferenceKeyPathStorage has been deallocated.") - } - return host[keyPath: keyPath] - } - nonmutating set { - guard let host = host else { - fatalError( - "Value accessed through weak ReferenceKeyPathStorage has been deallocated.") - } - host[keyPath: keyPath] = newValue - } - } -} - -/// Storage adapter that reads and writes through an unowned reference key path. -/// -/// Use this when the storage host is guaranteed to outlive the adapter and state -/// should be accessed through a reference type rather than copied locally. -public struct UnownedReferenceKeyPathStorage: Storage { - - init(host: Host, keyPath: ReferenceWritableKeyPath) { - self.host = host - self.keyPath = keyPath - } - - private unowned let host: Host - private let keyPath: ReferenceWritableKeyPath - - /// The value stored at `keyPath` on `host`. - public var value: Value { - get { - return host[keyPath: keyPath] - } - nonmutating set { - host[keyPath: keyPath] = newValue - } - } -} diff --git a/Sources/EffectComponents/Storage/Storage.swift b/Sources/EffectComponents/Storage/Storage.swift deleted file mode 100644 index 0eb8bc1..0000000 --- a/Sources/EffectComponents/Storage/Storage.swift +++ /dev/null @@ -1,39 +0,0 @@ -/// A protocol that abstracts different storage implementations for transducer state. -/// -/// `EffectView` uses `Storage` internally to read and write state through a common -/// interface, whether the backing state lives in local storage, a reference host, -/// or a SwiftUI `Binding`. -/// -/// Most library users will work with higher-level runtime types rather than with -/// `Storage` directly. -public protocol Storage { - associatedtype Value - - /// The current stored value. - var value: Value { get nonmutating set } -} - -internal struct LocalStorage: Storage { - final class Reference { - var value: Value - - init(value: Value) { - self.value = value - } - } - - init(value: Value) { - storage = Reference(value: value) - } - - private let storage: Reference - - var value: Value { - get { - storage.value - } - nonmutating set { - storage.value = newValue - } - } -} diff --git a/Sources/EffectComponents/Transducer/Errors.swift b/Sources/EffectComponents/Transducer/Errors.swift deleted file mode 100644 index c867d79..0000000 --- a/Sources/EffectComponents/Transducer/Errors.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation - -/// Boundary error indicating that the runtime can no longer accept or complete work. -public enum RuntimeError: LocalizedError, Equatable, Sendable { - /// The effect actor is not yet initialised. - case actorNotInitialised - - /// The effect actor is already initialised. - case actorAlreadyInitialised - - /// The effect actor cancelled the runtime before this call could proceed. - case actorCancelled - - /// The effect actor object was deallocated before this call could enter the runtime. - case actorDeallocated - - /// The runtime latched a critical system failure and stopped accepting work. - case systemError - - /// The current path was cancelled before completion. - case cancelled - - public var errorDescription: String? { - switch self { - case .actorNotInitialised: - return "The effect actor is not yet initialised." - case .actorAlreadyInitialised: - return "The effect actor is already initialised." - case .actorCancelled: - return "The effect actor is unavailable because it has already been cancelled." - case .actorDeallocated: - return "The effect actor is unavailable because it has already been deallocated." - case .systemError: - return "The runtime is unavailable because it has forcibly terminated because of a critical error." - case .cancelled: - return "The effect actor is unavailable because it has been cancelled." - } - } -} - -func runtimeBoundaryError(for error: any Swift.Error) -> any Swift.Error { - if error is CancellationError { - return error - } - if let runtimeUnavailable = error as? RuntimeError { - return runtimeUnavailable - } - return RuntimeError.systemError -} diff --git a/Sources/EffectComponents/Transducer/SendFunc.swift b/Sources/EffectComponents/Transducer/SendFunc.swift deleted file mode 100644 index 3f5d18d..0000000 --- a/Sources/EffectComponents/Transducer/SendFunc.swift +++ /dev/null @@ -1,226 +0,0 @@ -import Foundation - -/// Low-level runtime handle for routing events and control messages. -/// -/// Most feature code should use higher-level entry points such as -/// ``EffectView/Input-swift.struct`` or ``EffectObservable/Input-swift.struct``. -public struct Send: Identifiable -where Input: TransducerInput & Sendable { - typealias TaggedEvent = EffectComponents::TaggedEvent - typealias SendFunc = (isolated SystemActor, Event, Input?, Continuation?) async throws -> Void - typealias ControlFunc = (isolated SystemActor, ControlEvent) throws -> Void - - let send: SendFunc - let control: ControlFunc - - init(send: @escaping SendFunc, control: @escaping ControlFunc) { - self.send = send - self.control = control - self.id = .init() - } - - public let id: UUID - - /// Sends `event` through the runtime using the provided `input` handle. - /// - /// - Parameters: - /// - systemActor: The actor isolation that owns the runtime. - /// - event: The event to send into the runtime. - /// - input: The input handle forwarded into effect execution. - /// - continuation: An optional request continuation to resume when the chain settles. - /// - Throws: Any runtime error raised while processing the event. - @inline(__always) - public func callAsFunction( - systemActor: isolated SystemActor = #isolation, - _ event: Event, - input: Input, - continuation: Continuation? = nil - ) async throws { - try await send(systemActor, event, input, continuation) - } - - func control( - systemActor: isolated SystemActor = #isolation, - _ controlEvent: ControlEvent - ) throws { - try control(systemActor, controlEvent) - } -} - -extension Transducer where Effect == TransducerEffect, Env: Sendable, Output: Sendable { - - /// Creates the low-level `Send` handle for a global-actor runtime host. - /// - /// Use this overload when the runtime state is stored externally and - /// reduced through a ``Storage`` value. - /// - /// - Parameters: - /// - systemActor: The actor isolation that owns the runtime. - /// - input: The `TransducerInput` type to feed into effect execution. - /// - storage: The mutable state storage to reduce events against. - /// - env: The dependency environment captured for runtime work. - /// - Returns: The low-level send handle used to route events and control messages. - public static func makeSend & Sendable, S: Storage>( - systemActor: isolated SystemActor = #isolation, - with input: Input.Type = Input.self, - storage: S, - env: Env - ) -> Send - where S.Value == State - { - typealias SendFunc = Send.SendFunc - typealias ControlFunc = Send.ControlFunc - - // Important: a system error is initially created by an operation or - // action when it throws back an error. This error will be caught by - // the EffectManager and then sent through the callback function. - // The callback should map it to a control event and send it into the - // transducer. The transducer then throws in the compute function. - let taskManager = TaskManager() - let gate = ComputeGate() - let sendFunc: SendFunc = { isolator, event, input, continuation in - precondition(systemActor === isolator) - - await gate.enter(systemActor: isolator) - defer { gate.leave(systemActor: isolator) } - - try await compute( - systemActor: isolator, - event: event, - continuation: continuation, - storage: storage, - taskManager: taskManager, - input: input, - env: env - ) - } - - let controlFunc: ControlFunc = { isolator, controlEvent in - precondition(systemActor === isolator) - - try control( - systemActor: isolator, - controlEvent: controlEvent, - state: storage.value, - taskManager: taskManager - ) - } - - let send = Send(send: sendFunc, control: controlFunc) - - taskManager.systemErrorCallback = { systemError in - // TaskManager clears this callback when it latches a system error, - // breaking the send/taskManager retain cycle during teardown. - try? send.control(systemActor: systemActor, .systemError(systemError)) - } - - return send - } - - /// Creates the low-level `Send` handle for an ``EffectActor`` runtime host. - /// - /// Use this overload when the runtime state is owned by an - /// ``EffectActor`` and accessed through an ``ActorStateBinding``. - /// - /// - Parameters: - /// - systemActor: The `EffectActor` isolation that owns the runtime. - /// - input: The `TransducerInput` type to feed into effect execution. - /// - actorStateAcessor: The actor-backed state binding used to read and reduce state. - /// - env: The dependency environment captured for runtime work. - /// - Returns: The low-level send handle used to route events and control messages. - @available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) - public static func makeSend & Sendable>( - systemActor: isolated EffectActor = #isolation, - with input: Input.Type = Input.self, - actorStateAcessor: ActorStateBinding, State>, - env: Env - ) -> Send, Event, Input, Output> - { - typealias SendFunc = Send, Event, Input, Output>.SendFunc - typealias ControlFunc = Send, Event, Input, Output>.ControlFunc - - // Important: a system error is initially created by an operation or - // action when it throws back an error. This error will be caught by - // the EffectManager and then sent through the callback function. - // The callback should map it to a control event and send it into the - // transducer. The transducer then throws in the compute function. - let taskManager = TaskManager() - let gate = ComputeGate() - let sendFunc: SendFunc = { isolator, event, input, continuation in - precondition(systemActor === isolator) - - await gate.enter(systemActor: isolator) - defer { gate.leave(systemActor: isolator) } - - try await compute( - systemActor: isolator, - event: event, - continuation: continuation, - actorStateBinding: actorStateAcessor, - taskManager: taskManager, - input: input, - env: env - ) - } - - let controlFunc: ControlFunc = { isolator, controlEvent in - precondition(systemActor === isolator) - - try control( - systemActor: isolator, - controlEvent: controlEvent, - state: actorStateAcessor.state(), - taskManager: taskManager - ) - } - - let send = Send(send: sendFunc, control: controlFunc) - - taskManager.systemErrorCallback = { systemError in - // TaskManager clears this callback when it latches a system error, - // breaking the send/taskManager retain cycle during teardown. - try? send.control(systemActor: systemActor, .systemError(systemError)) - } - - return send - } - -} - -final class ComputeGate { - private var active = false - private var waiters: [CheckedContinuation] = [] - - func enter( - systemActor: isolated any Actor = #isolation - ) async { - if !active { - active = true - return - } - - await withCheckedContinuation { continuation in - waiters.append(continuation) - } - } - - func leave( - systemActor: isolated any Actor = #isolation - ) { - if waiters.isEmpty { - active = false - return - } - - let next = waiters.removeFirst() - next.resume() - } -} - -// In order to support a "failing" Actor, we need to wrap the Send function -// into an async "run" function. `run` could use withCheckedThrowingContinuation -// and pass the continuation into the compute function. With control events, -// we can resume the continuation. Then, the async throwing run function can -// be put into a Task. `run` also has a task cancellation handler installed which -// sends a control event to the actor, so that the transducer can cancel and -// terminate. diff --git a/Sources/EffectComponents/Transducer/TaskManager.swift b/Sources/EffectComponents/Transducer/TaskManager.swift deleted file mode 100644 index 0cbf128..0000000 --- a/Sources/EffectComponents/Transducer/TaskManager.swift +++ /dev/null @@ -1,544 +0,0 @@ - -/// Coordinates keyed async tasks and their waiting continuations for one runtime. -/// -/// `TaskManager` tracks tasks by logical identifier rather than only by task -/// instance. Equal identifiers mean equal in-flight work. When a new waiter -/// arrives for an identifier that already has an active task, the manager uses -/// ``TaskExecutionOption`` to decide whether the waiter subscribes to the -/// existing task or replaces it with a fresh task. -/// -/// The manager also defines the runtime's hard-stop semantics. Once -/// ``cancel(with:)`` begins cancellation, the manager rejects new tasks and -/// cancels tracked work. Graceful teardown is intentionally not part of this -/// type. A controlled shutdown must be modeled in transducer state and event -/// flow instead. -final class TaskManager { - - /// A waiter suspended on a task result managed by this instance. - typealias Continuation = CheckedContinuation - - /// Describes whether the manager is accepting work or shutting down. - enum State { - /// The manager accepts new tasks and waiters. - case active - /// Cancellation has begun and the manager has latched an optional error. - case cancelling(error: Swift.Error? = nil) - /// Cancellation has fully completed and the optional error remains latched. - case cancelled(error: Swift.Error? = nil) - } - - private var tasks: Dictionary = [:] - private var taskId: Int = 0 // a monotonic increasing integer used as a unique identifier for a task. - private(set) var state: State = .active - - var systemErrorCallback: ((any Swift.Error) async -> Void)? = nil - - /// Optional callback for surfacing a fatal system error back to the runtime owner. - init(systemErrorCallback: ((any Swift.Error) async -> Void)? = nil) { - self.systemErrorCallback = systemErrorCallback - #if DEBUG - print("EffectManager: init") - #endif - } - - deinit { - #if DEBUG - print("EffectManager: deinit") - #endif - let shutdownError = latchedShutdownError - tasks.values.forEach { taskValue in - var taskValue = taskValue - taskValue.cancel(with: shutdownError) - } - } - - /// Throws if the manager is no longer accepting work. - /// - /// Callers typically use this as a boundary check before entering the main - /// runtime loop. When the manager has latched a concrete shutdown error, - /// that error is rethrown. Otherwise `RuntimeUnavailable.cancelled` is - /// thrown. - /// - /// - Throws: The latched shutdown error, or `RuntimeUnavailable.cancelled` - /// when cancellation happened without a more specific reason. - @inline(__always) - func checkCancellation() throws { - switch state { - case .active: break - case .cancelling(let error), .cancelled(let error): - if let error { - throw error - } else { - throw RuntimeError.cancelled - } - } - } - - /// Starts hard cancellation of the manager and its tracked tasks. - /// - /// The first call latches `error`, transitions the manager out of the - /// active state, clears ``systemErrorCallback``, and cancels all tracked - /// tasks. Later calls are ignored. - /// - /// - Parameter error: An optional shutdown reason to latch for later - /// ``checkCancellation()`` calls. - func cancel(with error: (any Swift.Error)? = nil) { - guard case .active = self.state else { - return - } - self.state = .cancelling(error: error) - // Break the send/taskManager retain cycle once the runtime has irreversibly failed. - systemErrorCallback = nil - - for key in tasks.keys { - tasks[key]!.cancel(with: error) - } - if tasks.isEmpty { - state = .cancelled(error: error) - } - } - - /// Cancels the tracked task for `identifier`, if one exists. - /// - /// All waiters currently attached to that task are resumed with - /// `CancellationError()`. - /// - /// - Parameter identifier: The logical identifier of the task to cancel. - /// - Returns: `true` if an active tracked task was found and cancelled. - @discardableResult - func cancelTasks( - systemActor: isolated any Actor = #isolation, - with identifier: TaskIdentifier - ) -> Bool { - let taskKey = TaskKey(identifier) - if var taskValue = tasks[taskKey] { - if !taskValue.task.isCancelled { - taskValue.cancel() - tasks[taskKey] = taskValue - #if DEBUG - print("EffectManager cancelled task: \(identifier)-\(tasks[taskKey]!.id)") - #endif - return true - } else { - #if DEBUG - print("EffectManager did not cancel task with identifier \"\(identifier)\" - because it is already cancelled") - #endif - return false - } - } else { - #if DEBUG - print("EffectManager could not cancel task with identifier \"\(identifier)\" - not in tasks") - #endif - return false - } - } - - /// Adds a task or waiter to the manager. - /// - /// If the manager is still active, the task identified by `identifier` is - /// either reused or replaced according to `option`. When `continuation` is - /// non-`nil`, it is attached to the active task chosen for that identifier. - /// - /// If the manager is no longer active, `continuation` is resumed with the - /// latched shutdown error, or `RuntimeUnavailable.cancelled` when shutdown - /// happened without a more specific reason, and no new task is started. - /// - /// - Parameters: - /// - systemActor: The isolation the system is executing on. - /// - identifier: The logical task identity. Equal identifiers mean equal - /// overlapping work. - /// - option: Decides whether a new waiter reuses the active task or replaces it. - /// - continuation: If not `nil`, a waiter which will be resumed when the current - /// active task for this identifier completes. - /// - priority: The priority of the operation task. - /// - isolatedOperation: The operation to perform. - func addTask( - systemActor: isolated any Actor = #isolation, - with identifier: TaskIdentifier? = nil, - option: TaskExecutionOption = .switchToLatest, - continuation: Continuation?, - priority: TaskPriority? = nil, - isolatedOperation: @escaping (isolated any Actor) async throws -> Output? - ) { - guard case .active = state else { - // TODO: check if this should be better a precondition - if let continuation { - continuation.resume(throwing: latchedShutdownError) - } - return - } - // TODO: we should better use an enum for the valid variants, instead tuple (option, identifier, continuation) - switch (option, identifier, continuation) { - case (.switchToLatest, .some(let identifier), _): - replaceNewTask( - identifier: identifier, - continuation: continuation, - priority: priority, - isolatedOperation: isolatedOperation - ) - - case (.subscribe, .some(let identifier), .some(let continuation)): - addNewTaskOrSubscribe( - identifier: identifier, - continuation: continuation, - priority: priority, - isolatedOperation: isolatedOperation - ) - case (.subscribe, .none, _), (.switchToLatest, .none, _): - addNewTask( - identifier: identifier, - continuation: continuation, - priority: priority, - isolatedOperation: isolatedOperation - ) - case (.subscribe, .some, .none): - preconditionFailure("subcribing to a task without a continuation is not supported") - } - } - - private var latchedShutdownError: any Swift.Error { - switch state { - case .active: - return RuntimeError.cancelled - case .cancelling(let error), .cancelled(let error): - return error ?? RuntimeError.cancelled - } - } - - private func makeTask( - systemActor: isolated any Actor = #isolation, - taskKey: TaskKey, - id taskId: Int, - priority: TaskPriority?, - isolatedOperation: @escaping (isolated any Actor) async throws -> Output? - ) -> Task { - // CAUTION: `systemActor` is captured *strongly*!. In cases, where the - // systemActor keeps a strong reference to `self`, self will never be - // deallocated before all tasks are finished, because the captured - // `systemActor` establishes a reference cycle - until after the task - // finishes. This is important to know when implementing an "FSM Effect - // Actor" based on Swift Actors. That is, a proper implementation of an - // "FSM Effect Actor" should always have a `cancel()` method which cancels - // all running tasks and additionally prevents enqueueing new ones. - - #if DEBUG - let taskName = taskKey.string - #else - let taskName: String? = nil - #endif - let task = Task(name: taskName, priority: priority) { [weak self] in - _ = systemActor - let result: Result - do { - let output = try await isolatedOperation(systemActor) - result = .success(output) - } catch { - result = .failure(error) - } - switch result { - case .failure(let error): - if error is CancellationError && Task.isCancelled { - self?.finish(taskKey: taskKey, id: taskId, result: result) - } else { - if let systemErrorCallback = self?.systemErrorCallback { - await systemErrorCallback(error) - } else { - self?.cancel(with: error) - } - self?.complete(taskKey: taskKey, id: taskId) - } - case .success: - self?.finish(taskKey: taskKey, id: taskId, result: result) - } - } - return task - } - - /// Inserts a new tracked task under `identifier`. If a task already exists at the given - /// identifier, it cancels the previous one and all associated waiters. - /// - /// The new task captures `systemActor`, runs `isolatedOperation`, and then - /// either resumes the attached waiters with the operation result or begins - /// manager cancellation when the operation fails with a non-task-cancellation - /// error. - private func addNewTask( - systemActor: isolated any Actor = #isolation, - identifier: TaskIdentifier?, - continuation: Continuation?, - priority: TaskPriority?, - isolatedOperation: @escaping (isolated any Actor) async throws -> Output? - ) { - let taskKey: TaskKey - let id = taskId - if let identifier = identifier { - taskKey = TaskKey(identifier) - } else { - taskKey = TaskKey.makeAnon(with: id) - } - if var taskValue = tasks[taskKey] { - taskValue.cancel() - } - let task = makeTask( - taskKey: taskKey, - id: taskId, - priority: priority, - isolatedOperation: isolatedOperation - ) - let continuations = continuation != nil ? [continuation!] : [] - let taskValue = TaskValue(id: id, task: task, continuations: continuations) - tasks[taskKey] = taskValue - taskId += 1 - #if DEBUG - print("EffectManager added Task: \(taskKey)-\(taskValue.id)") - #endif - } - - /// If a task already exists at the given identifier, adds it as a subscriber and the closure - /// `isolatedOperation` will be discarded. Otherwise a new Task will be added. - /// - /// The new task captures `systemActor`, runs `isolatedOperation`, and then - /// either resumes the attached waiters with the operation result or begins - /// manager cancellation when the operation fails with a non-task-cancellation - /// error. - // TODO: elevate priority when this task will be added as a subscriber. - private func addNewTaskOrSubscribe( - systemActor: isolated any Actor = #isolation, - identifier: TaskIdentifier, - continuation: Continuation, - priority: TaskPriority?, - isolatedOperation: @escaping (isolated any Actor) async throws -> Output? - ) { - let taskKey = TaskKey(identifier) - if var taskValue = tasks[taskKey] { - taskValue.continuations.append(continuation) - tasks[taskKey] = taskValue - #if DEBUG - print("EffectManager added subscriber to Task: \(taskKey)-\(taskValue.id)") - #endif - } else { - addNewTask( - identifier: identifier, - continuation: continuation, - priority: priority, - isolatedOperation: isolatedOperation - ) - } - } - - /// Replaces a the task under `identifier`. Existing continuations will be kept. - /// - /// If no previous task exist with this identifier, a new task well be added. - /// - /// The new task captures `systemActor`, runs `isolatedOperation`, and then - /// either resumes the attached waiters with the operation result or begins - /// manager cancellation when the operation fails with a non-task-cancellation - /// error. - private func replaceNewTask( - systemActor: isolated any Actor = #isolation, - identifier: TaskIdentifier, - continuation: Continuation?, - priority: TaskPriority?, - isolatedOperation: @escaping (isolated any Actor) async throws -> Output? - ) { - let id = taskId - let taskKey = TaskKey(identifier) - - if var taskValue = tasks.removeValue(forKey: taskKey) { - taskValue.task.cancel() - - let task = makeTask( - taskKey: taskKey, - id: id, - priority: priority, - isolatedOperation: isolatedOperation - ) - taskValue.task = task - taskValue.id = id - if let continuation { - taskValue.continuations.append(continuation) - } - tasks[taskKey] = taskValue - taskId += 1 - #if DEBUG - print("EffectManager replaced Task: \(taskKey)-\(taskValue.id)") - #endif - } else { - addNewTask( - identifier: identifier, - continuation: continuation, - priority: priority, - isolatedOperation: isolatedOperation - ) - } - } - - /// Resumes all waiters for the matching task and removes it from tracking. - private func finish(taskKey: TaskKey, id: Int, result: Result) { - if var taskValue = tasks[taskKey], taskValue.id == id { - taskValue.resume(with: result) - tasks[taskKey] = nil - if tasks.isEmpty, case .cancelling(let error) = state { - state = .cancelled(error: error) - } - #if DEBUG - print("EffectManager task completed: \(taskKey.string)-\(id)") - #endif - } - } - - /// Removes the tracked task if `id` still matches the current entry. - private func complete(taskKey: TaskKey, id: Int) { - if let taskValue = tasks[taskKey], taskValue.id == id { - precondition(taskValue.continuations.isEmpty) - tasks[taskKey] = nil - } else { - // Currently, with TaskKey being hashed on the identifier, - // this can happen, when a subsequent task cancels the previous - // one (aka `switchToLatest`), and the previous task has not - // been completed (and removed) *before* the new task has been - // inserted into the dictionary with the *same* key. When the previous - // task eventually completes, there is no entry with its `id` - // anymore. - /* nothing */ - } - if tasks.isEmpty, case .cancelling(let error) = state { - state = .cancelled(error: error) - } - #if DEBUG - print("EffectManager task completed: \(taskKey.string)-\(id)") - #endif - } - -} - -extension TaskManager { - - /// The dictionary key used to track a logical task. - struct TaskKey: Hashable, Equatable, CustomStringConvertible { - init(_ identifier: TaskIdentifier) { - self.identifier = identifier - } - - static func makeAnon(with taskId: Int) -> TaskKey { - return .init(TaskIdentifier("__\(taskId)")) - } - - let identifier: TaskIdentifier? - - var description: String { string } - var string: String { "\(identifier, default: "__")" } - } - - /// The mutable tracked value for one logical task entry. - struct TaskValue { - var id: Int // unique task id - var task: Task - var continuations: [Continuation] - - init(id: Int, task: Task, continuations: [Continuation]) { - self.id = id - self.task = task - self.continuations = continuations - } - - /// Cancels the task and fails all current waiters with `error`, or with - /// `CancellationError()` when no more specific reason is available. - mutating func cancel(with error: (any Swift.Error)? = nil) { - task.cancel() - for continuation in continuations { - continuation.resume(throwing: error ?? CancellationError()) - } - continuations = [] - } - - /// Completes all current waiters with the finished task result. - mutating func resume(with result: Result) { - for continuation in continuations { - switch result { - case .failure(let error): - continuation.resume(throwing: error) - case .success(let output): - continuation.resume(returning: output) - } - } - continuations = [] - } - - /// Attaches a new waiter to the tracked task. - mutating func subscribe(continuation: Continuation) { - continuations.append(continuation) - } - } - -} - -/// A typed logical identifier for managed tasks. -/// -/// Equal `TaskIdentifier` values declare the same in-flight work. The -/// task manager uses that identity to decide whether a new task request should -/// subscribe to existing work or replace it. -public struct TaskIdentifier: @unchecked Sendable, Hashable { - private let wrapped: AnyHashable - - /// Creates an identifier from any hashable, sendable value. - /// - /// - Parameter wrapped: The logical identifier value to wrap. - public init(_ wrapped: some Hashable & Sendable) { - self.wrapped = .init(wrapped) - } -} - -extension TaskIdentifier: ExpressibleByStringLiteral { - /// Creates an identifier from a string literal. - /// - /// - Parameter stringLiteral: The string value to wrap as an identifier. - public init(stringLiteral: String) { - self.init(stringLiteral) - } -} - -extension TaskIdentifier: ExpressibleByIntegerLiteral { - /// Creates an identifier from an integer literal. - /// - /// - Parameter value: The integer value to wrap as an identifier. - public init(integerLiteral value: IntegerLiteralType) { - self.init(value) - } -} - -extension TaskIdentifier: ExpressibleByStringInterpolation {} - - -extension TaskIdentifier: CustomStringConvertible { - /// Human-readable representation of the identifier. - public var description: String { string } - - /// The identifier rendered as a string. - public var string: String { wrapped.description } -} - - -/// Controls how the runtime handles a new request for an identifier that already has -/// an active task. -/// -/// Equal task identifiers declare the same logical in-flight work. The option decides -/// whether the runtime reuses the current task instance or replaces it with a fresh one. -/// -/// A later request only competes with work that is still active for the same logical -/// identifier. Once the tracked task has completed, the next request starts fresh -/// regardless of which option was used previously. -public enum TaskExecutionOption { - /// Cancel the running task for this identifier, start a fresh task, and attach all - /// current waiters plus the new waiter to the replacement task. - /// - /// - > Caution: `switchToLatest` replaces the physical task instance but preserves - /// waiter ownership. Existing waiters do not fail merely because a replacement - /// starts; they move to the new current task for the same identifier. - case switchToLatest - - /// Keep the running task for this identifier and add the new waiter to it. - /// - /// Later callers share the current logical work and receive the same terminal - /// result or failure as the active task for that identifier. - case subscribe -} diff --git a/Sources/EffectComponents/Transducer/Transducer.run.swift b/Sources/EffectComponents/Transducer/Transducer.run.swift deleted file mode 100644 index fed5da6..0000000 --- a/Sources/EffectComponents/Transducer/Transducer.run.swift +++ /dev/null @@ -1,40 +0,0 @@ -#if false // Feature run is not yet implemented - -extension Transducer where Effect == TransducerEffect, Env: Sendable, Output: Sendable { - - /// Runs the transducer runtime directly with an explicit low-level send handle. - /// - /// This API is intended as the low-level entry point beneath higher-level hosts such - /// as ``EffectView`` and ``EffectObservable``. - /// - /// - Warning: This entry point is currently a stub and always throws - /// ``RunError/notImplemented``. - /// - Parameters: - /// - systemActor: The actor isolation that owns the runtime. - /// - send: The low-level send handle used to route events and control messages. - /// - initialState: The starting state for the runtime. - /// - input: The input handle to expose to effect execution. - /// - Throws: ``RunError/notImplemented``. - /// - Returns: The terminal `Output?` value once the runtime settles. - @discardableResult - public static func run & Sendable>( - systemActor: isolated any Actor = #isolation, - send: Send, - initialState: State, - input: Input - ) async throws -> Output? { - _ = send - _ = initialState - _ = input - throw RunError.notImplemented - } -} - -// TODO: when implemented, remove it -/// Placeholder error for the unfinished low-level ``Transducer/run(systemActor:send:initialState:input:)`` API. -public enum RunError: Error, Sendable { - /// The requested runtime entry point has not been implemented yet. - case notImplemented -} - -#endif diff --git a/Sources/EffectComponents/Transducer/Transducer.swift b/Sources/EffectComponents/Transducer/Transducer.swift deleted file mode 100644 index f380410..0000000 --- a/Sources/EffectComponents/Transducer/Transducer.swift +++ /dev/null @@ -1,581 +0,0 @@ -import Foundation - -/// Finite-state reducer contract for the effect runtime. -/// -/// A `Transducer` defines the domain model that `EffectView` or -/// `EffectObservable` hosts: mutable `State`, incoming `Event`s, optional -/// dependency `Env`, and the ``TransducerEffect`` values returned from -/// ``update(_:event:)``. -/// -/// The runtime treats ``update(_:event:)`` as the single mutation point. -/// `update` mutates state synchronously and may return an effect describing -/// follow-up work. That work can emit more events later, but direct state -/// mutation still flows back through `update`. -/// -/// -/// Example -/// ------- -/// A minimal counter feature showing how to declare `State`, an `Event` enum (note the enum), -/// the `update` reducer, and an `output` that returns a value from state. -/// -/// ```swift -/// enum CounterTransducer: Transducer { -/// // Feature state owned by the runtime -/// struct State { -/// var count: Int = 0 -/// } -/// -/// // Domain events that drive state transitions -/// // (note: this is an enum) -/// enum Event { -/// case increment -/// case decrement -/// case reset -/// case requestCurrentValue // e.g., a request-style event -/// } -/// -/// // No external dependencies for this simple example -/// typealias Env = Void -/// -/// // The effect returned by `update` defaults to -/// // `TransducerEffect` -/// // We keep `Output` as `Int` to return the current -/// // counter value from `output`. -/// typealias Output = Int -/// -/// // Synchronous state mutation and next-effect -/// // selection -/// static func update( -/// _ state: inout State, event: Event -/// ) -> Effect? { -/// switch event { -/// case .increment: -/// state.count += 1 -/// return nil -/// -/// case .decrement: -/// state.count -= 1 -/// return nil -/// -/// case .reset: -/// state.count = 0 -/// return nil -/// -/// case .requestCurrentValue: -/// // In a request flow, you can choose to end the -/// // chain here with no further effect. -/// // The runtime will call `output(state:event:)` -/// // to produce the result. -/// return nil -/// } -/// } -/// -/// // Produces the terminal result for request-style -/// // chains -/// static func output( -/// state: State, event: Event -/// ) -> Output { -/// // For this simple example, always return -/// // the current count -/// state.count -/// } -/// } -/// ``` -/// -/// Requesting the current value -/// ---------------------------- -/// If your runtime provides an `Input` that conforms to `TransducerInput`, you can -/// request the current value by sending a request-style event and awaiting the output: -/// -/// ```swift -/// // Pseudocode demonstrating usage inside -/// // a host runtime -/// var state = CounterTransducer.State() -/// let taskManager = TaskManager() -/// let input: (any TransducerInput)? = /* provided by host */ -/// let env: CounterTransducer.Env = () -/// -/// // Fire some events that mutate state -/// try await CounterTransducer.compute( -/// event: .increment, -/// continuation: nil, -/// state: &state, -/// taskManager: taskManager, -/// input: input, -/// env: env -/// ) -/// -/// // Later, request the current value. Depending on -/// // your host, this might be: -/// if let input { -/// // Resume with the value produced by -/// // `output(state:event:)` -/// let value: Int? = await input.request(.requestCurrentValue) -/// // `value` is the current `state.count` at the -/// // time the request settles -/// } -/// ``` -public protocol Transducer { - /// Mutable feature state owned by the host runtime. - associatedtype State - - /// Domain event type that drives state transitions. - associatedtype Event - - /// Value returned to callers suspended on `request`-style entry points. - /// - /// Use `Void` when the feature does not return a result. - associatedtype Output = Void - - /// Dependency environment captured for the runtime lifetime. - /// - /// Use `Void` when the feature has no external dependencies. - associatedtype Env = Void - - /// Effect type returned from ``update(_:event:)``. - associatedtype Effect = TransducerEffect - - /// Applies `event` to `state` and returns the next effect to execute. - /// - /// `update` is synchronous. Mutate `state` directly and return an optional - /// effect describing any follow-up work. Return `nil` when processing ends - /// with no further effect. - /// - /// - Parameters: - /// - state: The current mutable feature state. - /// - event: The incoming event to reduce. - /// - Returns: The next effect to execute, or `nil` if processing terminates. - static func update(_ state: inout State, event: Event) -> Effect? - - /// Produces the terminal result for a settled request-style event chain. - /// - /// The runtime calls `output` when a `request` reaches a terminal state - /// without handing its continuation off to a managed task. - /// - /// - Parameters: - /// - state: The final state after the event chain has settled. - /// - event: The terminal event that ended the chain. - /// - Returns: The value to resume the waiting request with. - static func output(state: State, event: Event) -> Output -} - -/// Continuation used internally to complete request-style callers. -public typealias Continuation = CheckedContinuation - - -extension Transducer where Output == Void { - /// Default terminal output for features that do not return a value. - /// - /// - Parameters: - /// - state: The final state after the event chain has settled. - /// - event: The terminal event that ended the chain. - /// - Returns: `Void`. - @inline(__always) - public static func output(state: State, event: Event) -> Output { () } -} - - -enum ControlEvent: Sendable { - case systemError(any Swift.Error) - case cancel -} - -enum TaggedEvent { - case event(Event) - case control(ControlEvent) -} - -extension TaggedEvent: Sendable where Event: Sendable {} - -enum SystemCompletion: Swift.Error { - case error(any Swift.Error) - case cancelled -} - - -// Note: A Transducer requires an isolation in order to compile! -extension Transducer where Effect == TransducerEffect { - - /// Processes an event using state stored in a ``Storage`` container. - /// - /// This overload adapts externally stored state into the base - /// ``compute(systemActor:event:continuation:state:taskManager:input:env:)`` - /// implementation. - /// - /// - Parameters: - /// - systemActor: The current isolation token. - /// - event: The initial event to reduce. - /// - continuation: A continuation to resume if the chain settles without - /// delegating completion to a managed task. - /// - storage: The storage container that provides mutable access to state. - /// - taskManager: The task manager coordinating in-flight effects. - /// - input: Optional runtime input made available to effect operations. - /// - env: The dependency environment captured for the runtime lifetime. - /// - Throws: A cancellation or runtime error propagated from the base - /// `compute` implementation. - @inline(__always) - static func compute>( - systemActor: isolated any Actor = #isolation, - event: Event, - continuation: Continuation?, - storage: some Storage, - taskManager: TaskManager, - input: Input?, - env: Env - ) async throws where Output: Sendable, Env: Sendable, Input: Sendable { - try await compute( - event: event, - continuation: continuation, - state: &storage.value, - taskManager: taskManager, - input: input, - env: env - ) - } - - /// Processes an event using state owned by an actor host. - /// - /// This overload reads and writes state through - /// ``ActorStateBinding`` before delegating to the base - /// ``compute(systemActor:event:continuation:state:taskManager:input:env:)`` - /// implementation. - /// - /// - Parameters: - /// - systemActor: The actor isolation that owns the bound state. - /// - event: The initial event to reduce. - /// - continuation: A continuation to resume if the chain settles without - /// delegating completion to a managed task. - /// - actorStateBinding: The actor-backed state binding used to access state. - /// - taskManager: The task manager coordinating in-flight effects. - /// - input: Optional runtime input made available to effect operations. - /// - env: The dependency environment captured for the runtime lifetime. - /// - Throws: A cancellation or runtime error propagated from the base - /// `compute` implementation. - @inline(__always) - static func compute>( - systemActor: isolated Host = #isolation, - event: Event, - continuation: Continuation?, - actorStateBinding: ActorStateBinding, - taskManager: TaskManager, - input: Input?, - env: Env - ) async throws where Output: Sendable, Env: Sendable, Input: Sendable { - try await actorStateBinding.withValue(systemActor: systemActor) { state in - try await compute( - systemActor: systemActor, - event: event, - continuation: continuation, - state: &state, - taskManager: taskManager, - input: input, - env: env - ) - } - } - - - /// Processes a domain event by repeatedly invoking ``update(_:event:)``. - /// - /// This is the base implementation used by the storage-backed and - /// actor-bound overloads. It mutates `state` synchronously through - /// ``update(_:event:)`` and executes any returned effects until the chain - /// terminates. - /// - /// On a normal return path, `continuation` has been fully consumed. It was - /// either resumed synchronously when the chain settled or transferred to - /// `taskManager` for completion by managed work. If this method throws, it - /// does not resume `continuation` directly. - /// - /// The method cooperates with `taskManager` cancellation. It checks for - /// cancellation between reduction steps and after effect execution. When - /// cancellation is detected, the task manager is responsible for failing or - /// cancelling any suspended work according to its policy. - /// - /// - Parameters: - /// - systemActor: The current isolation token. - /// - event: The initial event to reduce. - /// - continuation: A continuation to resume if the chain settles without - /// delegating completion to a managed task. - /// - state: The mutable feature state to reduce in place. - /// - taskManager: The task manager coordinating in-flight effects. - /// - input: Optional runtime input made available to effect operations. - /// - env: The dependency environment captured for the runtime lifetime. - /// - Throws: A cancellation error when `taskManager` is cancelled, or any - /// runtime error propagated while executing effects. - static func compute>( - systemActor: isolated any Actor = #isolation, - event: Event, - continuation: Continuation?, - state: inout State, - taskManager: TaskManager, - input: Input?, - env: Env - ) async throws where Output: Sendable, Env: Sendable, Input: Sendable { - #if DEBUG - print("*** start compute") - #endif - var nextEvent: Event? = event - var cont = continuation - while let event = nextEvent { - try taskManager.checkCancellation() - nextEvent = nil - if let effect = update(&state, event: event) { - (nextEvent, cont) = try await executeEffect( - effect, - continuation: cont, - taskManager: taskManager, - input: input, - env: env - ) - } else { - if let continuation { - let output = output(state: state, event: event) - continuation.resume(returning: output) - } - cont = nil - } - } - assert(cont == nil) - #if DEBUG - print("*** end compute") - #endif - } - - /// Handles system-level control events that affect the transducer’s runtime lifecycle, - /// such as cancellation and unrecoverable errors. - /// - /// This method is invoked by the runtime to react to control-plane signals that are - /// not part of the domain `Event` stream. It can cancel any in-flight work managed - /// by `taskManager` and surface cancellation to callers waiting on request-style - /// operations. After handling the control event, it verifies whether cancellation - /// has been triggered and throws if the task manager is cancelled. - /// - /// - Parameters: - /// - systemActor: The actor providing the current isolation context. Defaults to - /// `#isolation`. This is available for symmetry with other isolated operations, - /// but is not used directly in this implementation. - /// - controlEvent: The control-plane event to process. Supported cases: - /// - `.systemError(Error)`: Cancels all managed tasks with the provided error, - /// propagating failure to any suspended continuations. - /// - `.cancel`: Cancels all managed tasks without an error (treated as - /// cooperative cancellation). - /// - state: The current feature state at the time the control event is handled. - /// It is observed but not mutated here; included to allow implementations to - /// inspect state if customization is needed in the future. - /// - taskManager: The task manager responsible for coordinating in-flight effects - /// and request continuations. This method uses it to cancel tasks and to check - /// for cancellation status. - /// - /// - Throws: `TaskManager`-defined cancellation error if cancellation has been - /// triggered as a result of handling the control event (or was already in effect). - /// - /// - Important: This method does not resume any continuation directly. Instead, it - /// delegates cancellation to `taskManager`, which is responsible for resuming or - /// failing any suspended requests. Callers should be prepared to catch the thrown - /// cancellation after `checkCancellation()` and complete their own control flow. - /// - /// - SeeAlso: `compute(event:continuation:state:taskManager:input:env:)` for normal - /// event processing and effect execution; `TaskManager` for details on task and - /// continuation lifecycle management. - static func control( - systemActor: isolated any Actor = #isolation, - controlEvent: ControlEvent, - state: State, - taskManager: TaskManager - ) throws where Output: Sendable, Env: Sendable { - var error: Swift.Error? = nil - switch controlEvent { - case .systemError(let systemError): - error = systemError - taskManager.cancel(with: error) - case .cancel: - taskManager.cancel(with: error) - } - try taskManager.checkCancellation() - } - - /// Executes a single TransducerEffect and returns the next domain event to process, - /// along with an updated continuation if the request chain should remain in the caller. - /// - /// This helper is called by `compute(event:...)` to advance the effect chain one step at a time. - /// It interprets the effect, possibly scheduling or canceling tasks via the provided `taskManager`, - /// resuming continuations when appropriate, and propagating cancellation by throwing if the - /// task manager has been cancelled. - /// - /// Behavior by effect kind: - /// - ._task / ._taskIsolated: - /// Schedules an asynchronous task with the `taskManager`. The provided continuation (if any) - /// is handed off to the manager for completion by the task; this method returns `(nil, nil)`. - /// Requires a non-nil `input`; otherwise triggers a precondition failure. - /// - ._event: - /// Returns the embedded domain event to be reduced next and preserves the continuation, - /// yielding `(event, continuation)`. - /// - ._actionSync / ._actionAsync / ._actionAsyncIsolated: - /// Invokes the action to produce an optional next event. If the action returns `nil`, - /// the request chain is considered terminal and the continuation (if any) is resumed - /// with `nil`, returning `(nil, nil)`. Otherwise, returns `(event, continuation)`. - /// For async variants, cancellation is checked after the await. - /// - ._cancel: - /// Cancels any tasks matching the provided identifier, resumes the continuation - /// (if any) with `nil`, and returns `(nil, nil)`. - /// - ._sequence: - /// Executes each effect in order. All but the last are executed with a `nil` continuation - /// so they cannot complete the original request. The final effect is executed with the - /// provided continuation, and its result is returned. An empty sequence resumes the - /// continuation with `nil` and returns `(nil, nil)`. - /// - .none: - /// No-op; returns `(nil, nil)`. - /// - /// Cancellation: - /// - The method cooperates with `taskManager` cancellation and calls `taskManager.checkCancellation()` - /// at key points. If cancellation is detected, it throws. In that case, it does not resume - /// any provided continuation; the caller is responsible for handling the thrown cancellation. - /// - /// Continuations: - /// - When an effect schedules work with the task manager, ownership of the continuation is transferred - /// to the manager and this method returns a `nil` continuation. - /// - When an effect produces a terminal condition (e.g., action returns `nil` or explicit cancel), - /// this method resumes the continuation with `nil` and returns a `nil` continuation. - /// - Otherwise, the continuation is propagated to the caller to continue the effect chain. - /// - /// Isolation: - /// - `systemActor` is passed through to isolated operations to preserve isolation semantics - /// when executing `. _taskIsolated` and `. _actionAsyncIsolated`. - /// - /// - Parameters: - /// - systemActor: The current isolation token (`#isolation`) used to call isolated operations safely. - /// - effect: The effect to interpret and execute. - /// - continuation: A continuation to resume if the chain completes synchronously; otherwise may be - /// transferred to `taskManager` or returned unchanged for further processing. - /// - taskManager: Manages in-flight tasks, continuations, and cancellation. - /// - input: Optional runtime input required by task-based effects; must be non-nil for task variants. - /// - env: The dependency environment captured for the runtime lifetime. - /// - Returns: A tuple containing the next event to process (if any) and the continuation to carry forward - /// (or `nil` if ownership was consumed or the request was completed). - /// - Throws: A cancellation error if `taskManager` reports cancellation at a check point. - /// - Precondition: `input` must be non-nil when executing task-based effects (._task / ._taskIsolated). - private static func executeEffect>( - systemActor: isolated any Actor = #isolation, - _ effect: Effect, - continuation: Continuation?, - taskManager: TaskManager, - input: Input?, - env: Env - ) async throws -> (Event?, Continuation?) where Output: Sendable, Env: Sendable, Input: Sendable { - switch effect.type { - case ._task(id: let identifier, priority: let priority, let option, operation: let operation): - guard let input else { - preconditionFailure("No Input value given when creating a task") - } - try taskManager.checkCancellation() - taskManager.addTask( - with: identifier, - option: option, - continuation: continuation, - priority: priority, - isolatedOperation: { _ in - try await operation(input, env) - } - ) - return (nil, nil) - - case ._taskIsolated(id: let identifier, priority: let priority, let option, isolatedOperation: let isolatedOperation): - guard let input else { - preconditionFailure("No Input value given when creating a task") - } - try taskManager.checkCancellation() - taskManager.addTask( - with: identifier, - option: option, - continuation: continuation, - priority: priority, - isolatedOperation: { isolated in - _ = systemActor - return try await isolatedOperation(input, env, isolated) - } - ) - return (nil, nil) - - case ._taskNonsending(id: let identifier, priority: let priority, option: let option, nonsendingOperation: let nonsendingOperation): - guard let input else { - preconditionFailure("No Input value given when creating a task") - } - try taskManager.checkCancellation() - taskManager.addTask( - with: identifier, - option: option, - continuation: continuation, - priority: priority, - isolatedOperation: { isolated in - _ = systemActor - return try await nonsendingOperation(input, env) - } - ) - return (nil, nil) - - - case ._event(event: let event): - return (event, continuation) - - case ._actionSync(action: let action): - let event = action(env) - if event == nil { - continuation?.resume(returning: nil) - return (nil, nil) - } - return (event, continuation) - - case ._actionAsync(action: let action): - let event = await action(env) - try taskManager.checkCancellation() - - if event == nil { - continuation?.resume(returning: nil) - return (nil, nil) - } - return (event, continuation) - - case ._actionAsyncIsolated(action: let action): - let event = await action(env, systemActor) - try taskManager.checkCancellation() - - if event == nil { - continuation?.resume(returning: nil) - return (nil, nil) - } - return (event, continuation) - - case ._cancel(let identifier): - taskManager.cancelTasks(with: identifier) - continuation?.resume(returning: nil) - return (nil, nil) - - case ._sequence(let effects): - guard let last = effects.last else { - continuation?.resume(returning: nil) - return (nil, nil) - } - for effect in effects.dropLast() { - _ = try await executeEffect( - effect, - continuation: nil, - taskManager: taskManager, - input: input, - env: env - ) - } - return try await executeEffect( - last, - continuation: continuation, - taskManager: taskManager, - input: input, - env: env - ) - - case .none: - return (nil, nil) - } - } -} - diff --git a/Sources/EffectComponents/Transducer/TransducerEffect.factories.swift b/Sources/EffectComponents/Transducer/TransducerEffect.factories.swift deleted file mode 100644 index e15191b..0000000 --- a/Sources/EffectComponents/Transducer/TransducerEffect.factories.swift +++ /dev/null @@ -1,422 +0,0 @@ -extension TransducerEffect { - - /// Returns an effect which when invoked starts an async throwing operation isolated to a global actor - /// tracked by the effect engine. - /// - /// The `operation` closure receives an ``Input`` handle for dispatching events and - /// the captured `Env` for dependencies. Named tasks are automatically cancelled when - /// the view disappears, or when ``cancel(_:)`` is returned from `update` with the - /// same identifier. - /// - /// - Important: Managed cancellation takes precedence over racing task failures. - /// If the runtime cancels a tracked task and the operation concurrently throws, - /// the effect engine may classify that outcome as cancellation rather than as a - /// system error. This is intentional: once a task has been superseded or - /// cancelled by the runtime, late failures from that obsolete work no longer - /// participate in global error escalation. - /// - /// - Parameters: - /// - id: An optional identifier used to track and cancel the task. Pass `nil` for - /// anonymous tasks that run to completion without cancellation support. - /// - priority: The `TaskPriority` for the launched task. Pass `nil` to inherit - /// the current task's priority. - /// - option: Defines how overlapping waiters for the same `id` are handled. - /// `.subscribe` keeps the running task and attaches the new waiter to it. - /// `.switchToLatest` cancels the running task, starts a fresh one, and moves - /// all current waiters for that identifier onto the replacement task. - /// - operation: The async work to perform. Returns an optional `Output` value - /// forwarded to any caller suspended on ``Input/request(_:)``. - /// - /// When the `operation` throws, the effect engine treats this as a system error. This typically - /// indicates that the input channel failed (for example, its host actor was deallocated or an - /// internal buffer overflow occurred). A thrown error places the transducer into a failure mode: - /// the runtime stops processing further events, cancels all running tasks associated with the - /// transducer, and begins propagating the failure to any event senders and waiters. Prefer to - /// design operations that do not throw under normal circumstances; only system-level failures - /// should surface as thrown errors from `operation`. - /// - /// - Throws: A system error indicating that the transducer can no longer operate correctly. Upon - /// throw, the transducer transitions to a failure mode, stops processing new events, and cancels - /// all running tasks. - /// - /// - Returns: The effect. - @inline(__always) - public static func task( - id: TaskIdentifier? = nil, - priority: TaskPriority? = nil, - option: TaskExecutionOption = .switchToLatest, - operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async throws -> Output? - ) -> Self { - .init(._task(id: id, priority: priority, option: option, operation: operation)) - } - - /// Returns an effect which when invoked starts an async throwing operation isolated to a global actor - /// tracked by the effect engine. - /// - /// The `operation` closure receives an ``Input`` handle for dispatching events and - /// the captured `Env` for dependencies. Named tasks are automatically cancelled when - /// the view disappears, or when ``cancel(_:)`` is returned from `update` with the - /// same identifier. - /// - /// - Important: Managed cancellation takes precedence over racing task failures. - /// If the runtime cancels a tracked task and the operation concurrently throws, - /// the effect engine may classify that outcome as cancellation rather than as a - /// system error. This is intentional: once a task has been superseded or - /// cancelled by the runtime, late failures from that obsolete work no longer - /// participate in global error escalation. - /// - /// - Parameters: - /// - id: An optional identifier used to track and cancel the task. Pass `nil` for - /// anonymous tasks that run to completion without cancellation support. - /// - priority: The `TaskPriority` for the launched task. Pass `nil` to inherit - /// the current task's priority. - /// - option: Defines how overlapping waiters for the same `id` are handled. - /// `.subscribe` keeps the running task and attaches the new waiter to it. - /// `.switchToLatest` cancels the running task, starts a fresh one, and moves - /// all current waiters for that identifier onto the replacement task. - /// - operation: The async work to perform. Returns an optional `Output` value - /// forwarded to any caller suspended on ``Input/request(_:)``. - /// - /// When the `operation` throws, the effect engine treats this as a system error. This typically - /// indicates that the input channel failed (for example, its host actor was deallocated or an - /// internal buffer overflow occurred). A thrown error places the transducer into a failure mode: - /// the runtime stops processing further events, cancels all running tasks associated with the - /// transducer, and begins propagating the failure to any event senders and waiters. Prefer to - /// design operations that do not throw under normal circumstances; only system-level failures - /// should surface as thrown errors from `operation`. - /// - /// - Throws: A system error indicating that the transducer can no longer operate correctly. Upon - /// throw, the transducer transitions to a failure mode, stops processing new events, and cancels - /// all running tasks. - /// - /// - Returns: The effect. - @inline(__always) - public static func run( - id: TaskIdentifier? = nil, - priority: TaskPriority? = nil, - option: TaskExecutionOption = .switchToLatest, - operation: @escaping @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async throws -> Void - ) -> Self where Env: Sendable { - .init(._task(id: id, priority: priority, option: option, operation: { input, env in - try await operation(input, env) - return nil - })) - } - - - /// Returns an effect which when invoked starts an async throwing operation isolated to the system actor - /// tracked by the effect engine. - /// - /// The `isolatedOperation` closure receives an ``Input`` handle for dispatching events and - /// the captured `Env` for dependencies. Named tasks are automatically cancelled when - /// the view disappears, or when ``cancel(_:)`` is returned from `update` with the - /// same identifier. - /// - /// - Important: Managed cancellation takes precedence over racing task failures. - /// If the runtime cancels a tracked task and the operation concurrently throws, - /// the effect engine may classify that outcome as cancellation rather than as a - /// system error. This is intentional: once a task has been superseded or - /// cancelled by the runtime, late failures from that obsolete work no longer - /// participate in global error escalation. - /// - /// - Parameters: - /// - id: An optional identifier used to track and cancel the task. Pass `nil` for - /// anonymous tasks that run to completion without cancellation support. - /// - priority: The `TaskPriority` for the launched task. Pass `nil` to inherit - /// the current task's priority. - /// - option: Defines how overlapping waiters for the same `id` are handled. - /// `.subscribe` keeps the running task and attaches the new waiter to it. - /// `.switchToLatest` cancels the running task, starts a fresh one, and moves - /// all current waiters for that identifier onto the replacement task. - /// - isolatedOperation: The async work to perform. Returns an optional `Output` value - /// forwarded to any caller suspended on ``Input/request(_:)``. - /// - /// When the `operation` throws, the effect engine treats this as a system error. This typically - /// indicates that the input channel failed (for example, its host actor was deallocated or an - /// internal buffer overflow occurred). A thrown error places the transducer into a failure mode: - /// the runtime stops processing further events, cancels all running tasks associated with the - /// transducer, and begins propagating the failure to any event senders and waiters. Prefer to - /// design operations that do not throw under normal circumstances; only system-level failures - /// should surface as thrown errors from `operation`. - /// - /// - Throws: A system error indicating that the transducer can no longer operate correctly. Upon - /// throw, the transducer transitions to a failure mode, stops processing new events, and cancels - /// all running tasks. - /// - /// - Returns: An effect. - @inline(__always) - public static func task( - id: TaskIdentifier? = nil, - priority: TaskPriority? = nil, - option: TaskExecutionOption = .switchToLatest, - isolatedOperation: @escaping (any TransducerInput, Env, isolated any Actor) async throws -> Output? - ) -> Self { - .init(._taskIsolated(id: id, priority: priority, option: option, isolatedOperation: isolatedOperation)) - } - - /// Returns an effect which when invoked starts an async throwing operation isolated to the system actor - /// tracked by the effect engine. - /// - /// The `isolatedOperation` closure receives an ``Input`` handle for dispatching events and - /// the captured `Env` for dependencies. Named tasks are automatically cancelled when - /// the view disappears, or when ``cancel(_:)`` is returned from `update` with the - /// same identifier. - /// - /// - Important: Managed cancellation takes precedence over racing task failures. - /// If the runtime cancels a tracked task and the operation concurrently throws, - /// the effect engine may classify that outcome as cancellation rather than as a - /// system error. This is intentional: once a task has been superseded or - /// cancelled by the runtime, late failures from that obsolete work no longer - /// participate in global error escalation. - /// - /// - Parameters: - /// - id: An optional identifier used to track and cancel the task. Pass `nil` for - /// anonymous tasks that run to completion without cancellation support. - /// - priority: The `TaskPriority` for the launched task. Pass `nil` to inherit - /// the current task's priority. - /// - option: Defines how overlapping waiters for the same `id` are handled. - /// `.subscribe` keeps the running task and attaches the new waiter to it. - /// `.switchToLatest` cancels the running task, starts a fresh one, and moves - /// all current waiters for that identifier onto the replacement task. - /// - isolatedOperation: The async work to perform. Returns an optional `Output` value - /// forwarded to any caller suspended on ``Input/request(_:)``. - /// - /// When the `operation` throws, the effect engine treats this as a system error. This typically - /// indicates that the input channel failed (for example, its host actor was deallocated or an - /// internal buffer overflow occurred). A thrown error places the transducer into a failure mode: - /// the runtime stops processing further events, cancels all running tasks associated with the - /// transducer, and begins propagating the failure to any event senders and waiters. Prefer to - /// design operations that do not throw under normal circumstances; only system-level failures - /// should surface as thrown errors from `operation`. - /// - /// - Throws: A system error indicating that the transducer can no longer operate correctly. Upon - /// throw, the transducer transitions to a failure mode, stops processing new events, and cancels - /// all running tasks. - /// - /// - Returns: An effect. - @inline(__always) - public static func run( - id: TaskIdentifier? = nil, - priority: TaskPriority? = nil, - option: TaskExecutionOption = .switchToLatest, - isolatedOperation: sending @escaping (any TransducerInput, Env, isolated any Actor) async throws -> Void - ) -> Self { - .init(._taskIsolated(id: id, priority: priority, option: option, isolatedOperation: { input, env, isolation in - try await isolatedOperation(input, env, isolation) - return nil - })) - } - -} - -extension TransducerEffect { - - /// Return an effect which when invoked executes a synchronous step that may produce the next - /// event to process immediately. - /// - /// The `action` closure receives `Env` and returns the next `Event` to feed back - /// into `update`, or `nil` to end the chain. The entire chain runs synchronously - /// on the system actor before any other work proceeds. - /// - /// Unlike named tasks, actions do not participate in overlap management. If a - /// caller is suspended on ``Input/request(_:)``, the caller simply waits until the - /// synchronous action chain terminates or reaches a terminal task. - /// - /// - Parameter action: A synchronous closure receiving `Env` and returning an - /// optional next event. - /// - /// - Warning: Action chains unwind entirely on the system actor. - /// A cycle — two events that each produce an `.action` pointing back at the other — - /// may cause an infinite loop. Use ``run(id:priority:option:operation:)`` for any work - /// that could repeat or loop. - /// - /// - Returns: An effect. - @inline(__always) - public static func action( - _ action: @escaping (Env) -> Event? - ) -> Self { - .init(._actionSync(action)) - } - - /// Return an effect which when invoked executes a synchronous step that may produce the next - /// event to process immediately. - /// - /// The `action` closure receives `Env`. It runs synchronously on the system actor before any - /// other work proceeds. - /// - /// Unlike named tasks, actions do not participate in overlap management. If a - /// caller is suspended on ``Input/request(_:)``, the caller simply waits until the - /// synchronous action chain terminates or reaches a terminal task. - /// - /// - Parameter action: A synchronous closure receiving `Env`. - /// - Returns: An effect. - @inline(__always) - public static func action( - _ action: @escaping (Env) -> Void - ) -> Self { - .init(._actionSync({ env in - action(env) - return nil - })) - } - - /// Return an effect which when invoked executes an async step on a user specified global actor - /// that may produce the next event to process immediately. - /// - /// The `action` closure receives `Env` and returns the next `Event` to feed back - /// into `update`, or `nil` to end the chain. The entire chain runs synchronously - /// on the system actor before any other work proceeds. - /// - /// Async actions still do not create managed task identities of their own. Any - /// overlap semantics apply only once the chain reaches a named terminal task. - /// - /// - Parameter action: A synchronous closure receiving `Env` and returning an - /// optional next event. - /// - /// - Warning: Action chains unwind entirely on the system actor without yielding. - /// A cycle — two events that each produce an `.action` pointing back at the other — - /// may cause an infinite loop. Use ``run(id:priority:option:operation:)`` for any work - /// that could repeat or loop. - /// - /// - Returns: An effect. - @inline(__always) - public static func action( - _ action: sending @escaping @isolated(any) (Env) async -> sending Event? - ) -> Self { - .init(._actionAsync(action)) - } - - /// Return an effect which when invoked executes an async step on a user specified global actor. - /// - /// The `action` closure receives `Env`. It runs synchronously on the system actor before any - /// other work proceeds. - /// - /// Async actions still do not create managed task identities of their own. Any - /// overlap semantics apply only once the chain reaches a named terminal task. - /// - /// - Parameter action: A synchronous closure receiving `Env`. - /// - /// - Returns: An effect. - @inline(__always) - public static func action( - _ action: sending @escaping @isolated(any) (Env) async -> Void - ) -> Self where Env: Sendable { - .init(._actionAsync({ env in - await action(env) - return nil - })) - } - - /// Return an effect which when invoked executes an async step on the system actor - /// that may produce the next event to process immediately. - /// - /// The `action` closure receives `Env` and returns the next `Event` to feed back - /// into `update`, or `nil` to end the chain. The entire chain runs synchronously - /// on the system actor before any other work proceeds. - /// - /// Async isolated actions still do not create managed task identities of their own. - /// Any overlap semantics apply only once the chain reaches a named terminal task. - /// - /// - Parameter action: A synchronous closure receiving `Env` and returning an - /// optional next event. - /// - /// - Warning: Action chains unwind entirely on the system actor without yielding. - /// A cycle — two events that each produce an `.action` pointing back at the other — - /// may cause an infinite loop. Use ``run(id:priority:option:operation:)`` for any work - /// that could repeat or loop. - /// - /// - Returns: An effect. - @inline(__always) - public static func action( - _ action: @escaping (Env, isolated any Actor) async -> sending Event? - ) -> Self { - .init(._actionAsyncIsolated(action)) - } - - /// Return an effect which when invoked executes an async step on the system actor. - /// - /// The `action` closure receives `Env`. It runs synchronously on the system actor before any - /// other work proceeds. - /// - /// Async isolated actions still do not create managed task identities of their own. - /// Any overlap semantics apply only once the chain reaches a named terminal task. - /// - /// - Parameter action: A synchronous closure receiving `Env`. - /// - /// - Returns: An effect. - @inline(__always) - public static func action( - _ action: sending @escaping (Env, isolated any Actor) async -> Void - ) -> Self { - .init(._actionAsyncIsolated( { env, isolated in - await action(env, isolated) - return nil - })) - } - -} - -extension TransducerEffect { - - /// Returns an effect which, when invoked, feeds an event back into `update` immediately - /// in the current synchronous turn. - /// - /// Prefer this helper over the deprecated `event(_:)` to avoid naming conflicts with the - /// `update(_ state:event:)` parameter named `event`. - /// - /// - Parameter event: The next event to feed directly back into `update`. - /// - Returns: An effect. - @inline(__always) - public static func send(_ event: Event) -> Self { - .init(._event(event)) - } - - /// Returns an effect which when invoked feeds `event` back into `update` immediately, in - /// the current synchronous turn. - /// - /// ```swift - /// // Before: ambiguous inside `update` due to parameter - /// // named `event` - /// // return Self.event(.tick) - /// - /// // After: preferred helper - /// return send(.tick) - /// ``` - /// - /// - Parameter event: The next event to feed directly back into `update`. - /// - Returns: An effect. - @available(*, deprecated, message: "Use send(_:) instead to avoid shadowing with the `update` parameter named `event`.") - @inline(__always) - public static func event(_ event: Event) -> Self { - send(event) - } - - /// Returns an effect which cancels the running task with the given identifier, if any. - /// - /// - Parameter id: The logical task identifier to cancel. - /// - Returns: An effect. - @inline(__always) - public static func cancel(_ id: TaskIdentifier) -> Self { - .init(._cancel(id)) - } - - /// Returns an effect which contains a sequence of effects. The effects will be executed - /// from left to right, associating the caller's continuation with the last effect only. - /// - /// ```swift - /// // Cancel a stale load before starting a refresh: - /// return sequence([cancel("load"), .refreshMovies()]) - /// ``` - /// - /// - Important: Intermediate effects must be synchronous and terminal (`.cancel` - /// or side-effect `action` closures). An intermediate effect that returns an - /// event is not supported — the event is silently discarded. Use a dedicated - /// `update` step for event-producing chains instead. - /// - /// - Parameter effects: The ordered effects to execute from left to right. - /// - Returns: An effect. - @inline(__always) - public static func sequence(_ effects: [TransducerEffect]) -> Self { - .init(._sequence(effects)) - } -} diff --git a/Sources/EffectComponents/Transducer/TransducerEffect.swift b/Sources/EffectComponents/Transducer/TransducerEffect.swift deleted file mode 100644 index 76b50e3..0000000 --- a/Sources/EffectComponents/Transducer/TransducerEffect.swift +++ /dev/null @@ -1,157 +0,0 @@ - -/// A value describing a side effect to run after a state transition. -/// -/// `update` returns an `Effect` to declare what async or synchronous work should -/// happen next. The effect engine executes it; `update` itself stays synchronous -/// and free of side effects. `Env` is forwarded to every effect so operations and -/// actions can access dependencies without capturing them at the call site. -/// -/// ```swift -/// // Fire-and-forget task: -/// return run(id: "ticker") { input, env in -/// while true { -/// try await env.clock.sleep(for: .seconds(1)) -/// input(.tick) -/// } -/// } -/// -/// // Perform-driven task (caller awaits result): -/// return request(id: "load") { input, env in -/// let user = await env.api.fetchUser() -/// return await input.request(.loaded(user)) -/// } -/// -/// // Synchronous step — next event returned inline: -/// return action { env in -/// env.analytics.track(.buttonTapped) -/// return .next -/// } -/// ``` -/// -/// - Caution: Effects should only be created on the system actor. -/// - Important: Prefer the documented factory helpers such as -/// ``Transducer/run(id:priority:option:operation:)``, -/// ``Transducer/request(id:priority:option:operation:)``, -/// ``Transducer/action(_:)``, ``Transducer/event(_:)``, and -/// ``Transducer/cancel(_:)`` rather than constructing underscored -/// enum cases directly. The underscored cases are the runtime -/// representation. -/// -/// ### Generic parameters -/// -/// - `Event`: The event type of the FSM this effect belongs to. -/// - `Env`: The dependency environment forwarded into every task and action closure. -/// - `Output`: The value type returned to a caller suspended on ``Input/request(_:)``. -/// Use `Void` when no return value is needed. -public struct TransducerEffect { - typealias _Type = EffectType - let type: EffectType - - init(_ type: _Type) { - self.type = type - } -} - -enum EffectType { - - case none - - case _task( - id: TaskIdentifier?, - priority: TaskPriority?, - option: TaskExecutionOption, - operation: @Sendable @isolated(any) (any TransducerInput & Sendable, Env) async throws -> Output? - ) - - case _taskIsolated( - id: TaskIdentifier?, - priority: TaskPriority?, - option: TaskExecutionOption, - isolatedOperation: (any TransducerInput, Env, isolated any Actor) async throws -> Output? - ) - - case _taskNonsending( - id: TaskIdentifier?, - priority: TaskPriority?, - option: TaskExecutionOption, - nonsendingOperation: nonisolated(nonsending) (any TransducerInput, Env) async throws -> Output? - ) - - case _actionSync( - (Env) -> Event? - ) - - case _actionAsync( - @isolated(any) (Env) async -> sending Event? - ) - - case _actionAsyncIsolated( - (Env, isolated any Actor) async -> sending Event? - ) - - case _event(Event) - - case _cancel(TaskIdentifier) - - case _sequence([TransducerEffect]) -} - -extension EffectType: CustomStringConvertible { - - public var description: String { - - switch self { - case ._task( - id: let id, - priority: let priority, - option: let option, - _ - ): - return "task(\(id, default: "_"), option: \(option))" - - case ._taskIsolated( - id: let id, - priority: let priority, - option: let option, - _ - ): - return "task(\(id, default: "_"), option: \(option))" - - - case ._taskNonsending( - id: let id, - priority: let priority, - option: let option, - _ - ): - return "task(\(id, default: "_"), option: \(option))" - - case ._actionSync: - return "action" - - case ._actionAsync: - return "action async" - - case ._actionAsyncIsolated: - return "action async" - - case ._event(let event): - return "event(\(event))" - - case ._cancel(let taskIdentifier): - return "cancel(\(taskIdentifier))" - - case ._sequence(let effects): - return "sequence(\(effects))" - - case .none: - return "none" - } - } -} - -extension TransducerEffect: CustomStringConvertible { - public var description: String { - return "\(type)" - } -} diff --git a/Sources/EffectComponents/Transducer/TransducerHost.swift b/Sources/EffectComponents/Transducer/TransducerHost.swift deleted file mode 100644 index 0c5e6a6..0000000 --- a/Sources/EffectComponents/Transducer/TransducerHost.swift +++ /dev/null @@ -1,65 +0,0 @@ - -/// Common host interface for a transducer-driven runtime. -/// -/// A `TransducerHost` owns the runtime boundary around a feature's event loop. -/// It exposes cancellation, immediate event dispatch, request-style dispatch, -/// and an ``Input`` handle for handing those capabilities to other code. -/// -/// The host defines the isolation domain for these operations. Conforming -/// types may be actor-isolated, `@MainActor`-isolated, or provide another -/// host-specific execution context. -/// -/// ### Generic Parameters -/// -/// - `Event`: The event type accepted by the hosted runtime. -/// - `Output`: The terminal value returned by request-style interactions. -public protocol TransducerHost { - associatedtype Event: Sendable // TODO: for now, we need to make this Sendable, until this issue is fixed: https://github.com/swiftlang/swift/pull/88453 - associatedtype Output - associatedtype Input: TransducerInput - - /// Cancels the hosted runtime. - /// - /// Use this to dispose of the runtime from the host boundary. This is an - /// immediate host-level cancellation, not a graceful transducer shutdown - /// sequence. - func cancel() - - /// Cancels the hosted runtime with a caller-provided system error. - /// - /// Use this when pending work should observe a specific runtime failure - /// instead of a generic cancellation. - /// - /// - Parameter error: The system-level failure to latch and broadcast. - func cancel(with error: any Swift.Error) - - /// Sends `event` into the hosted runtime. - /// - /// `send` performs immediate event dispatch. The host processes the event's - /// synchronous reduction path before the call returns, but any task-based - /// effects started by that path may continue running after `send` completes. - /// - /// - Parameter event: The event to dispatch. - /// - Throws: If the runtime cannot accept the event, or if accepted work is - /// later cancelled or fails at the runtime boundary. - func send(_ event: sending Event) async throws - - /// Sends `event` and suspends until the resulting effect chain settles. - /// - /// Use `request` when the caller needs the terminal `Output?` produced by - /// the chain rather than only triggering work. The exact isolation and - /// lifetime semantics are defined by the conforming host. - /// - /// - Parameter event: The event to dispatch. - /// - Throws: If the request cannot enter the runtime, or if accepted work is - /// later cancelled or fails at the runtime boundary. - /// - Returns: The terminal output produced by the settled effect chain. - @discardableResult - func request(_ event: Event) async throws -> Output? - - /// Dispatch handle for sending events back into the hosted runtime. - /// - /// Use `input` to hand runtime interaction capabilities to views, tasks, or - /// callbacks without exposing the full host implementation. - var input: Input { get async } -} diff --git a/Sources/EffectComponents/Transducer/TransducerInput.swift b/Sources/EffectComponents/Transducer/TransducerInput.swift deleted file mode 100644 index bba793e..0000000 --- a/Sources/EffectComponents/Transducer/TransducerInput.swift +++ /dev/null @@ -1,89 +0,0 @@ - -/// A handle for feeding events back into a transducer runtime. -/// -/// `TransducerInput` has three dispatch styles: -/// -/// - ``post(_:)`` sends an event without awaiting a result. -/// - ``send(_:)`` suspends until the immediate reduction path has been processed. -/// - ``request(_:)`` suspends until the triggered effect chain settles and returns -/// the terminal `Output?` value, if any. -/// -/// When multiple `request` calls overlap and eventually drive a named task with the -/// same identifier, the task's ``TaskExecutionOption`` decides how the runtime treats -/// the active task for that identifier: -/// -/// - `.subscribe`: keep the running task and add the new waiter to it. -/// - `.switchToLatest`: cancel the running task, start a fresh one, and move all -/// current waiters for that identifier onto the replacement task. -/// -/// Equal task identifiers therefore mean more than "same cancellation key": they -/// declare the same logical in-flight work. Overlapping waiters for one identifier -/// must converge to one current result or one current error. -public protocol TransducerInput { - associatedtype Event - associatedtype Output - - /// Sends `event` and suspends until the runtime has processed its immediate - /// reduction path. - /// - /// `send` is a deliberate backpressure boundary. It returns only after the - /// runtime has run `update` for `event` and any immediately returned - /// event/action chain, or after that chain has reached a terminal managed - /// task. If a task effect is started, `send` returns after the task has been - /// accepted by the runtime; it does not wait for the task operation to - /// complete and it does not return `Output`. - /// - /// Custom queued hosts must preserve this semantic. If events are transported - /// through a channel or mailbox, `send` must be acknowledged after the - /// consumer has processed the immediate reduction path, not merely after the - /// event has been enqueued. A queued implementation typically transports an - /// envelope carrying either no continuation (`post`), an acknowledgement - /// continuation (`send`), or an output continuation (`request`). - /// - /// - Parameter event: The event to send into the runtime. - /// - Throws: A runtime entry failure if the event cannot be accepted, or a - /// later cancellation or runtime failure before the immediate reduction path - /// has been acknowledged. - func send(_ event: sending Event) async throws - - /// Schedules `event` without awaiting the resulting effect chain. - /// - /// `post` is the fire-and-forget dispatch entry point. It asks the runtime to - /// enqueue `event` and then returns immediately, without waiting for synchronous - /// reduction, spawned task effects, or a terminal `Output` value. - /// - /// If `post` throws, that failure is local to the call site: the implementation - /// could not accept the event for immediate scheduling. Once scheduling succeeds, - /// any later cancellation or runtime failure occurs on the runtime's own execution - /// path and is not reported back to the caller of `post`. - /// - /// - Parameter event: The event to enqueue into the runtime. - /// - Throws: A synchronous runtime entry failure if the implementation cannot - /// accept the event for scheduling. - func post(_ event: sending Event) throws - - /// Sends `event` and suspends until the resulting effect chain settles. - /// - /// If the chain reaches a named task, overlapping waiters for the same task - /// identifier are coalesced according to that task's ``TaskExecutionOption``. - /// The caller is waiting for the current active task for that identifier, not - /// necessarily for the first physical task instance that was started. - /// - /// - Parameter event: The event to send into the runtime. - /// - Throws: A runtime entry failure if the request cannot start, or a later - /// cancellation or runtime failure while the request is in flight. - @discardableResult - func request(_ event: Event) async throws -> Output? -} - -extension TransducerInput { - - /// Convenience call-as-function syntax for ``post(_:)``. - /// - /// - Parameter event: The event to enqueue into the runtime. - /// - Throws: Any error that ``post(_:)`` would throw for the same event. - @inline(__always) - public func callAsFunction(_ event: sending Event) throws { - try post(event) - } -} diff --git a/Sources/Transduce/Hosts/EffectView/EffectView.swift b/Sources/Transduce/Hosts/EffectView/EffectView.swift new file mode 100644 index 0000000..ea94210 --- /dev/null +++ b/Sources/Transduce/Hosts/EffectView/EffectView.swift @@ -0,0 +1,351 @@ +import SwiftUI + +/// A SwiftUI view that manages structured side effects via an Elm-style update loop. +/// +/// `EffectView` owns the task scheduler for the duration of its view identity. +/// State is held by the caller via `Binding` so ancestor views can observe changes. +/// The supplied transducer type is the single mutation authority: it receives events, +/// mutates state, and optionally returns an ``Effect`` to run or cancel. +/// +/// ### Basic usage +/// +/// ```swift +/// typealias Counter = CounterFeature.Transducer +/// +/// @State private var state = Counter.State() +/// +/// EffectView( +/// of: Counter.self, +/// state: $state, +/// ) { state, input in +/// Button("\(state.count)") { +/// try? input.post(.increment) +/// } +/// } +/// ``` +/// > Note: In this case it is safe to write `try?` since we can ignore the error when +/// attempting to dispatch an event when it happens within the Button action. +/// +/// ### Using `Env` for dependencies +/// +/// Pass dependencies (clocks, API clients, etc.) via `Env`. The value is captured +/// once when the view appears and forwarded to every effect. +/// +/// ```swift +/// typealias Feature = MoviesFeature.Transducer +/// @State private var state = Feature.State() +/// struct Env { let api: any APIClient } +/// +/// EffectView( +/// of: Feature.self, +/// state: $state, +/// initialEnv: Env(api: liveAPI), +/// ) { state, input in +/// Button("Load") { +/// try? input.post(.load) +/// } +/// } +/// ``` +/// +/// ### Env changes +/// +/// If `Env` changes during the view's lifetime, running effects keep the original +/// captured value. To restart with new dependencies, apply `.id(env)` at the call +/// site (requires `Env: Hashable`). This destroys the old view — cancelling all +/// tasks — and creates a fresh instance with the updated `Env`. +/// +/// ### Generic parameters +/// +/// - `State`: The type of the view's mutable state. +/// - `Event`: The event type driving state transitions. +/// - `Env`: The dependency environment. Use `Void` for no dependencies. +/// - `Response`: The value returned to callers of ``Input/request(_:)``. +/// Use `Void` when no return value is needed. +/// - `Content`: The view builder response type. +@MainActor +public struct EffectView< + T: Transducer, + Content: View +>: View where + T.Response: Sendable, + T.Env: Sendable, + T.Effect == TransducerEffect, + T.Event: Sendable, + T: SendableMetatype +{ + public typealias State = T.State + public typealias Event = T.Event + public typealias Response = T.Response + public typealias Env = T.Env + public typealias Effect = T.Effect + + public typealias Input = BaseTransducerInput + typealias Storage = Binding + typealias Runtime = BaseRuntime> + + @SwiftUI.State private var runtime: Runtime? // Send? + + private var state: Binding + private var initialEvent: Event? + private let env: Env + private let content: (State, Input) -> Content + + /// Creates an effect-managed view with a captured dependency environment. + /// + /// The transducer type, `initialEvent`, and `initialEnv` are captured once when + /// the view appears for the first time. Later changes are intentionally ignored + /// to avoid mid-flight dependency swaps during running effects. To restart with + /// new dependencies, use `.id(env)` at the call site when that identity model + /// makes sense for your feature. + /// + /// ```swift + /// EffectView( + /// of: Feature.self, + /// state: $state, + /// initialEnv: env, + /// ) { state, input in + /// Button("Start") { + /// try? input.post(.start) + /// } + /// } + /// .id(env.id) + /// ``` + /// + /// - Parameters: + /// - of: The transducer type. + /// - state: A `Binding` to the view's state, owned by the caller. + /// - initialEvent: An optional event sent when the view first appears. + /// - initialEnv: The environment captured for this view's lifetime. + /// - content: Builds the view from current state and an ``Input`` handle. + public init( + of: T.Type = T.self, + state: Binding, + initialEvent: Event? = nil, + initialEnv: Env, + @ViewBuilder content: @escaping (State, Input) -> Content + ) { + self.state = state + self.initialEvent = initialEvent + self.env = initialEnv + self.content = content + } + + public var body: some View { + HStack { + if let runtime { + content(self.state.wrappedValue, Input(runtime)) + } else { + // transparent placeholder; holds layout until effectManager is ready + Color.clear + .frame(maxWidth: 1, maxHeight: 1) + } + } + .task { + guard self.runtime == nil else { + return + } + self.runtime = Runtime(systemActor: MainActor.shared, storage: self.state, env: self.env) + if let event = initialEvent { + do { + try await runtime!.send(event) + } catch { + try? runtime!.control(.systemError(error)) + } + } + } + } + + /// A convenience accessor for an ``Input`` handle bound to this view’s runtime. + /// + /// Use `input` to obtain an input that can post, send, or request events + /// against the underlying transducer while the view is active. This accessor + /// throws if the runtime has not been created yet (for example, before the + /// view’s `.task` has initialized it or after it has been torn down). + /// + /// - Throws: ``RuntimeError/runtimeUnavailable`` if the runtime is not currently + /// available (e.g., the view has not appeared yet or has been deallocated). + /// - Returns: An ``Input`` instance scoped to this view’s runtime, suitable for + /// dispatching events and making requests. + public var input: Input { + get throws { + guard let runtime = self.runtime else { + throw RuntimeError.runtimeUnavailable + } + return Input(runtime) + } + } + + /// Sends an event to the underlying runtime and awaits its processing. + /// + /// Use this method to dispatch an event into the transducer’s update loop and + /// suspend until the event has been fully handled. This is appropriate when the + /// caller needs to ensure ordering with subsequent work or observe errors that + /// occur during synchronous processing of the event. + /// + /// - Parameter event: The event to send to the transducer. The parameter is + /// annotated as `sending` to emphasize transfer of ownership during dispatch. + /// - Throws: ``RuntimeError/runtimeUnavailable`` if the runtime has not been + /// created yet or has already been torn down (e.g., the view has not appeared + /// yet or has been deallocated). May also rethrow any error produced by the + /// underlying runtime while handling the event. + /// - Important: This method requires the view’s runtime to be active. If you + /// call it before the view’s `.task` has initialized the runtime, or after the + /// view has been removed, it will throw ``RuntimeError/runtimeUnavailable``. + /// - SeeAlso: ``post(_:)`` for fire‑and‑forget dispatch, and ``request(_:)`` + /// for sending an event and awaiting an optional return value. + public func send(_ event: sending T.Event) async throws { + guard let runtime = self.runtime else { + throw RuntimeError.runtimeUnavailable + } + try await runtime.send(event) + } + + /// Posts an event to the underlying runtime without awaiting completion. + /// + /// Use this fire-and-forget API when you want to enqueue an event for processing + /// by the transducer’s update loop but do not need to wait for the work to finish + /// or observe a return value. The event is scheduled immediately and control + /// returns to the caller once the runtime has accepted the dispatch. + /// + /// Prefer this over ``send(_:)`` when ordering with subsequent work does not + /// matter, and over ``request(_:)`` when you do not expect a response. + /// + /// - Parameter event: The event to dispatch to the transducer. The parameter is + /// annotated as `sending` to emphasize transfer of ownership during dispatch. + /// - Throws: ``RuntimeError/runtimeUnavailable`` if the runtime has not been + /// created yet or has already been torn down (for example, before the view’s + /// `.task` has initialized it or after the view has been removed). May also + /// rethrow errors produced by the runtime while attempting to enqueue the event. + /// - Important: This method requires the view’s runtime to be active. If called + /// before initialization or after teardown, it will throw + /// ``RuntimeError/runtimeUnavailable``. + /// - SeeAlso: ``send(_:)`` to await processing, and ``request(_:)`` to await an + /// optional return value. + public func post(_ event: sending Event) throws { + guard let runtime = self.runtime else { + throw RuntimeError.runtimeUnavailable + } + try runtime.post(event) + } + + /// Sends an event to the underlying runtime and awaits an optional response. + /// + /// Use this method when you want to dispatch an event into the transducer’s update + /// loop and potentially receive a value back. Unlike ``send(_:)``, which only + /// ensures the event has been processed, `request(_:)` allows the transducer to + /// return a value of type ``Response``. If the update path for the given event does + /// not produce a value, this method resolves to `nil`. + /// + /// - Parameter event: The event to request against the transducer. + /// - Returns: An optional ``Response`` value produced by handling the event, or + /// `nil` if no value is returned. + /// - Throws: ``RuntimeError/runtimeUnavailable`` if the runtime has not been + /// created yet or has already been torn down (for example, before the view’s + /// `.task` has initialized it or after the view has been removed). May also + /// rethrow any error produced by the runtime while handling the request. + /// - Important: The view’s runtime must be active. Calling this before the + /// runtime is initialized or after it has been cancelled will throw + /// ``RuntimeError/runtimeUnavailable``. + /// - SeeAlso: ``send(_:)`` to await processing without a return value, and + /// ``post(_:)`` for fire‑and‑forget dispatch. + public func request(_ event: T.Event) async throws -> T.Response { + guard let runtime = self.runtime else { + throw RuntimeError.runtimeUnavailable + } + return try await runtime.request(event) + } + + /// Cancels the view’s underlying runtime and all running effects. + /// + /// This method sends a cancellation control message to the internal runtime, + /// terminating any in‑flight tasks started by the transducer’s effects and + /// preventing further event processing. Use this to explicitly tear down + /// work when the view is about to disappear or when you want to stop the + /// feature’s activity early. + /// + /// - Note: If the runtime has not been created yet (for example, before the view's `.task` + /// initializes it) or has already been torn down, this method is a no-op rather than + /// triggering a fatal error. + /// + /// - Note: Any error produced while issuing the cancellation control message + /// is ignored. + public func cancel() { + guard let runtime else { + return + } + try? runtime.control(.cancel) + } + + /// Cancels all running effects by sending a system error control message to the runtime. + /// + /// Unlike `cancel()`, which sends a plain cancellation, this variant uses the provided + /// error as the failure reason. Any in-flight tasks started by the transducer's effects + /// receive this error and complete with it. + /// + /// - Parameter error: The error used to fail all running effects. + /// + /// This error becomes the cause of failure for every in-flight task managed by the + /// runtime. Tasks that were waiting on or passing events receive the error as their + /// immediate failure, rather than a plain cancellation result. + /// + /// - Note: If the runtime has not been created yet (for example, before the view's `.task` + /// initializes it) or has already been torn down, this method is a no-op rather than + /// triggering a fatal error. + /// + /// - Note: Any error produced while issuing the system error control message is ignored. + public func cancel(with error: any Error) { + guard let runtime else { + return + } + try? runtime.control(.systemError(error)) + } +} + + +extension EffectView where Env == Void { + + /// Creates an effect-managed view with no external dependencies. + /// + /// The transducer type and `initialEvent` are captured once when the view + /// appears for the first time. To reset the runtime, recreate the view's + /// identity with `.id(...)`. + /// + /// ```swift + /// EffectView( + /// of: Feature.self, + /// state: $state, + /// ) { state, input in + /// Button("Start") { + /// try? input.post(.start) + /// } + /// } + /// ``` + /// + /// - Parameters: + /// - of: The transducer type. + /// - state: A `Binding` to the view's state, owned by the caller. + /// - initialEvent: An optional event sent when the view first appears. + /// - content: Builds the view from current state and an ``Input`` handle. + public init( + of: T.Type = T.self, + state: Binding, + initialEvent: Event? = nil, + @ViewBuilder content: @escaping (State, Input) -> Content + ) { + self.state = state + self.initialEvent = initialEvent + self.env = () + self.content = content + } +} + +extension SwiftUI.Binding: TransducerStorage { + public mutating func withMutableState(_ body: (inout Value) throws -> R) throws -> R { + try body(&self.wrappedValue) + } + + public var state: Value { + get { self.wrappedValue } + set { self.wrappedValue = newValue } + } +} diff --git a/Sources/Transduce/Hosts/EffectView/EffectViewInput.swift b/Sources/Transduce/Hosts/EffectView/EffectViewInput.swift new file mode 100644 index 0000000..dc008cd --- /dev/null +++ b/Sources/Transduce/Hosts/EffectView/EffectViewInput.swift @@ -0,0 +1 @@ +public typealias EffectViewInput = BaseTransducerInput diff --git a/Sources/Transduce/Hosts/GlobalActorRuntime.swift b/Sources/Transduce/Hosts/GlobalActorRuntime.swift new file mode 100644 index 0000000..96070aa --- /dev/null +++ b/Sources/Transduce/Hosts/GlobalActorRuntime.swift @@ -0,0 +1,426 @@ +/// A runtime host for a transducer that is isolated to a specific global actor. +/// +/// `GlobalActorRuntime` manages the lifecycle, event processing, and effect execution +/// for a transducer (`T`), ensuring that all state mutations and side effects occur +/// on the bound global actor (`GA.shared`). This enables deterministic serialization +/// of event handling, state updates, and effect management on a well-defined +/// concurrency domain, such as the main actor. +/// +/// - Parameters: +/// - T: The `Transducer` type providing the state machine, event, effect, and environment definitions. +/// - GA: A `GlobalActor` type whose shared instance defines the concurrency domain and executor +/// for all reductions and effect execution performed by the runtime. +/// +/// - Discussion: +/// `GlobalActorRuntime` is suitable for integrating state machines and effect-driven +/// architectures into environments where strict isolation is needed, such as UI frameworks +/// or system services that require consistent serialization of events and state transitions. +/// All interaction with the runtime—event dispatch, state reads, and effect management—occurs +/// under the isolation guarantees of the provided global actor. +/// +/// - Conforms to: `Sendable` +/// +/// - Note: +/// This type is not used within the library itself but remains available as a convenience +/// for consumers building their own transducer host types. The architectural decision about +/// its long-term scope (keep vs. remove) has been documented and left as future work. +/// +/// - SeeAlso: +/// - ``Transducer``: The protocol defining the state machine. +/// - ``GlobalActor``: The protocol marking actor types for global isolation. +/// - ``BaseTransducerInput``: The input interface for dispatching events. +/// - ``send(_:)``: For immediate, awaited event dispatch. +/// - ``post(_:)``: For fire-and-forget event scheduling. +/// - ``request(_:)``: For awaited dispatch yielding a terminal response. +/// - ``cancel(with:)``: For runtime cancellation. +public struct GlobalActorRuntime: Sendable +where + T.Effect == TransducerEffect, + T.Event: Sendable, + T: Sendable +{ + public typealias Transducer = T + public typealias State = T.State + public typealias Event = T.Event + public typealias Env = T.Env + public typealias Response = T.Response + public typealias Effect = T.Effect + public typealias Input = BaseTransducerInput + + typealias BaseRuntime = Transduce::BaseRuntime> + typealias TaskManager = BaseRuntime.TaskManager + + + /// Initializes a global-actor–isolated runtime for a transducer, binding it to a specific + /// `GlobalActor` and providing an initial state and environment. + /// + /// This initializer constructs the underlying runtime so that all state mutations and + /// effect execution occur on the shared instance of the supplied `GlobalActor` (`GA.shared`). + /// Use this when you want deterministic serialization of event processing onto a specific + /// actor (for example, the main actor) while still leveraging the transducer’s effect model. + /// + /// - Parameters: + /// - transducer: The transducer type to host. Defaults to `T.self`. + /// - on: The `GlobalActor` type that provides isolation for the runtime. The runtime + /// is pinned to `GA.shared`, ensuring all reductions and state access happen on that actor. + /// Defaults to `GA.self`. + /// - initialState: The starting state for the transducer. Defaults to `T.initialState`. + /// - env: The environment value supplied to the transducer. Marked as `sending` to + /// indicate cross-actor transfer semantics when captured by the runtime. + /// + /// - Discussion: + /// The runtime created by this initializer: + /// - Executes synchronous reductions on `GA.shared`. + /// - Manages effect lifecycles and task execution under the same actor isolation, + /// unless effects explicitly hop to other executors. + /// - Preserves the provided `env` for the lifetime of the runtime, making it available + /// to reductions and effects. + /// +/// - Requirements: +/// - `T: Transducer` whose `Effect == TransducerEffect`. +/// - `T.Event` must be `Sendable`. +/// - `T` must conform to `SendableMetatype`. +/// +/// - Note: +/// This initializer is part of a convenience type that is not used within the library itself. +/// For most use cases, consider using ``BaseRuntime`` directly or implementing a custom host. +/// +/// - SeeAlso: +/// - ``send(_:)`` for immediate, awaited dispatch. +/// - ``post(_:)`` for fire-and-forget scheduling. +/// - ``request(_:)`` for awaited dispatch that returns a terminal response. +/// - ``cancel(with:)`` for cancelling the runtime with an optional error. +public init( + transducer: T.Type = T.self, + on: GA.Type = GA.self, + initialState: T.State = T.initialState, + env: sending T.Env + ) { + baseRuntime = .init(systemActor: GA.shared, initialState: initialState, env: env) + } + + /// Initializes a global-actor–isolated runtime for a transducer that has no environment (`Env == Void`). + /// + /// Use this convenience initializer when your transducer does not require an external environment + /// value. The runtime is bound to the shared instance of the provided `GlobalActor` (`GA.shared`), + /// ensuring that all state mutations and effect execution occur under that actor’s isolation. + /// + /// - Parameters: + /// - transducer: The transducer type to host. Defaults to `T.self`. + /// - on: The `GlobalActor` type that provides isolation for the runtime. The runtime is pinned + /// to `GA.shared`, ensuring all reductions and state access happen on that actor. Defaults to `GA.self`. + /// - initialState: The starting state for the transducer. Defaults to `T.initialState`. + /// + /// - Discussion: + /// This initializer constructs the underlying runtime without an environment, suitable for + /// transducers whose `Env` is `Void`. Synchronous reductions and effect lifecycle management + /// are executed on `GA.shared`, unless effects explicitly hop to other executors. + /// +/// - Requirements: +/// - `T: Transducer` whose `Effect == TransducerEffect`. +/// - `T.Event` must be `Sendable`. +/// - `T` must conform to `SendableMetatype`. +/// - `T.Env == Void`. +/// +/// - Note: +/// This initializer is part of a convenience type that is not used within the library itself. +/// For most use cases, consider using ``BaseRuntime`` directly or implementing a custom host. +/// +/// - SeeAlso: +/// - ``init(transducer:on:initialState:env:)`` for environment-bearing transducers. +/// - ``send(_:)`` for immediate, awaited dispatch. +/// - ``post(_:)`` for fire-and-forget scheduling. +/// - ``request(_:)`` for awaited dispatch that returns a terminal response. +/// - ``cancel(with:)`` for cancelling the runtime with an optional error. +public init( + transducer: T.Type = T.self, + on: GA.Type = GA.self, + initialState: T.State = T.initialState + ) where T.Env == Void { + baseRuntime = .init(systemActor: GA.shared, initialState: initialState) + } + + let baseRuntime: BaseRuntime + var systemActor: Actor { GA.shared } + var taskManager: TaskManager { baseRuntime.taskManager } + let runtimeType: BaseRuntime.Type = BaseRuntime.self + + + /// A typed handle for sending events and interacting with the runtime from outside its isolation domain. + /// + /// `input` exposes the runtime’s `BaseTransducerInput`, which provides safe entry points + /// (such as `send`, `post`, or `request`, depending on your configuration) for driving the + /// transducer without requiring direct access to the runtime instance or its actor isolation. + /// + /// - Discussion: + /// - Use this input to integrate with components that need to emit `Event` values without + /// capturing the entire runtime (e.g., UI bindings, delegates, or external services). + /// - The input preserves the same scheduling and isolation guarantees as the runtime; events + /// sent through it are processed on the bound global actor (`GA.shared`). + /// - Because the input is typed to `BaseTransducerInput`, it carries the transducer’s + /// `Event`, `State`, `Response`, and `Effect` semantics. + /// + /// - Important: The input does not expose or allow direct state mutation. All state changes + /// occur through event reduction on the runtime’s actor. + /// + /// - SeeAlso: + /// - ``send(_:)`` for awaited dispatch of an event. + /// - ``post(_:)`` for fire-and-forget scheduling. + /// - ``request(_:)`` for awaited dispatch that returns a terminal response. + /// - ``uniqueRequest(_:)`` for awaited dispatch that returns a terminal response. + /// - ``cancel(with:)`` to cancel the runtime and in-flight work. + public var input: Input { baseRuntime.input } + + /// The environment value supplied to the underlying transducer. + /// + /// - Discussion: + /// This is the immutable environment captured when the runtime was initialized. + /// It is made available to all reductions and effects executed by the runtime, + /// and is accessed under the same global-actor isolation (`GA.shared`) as state + /// mutations and event processing. + /// + /// - Note: + /// If the transducer’s `Env` type is `Void`, this property is `()`. + /// + /// - SeeAlso: + /// - ``init(transducer:on:initialState:env:)`` for providing a non-void environment. + /// - ``state`` for reading the current state under actor isolation. + public var env: T.Env { baseRuntime.env } + + /// Sends `event` into the runtime. + /// + /// `send` performs immediate event dispatch. The host processes the event's + /// synchronous reduction path before the call returns, but any task-based + /// effects started by that path may continue running after `send` completes. + /// + /// - Parameter event: The event to dispatch. + /// - Throws: If the runtime cannot accept the event, or if accepted work is + /// later cancelled or fails at the runtime boundary. + public func send( + _ event: T.Event + ) async throws { + try await baseRuntime.send(systemActor: systemActor, event) + } + + /// Schedules `event` without awaiting the resulting effect chain. + /// + /// `post` is the fire-and-forget dispatch entry point. It asks the runtime to + /// enqueue `event` and then returns immediately, without waiting for synchronous + /// reduction, spawned task effects, or a terminal `Response` value. + /// + /// If `post` throws, that failure is local to the call site: the implementation + /// could not accept the event for immediate scheduling. Once scheduling succeeds, + /// any later cancellation or runtime failure occurs on the runtime's own execution + /// path and is not reported back to the caller of `post`. + /// + /// - Parameter event: The event to enqueue into the runtime. + /// - Throws: A synchronous runtime entry failure if the implementation cannot + /// accept the event for scheduling. + public func post( + _ event: T.Event + ) async throws { + try await baseRuntime.post(systemActor: systemActor, event) + } + + /// Sends `event` and suspends until the resulting effect chain settles. + /// + /// Use `request` when the caller needs the terminal `Response?` produced by + /// the chain rather than only triggering work. The exact isolation and + /// lifetime semantics are defined by the conforming host. + /// + /// - Parameter event: The event to dispatch. + /// - Throws: If the request cannot enter the runtime, or if accepted work is + /// later cancelled or fails at the runtime boundary. + /// - Returns: The terminal output produced by the settled effect chain. + @discardableResult + public func request( + _ event: Event + ) async throws -> Response where T.Response: Sendable { + try await baseRuntime.request(systemActor: systemActor, event) + } + + /// Sends `event` and suspends until the resulting effect chain settles. + /// + /// Use `request` when the caller needs the terminal `Response?` produced by + /// the chain rather than only triggering work. The exact isolation and + /// lifetime semantics are defined by the conforming host. + /// + /// - Parameter event: The event to dispatch. + /// - Throws: If the request cannot enter the runtime, or if accepted work is + /// later cancelled or fails at the runtime boundary. + /// - Returns: The terminal output produced by the settled effect chain. + @discardableResult + public func uniqueRequest( + _ event: Event + ) async throws -> Response where T.Response: Sendable { + try await baseRuntime.uniqueRequest(systemActor: systemActor, event) + } + + + /// Cancels the hosted runtime with a caller-provided system error. + /// + /// Use this when pending work should observe a specific runtime failure + /// instead of a generic cancellation. + /// + /// - Parameter error: The system-level failure to latch and broadcast. + public func cancel( + with error: Swift.Error? = nil + ) async { + await baseRuntime.cancel(systemActor: systemActor, with: error) + } + + /// Handles system-level control events that affect the transducer’s runtime lifecycle, + /// such as cancellation and unrecoverable errors. + /// + /// This method is invoked by the runtime to react to control-plane signals that are + /// not part of the domain `Event` stream. It can cancel any in-flight work managed + /// by `taskManager` and surface cancellation to callers waiting on request-style + /// operations. After handling the control event, it verifies whether cancellation + /// has been triggered and throws if the task manager is cancelled. + /// + /// - Parameters: + /// - systemActor: The actor providing the current isolation context. Defaults to + /// `#isolation`. This is available for symmetry with other isolated operations, + /// but is not used directly in this implementation. + /// - controlEvent: The control-plane event to process. Supported cases: + /// - `.systemError(Error)`: Cancels all managed tasks with the provided error, + /// propagating failure to any suspended continuations. + /// - `.cancel`: Cancels all managed tasks - without awaiting their completion, tears + /// down the runtime and throws a ``RuntimeCancellationError``. + /// + /// - Throws: `TaskManager`-defined cancellation error if cancellation has been + /// triggered as a result of handling the control event (or was already in effect). + /// + /// - Important: This method does not resume any continuation directly. Instead, it + /// delegates cancellation to `taskManager`, which is responsible for resuming or + /// failing any suspended requests. Callers should be prepared to catch the thrown + /// cancellation after `checkCancellation()` and complete their own control flow. + /// + /// - Important: Witnesses must not mutate state. + /// + /// - SeeAlso: `compute(event:continuation:state:taskManager:input:env:)` for normal + /// event processing and effect execution; `TaskManager` for details on task and + /// continuation lifecycle management. + public func control( + _ controlEvent: ControlEvent + ) async throws { + try await baseRuntime.control(systemActor: systemActor, controlEvent) + } + + /// Retrieves the current state without entering the compute gate. + /// + /// Use this method when you need to inspect the current state for side-effect-free + /// queries without participating in the transduction cycle. This is a "read-only" + /// operation that hops to the bound global actor (`GA.shared`) but does not + /// coordinate with the compute gate used by ``getState()``. + /// + /// - Important: Because access is isolated to the global actor, reading state + /// is an asynchronous operation and may suspend while awaiting the actor turn. + /// + /// - Discussion: + /// - The value reflects all reductions that have completed prior to the read + /// on the global actor. In-flight effects may still be mutating state and + /// will be visible once their reductions complete. + /// - Unlike ``getState()``, this method does not enter the compute gate and + /// is suitable for one-off reads or debugging scenarios. + /// + /// - Returns: The latest `T.State` value managed by the runtime, retrieved on + /// `GA.shared`. + /// + /// - Throws: An error if reading the state from storage fails. + /// + /// - SeeAlso: + /// - ``getState()`` for state access that participates in the compute gate. + /// - ``peak`` for a property-based accessor to the current state. + public func peakState() async throws -> T.State where T.State: Sendable { + try await baseRuntime.peakState(systemActor: systemActor) + } + + /// Retrieves the current state while coordinating access through the compute gate. + /// + /// Use this method when you need to read the current state as part of a + /// transduction cycle. This method enters the compute gate, ensuring that + /// state access is properly coordinated with other in-flight operations. + /// + /// - Returns: The current state of type ``Transducer/State``. + /// - Throws: An error if reading the state from storage fails. + /// + /// - Note: Unlike ``peakState()``, this method enters the compute + /// gate, participating in the transduction cycle and ensuring serialized access. + /// - Seealso: ``peakState()``, ``peak`` + public func getState() async throws -> T.State where T.State: Sendable { + try await baseRuntime.getState(systemActor: systemActor) + } +} + +extension GlobalActorRuntime where T.State: Sendable { + /// The current state of the transducer, read under the bound global actor’s isolation. + /// + /// Accessing this property hops to the `GA.shared` actor to safely retrieve the current + /// state managed by the runtime. This guarantees that state + /// reads are serialized with event processing and effect-driven mutations. + /// + /// - Important: Because access is isolated to the global actor, reading `state` + /// is an asynchronous operation and may suspend while awaiting the actor turn. + /// + /// - Discussion: + /// - The value reflects all reductions that have completed prior to the read + /// on the global actor. In-flight effects may still be mutating state and + /// will be visible once their reductions complete. + /// - Use this property for one-off reads. If you need to observe state changes + /// over time, prefer an explicit observation mechanism provided by your + /// transducer or runtime. + /// + /// - Returns: The latest `T.State` value managed by the runtime, retrieved on + /// `GA.shared`. + /// + /// - SeeAlso: + /// - ``send(_:)`` for dispatching events that may mutate state. + /// - ``post(_:)`` for fire-and-forget event scheduling. + /// - ``request(_:)`` for dispatching events that yield a terminal response. + /// - ``uniqueRequest(_:)`` for dispatching events that yield a terminal response. + /// - ``env`` for the environment value captured by the runtime. + public var peak: T.State { + get async throws { + try await baseRuntime.peakState(systemActor: systemActor) + } + } + + /// Retrieves the current state while coordinating access through the compute gate. + /// + /// Use this property when you need to read the current state as part of a + /// transduction cycle. This method enters the compute gate, ensuring that + /// state access is properly coordinated with other in-flight operations. + /// + /// - Returns: The current state of type ``Transducer/State``. + /// - Throws: An error if reading the state from storage fails. + /// + /// - Note: Unlike ``peakState(systemActor:)``, this method enters the compute + /// gate, participating in the transduction cycle and ensuring serialized access. + /// - Seealso: ``getState(systemActor:)``, ``peakState(systemActor:)`` + var state: T.State { + get async throws { + return try await baseRuntime.getState(systemActor: systemActor) + } + } + +} + + +// Debug +extension GlobalActorRuntime: CustomStringConvertible { + public var description: String { + "GlobalActorRuntime<\(BaseRuntime.self)>" + } +} + +extension GlobalActorRuntime: CustomDebugStringConvertible { + public var debugDescription: String { + return """ + GlobalActorRuntime<\(T.self)>( + on: \(GA.self), + \(String(reflecting: baseRuntime)) + ) + """ + } +} diff --git a/Sources/Transduce/Hosts/Observable/TransducerObservable.swift b/Sources/Transduce/Hosts/Observable/TransducerObservable.swift new file mode 100644 index 0000000..6e3ab8d --- /dev/null +++ b/Sources/Transduce/Hosts/Observable/TransducerObservable.swift @@ -0,0 +1,90 @@ +#if canImport(Observation) +import Observation + +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) +/// A stateful, `@MainActor` observable container that drives a ``Transducer`` lifecycle. +/// +/// This class encapsulates the ``Runtime``, a ``Storage`` mechanism (backed by +/// `WeakReferenceKeyPathStorage`), and provides a thread-safe ``Input`` handle +/// to its external consumers. It serves as the runtime bridge for UI layers like +/// ``EffectView`` or other SwiftUI integration points. +@MainActor +public final class TransducerObservable where + T.Response: Sendable, + T.State: Sendable, // Required to satisfy BasicTransducerStateAccessor protocol bounds + // T.Env: Sendable, + T.Effect == TransducerEffect, + T.Event: Sendable, + T: SendableMetatype // Implicitly inherits SendableMetatype for @unchecked Sendable chain +{ + /// The mutable state of this transducer, automatically exposed via `@Observation`. + public typealias State = T.State + + /// A concrete runtime storage container for tracking state transitions. + internal typealias Storage = WeakReferenceKeyPathStorage + + /// The underlying effect scheduler and event processor bound to this storage. + internal typealias Runtime = BaseRuntime + + /// A standardised ``TransducerInput`` handle for dispatching events across the stack. + public typealias Input = BaseTransducerInput + + // MARK: - Stored Properties + + @ObservationIgnored + private var storage: Storage! + + @ObservationIgnored + private var runtime: Runtime! + + /// The current application state. Updates to this property trigger UI regeneration + /// automatically due to the `@Observable` macro. + public private(set) var state: T.State + + /// Provides a safe accessor to the ``BaseTransducerInput`` handle bound to this observable's active runtime. + @ObservationIgnored + public var input: Input { Input(runtime) } + + // MARK: - Initialization + + /// Initialize a new transducer observable instance. + /// + /// - Parameters: + /// - initialState: The initial state of the unidirectional data flow. + /// - initialEnv: The dependency environment captured for the entire runtime lifecycle. + public init(initialState: T.State, initialEnv: sending T.Env) { + self.state = initialState + + // Phase 2 kicks in here so `self` is available for keyPath reference + let storageContainer: Storage = .init(host: self, keyPath: \.state) + + // Configure the runtime with its dependencies and storage accessor + self.runtime = .init(systemActor: MainActor.shared, storage: storageContainer, env: initialEnv) + } + + public func send(_ event: T.Event) async throws { + try await runtime.send(systemActor: #isolation, event) + } + + public func post(_ event: sending T.Event) throws { + guard let runtime else { + throw RuntimeError.runtimeUnavailable + } + try runtime.post(event) + } + + public func request(_ event: T.Event) async throws -> T.Response { + guard let runtime = self.runtime else { + throw RuntimeError.runtimeUnavailable + } + return try await runtime.request(event) + } + + /// Cancels all running effects and terminates the underlying ``Runtime``. + func cancel(with error: (any Error)?) async { + guard let runtime else { return } + runtime.cancel(with: error) + } +} + +#endif diff --git a/Sources/Transduce/Hosts/TransducerHost.swift b/Sources/Transduce/Hosts/TransducerHost.swift new file mode 100644 index 0000000..3f85df0 --- /dev/null +++ b/Sources/Transduce/Hosts/TransducerHost.swift @@ -0,0 +1,189 @@ +/** + A host that manages the execution of a transducer within a global actor context. + + `TransducerHost` provides a thread-safe interface for interacting with transducers + by encapsulating the runtime and offering methods to send events, process effects, + and manage async tasks. + + - Note: The host requires the transducer to conform to `Sendable` and its associated + types to be sendable as well, ensuring safe concurrent access across actor boundaries. + + - Generic Parameters: + - T: The transducer type to host, which must conform to the `Transducer` protocol. + - GA: The global actor type that isolates transducer execution. + + - Requirements: + - `T.Effect` must equal `TransducerEffect`. + - `T.Event`, `T.Response`, and the transducer itself must conform to `Sendable`. + - The transducer must conform to both `SendableMetatype` and `Sendable`. + + - Example: + ```swift + @globalActor + actor MyActor: GlobalActor { + static let shared = MyActor() + } + + let host = try TransducerHost( + initialState: .init(), + env: MyEnv() + ) + + await host.send(.increment) + let response = await host.request(.getData) + ``` + */ +public struct TransducerHost: Sendable +where + T.Effect == TransducerEffect, + T.Event: Sendable, + T.Response: Sendable, + T: SendableMetatype & Sendable +{ + /// The runtime that executes the transducer logic within the global actor context. + typealias Runtime = GlobalActorRuntime + + /// The environment type required by the transducer for dependency injection. + public typealias Env = T.Env + + /// The event type that can be sent to the transducer. + public typealias Event = T.Event + + /// The state type managed by the transducer. + public typealias State = T.State + + /// The response type returned by request-based interactions with the transducer. + public typealias Response = T.Response + + /// The input interface for interacting with the transducer's runtime. + public typealias Input = BaseTransducerInput + + /// The underlying runtime instance managing transducer execution. + let runtime: Runtime + + /// Creates a new transducer host with the given initial state and environment. + /// + /// - Parameters: + /// - initialState: The initial state for the transducer. + /// - initialEvent: An optional initial event to process after initialization. + /// - env: The environment for dependency injection. + /// - Throws: Any error thrown during runtime initialization. + nonisolated + public init(initialState: T.State, initialEvent: Event? = nil, env: sending Env) throws { + self.runtime = Runtime(initialState: initialState, env: env) + } + + /// Creates a new transducer host with the given initial state and environment. + /// + /// - Parameters: + /// - initialState: The initial state for the transducer. + /// - initialEvent: An optional initial event to process after initialization. + /// - Throws: Any error thrown during runtime initialization. + nonisolated + public init(initialState: T.State, initialEvent: Event? = nil) throws where Env == Void { + self.runtime = Runtime(initialState: initialState) + } + + /// The input interface for sending events and managing the transducer runtime. + public var input: Input { + Input(self.runtime.baseRuntime) + } + + /// Sends an event to the transducer and awaits completion. + /// + /// This method processes the event asynchronously and returns once all effects have been + /// handled. Use this when you need to wait for event processing to complete. + /// + /// - Parameter event: The event to send. + /// - Throws: Any error thrown during event processing or effect execution. + public func send(_ event: Event) async throws { + try await runtime.send(event) + } + + /// Posts an event to the transducer without awaiting completion. + /// + /// This method dispatches the event and returns immediately, without waiting for + /// effect processing to complete. Use this for fire-and-forget event dispatching. + /// + /// - Parameter event: The event to post. + /// - Throws: Any error thrown during event dispatch. + public func post(_ event: Event) async throws { + try await runtime.post(event) + } + + /// Sends an event and awaits a response from the transducer. + /// + /// This method is used for request-response interactions where the transducer + /// produces a response value. Multiple concurrent requests may be processed. + /// + /// - Parameter event: The event to send. + /// - Returns: The response produced by the transducer. + /// - Throws: Any error thrown during event processing or response production. + public func request(_ event: Event) async throws -> Response { + try await runtime.request(event) + } + + /// Sends an event and awaits a unique response, canceling any pending request. + /// + /// This method ensures only one outstanding request at a time by canceling any + /// previous pending request before sending the new one. Useful for scenarios + /// where only the latest request matters (e.g., user input). + /// + /// - Parameter event: The event to send. + /// - Returns: The response produced by the transducer. + /// - Throws: Any error thrown during event processing or response production. + public func uniqueRequest(_ event: Event) async throws -> Response { + try await runtime.uniqueRequest(event) + } + + /// Cancels all ongoing tasks managed by the transducer runtime. + /// + /// This method cancels all active async tasks and optionally provides an error + /// to signal the reason for cancellation. + /// + /// - Parameter error: An optional error to associate with the cancellation. + public func cancel( + with error: Swift.Error? = nil + ) async { + await runtime.cancel(with: error) + } + +} + +extension TransducerHost where T.State: Sendable { + /// Returns the current state of the transducer. + /// + /// Accesses the runtime's storage to retrieve the immutable state snapshot. + /// This property throws if the runtime has not been initialized yet. + /// + /// - Throws: ``RuntimeError`` if the runtime is unavailable. + /// Retrieves the current state without waiting for compute cycles to complete. + /// + /// Use this property when you need immediate access to the state, potentially + /// seeing an intermediate value if operations are in progress. This method + /// reads directly from storage without entering the compute gate. + /// + /// - Returns: The current state of type ``Transducer/State``. + public var peak: State { + get async throws { + try await runtime.peakState() + } + } + + /// Retrieves the next visible state after the current compute cycle completes. + /// + /// Use this property when you need to read the state after all in-flight + /// operations have settled. This method enters the compute gate, waiting for + /// any concurrent state mutations to complete before returning. + /// + /// - Returns: The state of type ``Transducer/State`` after the compute cycle. + /// - Throws: An error if the runtime is not yet initialized or reading the state from storage fails. + /// + /// - Note: Unlike ``peak``, this property awaits the compute gate, ensuring + /// you see the state after all concurrent operations have completed. + public var state: State { + get async throws { + return try await runtime.getState() + } + } +} diff --git a/Sources/Transduce/Hosts/TransducerInput.swift b/Sources/Transduce/Hosts/TransducerInput.swift new file mode 100644 index 0000000..6bbc1e3 --- /dev/null +++ b/Sources/Transduce/Hosts/TransducerInput.swift @@ -0,0 +1,136 @@ +/// A handle for feeding events back into a transducer runtime. +/// +/// `BaseTransducerInput` has three dispatch styles: +/// +/// - ``post(_:)`` sends an event without awaiting a result. +/// - ``send(_:)`` suspends until the immediate reduction path has been processed. +/// - ``request(_:)`` suspends until the triggered effect chain settles and returns +/// the terminal `Response?` value, if any. +/// +/// When multiple `request` calls overlap and eventually drive a named task with the +/// same identifier, the task's ``TaskAdditionPolicy`` decides how the runtime treats +/// the active task for that identifier: +/// +/// - `.shareable`: keep the running task and add the new waiter to it. +/// - `.switchToLatest`: cancel the running task, start a fresh one, and move all +/// current waiters for that identifier onto the replacement task. +/// +/// Equal task identifiers therefore mean more than "same cancellation key": they +/// declare the same logical in-flight work. Overlapping waiters for one identifier +/// must converge to one current result or one current error. +public protocol TransducerInput: Sendable { + associatedtype Event + associatedtype Response + + /// Sends `event` and suspends until the runtime has processed its immediate + /// reduction path. + /// + /// `send` is a deliberate backpressure boundary. It returns only after the + /// runtime has run `update` for `event` and any immediately returned + /// event/action chain, or after that chain has reached a terminal managed + /// task. If a task effect is started, `send` returns after the task has been + /// accepted by the runtime; it does not wait for the task operation to + /// complete and it does not return `Response`. + /// + /// Custom queued hosts must preserve this semantic. If events are transported + /// through a channel or mailbox, `send` must be acknowledged after the + /// consumer has processed the immediate reduction path, not merely after the + /// event has been enqueued. A queued implementation typically transports an + /// envelope carrying either no continuation (`post`), an acknowledgement + /// continuation (`send`), or an response continuation (`request`). + /// + /// - Parameter event: The event to send into the runtime. + /// - Throws: A runtime entry failure if the event cannot be accepted, or a + /// later cancellation or runtime failure before the immediate reduction path + /// has been acknowledged. + func send(_ event: Event) async throws + + /// Schedules `event` without awaiting the resulting effect chain. + /// + /// `post` is the fire-and-forget dispatch entry point. It asks the runtime to + /// enqueue `event` and then returns immediately, without waiting for synchronous + /// reduction, spawned task effects, or a terminal `Response` value. + /// + /// If `post` throws, that failure is local to the call site: the implementation + /// could not accept the event for immediate scheduling. Once scheduling succeeds, + /// any later cancellation or runtime failure occurs on the runtime's own execution + /// path and is not reported back to the caller of `post`. + /// + /// - Parameter event: The event to enqueue into the runtime. + /// - Throws: A synchronous runtime entry failure if the implementation cannot + /// accept the event for scheduling. + func post(_ event: sending Event) throws + + /// Sends `event` and suspends until the resulting effect chain settles. + /// + /// If the chain reaches a named task, overlapping waiters for the same task + /// identifier are coalesced according to that task's ``TaskAdditionPolicy``. + /// The caller is waiting for the current active task for that identifier, not + /// necessarily for the first physical task instance that was started. + /// + /// ## Cancellation semantics + /// When the caller's task is cancelled, the request immediately throws `CancellationError` + /// and the caller stops waiting for a response. However, the event chain may continue + /// executing independently: + /// - If the action has not yet started, it may or may not run (non-deterministic). + /// - If the action is already running, it continues to completion. + /// - Any events returned by the action still feed into the runtime state machine. + /// This preserves event-driven semantics even when callers detach early. For strictly + /// exclusive request semantics where cancellation stops all work, use ``uniqueRequest(_:)``. + /// + /// - Parameter event: The event to send into the runtime. + /// - Returns: Returns the return value of the static transducer function `response(state:event:)` for the last event where the state settled. + /// - Throws: A runtime entry failure if the request cannot start, `CancellationError` if + /// the caller's task is cancelled, or any other runtime failure while the request is in flight. + /// + /// - SeeAlso: ``uniqueRequest(_:)``, ``send(_:)``. + @discardableResult + func request(_ event: Event) async throws -> Response + + + /// Asynchronously sends an event to the transducer and awaits a strictly matched response. + /// + /// This method forwards the provided event to the associated `TransducerRuntime` on its + /// `systemActor`, ensuring correct actor isolation and serialized access. Unlike `request(_:)`, + /// this variant is designed for scenarios requiring a strict, one-to-one correspondence between + /// the sent event and its response, or for routing events that must not be batched or deduplicated. + /// + /// ## Subscriber behavior: + /// - Creates a continuation attached to the task's waiter list with `.caller(continuationBox)` ownership — + /// meaning cancellation propagates both into the continuation (unsubscribing the caller) **and** + /// into the underlying SwiftTask via TaskManager. + /// - When used through shared task strategies, if you unsubscribe while being the sole subscriber on a + /// `.shareable(list)`, the underlying work keeps running. + /// To cancel the task when the last subscriber leaves, use `.cancel(id:)` from your transducer's effect output. + /// + /// ## Behavior: + /// - If the runtime or its `systemActor` is unavailable (e.g., deallocated), this method throws + /// `RuntimeError.runtimeUnavailable`. + /// - Propagates any error thrown by the underlying runtime's `uniqueRequest(systemActor:event:)`. + /// - Returns an optional `Response` value. The value is `nil` when the transducer elects not to + /// respond, or when no matching response exists. + /// + /// ## Concurrency: + /// - This function is `async` and must be awaited. + /// - Execution is coordinated via the runtime’s `systemActor`. + /// + /// - Parameter event: The event to deliver to the transducer for which a unique response is expected. + /// - Returns: The matched `Response` value, or `nil` if no response was produced. + /// - Throws: `RuntimeError.runtimeUnavailable` if the runtime or its system actor is missing, + /// or any error thrown by the underlying runtime while handling the request. + /// - SeeAlso: + /// ``request(_:)``, ``send(_:)``. + @discardableResult + func uniqueRequest(_ event: Event) async throws -> Response +} + +extension TransducerInput { + /// Convenience call-as-function syntax for ``post(_:)``. + /// + /// - Parameter event: The event to enqueue into the runtime. + /// - Throws: Any error that ``post(_:)`` would throw for the same event. + @inline(__always) + public func callAsFunction(_ event: sending Event) throws { + try post(event) + } +} diff --git a/Sources/Transduce/Hosts/TransducerStorage.swift b/Sources/Transduce/Hosts/TransducerStorage.swift new file mode 100644 index 0000000..4fb0741 --- /dev/null +++ b/Sources/Transduce/Hosts/TransducerStorage.swift @@ -0,0 +1,72 @@ +public protocol TransducerStorage { + associatedtype State + + mutating func withMutableState(_ body: (inout State) throws -> R) throws -> R + + var state: State { get throws } +} + +internal struct LocalStorage: TransducerStorage { + init(initialState: State) { + state = initialState + } + + var state: State + + mutating func withMutableState(_ body: (inout State) throws -> R) throws -> R { + try body(&state) + } +} + + +// Works for global actor isolated "hosts" — no @MainActor on the type so it can cross concurrency boundaries when used in Sendable containers. Isolation is enforced at the call site, not the storage level. +internal struct UnownedReferenceKeyPathStorage: TransducerStorage { + init(host: Host, keyPath: ReferenceWritableKeyPath) { + self.host = host + self.keyPath = keyPath + } + + private unowned let host: Host + private let keyPath: ReferenceWritableKeyPath + + mutating func withMutableState(_ body: (inout State) throws -> R) throws -> R { + var state = host[keyPath: keyPath] + defer { host[keyPath: keyPath] = state } + return try body(&state) + } + + var state: State { + host[keyPath: keyPath] + } +} + +internal struct WeakReferenceKeyPathStorage: TransducerStorage { + + struct HostDeinitializedError: Swift.Error {} + + init(host: Host, keyPath: ReferenceWritableKeyPath) { + self.host = host + self.keyPath = keyPath + } + + private weak let host: Host? + private let keyPath: ReferenceWritableKeyPath + + mutating func withMutableState(_ body: (inout State) throws -> R) throws -> R { + guard let host = host else { + throw HostDeinitializedError() + } + var state = host[keyPath: keyPath] + defer { host[keyPath: keyPath] = state } + return try body(&state) + } + + var state: State { + get throws { + guard let host = host else { + throw HostDeinitializedError() + } + return host[keyPath: keyPath] + } + } +} diff --git a/Sources/Transduce/Observation/Observation.swift b/Sources/Transduce/Observation/Observation.swift new file mode 100644 index 0000000..09f638d --- /dev/null +++ b/Sources/Transduce/Observation/Observation.swift @@ -0,0 +1,137 @@ +#if canImport(Observation) +import Mutex +import Observation + +/// Observes tracked dependencies using Swift Observation and re-runs the closure `apply` on +/// invalidation, then suspends until a change is signaled from the Observation framework. +/// +/// Establishes an observation boundary using `withObservationTracking`. The `apply` closure is +/// executed immediately to track dependencies; when any of those dependencies change, the Observation +/// framework coalesces invalidations and re-runs `apply` once with the latest values. +/// +/// > Important : The `observe` function needs to be called on the same isolation where the +/// observable is isolated to. +/// +///### Example +/// +/// In order to observe any value conforming to `Observable` within a Transducer, return a task +/// effect in the `transduce` function which has a literal global actor annotation matching the +/// actor isolation of the observable, here in this example it's the`@MainActor`. The `env` value +/// should provide the observable instance, in this example `timer`. +/// +/// You typical read one or more values from the observable and send them into the transducer via +/// the input and a dedicated event, here `.tick(Int)`, so that the transducer can handle the +/// modified value: +/// +/// ```swift +/// case .startObservation: +/// return .task(id: "observe") { @MainActor input, env in +/// try await observe { +/// let value = env.timer.value +/// try? input(.tick(value)) +/// } +/// } +/// ``` +/// +/// The observation can be cancelled simply by cancelling the task. In a`transduce` function +/// return a corresponding cancel effect: +/// ```swift +/// case .cancelObservation: +/// return .cancel("observe") +/// ``` +/// +/// ## Important behavior: +/// - Initial run: `apply` is always invoked once upon attachment to deliver the initial value(s). +/// - Coalescing: Under heavy system load it may happen that multiple mutations will be collapsed +/// into a single re-run. Observation is not a per-mutation event stream — intermediate values may +/// not be delivered. +/// - Backpressure: Due to the coalescing behavior, if your system setup requires that changes must +/// be processed one-at-a-time and without drops, do not use Swift Observation and +/// `observe(isolated:apply:)`. Possible solutions may consider using Swift Combine, +/// AsyncStream or AsycChannel, or use structured concurrency, or any suitable method which +/// implements a viable backpressure machanism. The transducer already provides the tools to +/// implement backpressure easily: use either `send(_:)` or `request(_:)` to send events into +/// the transducer. +/// - Cancellation: The observing loop checks for cancellation before invoking `apply` and ensures +/// that `apply` will not be executed when the observation has been asynchronously cancelled. +/// +/// - Parameters: +/// - isolated: The actor on which to run `apply` - typically the isolation of the observable. Defaults to the current isolation. +/// - apply: A synchronous, nonisolated closure that is executed under `withObservationTracking`. +/// - Throws: Rethrows from `apply` and cancellation errors.@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) +@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) +public func observe( + isolated: isolated any Actor = #isolation, + apply: @escaping () throws -> Void +) async throws { + let cont = BasicContinuationBox() + + func observeOnce(isolated: isolated any Actor) async throws { + try await withCheckedThrowingContinuation { continuation in + cont.setContinuation(continuation) + withObservationTracking { + do { + try Task.checkCancellation() + try apply() + } catch { + cont.resume(with: error) + } + } onChange: { + cont.resume() + } + } + } + + try await withTaskCancellationHandler { + while true { + try await observeOnce(isolated: isolated) + try Task.checkCancellation() + await Task.yield() + } + } onCancel: { + cont.resume(with: CancellationError()) + } +} + + +// MARK: - Private + +enum BasicObservationError: Swift.Error { + case observableDeallocated +} + +nonisolated +final class BasicContinuationBox: Sendable { + let mutex: Mutex?> = .init(nil) + + init() {} + + func setContinuation(_ continuation: CheckedContinuation) { + mutex.withLock { cont in + precondition(cont == nil) + cont = continuation + } + } + + func resume() { + mutex.withLock { cont in + cont?.resume() + cont = nil + } + } + + func resume(with error: Swift.Error) { + mutex.withLock { cont in + cont?.resume(throwing: error) + cont = nil + } + } + + deinit { + mutex.withLock { cont in + cont?.resume(throwing: CancellationError()) + } + } +} + +#endif diff --git a/Sources/Transduce/Runtime/BaseRuntime.swift b/Sources/Transduce/Runtime/BaseRuntime.swift new file mode 100644 index 0000000..8414a37 --- /dev/null +++ b/Sources/Transduce/Runtime/BaseRuntime.swift @@ -0,0 +1,189 @@ +/// `BaseRuntime` is a reference type that encapsulates all components required to execute a transducer. +/// +/// ## Purpose +/// This class brings together the mutable state, task manager, storage, compute gate, and environment needed to drive a transducer, +/// providing a single reference for use by transducer hosts. +/// +/// ## Reference Semantics +/// `BaseRuntime` must be a reference type because it coordinates several mutable components—especially the task manager, +/// internal state storage (if storage itself is a value type), and compute gate. Reference semantics ensure shared identity, +/// co-location, and predictable mutation for all operations requiring synchronization. +/// +/// ## Implementation Notes +/// Most core behavior is provided by default protocol implementations. `BaseRuntime` configures, wires, and exposes these subsystems +/// as required for each concrete transducer. +/// +/// ## Usage +/// Instances of `BaseRuntime` are typically owned by a higher-level transducer host, which defines the input type and +/// provides actor or global-actor isolation. +/// +/// - Important: The runtime obtains its isolation context (the system actor) from the host and is always used exclusively within that isolation. +/// +/// The host guarantees safe, serialized access; the runtime itself is never accessed +/// from multiple concurrency domains. +/// +/// ## Thread Safety +/// By design, all interactions with a runtime instance are isolated to the host's actor or global actor. Mutable state is never shared +/// unsafely; all mutation is coordinated through the single isolation domain supplied by the host. This allows the runtime to use +/// internal mutability safely, even when members are not strictly Sendable. +/// +/// - Note : `BaseRuntime` is declared as `@unchecked Sendable`. This is safe because its +/// access is strictly internal: +/// All usage is within "host" types that guarantee `BaseRuntime` is always +/// exclusively referenced from a unique actor context — +/// either a global actor or an actor instance. The library's implementation and +/// design ensure that there is never concurrent unsynchronized access to +/// instances of `BaseRuntime`. As such, although some members are not strictly +/// `Sendable`, the usage invariants enforced by the hosts maintain thread safety. +final class BaseRuntime< + T: Transducer, + Storage: TransducerStorage, +>: TransducerRuntime, @unchecked Sendable +where + T.Effect == TransducerEffect, + T: SendableMetatype, + T.Event: Sendable, + Storage.State == T.State +{ + typealias Input = BaseTransducerInput + typealias Storage = Storage + + init( + transducer: T.Type = T.self, + systemActor: any Actor, + initialState: T.State? = nil, + env: sending Env + ) where Storage == LocalStorage { + self.systemActor = systemActor + self.env = env + self.storage = .init(initialState: initialState ?? T.initialState) + self.taskManager = TaskManager(systemActor: systemActor) + self.computeGate = ComputeGate() + self.taskManager.runtime = self + } + + init( + transducer: T.Type = T.self, + systemActor: any Actor, + storage: Storage, + env: sending Env + ) { + self.systemActor = systemActor + self.env = env + self.storage = storage + self.taskManager = TaskManager(systemActor: systemActor) + self.computeGate = ComputeGate() + self.taskManager.runtime = self + } + + convenience init( + transducer: T.Type = T.self, + systemActor: any Actor, + initialState: T.State? = nil, + ) where Storage == LocalStorage, Env == Void { + self.init( + transducer: transducer, + systemActor: systemActor, + initialState: initialState, + env: Void() + ) + } + + convenience init( + transducer: T.Type = T.self, + systemActor: any Actor, + storage: Storage, + ) where Env == Void { + self.init( + transducer: transducer, + systemActor: systemActor, + storage: storage, + env: Void() + ) + } + + weak let systemActor: (any Actor)? + var id: ObjectIdentifier { .init(self) } + var storage: Storage + let env: Env + var input: Input { Input(self) } + let taskManager: TaskManager + var continuationBox_id: Int = 0 + let computeGate: ComputeGate + + + func response(event: Event) throws -> Response { + T.response(state: try storage.state, event: event) + } + + #if DEBUG + func transduce(event: Event) throws -> Effect { + try storage.withMutableState { state in + var msg: String = "\(event) @State \(state) -> (" + let effect = T.transduce(&state, event: event) + msg += "\(state), \(effect))" + // TODO: instead of printing state' we should print diff(state, state') + return effect + } + } + #else + func transduce(event: Event) throws -> Effect { + try storage.withMutableState { state in + T.transduce(&state, event: event) + } + } + #endif + + func newContinuationID() -> Int { + continuationBox_id += 1 + return continuationBox_id + } +} + +extension BaseRuntime where T.State: Sendable { + /// Retrieves the current state while coordinating access through the compute gate. + /// + /// Use this method when you need to read the current state as part of a + /// compute cycle. This method enters the compute gate, ensuring that + /// state access is properly coordinated with other in-flight operations. + /// + /// - Parameter systemActor: The isolation context. Defaults to the caller's + /// isolation context. + /// - Returns: The current state of type ``Transducer/State``. + /// - Throws: An error if reading the state from storage fails. + /// + /// - Note: Unlike ``peakState(systemActor:)``, this method enters the compute + /// gate, participating in the compute cycle and ensuring serialized access. + /// - Seealso: ``getState(systemActor:)``, ``peakState(systemActor:)`` + var state: T.State { + get async throws { + guard let systemActor else { + throw RuntimeError.noSystemActor + } + return try await self.getState(systemActor: systemActor) + } + } +} + +// MARK: - Debug + +extension BaseRuntime: CustomStringConvertible { + var description: String { + "BaseRuntime<\(T.self)>" + } +} + +extension BaseRuntime: CustomDebugStringConvertible { + var debugDescription: String { + let actorLabel = systemActor.map { "\($0)" } ?? "deallocated" + + return """ + BaseRuntime<\(T.self)>( + id: \(id), + systemActor: \(actorLabel), + storageType: (\(Storage.self)), + continuationCount: \(continuationBox_id) + ) + """ + } +} diff --git a/Sources/Transduce/Runtime/BaseTransducerInput.swift b/Sources/Transduce/Runtime/BaseTransducerInput.swift new file mode 100644 index 0000000..a5349b4 --- /dev/null +++ b/Sources/Transduce/Runtime/BaseTransducerInput.swift @@ -0,0 +1,203 @@ +/// A lightweight, sendable façade that provides safe, ergonomic access to a transducer's input API, +/// decoupled from the concrete runtime that executes the transducer. +/// +/// BaseTransducerInput is generic over a Transducer type `T` and conforms to +/// `TransducerInput`. It forwards input operations to an associated +/// `TransducerRuntime` instance while enforcing runtime availability and leveraging Swift +/// concurrency. +/// +/// Type Parameters: +/// - T: The concrete `Transducer` whose `Event` and `Response` types define this input's interface. +/// - Constraints: +/// - `T.Event: Sendable` +/// - `T: SendableMetatype` +/// +/// Conformance: +/// - `TransducerInput`: Provides the standard input surface for a transducer, +/// including sending fire-and-forget events, posting events asynchronously, and request/response. +/// - `Sendable`: Instances can be safely passed across concurrency domains, provided their captured +/// runtime reference is handled safely. +/// +/// Behavior and Threading: +/// - The struct holds a weak reference to a `TransducerRuntime` to avoid retain cycles. If the +/// runtime has been deallocated or is otherwise unavailable, operations throw `RuntimeError.runtimeUnavailable`. +/// - All operations that interact with the runtime rely on the runtime's `systemActor` to serialize +/// access and ensure correct actor isolation. +/// - `send(_:)` and `request(_:)` are async and must be awaited; they will throw if the runtime is +/// unavailable or if the underlying runtime operation fails. +/// - `post(_:)` is a fire-and-forget API that schedules the post on a detached `Task`. Errors from +/// the underlying runtime `post` are intentionally ignored. Use `send(_:)` or `request(_:)` if you +/// need error propagation. +/// +/// API Summary: +/// - `send(_:)`: Asynchronously deliver an event to the transducer without expecting a response. +/// - `post(_:)`: Schedule an event for delivery without awaiting completion; useful for non-critical +/// notifications. The parameter uses `sending Event` to emphasize send-only semantics. +/// - `request(_:) -> Response`: Asynchronously deliver an event that returns a`Response`, where +/// the underlying tasks may be shared. +/// - `uniqueRequest(_:) -> Response`: Asynchronously deliver an event which returns a`Response`, where +/// the underlying task is owned by the caller. +/// +/// Error Handling: +/// - Throws `RuntimeError.runtimeUnavailable` when either the runtime or its `systemActor` are missing. +/// - Propagates errors thrown by the underlying runtime’s `send` and `request` operations. +/// +/// Usage Notes: +/// - Obtain an instance from your transducer runtime or factory; do not construct directly unless you +/// have a valid `TransducerRuntime`. +/// - Prefer `send(_:)` when you want to await completion and capture errors; prefer `post(_:)` for +/// non-blocking, best-effort delivery; use `request(_:)` when you need a potential response. +/// +/// Safety: +/// - Because the runtime reference is weak, hold onto `BaseTransducerInput` only while the runtime is +/// expected to be alive. Calls after deallocation will throw `runtimeUnavailable`. +/// +/// Example: +/// - Send an event and await completion: +/// await try input.send(.userTapped) +/// - Fire-and-forget: +/// try input.post(.backgroundRefresh) +/// - Request a response: +/// let value = try await input.request(.loadCachedValue) +public struct BaseTransducerInput: TransducerInput, Sendable +where T.Event: Sendable, T: SendableMetatype +{ + typealias Runtime = TransducerRuntime + + weak let runtime: (any Runtime)? + + init(_ runtime: some Runtime) { + self.runtime = runtime + } + + /// Asynchronously sends an event to the underlying transducer and waits for delivery to complete. + /// + /// This method forwards the provided event to the associated `TransducerRuntime` on its `systemActor`, + /// ensuring correct actor isolation and serialized access. Use this when you need to await completion + /// and capture any errors that may occur during delivery. + /// + /// Behavior: + /// - If the runtime or its `systemActor` is unavailable (e.g., deallocated), this method throws + /// `RuntimeError.runtimeUnavailable`. + /// - Propagates any error thrown by the runtime’s `send(systemActor:event:)`. + /// + /// Concurrency: + /// - This function is `async` and must be awaited. + /// - Execution is coordinated via the runtime’s `systemActor`. + /// + /// - Parameter event: The event to deliver to the transducer. + /// - Throws: `RuntimeError.runtimeUnavailable` if the runtime or its system actor is missing, + /// or any error thrown by the runtime while sending. + /// - SeeAlso: `post(_:)` for fire-and-forget delivery, `request(_:)` for request/response semantics. + public func send(_ event: Event) async throws { + guard let runtime, let systemActor = runtime.systemActor else { + throw RuntimeError.runtimeUnavailable + } + try await runtime.send(systemActor: systemActor, event) + } + + /// Schedules an event to be delivered to the underlying transducer without awaiting completion. + /// + /// This fire-and-forget API forwards the provided event to the associated `TransducerRuntime` + /// on its `systemActor` using a detached `Task`. Any errors produced by the underlying + /// runtime `post` are intentionally ignored. Use this when delivery is best-effort and you + /// do not need to await completion or capture errors. + /// + /// Behavior: + /// - If the runtime or its `systemActor` is unavailable (e.g., deallocated), this method throws + /// `RuntimeError.runtimeUnavailable`. + /// - The actual posting occurs asynchronously; errors from the runtime are suppressed. + /// + /// Concurrency: + /// - Returns immediately; event delivery is performed on a new `Task`. + /// - Execution is coordinated via the runtime’s `systemActor` to maintain proper actor isolation. + /// + /// - Parameter event: The event to deliver to the transducer. Marked as `sending` to emphasize + /// one-way, send-only semantics. + /// - Throws: `RuntimeError.runtimeUnavailable` if the runtime or its system actor is missing. + /// - SeeAlso: `send(_:)` for awaiting completion and error propagation, `request(_:)` for + /// request/response semantics that may yield an `Response` value. + public func post(_ event: sending Event) throws { + guard let runtime, let systemActor = runtime.systemActor else { + throw RuntimeError.runtimeUnavailable + } + Task { + try? await runtime.post(systemActor: systemActor, event) + } + } + + /// Asynchronously sends an event to the transducer and awaits a potential response. + /// + /// This method forwards the given event to the associated `TransducerRuntime` on its `systemActor`, + /// preserving correct actor isolation and serialized access. Use this when you expect the transducer + /// to optionally produce a value in response to the event. + /// + /// ## Subscriber behavior: + /// - Creates a continuation attached to the task's waiter list with `.runtime(continuationBox)` ownership. + /// - When the caller cancels, the continuation is removed from the waiter list **but** the underlying work keeps running. + /// - If you unsubscribe while being the sole subscriber on a shared task, it continues until cancelled by a ControlEvent. + /// This is the V1 default (``SubscriberPolicy/keepRunning``). If you want the task to cancel instead, use `.cancel(id:)` + /// from your transducer's effect output. + /// + /// ## Behavior: + /// - If the runtime or its `systemActor` is unavailable (e.g., deallocated), this method throws + /// `RuntimeError.runtimeUnavailable`. + /// - Propagates any error thrown by the runtime’s `request(systemActor:event:)`. + /// - Returns an optional `Response` value. The value is `nil` when the transducer elects not to respond. + /// + /// ## Concurrency: + /// - This function is `async` and must be awaited. + /// - Execution is coordinated via the runtime’s `systemActor`. + /// + /// - Parameter event: The event to deliver to the transducer for which a response may be produced. + /// - Returns: The value returned from static function `response(state:event:)` for the last reduced event and the settled state. + /// - Throws: `RuntimeError.runtimeUnavailable` if the runtime or its system actor is missing, + /// or any error thrown by the underlying runtime while handling the request. + /// - SeeAlso: `send(_:)` for awaiting completion without expecting a response, `post(_:)` for + /// fire-and-forget delivery. + @discardableResult + public func request(_ event: Event) async throws -> Response where Response: Sendable { + guard let runtime, let systemActor = runtime.systemActor else { + throw RuntimeError.runtimeUnavailable + } + return try await runtime.request(systemActor: systemActor, event) + } + + /// Asynchronously sends an event to the transducer and awaits a strictly matched response. + /// + /// This method forwards the provided event to the associated `TransducerRuntime` on its + /// `systemActor`, ensuring correct actor isolation and serialized access. Unlike `request(_:)`, + /// this variant is designed for scenarios requiring a strict, one-to-one correspondence between + /// the sent event and its response, or for routing events that must not be batched or deduplicated. + /// + /// ## Subscriber behavior: + /// - Creates a continuation attached to the task's waiter list with `.caller(continuationBox)` ownership — + /// meaning cancellation propagates both into the continuation (unsubscribing the caller) **and** + /// into the underlying SwiftTask via TaskManager. + /// - When used through shared task strategies, if you unsubscribe while being the sole subscriber on a + /// `.shareable(list)`, the underlying work keeps running (V1 default: ``SubscriberPolicy/keepRunning``). + /// To cancel the task when the last subscriber leaves, use `.cancel(id:)` from your transducer's effect output. + /// + /// ## Behavior: + /// - If the runtime or its `systemActor` is unavailable (e.g., deallocated), this method throws + /// `RuntimeError.runtimeUnavailable`. + /// - Propagates any error thrown by the underlying runtime's `uniqueRequest(systemActor:event:)`. + /// - Returns: The `Response` produced by `response(state:event:)` after the event has been processed to completion, reflecting the transducer’s logic for a strictly matched, terminal event. The value represents the settled result of processing the event. + /// + /// ## Concurrency: + /// - This function is `async` and must be awaited. + /// - Execution is coordinated via the runtime’s `systemActor`. + /// + /// - Parameter event: The event to deliver to the transducer for which a unique response is expected. + /// - Returns: The result of `response(state:event:)` for the terminal state reached after handling the provided event. + /// - Throws: `RuntimeError.runtimeUnavailable` if the runtime or its system actor is missing, + /// or any error thrown by the underlying runtime while handling the request. + /// - SeeAlso: `request(_:)` for standard request/response delivery, `send(_:)` for fire-and-forget events. + @discardableResult + public func uniqueRequest(_ event: Event) async throws -> Response where Response: Sendable { + guard let runtime, let systemActor = runtime.systemActor else { + throw RuntimeError.runtimeUnavailable + } + return try await runtime.uniqueRequest(systemActor: systemActor, event) + } +} diff --git a/Sources/Transduce/Runtime/RuntimeError.swift b/Sources/Transduce/Runtime/RuntimeError.swift new file mode 100644 index 0000000..2e70167 --- /dev/null +++ b/Sources/Transduce/Runtime/RuntimeError.swift @@ -0,0 +1,31 @@ +import protocol Foundation.LocalizedError + +/// An error type representing possible errors that may occur within a `BaseRuntime`. +/// +/// `RuntimeError` is used to communicate specific failure cases when working with the runtime. +/// It conforms to `LocalizedError`, `Equatable`, and `Sendable`, allowing it to be used in +/// error handling, comparisons, and concurrency-safe contexts. +/// +/// - runtimeUnavailable: Indicates that the runtime is unavailable, such as when it has not been properly initialized or has been deallocated. +/// - alreadyInitialized: Indicates that the runtime has already been initialized and cannot be started again. +public enum RuntimeError: LocalizedError, Equatable, Sendable { + /// The runtime is nil. + case runtimeUnavailable + + /// The runtime has already been initialized. + case alreadyInitialized + + /// The runtime's system actor is `nil`. + case noSystemActor + + public var errorDescription: String? { + switch self { + case .runtimeUnavailable: + return "The runtime is unavailable." + case .alreadyInitialized: + return "The runtime has already been initialized." + case .noSystemActor: + return "The runtime has no system actor." + } + } +} diff --git a/Sources/Transduce/Runtime/TaskManager.swift b/Sources/Transduce/Runtime/TaskManager.swift new file mode 100644 index 0000000..c58238b --- /dev/null +++ b/Sources/Transduce/Runtime/TaskManager.swift @@ -0,0 +1,1010 @@ +#if canImport(OSLog) +import OSLog +#endif + +final class TaskManager { + + typealias Runtime = R + typealias Response = R.Response + typealias UnsafeContinuationBox = R.UnsafeContinuationBox + typealias TaskOwnership = R.TaskOwnership + typealias TaskAdditionPolicy = R.TaskAdditionPolicy + + /// Describes whether the manager is accepting work or shutting down. + enum State { + /// The manager accepts new tasks and waiters. + case active + /// Cancellation has begun and the manager has latched an optional error. + case cancelling(error: Swift.Error? = nil) + /// Cancellation has fully completed and the optional error remains latched. + case cancelled(error: Swift.Error? = nil) + } + + /// The dictionary key used to track a logical task. + struct TaskKey: Hashable, Equatable, CustomStringConvertible { + init(_ identifier: TaskIdentifier) { + self.identifier = identifier + } + + let identifier: TaskIdentifier? + + var description: String { string } + var string: String { "\(identifier, default: "__")" } + } + + /// The mutable tracked value for one logical task entry. + struct TaskValue { + var id: Int // unique task id + var task: Task + var waiters: Waiters + + init(id: Int, task: Task, waiters: Waiters) { + self.id = id + self.task = task + self.waiters = waiters + } + + /// Cancels the task and fails all current waiters with `error`, or with + /// `TaskManagerCancellationError()` when no more specific reason is available. + mutating func cancel(with error: (any Swift.Error)? = nil) { + task.cancel() + waiters.cancelAll(with: error ?? TaskManagerCancellationError()) + } + + /// Completes all current waiters with the finished task result. + mutating func resume(with result: Result) { + waiters.resumeAll(with: result) + } + + /// Attaches a new waiter to the tracked task. + mutating func addWaiter(_ continuation: UnsafeContinuationBox) { + waiters.addSharable(continuation) + } + } + + private var tasks: [TaskKey: TaskValue] = [:] + private(set) var nextTaskId: Int = 0 // a monotonic increasing integer used as the unique id for the next task which will be created. + private(set) var state: State = .active + + + weak let systemActor: (any Actor)? + + // Hold by self weakly + weak var runtime: Runtime? + + /// Initialises a Task Manager with the system actor - which is + /// kept weakly. + /// + /// The task manager will be created by a Runtime. + /// + /// - Important: After a task manager has been created, it requires + /// a runtime which is set once via the property `runtime`. This is kept + /// as a weak reference by self. The runtime keeps a strong reference + /// to the task manager. + /// + /// - Note:`systemActor` and `runtime` may be the same entity. + init(systemActor: any Actor) { + self.systemActor = systemActor + } + + deinit { + let shutdownError = latchedShutdownError + tasks.values.forEach { taskValue in + var taskValue = taskValue + taskValue.cancel(with: shutdownError) + } + } + + // Asks for an error value when shut down or deinit + private var latchedShutdownError: any Swift.Error { + switch state { + case .active: + return TaskManagerCancellationError() + case .cancelling(let error), .cancelled(let error): + return error ?? TaskManagerCancellationError() + } + } + + /// Throws if the manager is no longer accepting work. + /// + /// Callers typically use this as a boundary check before entering the main + /// runtime loop. When the manager has latched a concrete shutdown error, + /// that error is rethrown. Otherwise `RuntimeUnavailable.cancelled` is + /// thrown. + /// + /// - Throws: The latched shutdown error, or `TaskManagerCancellationError()` + /// when cancellation happened without a more specific reason. + @inline(__always) + func checkCancellation() throws { + switch self.state { + case .active: + break + case .cancelling(let error), .cancelled(let error): + throw error ?? TaskManagerCancellationError() + } + } + + /// Starts hard cancellation of the manager and its tracked tasks. + /// + /// The first call latches `error`, transitions the manager out of the + /// active state, and cancels all tracked tasks. Later calls are ignored. + /// + /// - Parameter error: An optional shutdown reason to latch for later + /// ``checkCancellation()`` calls. + func cancel( + systemActor: isolated (any Actor)? = #isolation, + with error: Swift.Error? = nil + ) { + if let _ = systemActor ?? self.systemActor { + guard case .active = self.state else { + return + } + self.state = .cancelling(error: error ?? TaskManagerCancellationError()) + + for key in tasks.keys { + tasks[key]!.cancel(with: error) + } + if tasks.isEmpty { + state = .cancelled(error: error) + } + } else { + // The actor is gone + #if canImport(OSLog) + logger.warning("no systemActor when attempting to cancel the TaskManager") + #endif + } + } + + /// Cancels the tracked task for `identifier`, if one exists. + /// + /// All waiters currently attached to that task are resumed with + /// `CancellationError()`. + /// + /// - Parameter identifier: The logical identifier of the task to cancel. + /// - Returns: `true` if an active tracked task was found and cancelled. + /// + /// For unique tasks, unsubscribing the owner via higher-level APIs cancels the task immediately. + @discardableResult + func cancelTasks( + systemActor: isolated any Actor = #isolation, + with identifier: TaskIdentifier + ) -> Bool { + let taskKey = TaskKey(identifier) + if var taskValue = tasks[taskKey] { + if !taskValue.task.isCancelled { + taskValue.cancel() + tasks[taskKey] = taskValue + return true + } else { + return false + } + } else { + return false + } + } + +} + +/// Fallback error - when the task manager has been cancelled without +/// an error specified, i.e. `cancel()` or `cancel(with: nil)`. +/// Usually the task manager should be cancelled only via the runtime which +/// decides which error will be used when a user does not provide one +/// when cancelling the runtime. +/// +/// Request waiters will throw a `TaskManagerCancellationError` if the runtime +/// has been cancelled without specifying an error. +struct TaskManagerCancellationError: Error {} + + +// MARK: - Continuation Unsubscribe +extension TaskManager { + + enum RemoveContinuationAction { + case none(handled: Bool) + case modify(key: TaskKey, taskValue: TaskValue) + } + + /// Resumes the continuation matching `continuationId` with error `CancellationError` + /// and removes it from any task's waiter list. + /// + /// - For `.unique` waiters: If the continuation is found and was the sole waiter, the + /// underlying `Task` is cancelled immediately. + /// - For `.shareable` waiters: The continuation is removed. The task **continues running** + /// regardless of whether the waiter list becomes empty. This is the V1 default behavior + /// (``SubscriberPolicy/keepRunning``). + /// - V1 policy: When the last waiter unsubscribes from a shared task, the underlying + /// SwiftTask continues running until it completes naturally or is cancelled by a + /// ControlEvent (`.cancel` or `.systemError`). This avoids inadvertently aborting work + /// just because all watchers dropped off. + /// - A monotonic `taskId` is never recycled for `.unique` waiters — once assigned, it's burned forever. + /// - The `TaskValue` entry remains in `tasks` until the task itself or a future operation removes it. + /// + /// - Parameter continuationId: The identity of the `UnsafeContinuationBox` to remove. + /// - Returns: `true` if the continuation was found and removed, `false` otherwise. + /// + /// ## Subscriber Lifecycle Policy + /// + /// The ``SubscriberPolicy`` enum defines what happens when the last waiter unsubscribes: + /// - ``SubscriberPolicy/keepRunning`` (V1 default): Task continues until cancelled + /// - ``SubscriberPolicy/cancelOnLastUnsub``: Cancel the task immediately (future API) + @discardableResult + func cancelContinuation( + systemActor: isolated any Actor = #isolation, + withId continuationId: Int + ) -> Bool { + func action() -> RemoveContinuationAction { + for (key, taskValue) in tasks { + switch taskValue.waiters { + case .anon: + continue + + case .unique(_, let box): + if box.id == continuationId { + // Note: the continuation will be resumed when the task completes. + // However, it is not determined when this happens, and if + // the task throws a CanellationError at all. So, we should + // egarly resume the continuation here. + // When the task completes, it resumes again (no-op) and + // removes the task from the tasks dictionary. + box.resume(throwing: CancellationError()) + taskValue.task.cancel() + return .none(handled: true) + } + + case .shareable: + // A continuation ID can only be in one waiters list. + // Thus, if we found it, we handle it and the break out of + // the loop. + guard !taskValue.waiters.continuations.isEmpty, + let index = taskValue.waiters.continuations.firstIndex(where: { + $0.id == continuationId + }) + else { + continue // not found + } + var newContinuations = taskValue.waiters.continuations + let continuation = newContinuations.remove(at: index) + continuation.resume(throwing: CancellationError()) + if newContinuations.isEmpty { + // V1 policy: When the last waiter unsubscribes from a shared task, + // the underlying SwiftTask **continues running** until it completes naturally + // or is cancelled by a ControlEvent (`.cancel` or `.systemError`). + // + // Rationale: A subscriber is an observer, not a controller. The work was + // initiated by a prior event and should complete regardless of whether + // anyone is watching. + // + // Future API: A SubscriberPolicy parameter may be added to allow callers + // to choose between `.keepRunning` (current default) and `.cancelOnLastUnsub`. + } + let newTaskValue: TaskValue = .init( + id: taskValue.id, + task: taskValue.task, + waiters: .shareable( + newContinuations + ) + ) + return .modify(key: key, taskValue: newTaskValue) + } + } + return .none(handled: false) + } + + switch action() { + case .none(let handled): return handled + case .modify(key: let key, taskValue: let taskValue): + tasks[key] = taskValue + return true + } + } +} + +// MARK: - Add Task +extension TaskManager { + + enum AddTaskStrategy { + case replaceAndCreateShared + case subscribeOrCreateShared + case createUnique + } + + + private var _anon: TaskIdentifier { + return .init(TaskIdentifier("__\(nextTaskId)")) + } + + private func _unique(_ taskId: TaskIdentifier, _ continuation: UnsafeContinuationBox) -> TaskIdentifier { + return TaskIdentifier(UniqueTaskIdentifier(taskId: taskId, continuation: continuation)) + } + + typealias NonsendingOperationFunc = nonisolated(nonsending) (R.Input, R.Env) async throws -> sending Response + + /// Adds a task with the given identifier to the task manager. + /// + /// When the task operation returns an event, the event will be dispatched to the transducer + /// with `request(_:)`. The task will only finish, when the request function returns. + /// + /// When the task operation returns `nil`, a response value will be computed from the current + /// state and the given event with the static transducer function `response(state:event:)`. + /// + /// Semantics of `continuation` (waiter) and sharing: + /// - When the task is declared as sharable by the transducer (via its effect `identifier` and `option`), + /// `continuation` (when non-`nil`) is treated as a subscriber and appended to the task's waiter list. + /// - When the task is created as a unique, caller-owned task by a higher-level dispatch API, the manager + /// will associate the task with a single owner waiter internally. In that case, the unique task does not + /// accept additional subscribers; attempts to subscribe are ignored or asserted in debug builds. + /// + /// ## Options + /// Controls how the runtime adds a new task. + /// + /// How a task is added to the runtime depends on the event dispatch method and + /// how the task effect is declared which is returned in the transition fucntion when + /// this event is processed. + /// + /// The transition function declares the task effect and specifies either option: `switchToLatest` + /// or `shareable`. The event dispatch method specfies the option `unique` or `shareable`. + /// This gives us 2 x 2 combinations. Each combination will determine the resulting taskId, whether a + /// a new task is actualy created and if this affects an already existing task with the same taskId. + /// + /// | TaskAdditionPolicy | Event Dispatch | Resulting taskID | Action on new | Action on existing | + /// |---|---|---|---|---| + /// | `shareable` | `unique` | unique(taskId) | creates unique task | unaffected | + /// | `shareable` | `shareable` | taskId | IFF none task exists creates sharebale task | adds waiter to existing| + /// | `switchToLatest` | `unique` | unique(taskId) | creates unique task | unaffected | + /// | `switchToLatest` | `shareable` | taskId | creates shareable task and merges waiters from existing task if any | moves waiters and cancels task | + /// + ///---- + /// + /// Cancellation behavior: + /// - Cancelling the task by identifier cancels the underlying operation and resumes all current waiters + /// with `TaskManagerCancellationError()` (or a latched shutdown error when present). + /// - For unique tasks, unsubscribing the owner (e.g., when the caller's Swift Task is cancelled) cancels + /// the task immediately. + /// + /// Behavior matches the isolated-event variant: + /// - Sharable tasks accept subscribers via `continuation`. + /// - Unique tasks are single-owner; extra subscribers are not accepted by the manager. + /// + /// The produced event is sent into the transducer via `request(_:)` from within the task closure. The closure remains + /// suspended until the full chain settles; waiters receive the terminal response. + /// + /// Cancellation and inactive-manager semantics match the other overloads. + /// + /// - Parameters: + /// - systemActor: The isolation the system is executing on. + /// - identifier: The logical task identity. Equal identifiers mean equal overlapping work. + /// - event: The event which created the effect. + /// - taskAdditionPolicy: Controls how the runtime handles a new add for an existing identifier — + /// `shareable` shares the in-flight task (or creates one if missing), while + /// `switchToLatest` replaces the current task with a new one and transfers all waiters. + /// - taskOwnership: Determines who manages the continuation returned by the task — + /// `.runtime(nil)` for fire-and-forget, `.runtime(UnsafeContinuationBox)` for subscriber + /// observation, or `.caller(UnsafeContinuationBox)` for caller-owned independent tasks. + /// - priority: The priority of the operation task. + /// - operation: The nonisolated, nonsending operation that returns an optional event to request. + func addTask( + systemActor: isolated any Actor = #isolation, + identifier: TaskIdentifier?, + event: R.Event, + taskAdditionPolicy: TaskAdditionPolicy, + taskOwnership: inout TaskOwnership, + priority: TaskPriority?, + nonsendingOperationOptionalEvent operation: nonisolated(nonsending) @escaping (R.Input, R.Env) async throws -> sending R.Event? + ) throws { + let continuation = taskOwnership.continuation + let (taskIdentifier, strategy): (TaskIdentifier, AddTaskStrategy) = switch (identifier, taskOwnership, taskAdditionPolicy) { + case (.none, _, _): (_anon, .createUnique) + case (.some(let id), .runtime, .switchToLatest): (id, .replaceAndCreateShared) + case (.some(let id), .caller(let cont), _): (_unique(id, cont), .createUnique) + case (.some(let id), .runtime, .shareable): (id, .subscribeOrCreateShared) + case (_, .none, _): fatalError("invalid taskOwnership") + } + guard let runtime else { throw RuntimeError.runtimeUnavailable } + try addTaskInternal( + with: taskIdentifier, + strategy: strategy, + continuation: continuation, + priority: priority, + nonsendingOperation: { input, env in + let newEvent = try await operation(input, env) + if let newEvent { + // TODO: determine how to dispatch the event! Maybe we use an enum + // enum Return { case post(Event), send(Event), request(Event) } + return try await input.request(newEvent) + } else { + return try runtime.response(event: event) + } + } + ) + taskOwnership = .runtime(nil) + } + + /// Handles an add task request for a `TaskReturn`-returning operation. + /// + /// The `TaskReturn` enum specifies both the event and how to dispatch it: + /// - `.request(event)` — dispatches via `input.request(_:)`, awaiting the full effect chain. + /// - `.uniqueRequest(event)` — dispatches via `input.uniqueRequest(_:)`, exclusive request. + /// - `.send(event)` — dispatches via `input.send(_:)`, awaiting only the transducer reduction. + /// - `.post(event)` — dispatches via `input.post(_:)`, fire-and-forget. + /// - `.response(event)` — derives response directly from the event, no dispatch. + func addTask( + systemActor: isolated any Actor = #isolation, + identifier: TaskIdentifier?, + event: sending R.Event, + taskAdditionPolicy: TaskAdditionPolicy, + taskOwnership: inout TaskOwnership, + priority: TaskPriority?, + nonsendingOperationReturn operation: nonisolated(nonsending) @escaping (R.Input, R.Env) async throws -> sending TaskReturn + ) throws { + let continuation = taskOwnership.continuation + let (taskIdentifier, strategy): (TaskIdentifier, AddTaskStrategy) = switch (identifier, taskOwnership, taskAdditionPolicy) { + case (.none, _, _): (_anon, .createUnique) + case (.some(let id), .runtime, .switchToLatest): (id, .replaceAndCreateShared) + case (.some(let id), .caller(let cont), _): (_unique(id, cont), .createUnique) + case (.some(let id), .runtime, .shareable): (id, .subscribeOrCreateShared) + case (_, .none, _): fatalError("invalid taskOwnership") + } + guard let runtime else { throw RuntimeError.runtimeUnavailable } + try addTaskInternal( + with: taskIdentifier, + strategy: strategy, + continuation: continuation, + priority: priority, + nonsendingOperation: { input, env in + let taskReturn = try await operation(input, env) + switch taskReturn { + case .response(let newEvent): + return try runtime.response(event: newEvent) + case .send(let newEvent): + try await input.send(newEvent) + return try runtime.response(event: newEvent) + case .post(let newEvent): + try input.post(newEvent) + return try runtime.response(event: event) + case .request(let newEvent): + return try await input.request(newEvent) + case .uniqueRequest(let newEvent): + return try await input.uniqueRequest(newEvent) + } + } + ) + taskOwnership = .runtime(nil) + } + + /// Handles an add task request. + /// + /// ## Options `effectOption` and `dispatchOption` + /// + /// Controls how the runtime adds a new task. + /// + /// How a task is added to the runtime depends on the event dispatch method and + /// how the task effect is declared which is returned in the transition fucntion when + /// this event is processed. + /// + /// The transition function declares the task effect and specifies either option: `switchToLatest` + /// or `shareable`. The event dispatch method specfies the option `unique` or `shareable`. + /// This gives us 2 x 2 combinations. Each combination will determine the resulting taskId, whether a + /// a new task is actualy created and if this affects an already existing task with the same taskId. + /// + /// - Parameters: + /// - systemActor: The isolation the system is executing on. + /// - identifier: The logical task identity. Equal identifiers mean equal overlapping work. + /// - strategy: Decides whether a new waiter reuses the active task or replaces it for sharable tasks. + /// - continuation: Optional waiter to attach; for sharable tasks it subscribes, for unique tasks ownership + /// is handled by the higher-level API and additional subscribers are not accepted. + /// - priority: The priority of the operation task. + /// - operation: The operation to perform. + /// - Throws: + func addTaskInternal( + systemActor: isolated any Actor = #isolation, + with identifier: TaskIdentifier, + strategy: AddTaskStrategy, + continuation: UnsafeContinuationBox?, + priority: TaskPriority?, + nonsendingOperation: nonisolated(nonsending) @escaping (R.Input, R.Env) async throws -> sending Response + ) throws { + try checkCancellation() + + switch strategy { + case .replaceAndCreateShared: + return replaceAndCreateShared( + id: identifier, + priority: priority, + continuation: continuation, + operation: nonsendingOperation + ) + case .subscribeOrCreateShared: + return subscribeOrCreateShared( + id: identifier, + priority: priority, + continuation: continuation, + operation: nonsendingOperation + ) + case .createUnique: + return createUnique( + id: identifier, + priority: priority, + continuation: continuation, + operation: nonsendingOperation + ) + } + } + + func replaceAndCreateShared( + systemActor: isolated any Actor = #isolation, + id: TaskIdentifier, + priority: TaskPriority?, + continuation: UnsafeContinuationBox?, + operation: @escaping NonsendingOperationFunc + ) { + let taskKey = TaskKey(id) + let newTaskId = nextTaskId + nextTaskId += 1 + + var waiters: Waiters = .shareable([]) + let previous = tasks[taskKey] + if let previous { + waiters = previous.waiters.asShareable() + } + if let continuation { + waiters.addSharable(continuation) + } + + let task = makeTask( + taskKey: taskKey, + id: newTaskId, + priority: priority, + nonsendingOperation: operation + ) + tasks[taskKey] = TaskValue(id: newTaskId, task: task, waiters: waiters) + previous?.task.cancel() + } + + func subscribeOrCreateShared( + systemActor: isolated any Actor = #isolation, + id: TaskIdentifier, + priority: TaskPriority?, + continuation: UnsafeContinuationBox?, + operation: @escaping NonsendingOperationFunc + ) { + let taskKey = TaskKey(id) + if var existing = tasks[taskKey] { + if let continuation { + existing.addWaiter(continuation) + tasks[taskKey] = existing + } + return + } + + let newTaskId = nextTaskId + nextTaskId += 1 + let task = makeTask( + taskKey: taskKey, + id: newTaskId, + priority: priority, + nonsendingOperation: operation + ) + let waiters: Waiters = if let continuation { + .shareable([continuation]) + } else { + .shareable([]) + } + tasks[taskKey] = TaskValue(id: newTaskId, task: task, waiters: waiters) + } + + func createUnique( + systemActor: isolated any Actor = #isolation, + id: TaskIdentifier, + priority: TaskPriority?, + continuation: UnsafeContinuationBox?, + operation: @escaping NonsendingOperationFunc + ) { + let taskKey = TaskKey(id) + precondition(tasks[taskKey] == nil, "A unique task already exists for id \(id).") + + let newTaskId = nextTaskId + nextTaskId += 1 + let task = makeTask( + taskKey: taskKey, + id: newTaskId, + priority: priority, + nonsendingOperation: operation + ) + let waiters: Waiters = if let continuation { + .unique(ownerId: continuation.id, box: continuation) + } else { + .anon + } + tasks[taskKey] = TaskValue(id: newTaskId, task: task, waiters: waiters) + } + + + struct UniqueTaskIdentifier: Hashable { + let taskId: TaskIdentifier + let continuation: UnsafeContinuationBox + + func hash(into hasher: inout Hasher) { + hasher.combine(taskId) + hasher.combine(continuation) + } + } + + private func makeTask( + systemActor: isolated any Actor = #isolation, + taskKey: TaskKey, + id taskId: Int, + priority: TaskPriority?, + nonsendingOperation: nonisolated(nonsending) @escaping (R.Input, R.Env) async throws -> sending Response + ) -> Task { + // CAUTION: `systemActor` is captured *strongly*!. In cases, where the + // systemActor keeps a strong reference to `self`, self will never be + // deallocated before all tasks are finished, because the captured + // `systemActor` establishes a reference cycle - until after the task + // finishes. This is important to know when implementing an "FSM Effect + // Actor" based on Swift Actors. That is, a proper implementation of an + // "FSM Effect Actor" should always have a `cancel()` method which cancels + // all running tasks and additionally prevents enqueueing new ones. + + #if DEBUG + let taskName = taskKey.string + #else + let taskName: String? = nil + #endif + guard let r = runtime else { + fatalError("runtime is nil") + } + let input = r.input + let env = r.env + + let task = Task(name: taskName, priority: priority) { [weak self, weak runtime] in + _ = systemActor + do { + let response = try await nonsendingOperation(input, env) + self?.finish(taskKey: taskKey, id: taskId, result: .success(response)) + } catch { + // print("TaskManager: task[\(taskName)] throwed error: \(error)") + if error is Swift.CancellationError && Task.isCancelled { + // The runtime/transducer/taskManager cancelled the task - which + // is not a system error. + self?.finish(taskKey: taskKey, id: taskId, result: .failure(error)) + } else { + // A task operation threw and error, or the transducer has been + // cancelled with an error. `error` can be any error including + // TaskManagerCancellationError, or even `Swift.CancellationError` + // when the current task has not been cancelled. These errors + // result in a system error and the transducer halts. + if let runtime { + runtime.cancel(systemActor: systemActor, with: error) + } else { + self?.cancel(with: error) + } + self?.complete(taskKey: taskKey, id: taskId) + } + } + } + return task + } + + /// Resumes all waiters for the matching task and removes it from tracking. + private func finish(taskKey: TaskKey, id: Int, result: Result) { + if var taskValue = tasks[taskKey], taskValue.id == id { + taskValue.resume(with: result) + tasks[taskKey] = nil + if tasks.isEmpty, case .cancelling(let error) = state { + state = .cancelled(error: error) + } + } + } + + /// Removes the tracked task if `id` still matches the current entry. + private func complete(taskKey: TaskKey, id: Int) { + if let taskValue = tasks[taskKey], taskValue.id == id { + precondition(taskValue.waiters.isUnique == false || taskValue.waiters.count == 0) + tasks[taskKey] = nil + } else { + // Currently, with TaskKey being hashed on the identifier, + // this can happen, when a subsequent task cancels the previous + // one (aka `switchToLatest`), and the previous task has not + // been completed (and removed) *before* the new task has been + // inserted into the dictionary with the *same* key. When the previous + // task eventually completes, there is no entry with its `id` + // anymore. + /* nothing */ + } + if tasks.isEmpty, case .cancelling(let error) = state { + state = .cancelled(error: error) + } + } +} + +// MARK: - Waiters +extension TaskManager { + enum Waiters { + case anon + case unique(ownerId: Int, box: UnsafeContinuationBox) + case shareable([UnsafeContinuationBox]) + + var isAnon: Bool { + if case .anon = self { return true } + return false + } + + var isUnique: Bool { + if case .unique = self { return true } + return false + } + + var isSharable: Bool { + if case .shareable = self { return true } + return false + } + + var count: Int { + switch self { + case .anon: return 0 + case .unique: return 1 + case .shareable(let boxes): return boxes.count + } + } + + var continuations: [UnsafeContinuationBox] { + switch self { + case .anon: return [] + case .unique: return [] + case .shareable(let boxes): return boxes + } + } + + func asShareable() -> Waiters { + switch self { + case .anon: + return .shareable([]) + case .unique(_, let box): + return .shareable([box]) + case .shareable: + return self + } + } + + mutating func addSharable(_ box: UnsafeContinuationBox) { + switch self { + case .anon: + preconditionFailure("Cannot subscribe to an anonymous task.") + case .unique: + preconditionFailure("Cannot subscribe to a unique task.") + case .shareable(var boxes): + boxes.append(box) + self = .shareable(boxes) + } + } + + mutating func resumeAll(with result: Result) { + switch self { + case .anon: + break + case .unique(_, let box): + switch result { + case .success(let output): box.resume(returning: output) + case .failure(let error): box.resume(throwing: error) + } + self = .shareable([]) // clear + case .shareable(let boxes): + for i in boxes.indices { + switch result { + case .success(let output): boxes[i].resume(returning: output) + case .failure(let error): boxes[i].resume(throwing: error) + } + } + self = .shareable([]) + } + } + + mutating func cancelAll(with error: any Error) { + switch self { + case .anon: + break + case .unique(_, let box): + box.resume(throwing: error) + self = .shareable([]) + case .shareable(let boxes): + for i in boxes.indices { + boxes[i].resume(throwing: error) + } + self = .shareable([]) + } + } + } +} + +/// A typed logical identifier for managed tasks. +/// +/// Equal `TaskIdentifier` values declare the same in-flight work. The +/// task manager uses that identity to decide whether a new task request should +/// subscribe to existing work or replace it. +/// +/// - Note: TaskIdentifier is safely @unchecked Sendable because only Hashable & Sendable +/// values can be wrapped, and the type contains no mutable or reference state. +public struct TaskIdentifier: @unchecked Sendable, Hashable { + private let wrapped: AnyHashable + + /// Creates an identifier from any hashable, sendable value. + /// + /// - Parameter wrapped: The logical identifier value to wrap. + public init(_ wrapped: some Hashable & Sendable) { + self.wrapped = .init(wrapped) + } +} + +extension TaskIdentifier: ExpressibleByStringLiteral { + /// Creates an identifier from a string literal. + /// + /// - Parameter stringLiteral: The string value to wrap as an identifier. + public init(stringLiteral: String) { + self.init(stringLiteral) + } +} + +extension TaskIdentifier: ExpressibleByIntegerLiteral { + /// Creates an identifier from an integer literal. + /// + /// - Parameter value: The integer value to wrap as an identifier. + public init(integerLiteral value: IntegerLiteralType) { + self.init(value) + } +} + +extension TaskIdentifier: ExpressibleByStringInterpolation {} + + +// MARK: - Logger + +#if canImport(OSLog) +extension TaskManager { + var logger: Logger { + Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.couchdeveloper.transduce", + category: "TaskManager<\(R.T.self)>" + ) + } +} +#endif + +// MARK: - Debug + +extension TaskIdentifier: CustomStringConvertible { + /// Human-readable representation of the identifier. + public var description: String { string } + + /// The identifier rendered as a string. + public var string: String { wrapped.description } +} + + +// MARK: Task Info +extension TaskManager { + + func taskInfo(for identifier: TaskIdentifier) -> TaskInfo? { + if let taskValue = tasks[TaskKey(identifier)] { + return TaskInfo(identifier: identifier, taskValue: taskValue) + } else { + return nil + } + } + + func taskInfo(id: Int) -> TaskInfo? { + for (key, value) in tasks { + if value.id == id { + return TaskInfo(identifier: key.identifier, taskValue: value) + } + } + return nil + } + + func taskInfoForUniqueTask(taskId: TaskIdentifier, continuationBox: UnsafeContinuationBox) -> TaskInfo? { + let taskId = TaskIdentifier(UniqueTaskIdentifier(taskId: taskId, continuation: continuationBox)) + return taskInfo(for: taskId) + } + + func taskInfo() -> [TaskInfo] { + let taskInfos: [TaskInfo] = tasks.map { key, value in + TaskInfo(identifier: key.identifier, taskValue: value) + } + return taskInfos + } + + struct TaskInfo: Equatable { + let id: Int + let identifier: TaskIdentifier? + let taskName: String? + let isCancelled: Bool + let waiters: WaitersInfo + + init(identifier: TaskIdentifier?, taskValue: TaskValue) { + self.id = taskValue.id + self.identifier = identifier + self.taskName = "" + self.isCancelled = taskValue.task.isCancelled + self.waiters = .init(waiters: taskValue.waiters) + } + } + + enum WaitersInfo: Equatable { + + init(waiters: Waiters) { + switch waiters { + case .anon: + self = .anon + case .unique(ownerId: let ownerId, box: let box): + self = .unique(ownerId: ownerId, box: .init(box: box)) + case .shareable(let continuations): + let c = continuations.map { ContinuationInfo(box: $0) } + self = .shareable(.init(c)) + } + } + + case anon + case unique(ownerId: Int, box: ContinuationInfo) + case shareable([ContinuationInfo]) + + var isAnon: Bool { + guard case .anon = self else { return false } + return true + } + + var isUnique: Bool { + guard case .unique = self else { return false } + return true + } + var isShareable: Bool { + guard case .shareable = self else { return false } + return true + } + + var continuations: [ContinuationInfo] { + switch self { + case .anon: return [] + case .unique(_, let box): return [box] + case .shareable(let waiters): return waiters + } + } + + var count: Int { + continuations.count + } + + var isEmpty: Bool { + continuations.count == 0 + } + } + + struct ContinuationInfo: Equatable { + init(box: UnsafeContinuationBox) { + self.id = box.id + self.isResumed = box.isResumed + } + var id: Int + var isResumed: Bool + } +} + +extension TaskManager.Waiters: CustomStringConvertible { + var description: String { + switch self { + case .anon: return "anon" + case .unique(ownerId: _, box: let box): return "unique: \(box)" + case .shareable(let waiters): + let wtrs = waiters.map { "\($0)" }.joined(separator: ", ") + return "shareable(\(wtrs)" + } + } +} + +extension UnsafeContinuationBox: CustomStringConvertible { + var description: String { + switch continuation { + case .completed: "UnsafeContinuationBox(\(id)) completed" + case .initialized: "UnsafeContinuationBox(\(id)) pending" + case .uninitialized: "UnsafeContinuationBox(\(id)) uninitialized" + } + } +} diff --git a/Sources/Transduce/Runtime/TransducerRuntime.swift b/Sources/Transduce/Runtime/TransducerRuntime.swift new file mode 100644 index 0000000..96f490a --- /dev/null +++ b/Sources/Transduce/Runtime/TransducerRuntime.swift @@ -0,0 +1,1218 @@ +#if canImport(OSLog) +import OSLog +#endif + +protocol TransducerRuntime: CancellableRuntime, AnyObject, Identifiable, CustomStringConvertible, Sendable +where + T.Effect == TransducerEffect, + T.Env: Sendable, + T.Response: Sendable // this is required because we have an effect operation that is sendable +{ + associatedtype T: Transducer + associatedtype Input: TransducerInput + associatedtype Storage: TransducerStorage + + typealias State = T.State + typealias Event = T.Event + typealias Env = T.Env + typealias Response = T.Response + typealias Effect = T.Effect + + + /// Continuation used internally to complete request-style callers. + typealias SystemContinuation = CheckedContinuation + /// Boxed request continuation with identity, used for waiter ownership and unsubscribe. + typealias UnsafeContinuationBox = Transduce::UnsafeContinuationBox + + typealias TaskOwnership = Transduce::TaskOwnership + typealias TaskAdditionPolicy = Transduce::TaskAdditionPolicy + typealias TaskManager = Transduce::TaskManager + + var systemActor: (any Actor)? { get } + + var taskManager: TaskManager { get } + + var env: Env { get } + + var input: Input { get } + + var storage: Storage { get } + + var computeGate: ComputeGate { get } + + /// Sends `event` into the runtime. + /// + /// `send` performs immediate event dispatch. The host processes the event's + /// synchronous reduction path before the call returns, but any task-based + /// effects started by that path may continue running after `send` completes. + /// + /// - Parameter event: The event to dispatch. + /// - Throws: If the runtime cannot accept the event, or if accepted work is + /// later cancelled or fails at the runtime boundary. + func send( + systemActor: isolated any Actor, + _ event: T.Event + ) async throws + + /// Schedules `event` without awaiting the resulting effect chain. + /// + /// `post` is the fire-and-forget dispatch entry point. It asks the runtime to + /// enqueue `event` and then returns immediately, without waiting for synchronous + /// reduction, spawned task effects, or a terminal `Response` value. + /// + /// If `post` throws, that failure is local to the call site: the implementation + /// could not accept the event for immediate scheduling. Once scheduling succeeds, + /// any later cancellation or runtime failure occurs on the runtime's own execution + /// path and is not reported back to the caller of `post`. + /// + /// - Parameter event: The event to enqueue into the runtime. + /// - Throws: A synchronous runtime entry failure if the implementation cannot + /// accept the event for scheduling. + func post( + systemActor: isolated any Actor, + _ event: T.Event + ) throws + + /// Sends `event` and suspends until the resulting effect chain settles. + /// + /// Use `request` when the caller needs the terminal `Response` produced by + /// the chain rather than only triggering work. + /// + /// ## Caller Detachment vs. Runtime Independence + /// + /// When the caller's task is cancelled, the request throws `CancellationError` and the + /// caller stops awaiting. However, any work already in flight continues independently: + /// - If an action has already begun executing, it runs to completion. + /// - Any events emitted by that action feed back into the runtime state machine. + /// - Managed tasks continue executing normally. + /// + /// This decouples caller lifetime from runtime work, preserving event-driven semantics + /// even when callers detach. The underlying computation is **unstoppable** once started: + /// caller cancellation only affects whether the caller receives the response. + /// + /// For semantics where cancellation terminates all associated work immediately, use + /// ``uniqueRequest(_:)`` instead, which establishes exclusive ownership and cancels + /// both the caller and the spawned task. + /// + /// - Parameter event: The event to dispatch. + /// - Returns: The response value which is the return value of the static transducer function + /// `response(state:event)` with the event from the last reduction where state settled. + /// - Throws: If the request cannot enter the runtime, `CancellationError` if the caller's + /// task is cancelled, or any other runtime failure while the request is in flight. + @discardableResult + func request( + systemActor: isolated any Actor, + _ event: Event + ) async throws -> Response + + + /// Dispatches `event` into the runtime via a dedicated, isolated compute path. + /// + /// Unlike standard request dispatching, `uniqueRequest` ensures that each invocation + /// establishes an exclusive context. It prevents shared cancellation states and avoids + /// merging in-flight work with concurrent callers by directly managing its continuation + /// lifecycle and checking task cancellation early to fail fast when appropriate. + /// + /// The event's synchronous reduction path is processed immediately before the call returns, + /// but any asynchronous effects spawned during that path may continue executing after this + /// function completes. + /// + /// - Parameters: + /// - systemActor: The isolation context used to process the event. Defaults to `#isolation`. + /// - event: The domain event to dispatch through the unique request chain. + /// - Returns: The terminal `Response` value computed when the effect chain settles. + /// - Throws: If the request cannot enter the runtime boundary, or if accepted work is + /// later cancelled or fails at the runtime boundary. + @discardableResult + func uniqueRequest( + systemActor: isolated any Actor, + _ event: Event + ) async throws -> Response + + /// Cancels the hosted runtime with a caller-provided system error. + /// + /// Use this when pending work should observe a specific runtime failure + /// instead of a generic cancellation. + /// + /// - Parameter error: The system-level failure to latch and broadcast. + func cancel( + systemActor: isolated any Actor, + with error: Swift.Error? + ) + + /// Handles system-level control events that affect the transducer’s runtime lifecycle, + /// such as cancellation and unrecoverable errors. + /// + /// This method is invoked by the runtime to react to control-plane signals that are + /// not part of the domain `Event` stream. It can cancel any in-flight work managed + /// by `taskManager` and surface cancellation to callers waiting on request-style + /// operations. After handling the control event, it verifies whether cancellation + /// has been triggered and throws if the task manager is cancelled. + /// + /// - Parameters: + /// - systemActor: The actor providing the current isolation context. Defaults to + /// `#isolation`. This is available for symmetry with other isolated operations, + /// but is not used directly in this implementation. + /// - controlEvent: The control-plane event to process. Supported cases: + /// - `.systemError(Error)`: Cancels all managed tasks with the provided error, + /// propagating failure to any suspended continuations. + /// - `.cancel`: Cancels all managed tasks and tears down the runtime. Does not throw. + /// + /// - Throws: Only `TaskManager`-defined cancellation error if already cancelled prior + /// to calling this method (no new cancellation is triggered by `.cancel`). + /// + /// - Important: This method does not resume any continuation directly. Instead, it + /// delegates cancellation to `taskManager`, which is responsible for resuming or + /// failing any suspended requests. + /// + /// - Important: Witnesses must not mutate state. + /// + /// - SeeAlso: `compute(event:continuation:state:taskManager:input:env:)` for normal + /// event processing and effect execution; `TaskManager` for details on task and + /// continuation lifecycle management. + func control( + systemActor: isolated any Actor, + _ controlEvent: ControlEvent + ) throws + + + /// Translates an incoming `Event` into a sequence of domain or runtime effects that describe + /// the next state transition. + /// + /// This method is responsible for interpreting the event, updating the runtime's internal + /// state (if applicable), and determining what happens next—such as triggering actions, + /// spawning asynchronous tasks, emitting new events to continue the reduction cycle, or + /// terminating the request. + /// + /// It may `throw` when the host-provided storage becomes inaccessible. + /// + /// - Parameter event: The event to interpret and reduce into effects. + /// - Returns: A typed effect describing the subsequent action, next event, or terminal response. + /// - Throws: When the host-furnished storage cannot be accessed or fails due to its underlying constraints. + func transduce(event: Event) throws -> Effect + + /// Produces the terminal `Response` value derived from a reduced domain `Event` by + /// computing response function: `reponse(state:event:)`. + /// + /// This method is invoked by the runtime after an effect chain completes processing + /// a given event which has been dispatched via a `request` function. The returned response is + /// used to resume awaiting callers. + /// + /// It may `throw` when the host-provided storage becomes inaccessible. + /// + /// - Parameter event: The domain event that was reduced to produce the terminal state. + /// - Returns: The computed response value representing the outcome of the event chain. + /// - Throws: When the host-furnished storage cannot be accessed or fails due to its underlying constraints. + func response(event: Event) throws -> Response + + /// Interprets a single TransducerEffect and returns the next domain event to process, + /// along with an updated continuation if the request chain should remain in the caller. + /// + /// ## Ownership and resumption of continuations: + /// - This method generally does NOT resume continuations itself. It either: + /// - Transfers ownership to TaskManager when scheduling a managed task, or + /// - Returns the continuation to the caller (compute) to decide when to resume. + /// - The compute(...) method is responsible for resuming the continuation when the + /// chain settles synchronously without delegating to a managed task. + /// + /// ## Behavior by effect kind: + /// - ._task / ._taskIsolated: + /// Schedules an asynchronous task with the TaskManager. The provided continuation + /// (if any) is handed off to the manager for completion by the task; this method + /// returns `(nil, nil)`. + /// - ._event: + /// Returns the embedded domain event to be reduced next and PRESERVES the continuation, + /// yielding `(event, continuation)`. + /// - ._actionSync / ._actionAsync / ._actionAsyncIsolated: + /// Invokes the action. If the action produces a next event, we return `(event, continuation)`. + /// If it’s a terminal (Void) action, we return `(nil, continuation)` so that compute can + /// finish the chain and resume the continuation exactly once. + /// For async variants, cancellation is checked after the await. + /// - ._cancel: + /// Cancels any tasks matching the provided identifier. Returns `(nil, nil)` after + /// consuming the continuation (if present) because the chain is terminal. + /// - ._sequence: + /// Executes each effect in order. All but the last are executed with a `nil` continuation + /// so they cannot complete the original request. The final effect is executed with the + /// provided continuation, and its result is returned. An empty sequence returns `(nil, continuation)` + /// so that compute can decide how to settle the chain. + /// - .none: + /// No-op; returns `(nil, continuation)` so compute can decide how to settle the chain. + /// + /// ## Cancellation: + /// - This method cooperates with TaskManager cancellation and calls `taskManager.checkCancellation()` + /// after async awaits. If cancellation is detected, it throws. In that case, it does not resume + /// any provided continuation; the caller (compute) is responsible for handling the thrown cancellation. + /// + /// ## Isolation: + /// `systemActor` is passed through to isolated operations to preserve isolation semantics + /// when executing isolated async actions or tasks. + /// + /// ## Recursive invocations + /// `executeEffect` will be called recursively when an effect returns an event. There's a + /// maximum stack depth configured which when it succeeds the runtime will throw with + /// a system error + /// + /// - Parameters: + /// - systemActor: The current isolation token (`#isolation`) used to call isolated operations safely. + /// - effect: The effect to interpret and execute. + /// - event: The domain event associated with this effect in the reduction chain, typically + /// originating from the initial dispatched request or a prior `._event` effect. + /// - taskOwnership: Tracks the current ownership of the request's continuation while executing effects. + /// - stackDepth: The recursion counter for tracking depth within the effect execution stack. + /// - Returns: The next event to process (if any), or `nil` if the chain has settled or been terminated. + /// - Throws: A cancellation error if `taskManager` reports cancellation at a checkpoint. + func executeEffect( + systemActor: isolated any Actor, + _ effect: T.Effect, + event: Event, + taskOwnership: inout TaskOwnership, + stackDepth: Int + ) async throws -> Event? + + /// Processes an event using state owned by an actor host. + /// + /// On a normal return path, `continuation` has been fully consumed. It was + /// either resumed synchronously when the chain settled or transferred to + /// `taskManager` for completion by managed work. `compute` handles all internal + /// errors and if one occurs, resumes the continuation (if any) and rethrows the error. + /// + /// The method cooperates with `taskManager` cancellation. It checks for + /// cancellation between reduction steps and after effect execution. When + /// cancellation is detected, the task manager is responsible for failing or + /// cancelling any suspended work according to its policy. + /// + /// - Parameters: + /// - systemActor: The current isolation token (`#isolation`) used to call isolated operations safely. + /// - effect: The effect to interpret and execute. + /// - event: The domain event associated with this effect in the reduction chain, typically + /// originating from the initial dispatched request or a prior `._event` effect. + /// - taskOwnership: Tracks the current ownership of the request's continuation while executing effects. + /// - stackDepth: The recursion counter for tracking depth within the effect execution stack. + /// - Returns: The next event to process (if any), or `nil` if the chain has settled or been terminated. + /// - Throws: A cancellation error if `taskManager` reports cancellation at a checkpoint. + func compute( + systemActor: isolated any Actor, + event: T.Event, + taskOwnership: inout TaskOwnership + ) async throws + + /// Generates a unique identifier for a continuation box. + /// + /// Each time a request-style dispatch (`request(_:)` or `uniqueRequest(_:)`) + /// creates a new continuation to await a response, this method provides a + /// unique integer ID that identifies that continuation within the runtime. + /// + /// The runtime maintains an internal counter that increments with each call, + /// ensuring that every continuation box can be tracked and managed + /// independently by the ``TaskManager``. + /// + /// - Returns: A monotonically increasing integer ID for the new continuation. + func newContinuationID() -> Int + + #if canImport(OSLog) + static var logger: Logger { get } + #endif +} + +/// A control-plane event that affects the runtime's lifecycle. +/// +/// `ControlEvent` is used to signal system-level changes that are not part of the +/// domain event stream. These events affect the runtime's task management and +/// can cancel in-flight work. +/// +/// ## Cases +/// +/// ### `.systemError(Error)` +/// Signals that the runtime encountered a recoverable system error. All managed +/// tasks are cancelled with the provided error, and any suspended continuations +/// are resumed with that error. +/// +/// Use this when you want pending work to observe a specific failure reason +/// instead of a generic cancellation. +/// +/// ```swift +/// // Example: Signal a configuration error +/// try runtime.control(.systemError(ConfigurationError.missingAPIKey)) +/// ``` +/// + /// ### `.cancel` + /// Signals that the runtime should shut down gracefully. All managed tasks are + /// cancelled with a ``RuntimeCancellationError``, and the runtime is torn down. + /// + /// Use this when the host (for example, a SwiftUI view) is being dismissed and + /// all associated work should be cancelled. + /// + /// ```swift + /// // Example: Cancel when view disappears + /// runtime.control(.cancel) + /// ``` +/// +/// ## Usage +/// +/// Control events are dispatched via the ``control(_:)-7c6cw`` method on the +/// runtime. They are processed on a dedicated control path that can interleave +/// with suspended compute paths, allowing immediate interruption of the runtime. +/// +/// - SeeAlso: ``TransducerRuntime/control(systemActor:_:)`` for the method that +/// processes control events. +/// - SeeAlso: ``RuntimeCancellationError`` for the error used when cancelling. +public enum ControlEvent: Sendable { + /// A recoverable system error that should be propagated to all managed tasks. + case systemError(any Swift.Error) + + /// A graceful shutdown signal that cancels all managed tasks. + case cancel +} + +/// Represents who currently owns the request’s continuation during a transduction chain, +/// and whether that continuation is still present and eligible to be resumed. +/// +/// A continuation is created by request-style entry points (for example, `request(...)`) +/// so the caller can be resumed when the effect chain settles. As work flows through the +/// runtime, ownership of this continuation may be transferred to the TaskManager (when +/// spawning managed tasks) or retained locally to be resumed synchronously if the chain +/// settles without delegating to managed work. +/// +/// Cases: +/// - `caller(UnsafeContinuationBox)`: +/// The caller still owns the continuation. This is typically used at the very start of +/// a request, before any managed task has been scheduled. The continuation must be +/// resumed exactly once by the compute path if the chain settles synchronously without +/// handing it to the TaskManager. +/// - `runtime(UnsafeContinuationBox?)`: +/// The runtime (TaskManager) owns the continuation, or has already consumed it. +/// When non-nil, the continuation has been transferred to the TaskManager along with +/// any scheduled task and will be resumed by that managed task on completion. When nil, +/// the continuation has already been consumed (resumed or failed) and must not be used +/// again. +/// - `none`: +/// There is no continuation to manage. This is used for fire-and-forget paths or after +/// a continuation has been fully consumed and cleared. +/// +/// Behavior: +/// - Only a non-nil continuation may be resumed. The compute path is responsible for +/// resuming the continuation exactly once when the chain settles synchronously. When +/// a managed task is created, ownership and resumption responsibility are transferred +/// to the TaskManager. +/// - Cancellation is coordinated by the TaskManager. If cancellation is detected, +/// the manager is responsible for failing any owned continuation; callers should not +/// attempt to resume a continuation after cancellation. +/// +/// Accessor: +/// - `continuation`: Returns the boxed continuation if it is still present and owned +/// by either the caller or the runtime; returns `nil` when no continuation exists or +/// after it has been consumed. +enum TaskOwnership { + typealias UnsafeContinuationBox = R.UnsafeContinuationBox + + case caller(UnsafeContinuationBox) + case runtime(UnsafeContinuationBox?) + case none + + var continuation: UnsafeContinuationBox? { + switch self { + case .caller(let continuation): continuation + case .runtime(let continuation): continuation + case .none: nil + } + } + + var id: UnsafeContinuationBox.ID? { + switch self { + case .caller(let continuation): continuation.id + case .runtime(let continuation): continuation?.id + case .none: nil + } + } +} + +// MARK:- Compute | ExecuteEffect +extension TransducerRuntime { + + func compute( + systemActor: isolated any Actor = #isolation, + event: T.Event, + taskOwnership: inout TaskOwnership + ) async throws { + assert(systemActor === self.systemActor) + var event: Event = event + do { + loop: while true { + try taskManager.checkCancellation() + let effect = try transduce(event: event) + switch effect.type { + case .none: break loop + default: + let nextEvent: Event? + nextEvent = try await executeEffect( + effect, + event: event, + taskOwnership: &taskOwnership + ) + if let nextEvent { + event = nextEvent + continue loop + } else { + break loop + } + } + } + if let cont = taskOwnership.continuation { + let response = try response(event: event) + cont.resume(returning: response) + } + } catch { + if let cont = taskOwnership.continuation { + cont.resume(throwing: error) + } + throw error + } + } + + func executeEffect( + systemActor: isolated any Actor = #isolation, + _ effect: T.Effect, + event: Event, + taskOwnership: inout TaskOwnership, + stackDepth: Int = 0 + ) async throws -> Event? { + #if DEBUG && canImport(OSLog) + let taskOwnershipDescription = "\(taskOwnership)" + let id = "\(self.id)" + Self.logger.debug("\(Self.self)[\(id)]: executing effect: \(effect)\n taskOwnership: \(taskOwnershipDescription), stackDepth: \(stackDepth))") + #endif + if stackDepth > T.executeEffectMaxStackDepth { + throw ExecuteEffectStackDepthExceededError(transducer: T.self) + } + + switch effect.type { + case .none: + return nil + + case .event(let event): + return event + + case .actionSync(action: let action): + try action(env) + return nil + + case .actionPartialSync(action: let action): + let event = try action(env) + return event + + case .actionMaybePartialSync(action: let action): + let event: Event? = try action(env) + return event + + case .actionNonsendingAsync(action: let action): + try await action(env) + try taskManager.checkCancellation() + return nil + + case .actionNonsendingPartialAsync(action: let action): + let event = try await action(env) + try taskManager.checkCancellation() + return event + + case .actionNonsendingMaybePartialAsync(action: let action): + let event = try await action(env) + try taskManager.checkCancellation() + return event + + case .taskNonsendingOperationOptionalEvent( + id: let id, + priority: let priority, + option: let taskAdditionOption, + nonsendingOperation: let operation + ): + try taskManager.addTask( + identifier: id, + event: event, + taskAdditionPolicy: taskAdditionOption, + taskOwnership: &taskOwnership, + priority: priority, + nonsendingOperationOptionalEvent: operation + ) + return nil + + case .taskNonsendingOperationReturn( + id: let id, + priority: let priority, + option: let taskAdditionOption, + nonsendingOperation: let operation + ): + try taskManager.addTask( + identifier: id, + event: event, + taskAdditionPolicy: taskAdditionOption, + taskOwnership: &taskOwnership, + priority: priority, + nonsendingOperationReturn: operation + ) + return nil + + + case .cancel(let id): + taskManager.cancelTasks(with: id) + // Note: A .cancel(id) does not complete a request dispatch method + // by itself. If used as the last effect with request(...), the + // request will settle normally and it returns the response value + // of the last reduces event. If used as an intermediate effect + // in a sequence (with the current sequence rules) the last effect + // determines the response value. + // A user can always add an action after a cancel to deterministically + // set the reponse value if needed. + return nil + + case .sequence(let effects): + // Handles an effect which consists of a sequence of effects. + // + // The effects will be executed from first to last. All effects will be executed in the same + // computation cycle. Task effects will be launched but not awaited. If an effect returns an + // event it will be ignored. + // + // A sequence effect may contain an effect which is another sequence effect. In that + // case the executeEffect function will be rentered. The depths is limited by a + // maximum of 10 levels. The computation cycle ends with the top level sequence. + // + // **Returns:** `nil` Event + for effect in effects { + // NOTE: the returned event will currently not handled - this is clearly documented. + var discardOwnership: TaskOwnership = .runtime(nil) + let event = try await executeEffect( + effect, + event: event, + taskOwnership: &discardOwnership, + stackDepth: stackDepth + 1 + ) + #if DEBUG && canImport(OSLog) + if let event { + let eventDescription = "\(event)" + let id = "\(self.id)" + Self.logger.debug("\(Self.self)[\(id)]: sequence intermediate effect returned event \(eventDescription) which will be ignored") + // Optionally: assert/preconditionFailure in DEBUG to force correction. + } + #endif + } + return nil + } + } +} + + +// MARK: - Send | Post | Request | Control +extension TransducerRuntime { + + func send( + systemActor: isolated any Actor = #isolation, + _ event: T.Event + ) async throws { + await computeGate.enter() + defer { computeGate.leave() } + var taskOwnership: TaskOwnership = .runtime(nil) + try await compute( + systemActor: systemActor, + event: event, + taskOwnership: &taskOwnership + ) + } + + func post( + systemActor: isolated any Actor = #isolation, + _ event: T.Event + ) throws { + // Preflight: fail fast if the runtime is already cancelled. + try taskManager.checkCancellation() + Task(priority: nil) { + do { + try await send(systemActor: systemActor, event) + } catch { + let id = "\(self.id)" + #if canImport(OSLog) + Self.logger.error("\(Self.self)[\(id)]: post failed: \(error)") + #endif + } + } + } + + /// Subscribes to the response of event dispatch via a shared waiter list. + /// + /// Creates a `UnsafeContinuationBox` attached to the runtime's compute loop. When the caller cancels + /// (e.g., via `withTaskCancellationHandler`), this removes the continuation from the task's + /// subscriber list — but **does not** cancel the underlying work. The task continues until + /// it completes or is cancelled by a ControlEvent. This is the V1 default behavior + /// (``SubscriberPolicy/keepRunning``). + /// + /// If you want the task to cancel when the last subscriber unsubscribes, use `.cancel(id:)` + /// from your transducer's effect output to explicitly cancel the task. + @discardableResult + func request( + systemActor: isolated any Actor = #isolation, + _ event: Event + ) async throws -> Response { + // try Task.checkCancellation() + let continuationId = newContinuationID() + let unsafeContinuationBox = UnsafeContinuationBox(with: continuationId) + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: SystemContinuation) in + unsafeContinuationBox.setContinuation(continuation) + _ = Task { + await computeGate.enter(systemActor: systemActor) + defer { computeGate.leave(systemActor: systemActor) } + var taskOwnership: TaskOwnership = .runtime(unsafeContinuationBox) + try await compute( + systemActor: systemActor, + event: event, + taskOwnership: &taskOwnership + ) + } + } + } onCancel: { + // Caller cancelled before response arrived. Resume with CancellationError. + // Note: compute path may race to resume first (if action just completed). + // The continuation.nil check in resume(throwing:) prevents double-resume. + Task { + await cancelContinuation(systemActor: systemActor, unsafeContinuationBox) + } + } + } + + /// Creates a caller-owned task and subscribes to its response via an exclusive waiter list. + /// + /// Unlike `request(_)`, the continuation here is **caller-owned** — cancellation propagates + /// both into the continuation (unsubscribing the caller) **and** into the underlying SwiftTask + /// via TaskManager. + @discardableResult + func uniqueRequest( + systemActor: isolated any Actor = #isolation, + _ event: Event + ) async throws -> Response { + // TODO: See `SubscriberPolicy.keepRunning` for the policy when the sole watcher drops off. + // Derive identity synchronously to avoid races. + let continuationId = newContinuationID() + let unsafeContinuationBox = UnsafeContinuationBox(with: continuationId) + + // A handle to the compute-driving task, set after we obtain the continuation. + #if swift(<6.4) + nonisolated(unsafe) var computeTask: Task? = nil + #else + var computeTask: Task? = nil + #endif + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: SystemContinuation) in + // Create the compute-driving task now that we have the continuation. + unsafeContinuationBox.setContinuation(continuation) + computeTask = Task { + do { + await computeGate.enter(systemActor: systemActor) + defer { computeGate.leave(systemActor: systemActor) } + try Task.checkCancellation() + var taskOwnership: TaskOwnership = .caller(unsafeContinuationBox) + try await compute( + systemActor: systemActor, + event: event, + taskOwnership: &taskOwnership + ) + } catch { + // Ensure the waiter is not left hanging. + unsafeContinuationBox.resume(throwing: error) + } + } + } + } onCancel: { + computeTask?.cancel() + Task { + await cancelContinuation(systemActor: systemActor, unsafeContinuationBox) + } + } + } + + func control( + systemActor: isolated any Actor = #isolation, + _ controlEvent: ControlEvent, + ) throws { + switch controlEvent { + case .systemError(let systemError): + taskManager.cancel(with: systemError) + case .cancel: + taskManager.cancel(with: RuntimeCancellationError(transducer: T.self)) + } + } + + func cancelTask( + systemActor: isolated any Actor = #isolation, + id: TaskIdentifier + ) { + self.taskManager.cancelTasks(with: id) + } + + /// Cancels a continuation by its identifier. + /// + /// This method resumes the continuation matching `continuationId` with a ``CancellationError`` + /// and removes it from any task's waiter list. It is typically called when a caller cancels + /// their task before the runtime completes processing their request. + /// + /// - For unique waiters: If the continuation is found and was the sole waiter, the underlying + /// task is cancelled immediately. + /// - For shareable waiters: The continuation is removed, but the task continues running + /// regardless of whether the waiter list becomes empty. This is the V1 default behavior. + /// + /// - Parameter systemActor: The isolation context. Defaults to the caller's isolation context. + /// - Parameter id: The unique identifier of the ``UnsafeContinuationBox`` to cancel. + /// + /// - SeeAlso: ``TaskManager/cancelContinuation(systemActor:withId:)`` for the underlying + /// implementation that manages waiter lists and task cancellation. + @discardableResult + func cancelContinuation( + systemActor: isolated any Actor = #isolation, + _ continuation: UnsafeContinuationBox + ) -> Bool { + // Note: we need to ensure the box will always be resumed by checking + // the boolean return value of the task manager. It will return false + // when the continuation has not been transfered to the task manager + // yet. In that case, we need to resume it directly. + if !taskManager.cancelContinuation(withId: continuation.id) { + continuation.resume(throwing: CancellationError()) + return false + } else { + return true + } + } +} + +// MARK: - peackState | getState +extension TransducerRuntime { + + /// Retrieves the current state without waiting for transduction cycles to complete. + /// + /// Use this method when you need immediate access to the state, potentially + /// seeing an intermediate value if operations are in progress. This is a + /// "peek" operation—it reads state without blocking or coordinating with + /// other in-flight work. + /// + /// - Parameter systemActor: The isolation context. Defaults to the caller's + /// isolation context. + /// - Returns: The current state of type ``Transducer/State``. + /// - Throws: An error if reading the state from storage fails. + /// + /// - Note: Unlike ``getState(systemActor:)``, this method does not await the + /// compute gate, so it doesn't wait for concurrent operations to complete. + func peakState( + systemActor: isolated any Actor = #isolation + ) throws -> T.State { + try storage.state + } + + /// Retrieves the next visible state after the current transduction cycle completes. + /// + /// Use this method when you need to read the state after all in-flight + /// transduction operations have settled. This method enters the compute gate, + /// waiting for any concurrent state mutations to complete before returning. + /// + /// - Parameter systemActor: The isolation context. Defaults to the caller's + /// isolation context. + /// - Returns: The state of type ``Transducer/State`` after the transduction cycle. + /// - Throws: An error if reading the state from storage fails. + /// + /// - Note: Unlike ``peakState(systemActor:)``, this method awaits the compute + /// gate, ensuring you see the state after all concurrent operations have completed. + func getState( + systemActor: isolated any Actor = #isolation + ) async throws -> T.State { + await computeGate.enterNext() + defer { computeGate.leave() } + return try storage.state + } +} + +// MARK: - CancellableRuntime +extension TransducerRuntime { + + /// Cancels the hosted runtime with a caller-provided system error. + /// + /// Use this when pending work should observe a specific runtime failure + /// instead of a generic cancellation. + /// + /// - Parameter error: The system-level failure to latch and broadcast. If no + /// error is provided the error will be set to an internal error which represents + /// a runtime cancellation. + /// + func cancel( + systemActor: isolated any Actor = #isolation, + with error: Swift.Error? = nil + ) { + guard case .active = taskManager.state else { + return + } + let error = error ?? RuntimeCancellationError(transducer: T.self) + let controlEvent: ControlEvent = .systemError(error) + try? self.control(controlEvent) + } + + func checkCancellation() throws { + // A runtime is cancelled, when the task manager is cancelled. + // Normally, the task manager will only be cancelled by the + // runtime via `cancel(systemActor:with)`. + try taskManager.checkCancellation() + } +} + +// MARK: - Internal TaskOwnership Helpers - for testing +extension TransducerRuntime { + + func makeCallerTaskOwnership(with continuation: UnsafeContinuationBox) -> TaskOwnership { + return .caller(continuation) + } + + func makeRuntimeTaskOwnership(with continuation: UnsafeContinuationBox? = nil) -> TaskOwnership { + return .runtime(continuation) + } + +} + +#if canImport(OSLog) +extension TransducerRuntime { + // MARK: - file level logger + static var logger: Logger { + Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.couchdeveloper.transduce", + category: "Runtime" + ) + } +} +#endif + + + +/// Controls how the runtime handles a new request for an identifier that already has +/// an active task. +/// +/// Equal task identifiers declare the same logical in-flight work. The option decides +/// whether the runtime reuses the current task instance or replaces it with a fresh one. +/// +/// A later request only competes with work that is still active for the same logical +/// identifier. Once the tracked task has completed, the next request starts fresh +/// regardless of which option was used previously. +public enum TaskAdditionPolicy: Sendable { + + /// Cancel the running task for this identifier, start a fresh task, and attach all + /// current waiters plus the new waiter to the replacement task. + /// + /// - > Caution: `switchToLatest` replaces the physical task instance but preserves + /// waiter ownership. Existing waiters do not fail merely because a replacement + /// starts; they move to the new current task for the same identifier. + case switchToLatest + + /// Keep the running task for this identifier and add the new waiter to it. + /// + /// Later callers share the current logical work and receive the same terminal + /// result or failure as the active task for that identifier. + case shareable +} + +// MARK: - Compute Gate + +final class ComputeGate { + private var active = false + private var waiters: [CheckedContinuation] = [] + + func enter( + systemActor: isolated any Actor = #isolation + ) async { + if !active { + active = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func enterNext( + systemActor: isolated any Actor = #isolation + ) async { + if !active { + active = true + return + } + + await withCheckedContinuation { continuation in + waiters.insert(continuation, at: 0) + } + } + + func leave( + systemActor: isolated any Actor = #isolation + ) { + if waiters.isEmpty { + active = false + return + } + + let next = waiters.removeFirst() + next.resume() + } +} + +protocol CancellableRuntime { + + /// Cancels the hosted runtime with a caller-provided system error. + /// + /// Use this when pending work should observe a specific runtime failure + /// instead of a generic cancellation. + /// + /// - Parameter error: The system-level failure to latch and broadcast. If no + /// error is provided the error will be set internally to `CancellationError`. + /// + func cancel( + systemActor: isolated any Actor, + with error: Swift.Error? + ) + + /// Throws if the runtime has been cancelled. + func checkCancellation() throws +} + + +// MARK: - Errors + +import protocol Foundation.LocalizedError + +/// Fallback error - when the runtime has been cancelled without +/// an error specified, i.e. `cancel()` or `cancel(with: nil)`. +/// +/// Request waiters will throw a ``RuntimeCancellationError`` if the runtime +/// has been cancelled without specifying an error. +public struct RuntimeCancellationError: LocalizedError, Sendable { + let transducer: String + init(transducer: T.Type = T.self) { + self.transducer = "\(T.self)" + } + + public var errorDescription: String? { "Runtime '\(transducer)' cancelled" } +} + +public struct ExecuteEffectStackDepthExceededError: LocalizedError, Sendable { + let transducer: String + init(transducer: T.Type = T.self) { + self.transducer = "\(T.self)" + } + + public var errorDescription: String? { "Runtime '\(transducer)' stack depth exceeded in execute effect" } +} + + +// MARK: - UnsafeContinuationBox + +protocol BoxableContinuation: Sendable where Failure: Swift.Error { + associatedtype Success + associatedtype Failure: Error + func resume(returning: sending Success) + func resume(throwing: Failure) +} + +extension CheckedContinuation: BoxableContinuation { + typealias Success = T + typealias Failure = E +} + +extension UnsafeContinuation: BoxableContinuation { + typealias Success = T + typealias Failure = E +} + +/// An identity box for holding and resuming a Swift continuation, used internally for +/// request/cancellation control. +/// +/// # Actor Isolation Warning +/// `UnsafeContinuationBox` is declared `@unchecked Sendable` but **must not** be used +/// from multiple concurrency domains. +/// All operations on a given box instance must occur strictly on the runtime's system actor (the i +/// solation domain for its host runtime). +/// +/// This type is only marked `@unchecked Sendable` to satisfy type requirements for collections +/// and async/cancellation APIs— it is **not safe** for actual cross-actor or multi-threaded access. +/// Any concurrent or cross-actor Runtiuse can result in races or undefined behavior. +/// +/// In particular, closures such as `onCancel` in `withTaskCancellationHandler` (as seen +/// in `request(_:)` and `uniqueRequest(_:)`) must never capture and operate on a box from +/// a different actor or thread. +/// +/// All usages in the current implementation are correct, as every access is actor-confined. It is +/// critical that future maintenance preserves this invariant to avoid subtle and dangerous +/// concurrency bugs. +final class UnsafeContinuationBox: Hashable, Identifiable, @unchecked Sendable { + typealias Success = Continuation.Success + typealias Failure = Continuation.Failure + + enum Cont { + case uninitialized + case completed + case initialized(Continuation) + + var continuation: Continuation? { + guard case let .initialized(continuation) = self else { return nil } + return continuation + } + } + + var continuation: Cont + let id: Int + + var isResumed: Bool { + if case .completed = continuation { return true } else { return false } + } + + nonisolated init(with id: Int) { + #if DEBUG && canImport(OSLog) + continuationBoxLogger.debug("*** UnsafeContinuationBox.init(\(id))") + #endif + self.continuation = .uninitialized + self.id = id + } + + nonisolated init(with id: Int, continuation: Continuation) { + continuationBoxLogger.debug("*** UnsafeContinuationBox.init(\(id)) and continuation") + self.id = id + self.continuation = .initialized(continuation) + } + + #if DEBUG && canImport(OSLog) + deinit { + switch continuation { + case .initialized: + let id = self.id + continuationBoxLogger.critical("*** UnsafeContinuationBox(\(id) continuation not resumed") + default: + break + } + } + #endif + + func setContinuation( + _ continuation: Continuation + ) { + guard case .uninitialized = self.continuation else { + preconditionFailure("continuation already set") + } + let id = self.id + #if DEBUG && canImport(OSLog) + continuationBoxLogger.debug("*** UnsafeContinuationBox(\(id)) did set continuation") + #endif + self.continuation = .initialized(continuation) + } + + /// Resume the task awaiting the continuation by having it return normally + /// from its suspension point. + /// + /// This method must be called only once per continuation. The continuation reference + /// is cleared after resumption, preventing double-resume. + /// + /// - Parameter value: The value to return from the continuation. + func resume( + returning value: sending Success + ) { + switch self.continuation { + case .completed: + #if DEBUG && canImport(OSLog) + let id = self.id + continuationBoxLogger.debug("*** UnsafeContinuationBox(\(id)) resuming: already completed") + #endif + return + case .initialized(let cont): + cont.resume(returning: value) + self.continuation = .completed + #if DEBUG && canImport(OSLog) + let id = self.id + continuationBoxLogger.debug("*** UnsafeContinuationBox(\(id)) resuming with value") + #endif + case .uninitialized: + let id = self.id + continuationBoxLogger.warning("*** UnsafeContinuationBox(\(id)) resuming: uninitialized") + return + } + } + + /// Resume the task awaiting the continuation by having it throw an error + /// from its suspension point. + /// + /// This method must be called only once per continuation. The continuation reference + /// is cleared after resumption, preventing double-resume. + /// + /// - Parameter error: The error to throw from the continuation. + func resume( + throwing error: Failure + ) { + switch self.continuation { + case .completed: + #if DEBUG && canImport(OSLog) + let id = self.id + continuationBoxLogger.debug("*** UnsafeContinuationBox(\(id)) resuming with error: already completed") + #endif + return + case .initialized(let cont): + cont.resume(throwing: error) + self.continuation = .completed + #if DEBUG && canImport(OSLog) + let id = self.id + continuationBoxLogger.debug("*** UnsafeContinuationBox(\(id)) resuming with error \(error)") + #endif + case .uninitialized: + let id = self.id + continuationBoxLogger.warning("*** UnsafeContinuationBox(\(id)): uninitialized while attempting to resume with error: \(error) ") + return + } + } +} + +extension UnsafeContinuationBox { + + static func == ( + lhs: borrowing UnsafeContinuationBox, + rhs: borrowing UnsafeContinuationBox + ) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +#if canImport(OSLog) +// Logger +// MARK: - file level logger +let continuationBoxLogger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.couchdeveloper.transduce", + category: "Runtime.UnsafeContinuationBox" +) +#endif + + +// Mark: - Logging + +extension TransducerRuntime { + var description: String { + return "TransducerRuntime<\(T.self)>[\(self.id)]" + } +} + +extension TaskOwnership: CustomStringConvertible { + var description: String { + switch self { + case .none: "none" + case .caller(let cont): "caller(\(cont))" + case .runtime(let cont): "runtime(\(cont, default: "nil"))" + } + } +} + +extension Actor { + var description: String { + String(describing: Self.self) + } +} diff --git a/Sources/Transduce/Transducer/Transducer.swift b/Sources/Transduce/Transducer/Transducer.swift new file mode 100644 index 0000000..c860437 --- /dev/null +++ b/Sources/Transduce/Transducer/Transducer.swift @@ -0,0 +1,220 @@ +/// Finite-state reducer contract for the effect runtime. +/// +/// A `Transducer` defines the domain model that `EffectView` or +/// `TransducerObservable` hosts: mutable `State`, incoming `Event`s, optional +/// dependency `Env`, and the ``TransducerEffect`` values returned from +/// ``transduce(_:event:)``. +/// +/// The runtime treats ``transduce(_:event:)`` as the single mutation point. +/// `transduce` mutates state synchronously and may return an effect describing +/// follow-up work. That work can emit more events later, but direct state +/// mutation still flows back through `transduce`. +/// +/// - Requires: A conforming transducer needs to be nonisolated. +/// +/// Example +/// ------- +/// A minimal counter feature showing how to declare `State`, an `Event` enum (note the enum), +/// the `transduce` reducer, and a `response` that returns a value from state. +/// +/// ```swift +/// nonisolated enum CounterTransducer: Transducer { +/// // Feature state owned by the runtime +/// struct State { +/// var count: Int = 0 +/// } +/// +/// // Domain events that drive state transitions +/// // (note: this is an enum) +/// enum Event { +/// case increment +/// case decrement +/// case reset +/// case requestCurrentValue // e.g., a request-style event +/// } +/// +/// // No external dependencies for this simple example +/// typealias Env = Void +/// +/// // The effect returned by `transduce` defaults to +/// // `TransducerEffect` +/// // We keep `Response` as `Int` to return the current +/// // counter value from `response`. +/// typealias Response = Int +/// +/// // Synchronous state mutation and next-effect +/// // selection +/// static func transduce( +/// _ state: inout State, event: Event +/// ) -> Effect { +/// switch event { +/// case .increment: +/// state.count += 1 +/// return .none +/// +/// case .decrement: +/// state.count -= 1 +/// return .none +/// +/// case .reset: +/// state.count = 0 +/// return .none +/// +/// case .requestCurrentValue: +/// // In a request flow, you can choose to end the +/// // chain here with no further effect. +/// // The runtime will call `response(state:event:)` +/// // to produce the result. +/// return .none +/// } +/// } +/// +/// // Produces the terminal result for request-style +/// // chains +/// static func response( +/// state: State, event: Event +/// ) -> Response { +/// // For this simple example, always return +/// // the current count +/// state.count +/// } +/// } +/// ``` +/// +/// Requesting the current value +/// ---------------------------- +/// If your runtime provides an `Input` that conforms to `TransducerInput`, you can +/// request the current value by sending a request-style event and awaiting the output: +/// +/// ```swift +/// // Pseudocode demonstrating usage inside +/// // a host runtime +/// var state = CounterTransducer.State() +/// let taskManager = TaskManager() +/// let input: (any TransducerInput)? = /* provided by host */ +/// let env: CounterTransducer.Env = () +/// +/// // Fire some events that mutate state +/// try await CounterTransducer.compute( +/// event: .increment, +/// continuation: nil, +/// state: &state, +/// taskManager: taskManager, +/// input: input, +/// env: env +/// ) +/// +/// // Later, request the current value. Depending on +/// // your host, this might be: +/// if let input { +/// // Resume with the value produced by +/// // `response(state:event:)` +/// let value: Int? = await input.request(.requestCurrentValue) +/// // `value` is the current `state.count` at the +/// // time the request settles +/// } +/// ``` +nonisolated +public protocol Transducer { + /// Mutable feature state owned by the host runtime. + associatedtype State + + /// Domain event type that drives state transitions. + associatedtype Event + + /// Value returned to callers suspended on `request`-style entry points. + /// + /// Use `Void` when the feature does not return a result. + associatedtype Response = Void + + /// Effect type returned from ``transduce(_:event:)``. + associatedtype Effect = TransducerEffect + + /// Dependency environment captured for the runtime lifetime. + /// + /// Use `Void` when the feature has no external dependencies. + associatedtype Env = Void + + /// Applies `event` to `state` and returns the next effect to execute. + /// + /// `transduce` is synchronous. Mutate `state` directly and return an + /// effect describing any follow-up work. Return `.none` when processing ends + /// with no further effect. + /// + /// - Parameters: + /// - state: The current mutable feature state. + /// - event: The incoming event to reduce. + /// - Returns: The next effect to execute. + static func transduce(_ state: inout State, event: Event) -> Effect + + /// Produces the terminal result for a settled request-style event chain. + /// + /// The runtime calls `response(state:event:)` when a `request` reaches a terminal state + /// without handing its continuation off to a managed task. + /// + /// - Parameters: + /// - state: The final state after the event chain has settled. + /// - event: The terminal event that ended the chain. + /// - Returns: The value to resume the waiting request with. + static func response(state: State, event: Event) -> Response + + /// Initial state for the conformance. + /// + /// Used by the host runtime to initialize the feature's mutable ``State`` before processing begins. + static var initialState: State { get } + + /// Maximum depth for the effect execution stack within a single compute cycle. + /// + /// This limit tracks **effect execution depth** — the nesting of `executeEffect` calls + /// triggered by partial actions (returning an `Event`) and `.event` effects that chain + /// synchronously within the same compute cycle. It does **not** limit the number of times + /// ``transduce(_:event:)`` is called: `transduce` can be invoked more than 10 times across + /// a chain of effects that settle (return `.none`) and re-enter the compute loop as new + /// cycles. + /// + /// If the effect execution depth exceeds this limit, the runtime throws + /// ``ExecuteEffectStackDepthExceededError`` to prevent unbounded recursion from runaway + /// event chains. + /// + /// The default value is `10`. Override this property on your transducer conformance + /// to raise or lower the limit for features with deeper effect chains. + static var executeEffectMaxStackDepth: Int { get } +} + +public protocol BaseTransducer: Transducer where Effect == Void, Env == Void {} +public protocol EffectTransducer: Transducer where Effect == TransducerEffect {} + +extension Transducer where Response == Void { + /// Default terminal output for features that do not return a value. + /// + /// - Parameters: + /// - state: The final state after the event chain has settled. + /// - event: The terminal event that ended the chain. + /// - Returns: `Void`. + @inline(__always) + public static func response(state: State, event: Event) -> Response { Void() } +} + +extension Transducer where State: DefaultInitializable { + public static var initialState: State { .init() } +} + + +extension Transducer where Effect == Void { + public static func compute( + isolated: isolated any Actor = #isolation, + state: inout State, + event: Event + ) -> Response { + transduce(&state, event: event) + return response(state: state, event: event) + } +} + +extension Transducer { + public static var executeEffectMaxStackDepth: Int { 10 } +} + +public protocol DefaultInitializable { + init() +} diff --git a/Sources/Transduce/Transducer/TransducerEffect.swift b/Sources/Transduce/Transducer/TransducerEffect.swift new file mode 100644 index 0000000..7fa6f22 --- /dev/null +++ b/Sources/Transduce/Transducer/TransducerEffect.swift @@ -0,0 +1,646 @@ +public struct TransducerEffect { + public typealias Event = T.Event + public typealias Env = T.Env + public typealias Input = any TransducerInput + public typealias Response = T.Response + + public typealias TaskReturn = Transduce::TaskReturn + + + typealias NonsendingOperationOptionalFunc = nonisolated(nonsending) (Input, Env) async throws -> sending Event? + typealias NonsendingOperationReturnFunc = nonisolated(nonsending) (Input, Env) async throws -> sending TaskReturn + + enum Effect { + case taskNonsendingOperationOptionalEvent(id: TaskIdentifier?, priority: TaskPriority?, option: TaskAdditionPolicy, nonsendingOperation: NonsendingOperationOptionalFunc) + case taskNonsendingOperationReturn(id: TaskIdentifier?, priority: TaskPriority?, option: TaskAdditionPolicy, nonsendingOperation: NonsendingOperationReturnFunc) + + case actionSync(_ action: (Env) throws -> Void ) + case actionPartialSync(_ action: (Env) throws -> Event) + case actionMaybePartialSync(_ action: (Env) throws -> Event?) + case actionNonsendingAsync(_ action: nonisolated(nonsending) (Env) async throws -> Void ) + case actionNonsendingPartialAsync(_ action: nonisolated(nonsending) (Env) async throws -> sending Event) + case actionNonsendingMaybePartialAsync(_ action: nonisolated(nonsending) (Env) async throws -> sending Event?) + + /// An effect that when invoked sends the given event to the compute + /// function for immediate execution. + case event(Event) + + /// An effect that when invoked cancells the task with the given identifier if it exists. + case cancel(TaskIdentifier) + + /// Has a sequence of effects. + /// + /// Creats an effect which consists of a sequence of effects. + /// + /// The effects will be executed from left to right. All effects will be executed in the same + /// computation cycle. Task effects will be launched but not awaited. If an effect returns an + /// event it will be ignored. + /// + /// A sequence effect may contain an effect which is another sequence effect. The computation + /// cycle ends with the top level sequence. The depths is limited by a maximum of 10 levels. + /// + /// A request waiter receives the response value from the event of the top level sequence and the + /// state after the computation cycle has finished. + case sequence([TransducerEffect]) + + /// An empty effect which will not be executed. + case none + } + + let type: Effect + + init(_ type: Effect) { + self.type = type + } +} + +// MARK: - TaskReturn + +/// Describes how a task effect should dispatch its returned event and derive a response value. +/// +/// When a task operation completes, it returns a `TaskReturn` value that tells the runtime +/// how to handle the resulting event. Each case specifies both the event and the dispatch style: +/// +/// - `.response(event)`: A waiter, i.e. `request()` or `uniqueRequest()` will be completed +/// with the value `response(state:event:)` with the current state and the event `event`. +/// - `.send(event)`: The runtime calls `input.send(event)`. Then a waiter, i.e. `request()` +/// or `uniqueRequest()` will be completed with the value `response(state:event:)` +/// with the current state and the event `event`. +/// - `.post(event)`: The runtime calls `input.post(event)`. Then a waiter, i.e. `request()` +/// or `uniqueRequest()` will be completed with the value `response(state:event:)` +/// with the current state and the event `event`. +/// - `.request(event)` +/// - `.uniqueRequest(event)` +public enum TaskReturn: Sendable { + + /// When a task effect returns `.response(event)`, the corresponding `request()` or + /// `uniqueRequest()` dispatch event function, which initiated the task, will complete with + /// the value returned by the Transducer's `response(state:event:)` function with the + /// current state and the given event. + case response(Event) + + /// When a task effect returns `.send(event)`, the TaskManager calls `input.send(event)` + /// and then the corresponding `request()` or `uniqueRequest()` dispatch event function, + /// which initiated the task, will complete with the value returned by the Transducer's + /// `response(state:event:)` function with the current state and the given event. + case send(Event) + + /// When a task effect returns `.post(event)`, the TaskManager calls `input.post(event)` + /// and then the corresponding `request()` or `uniqueRequest()` dispatch event function, + /// which initiated the task, will complete with the value returned by the Transducer's + /// `response(state:event:)` function with the current state and the given event. + case post(Event) + + /// When a task effect returns `.request(event)`, the TaskManager calls ` + /// input.request(event)`, which adds another continuation to the initial event dispatch + /// `request()` or `uniqueRequest()` + case request(Event) + + /// When a task effect returns `.uniqueRequest(event)`, the TaskManager calls + /// `input.uniqueRequest(event)`, which adds another continuation to the initial event + /// dispatch `request()` or `uniqueRequest()` + case uniqueRequest(Event) + +} + +// MARK: - Effect Factories + +// MARK: - None +extension TransducerEffect { + /// Creates a terminal effect that performs no work. + /// + /// Use `.none` when a reducer case produces no follow-up event or managed task — for example, after + /// updating state synchronously and having nothing else to do: + /// + /// ```swift + /// case .loaded(let items): + /// state.items = items + /// return .none + /// ``` + public static var none: Self { .init(Effect.none) } +} + +// MARK: - Event +extension TransducerEffect { + /// Creates an effect that dispatches a domain event for immediate processing within the current compute cycle. + /// + /// This is useful when you want to continue the event chain without triggering another dispatch from + /// outside the transducer — for example, chaining states after a synchronous update: + /// + /// ```swift + /// case .refreshing: + /// state.status = .loading + /// return .event(.requestCurrentTime) + /// ``` + /// + /// The returned event is processed synchronously in-place within the same computation cycle. + /// If its handler returns `.none`, the cycle ends; if it also produces another `.event`, chaining continues. + public static func event(_ event: Event) -> Self { + .init(.event(event)) + } +} + +// MARK: - Cancel +extension TransducerEffect { + /// Creates an effect that cancels a managed task with the given identifier, if one is in flight. + /// + /// A no-op if no such task exists — it is safe to call even when no task is running: + /// + /// ```swift + /// case .refreshPressed: + /// return .sequence([ + /// .cancel("load"), // discard stale work + /// .task(id: "load") { /* ... */ } // start fresh + /// ]) + /// ``` + public static func cancel(_ id: TaskIdentifier) -> Self { + .init(.cancel(id)) + } +} + +// MARK: Effect Sequence +extension TransducerEffect { + + /// Returns an effect which consists of a sequence of effects. + /// + /// The effects will be executed from left to right. All effects will be executed in the same + /// computation cycle. Task effects will be launched but not awaited. If an effect returns an + /// event it will be ignored. + /// + /// A sequence effect may contain an effect which is another sequence effect. The computation + /// cycle ends with the top level sequence. The depths is limited by a maximum of 10 levels. + /// + /// A request waiter receives the response value from the event of the top level sequence and the + /// state after the computation cycle has finished. + /// + /// The following example shows a sequence effect which first cancels the task "loadMovies" + /// if it exists, and then invokes a task effect `refreshMovies`. The second effect creates a + /// task where other calls sites may subsequently subscribe to - if this is a shareable task. + /// Note that call sites awaiting a sequence effect with a request call cannot await the effects + /// declared as the elements in the sequence - only *task* effects can be awaited. + /// + /// ```swift + /// // Cancel a stale load before starting a refresh: + /// return sequence(.cancel("loadMovies"), .refreshMovies()) + /// ``` + /// + /// - Parameter effects: The ordered effects to execute from left to right. + /// - Returns: An effect. + public static func sequence(_ effects: TransducerEffect...) -> Self { + Self.sequence(effects: effects) + } + + public static func sequence(effects: [TransducerEffect]) -> Self { + if effects.isEmpty { + return .none + } else { + return .init(.sequence(effects)) + } + } + +} + +// MARK: - Effect Actions + +/// Action effects create immediate, non-escaping work that runs inside the runtime's +/// current `compute` invocation. Unlike task effects, actions do not create managed +/// tasks and therefore cannot outlive the `compute` call that produced them. +/// +/// ## Concurrency and re-entrancy +/// - Even if an action suspends (e.g., `await`), no other event can enter the transducer +/// while the action is running. The runtime's compute gate prevents overlapping +/// `compute` executions. When the action resumes and returns, `compute` continues +/// from the same logical state as when the action started. +/// +/// ## Partial vs. terminal actions +/// - Actions that return `Void` are terminal for the current step: they do not emit a +/// next domain event. The effect chain settles (unless composed in a surrounding +/// sequence with additional effects). +/// - Actions that return `Event` are partial: they produce the next domain event that +/// is processed immediately by the transducer within the same `compute` cycle. That +/// transition may again return an action (partial or terminal), continuing until a +/// terminal condition is reached (e.g., a `Void` action, `.none`, or a task effect). +/// +/// ## Event chain depth limits +/// - The runtime enforces a maximum stack depth of 10 for effect execution to prevent +/// unbounded recursion. Exceeding this limit throws `ExecuteEffectStackDepthExceededError`. +/// - Partial actions and `.event` effects chain synchronously within the same compute cycle. +/// Design your chains to be bounded — there is no explicit cap on `transduce` call count, +/// but the effect execution stack depth limit will catch runaway recursion. +/// +/// ## Isolation: +/// Action closures execute on the system actor, unless an asynchronous closure has been +/// annotated with a literal global actor, in which case it executes on the specified actor. +/// ```swift +/// return .action { @MyGlobalActor env in +/// // executes on MyGlobalActor +/// } +/// ``` +/// +/// - `operation` variants annotated with `@isolated(any)` execute under the runtime's +/// current actor isolation. You may further constrain the closure to a specific global +/// actor (e.g., `@MainActor`) to assert and use that isolation. +/// - `nonsendingOperation` variants are declared `nonisolated(nonsending)` and execute +/// on a cooperative thread without a specific global actor requirement. Prefer the +/// isolated variants when touching actor-isolated state. +/// +/// Guidance: +/// - Use actions for short-lived, immediate effects that should not escape the current +/// `compute` call. For long-running or cancellable work, prefer task effects. +extension TransducerEffect { + + /// Creates a synchronous terminal action. + /// + /// The closure runs inside the current `compute` call and does not escape. No managed + /// task is created. When it returns, the effect chain for this step settles (unless it + /// is composed inside a surrounding sequence that continues with more effects). + /// + /// When an error is thrown from the closure the transducer treats it as a system error and + /// halts execution. + /// + /// - Parameter action: A synchronous throwing closure that can mutate `Env`. + /// - Returns: An action effect that completes the current step without emitting a next event. + public static func action(_ action: @escaping (Env) throws -> Void) -> Self { + .init(.actionSync(action)) + } + + /// Creates a synchronous partial action that emits the next domain event. + /// + /// The closure runs inside the current `compute` call and returns the next `Event` to + /// process immediately. The returned event re-enters the transducer within the same + /// compute cycle; this may repeat until a terminal condition is reached. + /// + /// When an error is thrown from the closure the transducer treats it as a system error and + /// halts execution. + /// + /// - Parameter action: A synchronous throwing closure that returns the next `Event`. + /// - Returns: An action effect that produces a follow-up event for immediate processing. + @_disfavoredOverload + public static func action(_ action: @escaping (Env) throws -> Event) -> Self { + .init(.actionPartialSync(action)) + } + + /// Creates a synchronous partial action that may emit the next domain event or `nil` + /// + /// The closure runs inside the current `compute` call and returns the next `Event` to + /// process immediately. The returned event re-enters the transducer within the same + /// compute cycle; this may repeat until a terminal condition is reached. + /// + /// When the closure returns `nil`, the chain settles and the effect completes without + /// producing a follow-up event. This is useful for conditional chaining where you may + /// or may not want to continue the event chain based on runtime conditions. + /// + /// When an error is thrown from the closure the transducer treats it as a system error and + /// halts execution. + /// + /// - Parameter action: A synchronous throwing closure that returns the next `Event` or `nil`. + /// - Returns: An action effect that produces a follow-up event for immediate processing, or settles if `nil`. + @_disfavoredOverload + public static func action(_ action: @escaping (Env) throws -> Event?) -> Self { + .init(.actionMaybePartialSync(action)) + } + + /// Creates an asynchronous terminal action that executes on the system actor. + /// + /// This variant is declared `nonisolated(nonsending)` and does not require a specific + /// global actor. It may suspend but still does not escape the current `compute` call. + /// Prefer the isolated variant when touching actor-isolated state. + /// + /// When an error is thrown from the closure the transducer treats it as a system error and + /// halts execution. + /// + /// - Parameter nonsendingOperation: A nonisoloated nonsending throwing closure closure that completes without emitting a next event. + /// - Returns: An action effect that completes the current step. + /// - Throws:Any error + public static func action(nonsendingOperation: nonisolated(nonsending) @escaping (Env) async throws -> Void) -> Self { + .init(.actionNonsendingAsync(nonsendingOperation)) + } + + /// Creates an asynchronous partial action that executes on the system actor and + /// emits the next domain event. + /// + /// This variant is declared `nonisolated(nonsending)` and does not require a specific + /// global actor. It may suspend but still does not escape the current `compute` call. + /// + /// When an error is thrown from the closure the transducer treats it as a system error and + /// halts execution. + /// + /// - Parameter nonsendingOperation: A nonisolated nonsending async throwing closure that returns the next `Event` to process. + /// - Returns: An action effect that produces a follow-up event for immediate processing. + @_disfavoredOverload + public static func action(nonsendingOperation: nonisolated(nonsending) @escaping (Env) async throws -> sending Event) -> Self { + .init(.actionNonsendingPartialAsync(nonsendingOperation)) + } + + /// Creates an asynchronous partial action that executes on the system actor and + /// emits the next domain event. + /// + /// This variant is declared `nonisolated(nonsending)` and does not require a specific + /// global actor. It may suspend but still does not escape the current `compute` call. + /// + /// When the closure returns an event, that event is processed immediately in the same + /// compute cycle. When it returns `nil`, the chain settles without producing a follow-up event. + /// + /// When an error is thrown from the closure the transducer treats it as a system error and + /// halts execution. + /// + /// - Parameter nonsendingOperation: A nonisolated nonsending async throwing closure that returns the next `Event` or `nil`. + /// - Returns: An action effect that produces a follow-up event for immediate processing, or settles if `nil`. + @_disfavoredOverload + public static func action(nonsendingOperation: nonisolated(nonsending) @escaping (Env) async throws -> sending Event?) -> Self { + .init(.actionNonsendingMaybePartialAsync(nonsendingOperation)) + } + +} + +// MARK: - Effect Tasks +extension TransducerEffect { + + /// Creates a task effect that runs an asynchronous, nonisolated operation and emits a follow-up event upon completion. + /// + /// When the task operation returns an event, the event will be dispatched to the transducer + /// with `request(_:)`. The task will only finish, when the request function returns. + /// + /// When the task operation returns `nil`, a response value will be computed from the current + /// state and the given event with the static transducer function `response(state:event:)`. + /// + /// This variant schedules work that may suspend and outlive the current compute cycle. The operation + /// is declared `nonisolated(nonsending)`, meaning it does not require a specific global actor and is + /// safe to call from any context. When the operation finishes successfully, it returns the next domain + /// `Event`, which will be fed back into the transducer. If it throws, the error is surfaced to the + /// runtime, which may cancel the task or ignore the failure depending on integration. + /// + /// ## Event return semantics + /// - **Returning `Event`**: The event is dispatched to the transducer as a new event, entering the + /// compute loop as if it were dispatched from outside. This is a *terminal* operation for the task + /// (the task ends), but the event becomes the start of a *new* compute cycle. + /// - **Returning `nil`**: The task completes without producing a follow-up event. A response value + /// is computed from the current state and the last event via `response(state:event:)`, which is + /// used to resume any waiting request callers. + /// + /// Task identity and execution policy: + /// - `id`: When provided, the runtime associates the task with this identifier, enabling cancellation + /// and deduplication behaviors. Reissuing a task with the same `id` allows the `option` policy to + /// determine how overlapping work is handled. + /// - `option`: Controls how new tasks interact with in-flight tasks sharing the same `id`. + /// For example, `.switchToLatest` cancels the previous task and starts the new one, while other + /// options may buffer or merge results depending on your implementation. + /// + /// Priority: + /// - `priority`: If specified, the task is created with the given `TaskPriority`, influencing its + /// scheduling relative to other tasks. + /// + /// Global actor isolation: + /// - Although the parameter is declared `nonisolated(nonsending)`, you can still pass a + /// closure that is isolated to a global actor (for example, `@MainActor`). Swift allows a + /// more-constrained closure to satisfy a less-constrained parameter. In that case, the task runs + /// under the specified global actor isolation. If you don't specify a global actor, the closure + /// executes on the system actor. + /// + /// Example: + /// ```swift + /// // Runs on the system actor by default (no explicit global actor) + /// .task(id: .init("load")) { input, env in + /// let value = try await env.api.fetch(input) + /// return .didLoad(value) + /// } + /// + /// // Runs isolated to the main actor + /// .task(id: .init("ui-work")) { @MainActor (input, env) in + /// // Touch main-actor state safely here + /// try await env.uiCoordinator.animateFor(input) + /// return .uiDidAnimate + /// } + /// ``` + /// + /// Operation: + /// - `nonsendingOperation`: A nonisolated, nonsending async closure that receives the current + /// transducer input and environment and returns the next `Event`. This closure may suspend and + /// perform long-running work. It can throw to signal failure. + /// + /// - Parameters: + /// - id: An optional identifier used to manage and cancel this task across effect emissions. + /// - priority: An optional `TaskPriority` for the created task. + /// - option: A `TaskAdditionPolicy` that defines how to handle overlapping tasks with the same `id`. + /// - nonsendingOperation: A nonisolated, nonsending async closure that produces the next `Event`. + /// - Returns: A task effect that, when executed by the runtime, starts the asynchronous operation and + /// emits its resulting `Event` back into the system. + public static func task( + id: TaskIdentifier? = nil, + priority: TaskPriority? = nil, + _ option: TaskAdditionPolicy = .switchToLatest, + nonsendingOperation: nonisolated(nonsending) @escaping (Input, Env) async throws -> sending Event? + ) -> Self { + .init( + .taskNonsendingOperationOptionalEvent( + id: id, + priority: priority, + option: option, + nonsendingOperation: nonsendingOperation + ) + ) + } + + /// Creates a task effect that runs an asynchronous, nonisolated operation. + /// + /// When the task completes, a response value will be computed from the current state and + /// the current event with the static transducer function `response(state:event:)`. Waiters + /// will be resumed with this response value. + /// + /// This variant schedules work that may suspend and outlive the current compute cycle. The operation + /// is declared `nonisolated(nonsending)`, meaning it does not require a specific global actor and is + /// safe to call from any context. When the operation finishes successfully, it returns the next domain + /// `Event`, which will be fed back into the transducer. If it throws, the error is surfaced to the + /// runtime, which may cancel the task or ignore the failure depending on integration. + /// + /// Task identity and execution policy: + /// - `id`: When provided, the runtime associates the task with this identifier, enabling cancellation + /// and deduplication behaviors. Reissuing a task with the same `id` allows the `option` policy to + /// determine how overlapping work is handled. + /// - `option`: Controls how new tasks interact with in-flight tasks sharing the same `id`. + /// For example, `.switchToLatest` cancels the previous task and starts the new one, while other + /// options may buffer or merge results depending on your implementation. + /// + /// Priority: + /// - `priority`: If specified, the task is created with the given `TaskPriority`, influencing its + /// scheduling relative to other tasks. + /// + /// Global actor isolation: + /// - Although the parameter is declared `nonisolated(nonsending)`, you can still pass a + /// closure that is isolated to a global actor (for example, `@MainActor`). Swift allows a + /// more-constrained closure to satisfy a less-constrained parameter. In that case, the task runs + /// under the specified global actor isolation. If you don't specify a global actor, the closure + /// executes on the system actor. + /// + /// Example: + /// ```swift + /// // Runs on the system actor by default (no explicit global actor) + /// .task(id: .init("load")) { input, env -> Void in + /// try await env.api.fetch(input) + /// } + /// + /// // Runs isolated to the main actor + /// .task(id: .init("ui-work")) { @MainActor (input, env) in + /// // Touch main-actor state safely here + /// try await env.uiCoordinator.animateFor(input) + /// } + /// ``` + /// + /// Operation: + /// - `nonsendingOperation`: A nonisolated, nonsending async closure that receives the current + /// transducer input and environment. This closure may suspend and perform long-running work. + /// It can throw to signal failure. + /// + /// - Parameters: + /// - id: An optional identifier used to manage and cancel this task across effect emissions. + /// - priority: An optional `TaskPriority` for the created task. +/// - option: A `TaskAdditionPolicy` that defines how to handle overlapping tasks with the same `id`. +/// - nonsendingOperation: A nonisolated, nonsending async closure. + /// - Returns: A task effect that, when executed by the runtime, starts the asynchronous operation. + public static func task( + id: TaskIdentifier? = nil, + priority: TaskPriority? = nil, + _ option: TaskAdditionPolicy = .switchToLatest, + nonsendingOperation: nonisolated(nonsending) @escaping (Input, Env) async throws -> Void + ) -> Self { + .init( + .taskNonsendingOperationOptionalEvent( + id: id, + priority: priority, + option: option, + nonsendingOperation: { input, event in + try await nonsendingOperation(input, event) + return nil + } + ) + ) + } + + /// Creates a task effect that runs an asynchronous, nonisolated operation and returns + /// a `TaskReturn` value that specifies how to dispatch the resulting event. + /// + /// + /// This variant gives full control over the dispatch style via value `TaskReturn` - which is + /// a dispostion value that tells the runtime whether to resume the waiters with a response value, + /// or if another task should be added - which prolonges the suspension of waiters. + /// + /// ## Operation Return Value `TaskReturn` + /// + /// The following return values are "terminal", and resume the waiters: + /// - `.response(event)`: resumes the waiters with the value of`response(state: S, event: event)`. + /// - `.send(event)`: awaits `input.send(event)`, then resumes the waiters with the value of `response(state: S, event: event)`. + /// - `.post(event)`: calls `input.post(event)`, then resumes the waiters with the value of `response(state: S, event: event)`. + /// + /// The following return values prolong the suspension of waiters: + /// - `.request(event)`: awaits and returns the value of`input.request(event)`. + /// - `.uniqueRequest(event)`: awaits and returns the value of`input.uniqueRequest(event)` + /// + /// ## Examples + /// ### Derive the response value directly from event (no dispatch) + /// + /// ```swift + /// case (_, .fetchImage(let url): + /// .task(id: "fetchImage-\(url)", .shareable) { input, env -> TaskReturn in + /// let data = try await env.client.fetch(url) + /// return .response(.receivedImage(data)) // send data into the system + /// } + ///``` + /// + /// ```swift + /// enum Response { case image(Data), .none } + /// static func response(state: State, event: Event) -> Response { + /// switch event { + /// case .receivedImage(let data): return .image(data) // send data out from the system + /// default: return .none + /// } + /// } + /// ``` + /// + /// ### Dispatch via request (full chain) + /// ```swift + /// case (_, .loadImage(let query): + /// .task(id: "loadImage-\(query)", .shareable) { input, env -> TaskReturn in + /// let url = try await env.api.fetchImageURL(query) + /// return .request(.fetchImage(url)) + /// } + ///``` + /// + /// ### Dispatch via send (transduce only) + ///```swift + /// .task(id: "update") { input, env -> TaskReturn in + /// try await env.service.update() + /// return .send(.updated) + /// } + /// ``` + /// + /// - Parameters: + /// - id: An optional identifier used to manage and cancel this task across effect emissions. + /// - priority: An optional `TaskPriority` for the created task. + /// - option: A `TaskAdditionPolicy` that defines how to handle overlapping tasks with the same `id`. + /// - nonsendingOperation: A nonisolated, nonsending async closure that returns a `TaskReturn` value. + /// - Returns: A task effect that, when executed by the runtime, starts the asynchronous operation + /// and dispatches the resulting event according to the returned `TaskReturn` case. + /// + /// > Important: The Transduce library does not allow to have task return a `Response` value directly. + /// The authoritive to compute a `Response` is the Tranducer's static function `response(state:event:)`. + public static func task( + id: TaskIdentifier? = nil, + priority: TaskPriority? = nil, + _ option: TaskAdditionPolicy = .switchToLatest, + nonsendingOperation: nonisolated(nonsending) @escaping (Input, Env) async throws -> sending TaskReturn + ) -> Self { + .init( + .taskNonsendingOperationReturn( + id: id, + priority: priority, + option: option, + nonsendingOperation: nonsendingOperation + ) + ) + } + +} + + +extension TransducerEffect.Effect: CustomStringConvertible { + + public var description: String { + switch self { + case .none: + return "none" + + case .taskNonsendingOperationOptionalEvent(id: let id, priority: let priority, option: let option, nonsendingOperation: _): + return "task(id: \(id, default: "nil"), priority: \(priority, default: "nil"), option: \(option))" + + case .taskNonsendingOperationReturn(id: let id, priority: let priority, option: let option, nonsendingOperation: _): + return "taskReturn(id: \(id, default: "nil"), priority: \(priority, default: "nil"), option: \(option))" + + case .actionSync(_): + return "actionSync()" + case .actionPartialSync(_): + return "partialActionSync()" + case .actionMaybePartialSync(_): + return "maybePartialActionSync()" + case .actionNonsendingAsync(_): + return "nonsendingActionAsync()" + case .actionNonsendingPartialAsync(_): + return "nonsendingPartialActionAsync()" + case .actionNonsendingMaybePartialAsync(_): + return "nonsendingMaybePartialActionAsync()" + + case .event(let event): + return "event(\(event))" + case .cancel(let id): + return "cancel(\(id))" + case .sequence(let effects): + return "sequence: " + effects.map { "\($0)"}.joined(separator: ", ") + } + } +} + +extension TransducerEffect: CustomStringConvertible { + public var description: String { + return "\(type)" + } +} + diff --git a/Tests/EffectComponents/AsyncActionRuntimeTests.swift b/Tests/EffectComponents/AsyncActionRuntimeTests.swift deleted file mode 100644 index 23c7a1e..0000000 --- a/Tests/EffectComponents/AsyncActionRuntimeTests.swift +++ /dev/null @@ -1,176 +0,0 @@ -import Testing -@testable import EffectComponents - -#if canImport(Observation) - -@Suite("Async action runtime") -@MainActor -struct AsyncActionRuntimeTests { - - actor AsyncGate: Sendable { - private var waiters: [CheckedContinuation] = [] - - func wait() async { - await withCheckedContinuation { continuation in - waiters.append(continuation) - } - } - - func open() { - let pending = waiters - waiters = [] - for waiter in pending { - waiter.resume() - } - } - } - - enum AsyncActionCancellationTransducer: Transducer { - struct State: Equatable { - var phases: [String] = [] - } - - enum Event: Sendable { - case start - case finished - } - - struct Env: Sendable { - let started: Expectation - let release: AsyncGate - } - - typealias Output = String - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .start: - state.phases.append("start") - return .action { env in - env.started.fulfill() - await env.release.wait() - return .finished - } - - case .finished: - state.phases.append("finished") - return nil - } - } - - static func output(state: State, event: Event) -> String { - state.phases.joined(separator: ",") - } - } - - @Test func requestThrowsCancellationWhenCancelledDuringAsyncAction() async throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - let started = Expectation() - let release = AsyncGate() - let timeout: UInt64 = 5_000_000_000 - - let observable = EffectObservable( - initialState: .init(), - env: .init(started: started, release: release) - ) - - let waiter = Task { - try await observable.request(.start) - } - - try await started.await(nanoseconds: timeout) - - observable.cancel() - await Task.yield() - - await release.open() - - do { - _ = try await waiter.value - Issue.record("Expected accepted request to receive RuntimeError.cancelled") - } catch let error as RuntimeError { - #expect(error == .cancelled) - } catch { - Issue.record("Unexpected waiter error: \(error)") - } - - #expect(observable.state.phases == ["start"]) - } - - @Test func concurrentSendWaitsForEarlierAsyncActionToFinish() async throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - enum T: Transducer { - struct State: Equatable { - var events: [String] = [] - } - - enum Event: Sendable { - case first - case firstFinished - case second - case finished - } - - struct Env { - let firstStartedExpectation = Expectation() - let finishedExpectation = Expectation() - } - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .first: - state.events.append("first") - return .action { env in - env.firstStartedExpectation.fulfill() - try? await Task.sleep(nanoseconds: 10_000_000) - return .firstFinished - } - - case .firstFinished: - state.events.append("firstFinished") - return nil - - case .second: - state.events.append("second") - return .send(.finished) - - case .finished: - return .action { env in - env.finishedExpectation.fulfill() - return nil - } - } - } - } - - let env: T.Env = .init() - let observable = EffectObservable( - initialState: .init(), - env: env - ) - - let firstSender = Task { - try await observable.send(.first) - } - - try await env.firstStartedExpectation.await(nanoseconds: 5_000_000_000) - - let secondSender = Task { - try await observable.send(.second) - } - - try await env.finishedExpectation.await(nanoseconds: 5_000_000_000) - try await firstSender.value - try await secondSender.value - - #expect(observable.state.events == ["first", "firstFinished", "second"]) - } -} - -#endif diff --git a/Tests/EffectComponents/EffectActorInputTests.swift b/Tests/EffectComponents/EffectActorInputTests.swift deleted file mode 100644 index 26d9de5..0000000 --- a/Tests/EffectComponents/EffectActorInputTests.swift +++ /dev/null @@ -1,152 +0,0 @@ -import EffectComponents -import Testing - -@Suite("EffectActor.Input") -struct EffectActorInputTests { - - enum IdleTransducer: Transducer { - struct State: Equatable { - var value = 0 - } - - enum Event: Sendable { - case ping - } - - static func update(_ state: inout State, event: Event) -> Effect? { - nil - } - } - - enum CounterTransducer: Transducer { - struct State: Equatable { - var count = 0 - } - - enum Event: Sendable { - case increment - } - - typealias Output = Int - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .increment: - state.count += 1 - return nil - } - } - - static func output(state: State, event: Event) -> Int { - state.count - } - } - - @Test func inputSendThrowsWhenActorIsNotInitialised() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - let actor = EffectActor(initialState: .init()) - let input = await actor.input - - do { - try await input.send(.ping) - Issue.record("Expected RuntimeError.actorNotInitialised") - } catch let error as RuntimeError { - #expect(error == .actorNotInitialised) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func inputRequestThrowsWhenActorIsNotInitialised() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - let actor = EffectActor(initialState: .init()) - let input = await actor.input - - do { - _ = try await input.request(.ping) - Issue.record("Expected RuntimeError.actorNotInitialised") - } catch let error as RuntimeError { - #expect(error == .actorNotInitialised) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func inputAcquiredBeforeStartWorksAfterActorStarts() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - let actor = EffectActor(initialState: .init()) - let input = await actor.input - try await actor.start(env: ()) - - let output = try await input.request(.increment) - - #expect(output == 1) - #expect(await actor.state.count == 1) - } - - @Test func inputSendThrowsWhenActorIsDeallocated() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - var actor: EffectActor? = EffectActor(initialState: .init()) - let requiredInput = try await #require(actor).input - actor = nil - - do { - try await requiredInput.send(.ping) - Issue.record("Expected RuntimeError.actorDeallocated") - } catch let error as RuntimeError { - #expect(error == .actorDeallocated) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func inputRequestThrowsWhenActorIsDeallocated() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - var actor: EffectActor? = EffectActor(initialState: .init()) - let requiredInput = try await #require(actor).input - actor = nil - - do { - _ = try await requiredInput.request(.ping) - Issue.record("Expected RuntimeError.actorDeallocated") - } catch let error as RuntimeError { - #expect(error == .actorDeallocated) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func inputPostThrowsWhenActorIsDeallocated() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - var actor: EffectActor? = EffectActor(initialState: .init()) - let requiredInput = try await #require(actor).input - actor = nil - - do { - try requiredInput.post(.ping) - Issue.record("Expected RuntimeError.actorDeallocated") - } catch let error as RuntimeError { - #expect(error == .actorDeallocated) - } catch { - Issue.record("Unexpected error: \(error)") - } - } -} \ No newline at end of file diff --git a/Tests/EffectComponents/EffectActorTests.swift b/Tests/EffectComponents/EffectActorTests.swift deleted file mode 100644 index f4404e0..0000000 --- a/Tests/EffectComponents/EffectActorTests.swift +++ /dev/null @@ -1,320 +0,0 @@ -import Foundation -import EffectComponents -import Testing - -@Suite("EffectActor") -struct EffectActorTests { - - enum IdleTransducer: Transducer { - struct State: Equatable { - var value = 0 - } - - enum Event: Sendable { - case ping - } - - static func update(_ state: inout State, event: Event) -> Effect? { - nil - } - } - - enum SystemFailure: Error, Equatable { - case boom - } - - enum RequestCancellationTransducer: Transducer { - struct State: Equatable {} - - enum Event: Sendable { - case start - } - - struct Env: Sendable { - let started: Expectation - } - - typealias Output = String - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .start: - return .task(id: "work") { _, env in - env.started.fulfill() - try await Task.sleep(nanoseconds: 10_000_000_000) - return "done" - } - } - } - - static func output(state: State, event: Event) -> String { - "" - } - } - - @Test func testActorStartsWithInitialEvent() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - enum T: Transducer { - enum State { - case start - case running - case finished - } - enum Event { - case start - case tick - } - struct Env { - let finishedExpectation = Expectation() - } - - static func update(_ state: inout State, event: Event) -> Effect? { - switch (state, event) { - case (.start, .start): - state = .running - return .send(.tick) - case (.start, .tick): - return nil - case (.running, .tick): - state = .finished - return .action { env in - env.finishedExpectation.fulfill() - } - case (.running, .start): - return nil - case (.finished, _): - return nil - } - } - } - - let env = T.Env() - let actor = EffectActor(initialState: .start) - try await actor.start(initialEvent: .start, env: env) - - try await env.finishedExpectation.await(nanoseconds: 1_000_000_000) - } - - - - /// Tests, if the initialisation of an Effect Actor can be made safe from race conditions. - /// Note: A Swift Actor cannot always be fully initialised with the init function because - /// it is non-isolated. A initialisation which requires isolation is thus cumbersome. Effect - /// Actor provides an easy way to accomplish this. - @Test func raceFreeInitialization() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - enum T: Transducer { - enum State { - case start - case initializing - case idle - case tearingDown - case terminal - } - enum Event { - case start - case didSetup - case tick - case teardown - case completed - } - final actor Env { - func setUpCalled() { - setupCount += 1 - } - func tickCalled() { - tickCount += 1 - } - private(set) var setupCount: Int = 0 - private(set) var tickCount: Int = 0 - let finishedExpectation = Expectation() - } - - static func update(_ state: inout State, event: Event) -> Effect? { - switch (state, event) { - case (.start, .start): - state = .initializing - return .action { env in - await env.setUpCalled() - return .didSetup - } - - case (.initializing, .didSetup): - // setup function should be synchronous - state = .idle - return nil - - case (.idle, .tick): - return .action { env in - await env.tickCalled() - } - - case (.idle, .teardown): - state = .tearingDown - return .action { env in - return .completed - } - - case (.tearingDown, .completed): - state = .terminal - return .action { env in - env.finishedExpectation.fulfill() - } - - case (.start, _): - return nil - case (.initializing, _): - return nil - case (.idle, _): - return nil - case (.tearingDown, _): - return nil - case (.terminal, _): - return nil - } - } - } - - let env = T.Env() - let actor = EffectActor(initialState: .start) - try await actor.start( env: env) - - Task { - try await actor.request(.start) - try await actor.send(.tick) - } - Task { - try await actor.request(.start) - try await actor.send(.tick) - } - Task { - try await actor.request(.start) - try await actor.send(.tick) - try await Task.sleep(nanoseconds: 100_000_000) - try await actor.send(.teardown) - } - - try await env.finishedExpectation.await(nanoseconds: 5_000_000_000) - #expect(await env.setupCount == 1) - #expect(await env.tickCount == 3) - } - - @Test func startThrowsWhenCalledTwice() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - let actor = EffectActor(initialState: .init()) - try await actor.start(env: ()) - - do { - try await actor.start(env: ()) - Issue.record("Expected RuntimeError.actorAlreadyInitialised") - } catch let error as RuntimeError { - #expect(error == .actorAlreadyInitialised) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func sendThrowsWhenActorIsNotInitialised() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - let actor = EffectActor(initialState: .init()) - - do { - try await actor.send(.ping) - Issue.record("Expected RuntimeError.actorNotInitialised") - } catch let error as RuntimeError { - #expect(error == .actorNotInitialised) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func requestThrowsWhenActorIsNotInitialised() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - let actor = EffectActor(initialState: .init()) - - do { - _ = try await actor.request(.ping) - Issue.record("Expected RuntimeError.actorNotInitialised") - } catch let error as RuntimeError { - #expect(error == .actorNotInitialised) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func acceptedRequestReceivesCustomSystemErrorWhenCancelled() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - let started = Expectation() - let actor = EffectActor(initialState: .init()) - try await actor.start(env: .init(started: started)) - - let waiter = Task { - try await actor.request(.start) - } - - try await started.await(nanoseconds: 5_000_000_000) - actor.cancel(with: SystemFailure.boom) - - do { - _ = try await waiter.value - Issue.record("Expected accepted request to receive SystemFailure.boom") - } catch let error as SystemFailure { - #expect(error == .boom) - } catch { - Issue.record("Unexpected waiter error: \(error)") - } - } - - @Test func laterEntryPointsThrowSystemErrorAfterCancelWithError() async throws { - guard #available(iOS 18.4, macOS 15.4.0, tvOS 18.4, watchOS 11.4, *) else { - return - } - - let started = Expectation() - let actor = EffectActor(initialState: .init()) - try await actor.start(env: .init(started: started)) - - let waiter = Task { - try await actor.request(.start) - } - - try await started.await(nanoseconds: 5_000_000_000) - actor.cancel(with: SystemFailure.boom) - - do { - _ = try await waiter.value - Issue.record("Expected accepted request to receive SystemFailure.boom") - } catch let error as SystemFailure { - #expect(error == .boom) - } catch { - Issue.record("Unexpected waiter error: \(error)") - } - - do { - _ = try await actor.request(.start) - Issue.record("Expected RuntimeError.systemError") - } catch let error as RuntimeError { - #expect(error == .systemError) - } catch { - Issue.record("Unexpected error: \(error)") - } - } -} diff --git a/Tests/EffectComponents/EffectObservableInputTests.swift b/Tests/EffectComponents/EffectObservableInputTests.swift deleted file mode 100644 index f79328c..0000000 --- a/Tests/EffectComponents/EffectObservableInputTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Testing -@testable import EffectComponents - -#if canImport(Observation) - -@Suite("EffectObservable.Input") -@MainActor -struct EffectObservableInputTests { - - enum IdleTransducer: Transducer { - struct State: Equatable {} - enum Event: Sendable { case ping } - - static func update(_ state: inout State, event: Event) -> Effect? { - nil - } - } - - @Test func inputSendThrowsWhenOwnerIsDeallocated() async throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - var observable: EffectObservable? = EffectObservable(initialState: .init(), env: ()) - let input = try #require(observable?.input) - observable = nil - - do { - try await input.send(.ping) - Issue.record("Expected RuntimeError.actorDeallocated") - } catch let error as RuntimeError { - #expect(error == .actorDeallocated) - #expect(error.errorDescription == "The effect actor is unavailable because it has already been deallocated.") - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func inputRequestThrowsWhenOwnerIsDeallocated() async throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - var observable: EffectObservable? = EffectObservable(initialState: .init(), env: ()) - let input = try #require(observable?.input) - observable = nil - - do { - _ = try await input.request(.ping) - Issue.record("Expected RuntimeError.actorDeallocated") - } catch let error as RuntimeError { - #expect(error == .actorDeallocated) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func inputPostThrowsWhenOwnerIsDeallocated() throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - var observable: EffectObservable? = EffectObservable(initialState: .init(), env: ()) - let input = try #require(observable?.input) - observable = nil - - do { - try input.post(.ping) - Issue.record("Expected RuntimeError.actorDeallocated") - } catch let error as RuntimeError { - #expect(error == .actorDeallocated) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func inputPostThrowsWhenObservableIsCancelled() throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - let observable = EffectObservable(initialState: .init(), env: ()) - let input = observable.input - observable.cancel() - - do { - try input.post(.ping) - Issue.record("Expected RuntimeError.actorCancelled") - } catch let error as RuntimeError { - #expect(error == .actorCancelled) - } catch { - Issue.record("Unexpected error: \(error)") - } - } -} - -#endif \ No newline at end of file diff --git a/Tests/EffectComponents/EffectObservableTests.swift b/Tests/EffectComponents/EffectObservableTests.swift deleted file mode 100644 index 41f01f1..0000000 --- a/Tests/EffectComponents/EffectObservableTests.swift +++ /dev/null @@ -1,275 +0,0 @@ -import Testing -@testable import EffectComponents - -#if canImport(Observation) - -@Suite("EffectObservable") -@MainActor -struct EffectObservableTests { - - enum LatchedSystemError: Error, Equatable { - case boom - } - - enum ExternalSystemFailure: Error, Equatable { - case boom - } - - enum IdleTransducer: Transducer { - struct State: Equatable {} - enum Event: Sendable { case ping } - - static func update(_ state: inout State, event: Event) -> Effect? { - nil - } - } - - enum RequestCancellationTransducer: Transducer { - struct State: Equatable {} - enum Event: Sendable { case start } - - struct Env: Sendable { - let started: Expectation - let cancelled: Expectation - } - - typealias Output = String - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .start: - return .task(id: "work") { _, env in - env.started.fulfill() - do { - while true { - try await Task.sleep(nanoseconds: 50_000_000) - } - } catch is CancellationError { - env.cancelled.fulfill() - throw CancellationError() - } - } - } - } - - static func output(state: State, event: Event) -> String { - "" - } - } - - enum LatchedFailureTransducer: Transducer { - struct State: Equatable {} - enum Event: Sendable { case start } - - typealias Output = String - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .start: - return .task(id: "work") { _, _ in - throw LatchedSystemError.boom - } - } - } - - static func output(state: State, event: Event) -> String { - "" - } - } - - @Test func sendThrowsActorCancelledWhenObservableIsCancelled() async throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - let observable = EffectObservable(initialState: .init(), env: ()) - observable.cancel() - - do { - try await observable.send(.ping) - Issue.record("Expected RuntimeError.actorCancelled") - } catch let error as RuntimeError { - #expect(error == .actorCancelled) - #expect(error.errorDescription == "The effect actor is unavailable because it has already been cancelled.") - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func requestThrowsActorCancelledWhenObservableIsCancelled() async throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - let observable = EffectObservable(initialState: .init(), env: ()) - observable.cancel() - - do { - _ = try await observable.request(.ping) - Issue.record("Expected RuntimeError.actorCancelled") - } catch let error as RuntimeError { - #expect(error == .actorCancelled) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test func acceptedRequestIsCancelledWhenObservableIsCancelled() async throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - let started = Expectation() - let cancelled = Expectation() - let timeout: UInt64 = 5_000_000_000 - let observable = EffectObservable( - initialState: .init(), - env: .init(started: started, cancelled: cancelled) - ) - - let waiter = Task { - try await observable.request(.start) - } - - try await started.await(nanoseconds: timeout) - observable.cancel() - - do { - _ = try await waiter.value - Issue.record("Expected accepted request to receive CancellationError") - } catch is CancellationError { - } catch { - Issue.record("Unexpected waiter error: \(error)") - } - - try await cancelled.await(nanoseconds: timeout) - } - - @Test func acceptedRequestReceivesCustomSystemErrorWhenCancelled() async throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - let started = Expectation() - let cancelled = Expectation() - let timeout: UInt64 = 5_000_000_000 - let observable = EffectObservable( - initialState: .init(), - env: .init(started: started, cancelled: cancelled) - ) - - let waiter = Task { - try await observable.request(.start) - } - - try await started.await(nanoseconds: timeout) - observable.cancel(with: ExternalSystemFailure.boom) - - do { - _ = try await waiter.value - Issue.record("Expected accepted request to receive ExternalSystemFailure.boom") - } catch let error as ExternalSystemFailure { - #expect(error == .boom) - } catch { - Issue.record("Unexpected waiter error: \(error)") - } - - try await cancelled.await(nanoseconds: timeout) - } - - @Test func laterEntryPointsThrowSystemErrorAfterCancelWithError() async throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - let started = Expectation() - let cancelled = Expectation() - let timeout: UInt64 = 5_000_000_000 - let observable = EffectObservable( - initialState: .init(), - env: .init(started: started, cancelled: cancelled) - ) - - let waiter = Task { - try await observable.request(.start) - } - - try await started.await(nanoseconds: timeout) - observable.cancel(with: ExternalSystemFailure.boom) - - do { - _ = try await waiter.value - Issue.record("Expected accepted request to receive ExternalSystemFailure.boom") - } catch let error as ExternalSystemFailure { - #expect(error == .boom) - } catch { - Issue.record("Unexpected waiter error: \(error)") - } - - try await cancelled.await(nanoseconds: timeout) - - do { - _ = try await observable.request(.start) - Issue.record("Expected RuntimeError.systemError") - } catch let error as RuntimeError { - #expect(error == .systemError) - } catch { - Issue.record("Unexpected later request error: \(error)") - } - } - - @Test func requestThrowsLatchedSystemErrorInsteadOfHanging() async throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - let observable = EffectObservable(initialState: .init(), env: ()) - - do { - _ = try await observable.request(.start) - Issue.record("Expected accepted request to receive the task failure") - } catch let error as LatchedSystemError { - #expect(error == .boom) - } catch { - Issue.record("Unexpected first request error: \(error)") - } - - do { - _ = try await observable.request(.start) - Issue.record("Expected later request to throw RuntimeError.systemError") - } catch let error as RuntimeError { - #expect(error == .systemError) - } catch { - Issue.record("Unexpected later request error: \(error)") - } - } - - @Test func sendThrowsSystemErrorWhenObservableHasLatchedFailure() async throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return - } - - let observable = EffectObservable(initialState: .init(), env: ()) - - do { - _ = try await observable.request(.start) - Issue.record("Expected accepted request to receive the task failure") - } catch let error as LatchedSystemError { - #expect(error == .boom) - } catch { - Issue.record("Unexpected first request error: \(error)") - } - - do { - try await observable.send(.start) - Issue.record("Expected RuntimeError.systemError") - } catch let error as RuntimeError { - #expect(error == .systemError) - #expect(error.errorDescription == "The runtime is unavailable because it has forcibly terminated because of a critical error.") - } catch { - Issue.record("Unexpected later send error: \(error)") - } - } -} - -#endif diff --git a/Tests/EffectComponents/EffectViewInputTests.swift b/Tests/EffectComponents/EffectViewInputTests.swift deleted file mode 100644 index 0540fed..0000000 --- a/Tests/EffectComponents/EffectViewInputTests.swift +++ /dev/null @@ -1,279 +0,0 @@ -#if canImport(SwiftUI) && (canImport(UIKit) || canImport(AppKit)) -import Foundation -import Testing -import SwiftUI -@testable import EffectComponents - -@Suite("EffectView.Input") -@MainActor -struct EffectViewInputTests { - - @Test func inputIdentityRemainsStableAcrossRerenders() async throws { - enum T: Transducer { - struct State: Equatable { var count = 0 } - enum Event: Sendable { case increment } - - static func update(_ state: inout State, event: Event) -> Effect? { - state.count += 1 - return nil - } - } - - var capturedInputs: [EffectViewInput] = [] - let rerenderExpectation = Expectation() - let timeout: UInt64 = 5_000_000_000 - - try await testView(initialState: T.State()) { binding in - EffectView(of: T.self, state: binding) { state, input in - Text("\(state.count)") - .onAppear { - capturedInputs.append(input) - } - .onChange(of: state.count) { _, _ in - capturedInputs.append(input) - rerenderExpectation.fulfill() - } - } - } expect: { - #expect(capturedInputs.count == 1) - try await capturedInputs[0].send(.increment) - try await rerenderExpectation.await(nanoseconds: timeout) - #expect(capturedInputs.count == 2) - #expect(capturedInputs[0] == capturedInputs[1]) - #expect(capturedInputs[0].id == capturedInputs[1].id) - } - } - - @Test func inputCanBePassedToChildViewAndUsedToUpdateState() async throws { - enum T: Transducer { - struct State: Equatable { var count = 0 } - enum Event: Sendable { case increment } - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .increment: - state.count += 1 - return nil - } - } - } - - struct ChildView: View { - let count: Int - let input: EffectViewInput - let onAppear: (EffectViewInput) -> Void - - var body: some View { - Text("\(count)") - .onAppear { onAppear(input) } - } - } - - var capturedInput: EffectViewInput? - let updateExpectation = Expectation() - let timeout: UInt64 = 5_000_000_000 - - try await testView(initialState: T.State()) { binding in - EffectView(of: T.self, state: binding) { state, input in - ChildView(count: state.count, input: input) { propagatedInput in - capturedInput = propagatedInput - } - .onChange(of: state.count) { _, _ in - updateExpectation.fulfill() - } - } - } expect: { - guard let input = capturedInput else { - Issue.record("Input not captured from child view") - return - } - - try await input.send(.increment) - try await updateExpectation.await(nanoseconds: timeout) - } - } - - @Test func requestSuspendsUntilUpdateCompletes() async throws { - enum T: Transducer { - struct State: Equatable { var count = 0 } - enum Event: Sendable { case increment } - static func update(_ state: inout State, event: Event) -> Effect? { - state.count += 1 - return nil - } - } - - var capturedInput: EffectViewInput? - - try await testView(initialState: T.State()) { binding in - EffectView(of: T.self, state: binding) { _, input in - Color.clear.onAppear { - capturedInput = input - } - } - } expect: { - guard let input = capturedInput else { Issue.record("Input not captured"); return } - - try await input.request(.increment) - try await input.request(.increment) - try await input.request(.increment) - } - } - - @Test func multipleEventsProcessedInOrder() async throws { - enum T: Transducer { - struct State { var log: [Int] = [] } - enum Event: Sendable { case record(Int) } - typealias Output = [Int] - - static func update(_ state: inout State, event: Event) -> Effect? { - if case .record(let n) = event { state.log.append(n) } - return nil - } - - static func output(state: State, event: Event) -> [Int] { - state.log - } - } - - var capturedInput: EffectViewInput? - - try await testView(initialState: T.State()) { binding in - EffectView(of: T.self, state: binding) { state, input in - Text("\(state.log.count)") - .onAppear { - capturedInput = input - } - } - } expect: { - guard let input = capturedInput else { Issue.record("Input not captured"); return } - - var outputs: [[Int]] = [] - for i in 1...5 { - outputs.append(try await input.request(.record(i)) ?? []) - } - - #expect(outputs == [ - [1], - [1, 2], - [1, 2, 3], - [1, 2, 3, 4], - [1, 2, 3, 4, 5], - ]) - } - } - - @Test func requestReturnsOutputFromTaskClosure() async throws { - enum T: Transducer { - struct State: Equatable { var value: String = "" } - enum Event: Sendable { case load, loaded(String) } - typealias Output = String - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .load: - return .task(id: "load") { input, _ in - do { - try await Task.sleep(for: .milliseconds(1)) - } catch { - } - let result = "hello" - let output = try? await input.request(Event.loaded(result)) - return output - } - case .loaded(let value): - state.value = value - return nil - } - } - - static func output(state: State, event: Event) -> String { - state.value - } - } - - var capturedInput: EffectViewInput? - - try await testView(initialState: T.State()) { binding in - EffectView(of: T.self, state: binding) { _, input in - Color.clear.onAppear { capturedInput = input } - } - } expect: { - guard let input = capturedInput else { Issue.record("Input not captured"); return } - let output = try await input.request(.load) - #expect(output == "hello") - } - } - - @Test func requestThrowsLatchedSystemErrorInsteadOfHanging() async throws { - enum TestError: Error, Equatable { - case boom - } - - enum T: Transducer { - struct State: Equatable {} - enum Event: Sendable { case load } - typealias Output = String - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .load: - return .task(id: "load") { _, _ in - throw TestError.boom - } - } - } - - static func output(state: State, event: Event) -> String { - "" - } - } - - var capturedInput: EffectViewInput? - let completion = Expectation() - let timeout: UInt64 = 5_000_000_000 - - try await testView(initialState: T.State()) { binding in - EffectView(of: T.self, state: binding) { _, input in - Color.clear.onAppear { capturedInput = input } - } - } expect: { - guard let input = capturedInput else { Issue.record("Input not captured"); return } - - do { - _ = try await input.request(.load) - Issue.record("Expected first request to receive the task failure") - } catch let error as TestError { - #expect(error == .boom) - } catch { - Issue.record("Unexpected first request error: \(error)") - } - - let secondRequest = Task { - do { - _ = try await input.request(.load) - Issue.record("Expected second request to throw RuntimeError.systemError") - } catch let error as RuntimeError { - #expect(error == .systemError) - } catch { - Issue.record("Unexpected second request error: \(error)") - } - completion.fulfill() - } - - try await completion.await(nanoseconds: timeout) - _ = await secondRequest.result - } - } -} - -#else -import Testing - -@Suite("EffectView.Input (SwiftUI unavailable)") -struct EffectViewInputTests { - @Test func skipped() { - } -} - -#endif diff --git a/Tests/EffectComponents/RunFailureLifecycleTests.swift b/Tests/EffectComponents/RunFailureLifecycleTests.swift deleted file mode 100644 index bc735ab..0000000 --- a/Tests/EffectComponents/RunFailureLifecycleTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Foundation -import Testing -import EffectComponents - -#if false // Feature run is not yet implemented -@Suite("Run stub") -struct RunFailureLifecycleTests { - - private final class TestStorage: Storage { - init(value: Value) { - self.value = value - } - - var value: Value - } - - private struct StubInput: TransducerInput, Sendable { - func send(_ event: sending Event) async throws {} - - func post(_ event: sending Event) throws {} - - func request(_ event: Event) async throws -> Output? { - nil - } - } - - @MainActor - @Test func mainActorRunStubThrowsNotImplemented() async throws { - enum T: Transducer { - struct State: Equatable, Sendable { var count = 0 } - enum Event: Sendable { case increment } - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .increment: - state.count += 1 - return nil - } - } - } - - let input = StubInput() - let send = T.makeSend( - with: StubInput.self, - storage: TestStorage(value: T.State()), - env: () - ) - - do { - _ = try await T.run(send: send, initialState: T.State(), input: input) - Issue.record("Expected RunError.notImplemented") - } catch let error as RunError { - #expect(error == .notImplemented) - } catch { - Issue.record("Unexpected error: \(error)") - } - } - - @Test - @TestGlobalActor - func globalActorRunStubThrowsNotImplemented() async throws { - enum T: Transducer { - struct State: Equatable, Sendable { var count = 0 } - enum Event: Sendable { case increment } - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .increment: - state.count += 1 - return nil - } - } - } - - let input = StubInput() - let send = T.makeSend( - systemActor: TestGlobalActor.shared, - with: StubInput.self, - storage: TestStorage(value: T.State()), - env: () - ) - - do { - _ = try await T.run(send: send, initialState: T.State(), input: input) - Issue.record("Expected RunError.notImplemented") - } catch let error as RunError { - #expect(error == .notImplemented) - } catch { - Issue.record("Unexpected error: \(error)") - } - } -} -#endif diff --git a/Tests/EffectComponents/TaskManagerTests.swift b/Tests/EffectComponents/TaskManagerTests.swift deleted file mode 100644 index be91165..0000000 --- a/Tests/EffectComponents/TaskManagerTests.swift +++ /dev/null @@ -1,282 +0,0 @@ -import Foundation -import Testing -@testable import EffectComponents - -private enum MyError: Error, Equatable { - case boom - case later -} - - -private enum TaskManagerStateSnapshot: Equatable, Sendable { - case active - case cancellingNoError - case cancellingBoom - case cancellingOther - case cancelledNoError - case cancelledBoom - case cancelledOther -} - -private actor TaskManagerHarness { - typealias OperationFunc = nonisolated(nonsending) () async throws -> Output? - typealias Continuation = TaskManager.Continuation - - let taskManager = TaskManager() - - func addTask( - with identifier: TaskIdentifier? = nil, - option: TaskExecutionOption, - continuation: CheckedContinuation?, - priority: TaskPriority? = nil, - operation: sending @escaping OperationFunc - ) { - taskManager.addTask( - with: identifier, - option: option, - continuation: continuation, - priority: priority, - isolatedOperation: { actor in - self.assertIsolated() - return try await operation() - } - ) - } - - func request( - identifier: TaskIdentifier, - option: TaskExecutionOption = .subscribe, - started: Expectation? = nil, - finished: Expectation? = nil, - failure: Expectation? = nil, - cancelled: Expectation? = nil, - operation: sending @escaping OperationFunc - ) async throws -> Output? { - try await withCheckedThrowingContinuation { (continuation: Continuation) in - addTask( - with: identifier, - option: option, - continuation: continuation, - operation: { - do { - defer { finished?.fulfill() } - started?.fulfill() - return try await operation() - } catch is CancellationError { - cancelled?.fulfill() - throw CancellationError() - } catch { - failure?.fulfill() - throw error - } - } - ) - } - } - - func cancelWithError(_ error: any Error) { - taskManager.cancel(with: error) - } - - func cancelWithoutError() { - taskManager.cancel() - } - - func failTrackedTask( - identifier: TaskIdentifier, - started: Expectation? - ) async throws -> Output? { - try await withCheckedThrowingContinuation { continuation in - taskManager.addTask( - systemActor: self, - with: identifier, - option: .subscribe, - continuation: continuation, - isolatedOperation: { _ in - started?.fulfill() - throw MyError.boom - } - ) - } - } - - func checkCancellation() throws { - try taskManager.checkCancellation() - } - - func stateSnapshot() -> TaskManagerStateSnapshot { - switch taskManager.state { - case .active: - return .active - case .cancelling(let error): - switch error { - case nil: - return .cancellingNoError - case let error as MyError where error == .boom: - return .cancellingBoom - default: - return .cancellingOther - } - case .cancelled(let error): - switch error { - case nil: - return .cancelledNoError - case let error as MyError where error == .boom: - return .cancelledBoom - default: - return .cancelledOther - } - } - } -} - -@Suite("Task manager") -struct TaskManagerTests { - - @Test func cancelWithSystemErrorCancelsTrackedTasksAndRejectsNewAdds() async throws { - let harness = TaskManagerHarness() - let started = Expectation() - let cancelled = Expectation() - let timeout: UInt64 = 5_000_000_000 - - let waiter = Task { - try await harness.request( - identifier: "tracked", - started: started, - cancelled: cancelled - ) { - while true { - try await Task.sleep(nanoseconds: 50_000_000) - } - } - } - - try await started.await(nanoseconds: timeout) - await harness.cancelWithError(MyError.boom) - - await #expect(throws: MyError.boom, "Expected latched system error") { - try await harness.checkCancellation() - } - await #expect(throws: MyError.boom) { - _ = try await waiter.value - } - - try await cancelled.await(nanoseconds: timeout) - await #expect(throws: MyError.boom, "Expected new waiter to be rejected after system error") { - _ = try await harness.request( - identifier: "tracked" - ) { - Issue.record("Unexpected execution of task") - while true { - try await Task.sleep(nanoseconds: 50_000_000) - } - } - } - } - - @Test func cancelWithSystemErrorKeepsFirstError() async throws { - let harness = TaskManagerHarness() - - await harness.cancelWithError(MyError.boom) - await harness.cancelWithError(MyError.later) - - await #expect(throws: MyError.boom, "Expected first latched system error") { - try await harness.checkCancellation() - } - } - - @Test func thrownTaskErrorLatchesSystemErrorAndCancelsItsWaiters() async throws { - let harness = TaskManagerHarness() - let started = Expectation() - let timeout: UInt64 = 5_000_000_000 - - let waiter = Task { - try await harness.failTrackedTask(identifier: "tracked", started: started) - } - - try await started.await(nanoseconds: timeout) - - await #expect(throws: MyError.boom, "Expected thrown task waiter to receive the latched system error") { - _ = try await waiter.value - } - await #expect(throws: MyError.boom, "Expected latched system error") { - try await harness.checkCancellation() - } - } - - @Test func cancelWithoutErrorCancelsActiveWaitersButRejectsFutureOnesAsRuntimeUnavailable() async throws { - let harness = TaskManagerHarness() - let started = Expectation() - let cancelled = Expectation() - let timeout: UInt64 = 5_000_000_000 - - let waiter = Task { - try await harness.request( - identifier: "tracked", - started: started, - cancelled: cancelled - ) { - while true { - try await Task.sleep(nanoseconds: 50_000_000) - } - } - } - - try await started.await(nanoseconds: timeout) - await harness.cancelWithoutError() - - await #expect(throws: RuntimeError.cancelled, "Expected runtime unavailable cancellation") { - try await harness.checkCancellation() - } - await #expect(throws: CancellationError.self, "Expected active waiter to receive CancellationError") { - _ = try await waiter.value - } - try await cancelled.await(nanoseconds: timeout) - await #expect(throws: RuntimeError.cancelled, "Expected future waiter to be rejected as runtime unavailable") { - _ = try await harness.request( - identifier: "tracked", - started: nil, - cancelled: nil - ) { - while true { - try await Task.sleep(nanoseconds: 50_000_000) - } - } - } - } - - @Test func cancelTransitionsToCancelledStateImmediatelyWhenNoTasksAreTracked() async { - let harness = TaskManagerHarness() - await harness.cancelWithError(MyError.boom) - #expect(await harness.stateSnapshot() == .cancelledBoom) - } - - @Test func cancelTransitionsFromCancellingToCancelledAfterTrackedTaskDrains() async throws { - let harness = TaskManagerHarness() - let started = Expectation() - let cancelled = Expectation() - let timeout: UInt64 = 5_000_000_000 - - let waiter = Task { - try await harness.request( - identifier: "tracked", - started: started, - cancelled: cancelled - ) { - while true { - try await Task.sleep(nanoseconds: 50_000_000) - } - } - } - - try await started.await(nanoseconds: timeout) - await harness.cancelWithError(MyError.boom) - #expect(await harness.stateSnapshot() == .cancellingBoom) - - await #expect(throws: MyError.boom, "Expected active waiter to receive the latched system error") { - _ = try await waiter.value - } - try await cancelled.await(nanoseconds: timeout) - #expect(await harness.stateSnapshot() == .cancelledBoom) - } -} diff --git a/Tests/EffectComponents/TaskSubscriptionTests.swift b/Tests/EffectComponents/TaskSubscriptionTests.swift deleted file mode 100644 index d610c07..0000000 --- a/Tests/EffectComponents/TaskSubscriptionTests.swift +++ /dev/null @@ -1,718 +0,0 @@ -#if canImport(SwiftUI) && (canImport(UIKit) || canImport(AppKit)) -import Foundation -import Testing -import SwiftUI -@testable import EffectComponents - -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif - - -@Suite("Task subscription") -@MainActor -struct TaskSubscriptionTests { - - final actor InvocationCounter: Sendable { - private(set) var count = 0 - - init() {} - - @discardableResult - func increment() -> Int { - count += 1 - return count - } - } -} - -@MainActor -extension TaskSubscriptionTests { - - private func startRequestAndWaitUntilEnqueued( - _ event: Event, - input: EffectViewInput, - enqueued: Expectation - ) -> Task { - Task { - try await withCheckedThrowingContinuation { (continuation: Continuation) in - Task { @MainActor in - do { - try await input._send(event, input, continuation) - enqueued.fulfill() - } catch { - continuation.resume(throwing: runtimeBoundaryError(for: error)) - } - } - } - } - } - - @Test func subscribeSharesNamedTaskResultBetweenWaiters() async throws { - struct WorkerEnv: Sendable { - let counter: InvocationCounter - let started: Expectation - let release: Expectation - let timeout: UInt64 - } - - enum T: Transducer { - struct State: Equatable {} - enum Event: Sendable { case load } - typealias Output = String - typealias Env = WorkerEnv - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .load: - return .task(id: "shared-load", option: .subscribe) { _, env in - await env.counter.increment() - env.started.fulfill() - do { - try await env.release.await(nanoseconds: env.timeout) - } catch { - Issue.record(error, "Test Invariant failure: timeout. Increase the timeout value and run tests again.") - } - return "shared-output" - } - } - } - - static func output(state: State, event: Event) -> String { "" } - } - - let counter = InvocationCounter() - let startedExpectation = Expectation() - let secondRequestEnqueuedExpectation = Expectation() - let releaseExpectation = Expectation() - let timeout: UInt64 = 10_000_000_000 - var capturedInput: EffectViewInput? - - try await testView(initialState: T.State()) { binding in - EffectView( - of: T.self, - state: binding, - initialEnv: WorkerEnv( - counter: counter, - started: startedExpectation, - release: releaseExpectation, - timeout: timeout - ) - ) { _, input in - Color.clear.onAppear { - capturedInput = input - } - } - } expect: { - guard let input = capturedInput else { Issue.record("Input not captured"); return } - - let firstWaiter = Task.detached { try await input.request(.load) } - try await startedExpectation.await(nanoseconds: timeout) - - let secondWaiter = startRequestAndWaitUntilEnqueued( - .load, - input: input, - enqueued: secondRequestEnqueuedExpectation - ) - try await secondRequestEnqueuedExpectation.await(nanoseconds: timeout) - releaseExpectation.fulfill() - - let firstOutput = try await firstWaiter.value - let secondOutput = try await secondWaiter.value - let count = await counter.count - - #expect(firstOutput == "shared-output") - #expect(secondOutput == "shared-output") - #expect(count == 1, "subscribe should share one underlying named task") - } - } - - @Test func subscribeSharedFailureLatchesSystemErrorAndCancelsWaiters() async throws { - struct WorkerEnv: Sendable { - let counter: InvocationCounter - let started: Expectation - let release: Expectation - let timeout: UInt64 - } - - enum SharedFailure: Error { - case boom - } - - enum T: Transducer { - struct State: Equatable {} - enum Event: Sendable { case load } - typealias Output = String - typealias Env = WorkerEnv - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .load: - return .task(id: "shared-load", option: .subscribe) { _, env in - await env.counter.increment() - env.started.fulfill() - try? await env.release.await(nanoseconds: env.timeout) - throw SharedFailure.boom - } - } - } - - static func output(state: State, event: Event) -> String { "" } - } - - let counter = InvocationCounter() - let startedExpectation = Expectation() - let releaseExpectation = Expectation() - let timeout: UInt64 = 50_000_000_000 - var capturedInput: EffectViewInput? - - try await testView(initialState: T.State()) { binding in - EffectView( - of: T.self, - state: binding, - initialEnv: WorkerEnv( - counter: counter, - started: startedExpectation, - release: releaseExpectation, - timeout: timeout - ) - ) { _, input in - Color.clear.onAppear { capturedInput = input } - } - } expect: { - guard let input = capturedInput else { Issue.record("Input not captured"); return } - - let firstWaiter = Task { try await input.request(.load) } - try await startedExpectation.await(nanoseconds: timeout) - - let secondRequestEnqueuedExpectation = Expectation() - let secondWaiter = startRequestAndWaitUntilEnqueued( - .load, - input: input, - enqueued: secondRequestEnqueuedExpectation - ) - try await secondRequestEnqueuedExpectation.await(nanoseconds: timeout) - releaseExpectation.fulfill() - - do { - _ = try await firstWaiter.value - Issue.record("Expected first waiter to throw the shared task failure") - } catch let error as SharedFailure { - #expect(error == .boom) - } catch { - Issue.record("Unexpected first waiter error: \(error)") - } - - do { - _ = try await secondWaiter.value - Issue.record("Expected second waiter to throw the shared task failure") - } catch let error as SharedFailure { - #expect(error == .boom) - } catch { - Issue.record("Unexpected second waiter error: \(error)") - } - - let count = await counter.count - #expect(count == 1, "subscribe should share one underlying named task") } - } - - @Test func subscribeStartsFreshNamedTaskAfterPreviousOneCompletes() async throws { - struct WorkerEnv: Sendable { - let counter: InvocationCounter - } - - enum T: Transducer { - struct State: Equatable {} - enum Event: Sendable { case load } - typealias Output = String - typealias Env = WorkerEnv - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .load: - return .task(id: "shared-load", option: .subscribe) { _, env in - let count = await env.counter.increment() - return "output-\(count)" - } - } - } - - static func output(state: State, event: Event) -> String { "" } - } - - let counter = InvocationCounter() - var capturedInput: EffectViewInput? - - try await testView(initialState: T.State()) { binding in - EffectView( - of: T.self, - state: binding, - initialEnv: WorkerEnv(counter: counter) - ) { _, input in - Color.clear.onAppear { capturedInput = input } - } - } expect: { - guard let input = capturedInput else { Issue.record("Input not captured"); return } - - let firstOutput = try await input.request(.load) - let secondOutput = try await input.request(.load) - - #expect(firstOutput == "output-1") - #expect(secondOutput == "output-2") - #expect(await counter.count == 2, "a later subscriber should start a fresh named task after completion") - } - } - - @Test func subscribeAttachesToCancelledTrackedTaskAndReceivesItsLateResult() async throws { - struct WorkerEnv: Sendable { - let counter: InvocationCounter - let started: Expectation - let cancelled: Expectation - let release: Expectation - let timeout: UInt64 - } - - enum T: Transducer { - struct State: Equatable {} - enum Event: Sendable { case load, stop } - typealias Output = String - typealias Env = WorkerEnv - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .load: - return .task(id: "shared-load", option: .subscribe) { _, env in - let invocation = await env.counter.increment() - if invocation > 1 { - return "fresh-output-\(invocation)" - } - env.started.fulfill() - do { - try await Task.sleep(nanoseconds: env.timeout) - return "stale-output" - } catch is CancellationError { - env.cancelled.fulfill() - do { - try await env.release.await(nanoseconds: env.timeout) - } catch { - Issue.record(error, "Test invariant failure: timeout while waiting to release cancelled task") - } - return "late-output" - } catch { - return error.localizedDescription - } - } - case .stop: - return .cancel("shared-load") - } - } - - static func output(state: State, event: Event) -> String { "" } - } - - let counter = InvocationCounter() - let startedExpectation = Expectation() - let cancelledExpectation = Expectation() - let secondRequestEnqueuedExpectation = Expectation() - let releaseExpectation = Expectation() - let timeout: UInt64 = 5_000_000_000 - var capturedInput: EffectViewInput? - - try await testView(initialState: T.State()) { binding in - EffectView( - of: T.self, - state: binding, - initialEnv: WorkerEnv( - counter: counter, - started: startedExpectation, - cancelled: cancelledExpectation, - release: releaseExpectation, - timeout: timeout - ) - ) { _, input in - Color.clear.onAppear { capturedInput = input } - } - } expect: { - guard let input = capturedInput else { Issue.record("Input not captured"); return } - - let firstWaiter = Task { try await input.request(.load) } - try await startedExpectation.await(nanoseconds: timeout) - - try await input.send(.stop) - try await cancelledExpectation.await(nanoseconds: timeout) - - do { - _ = try await firstWaiter.value - Issue.record("Expected first waiter to throw CancellationError") - } catch is CancellationError { - /* expected */ - } catch { - Issue.record("Unexpected first waiter error: \(error)") - } - - let secondWaiter = startRequestAndWaitUntilEnqueued( - .load, - input: input, - enqueued: secondRequestEnqueuedExpectation - ) - try await secondRequestEnqueuedExpectation.await(nanoseconds: timeout) - releaseExpectation.fulfill() - - let secondOutput = try await secondWaiter.value - #expect(secondOutput == "late-output") - #expect(await counter.count == 1, "subscribe should attach to the cancelled tracked task instead of starting fresh work") - } - - } - - @Test func subscribeAttachesToCancelledTrackedTaskAndCancelsWaitersOnLateFailure() async throws { - struct WorkerEnv: Sendable { - let counter: InvocationCounter = InvocationCounter() - let startedExpectation: Expectation = .init() - let cancelledExpectation: Expectation = .init() - let releaseExpectation: Expectation = .init() - let timeout: UInt64 - } - - enum LateFailure: Error { - case boom - } - - enum T: Transducer { - struct State: Equatable {} - enum Event: Sendable { case load, stop } - typealias Output = String - typealias Env = WorkerEnv - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .load: - return .task(id: "shared-load", option: .subscribe) { _, env in - let invocation = await env.counter.increment() - if invocation > 1 { - return "fresh-output-\(invocation)" - } - env.startedExpectation.fulfill() - do { - try await Task.sleep(nanoseconds: env.timeout) - return "stale-output" - } catch is CancellationError { - env.cancelledExpectation.fulfill() - try? await env.releaseExpectation.await(nanoseconds: env.timeout) - throw LateFailure.boom - } - } - case .stop: - return .cancel("shared-load") - } - } - - static func output(state: State, event: Event) -> String { "" } - } - - let timeout: UInt64 = 5_000_000_000 - var capturedInput: EffectViewInput? - let env = WorkerEnv(timeout: timeout) - let secondRequestEnqueuedExpectation = Expectation() - - try await testView(initialState: T.State()) { binding in - EffectView( - of: T.self, - state: binding, - initialEnv: env - ) { _, input in - Color.clear.onAppear { capturedInput = input } - } - } expect: { - guard let input = capturedInput else { Issue.record("Input not captured"); return } - - let firstWaiter = Task { try await input.request(.load) } - try await env.startedExpectation.await(nanoseconds: timeout) - - try await input.send(.stop) - try await env.cancelledExpectation.await(nanoseconds: timeout) - - do { - _ = try await firstWaiter.value - Issue.record("Expected first waiter to throw CancellationError") - } catch is CancellationError { - /* expected */ - } catch { - Issue.record("Unexpected first waiter error: \(error)") - } - - let secondWaiter = startRequestAndWaitUntilEnqueued( - .load, - input: input, - enqueued: secondRequestEnqueuedExpectation - ) - try await secondRequestEnqueuedExpectation.await(nanoseconds: timeout) - env.releaseExpectation.fulfill() - - do { - _ = try await secondWaiter.value - Issue.record("Expected second waiter to throw the late task failure") - } catch let error as LateFailure { - #expect(error == .boom) - } catch { - Issue.record("Unexpected second waiter error: \(error)") - } - - #expect(await env.counter.count == 1, "subscribe should attach to the cancelled tracked task instead of starting fresh work") - } - } - - @Test func switchToLatestRestartsTaskAndReturnsReplacementResultToAllWaiters() async throws { - struct WorkerEnv: Sendable { - let counter: InvocationCounter = InvocationCounter() - let firstStartedExpectation: Expectation = .init() - let firstCancelledExpectation: Expectation = .init() - let secondStartedExpectation: Expectation = .init() - let secondReleaseExpectation: Expectation = .init() - let timeout: UInt64 - } - - enum T: Transducer { - struct State: Equatable {} - enum Event: Sendable { case first, second } - typealias Output = String - typealias Env = WorkerEnv - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .first: - return .task(id: "replaceable", option: .switchToLatest) { _, env in - let invocation = await env.counter.increment() - if invocation == 1 { - env.firstStartedExpectation.fulfill() - do { - try await Task.sleep(nanoseconds: env.timeout) - return "stale-first-output" - } catch is CancellationError { - env.firstCancelledExpectation.fulfill() - throw CancellationError() - } - } - env.secondStartedExpectation.fulfill() - try? await env.secondReleaseExpectation.await(nanoseconds: env.timeout) - return "replacement-output" - } - case .second: - return .task(id: "replaceable", option: .switchToLatest) { _, env in - let invocation = await env.counter.increment() - if invocation == 1 { - env.firstStartedExpectation.fulfill() - do { - try await Task.sleep(nanoseconds: env.timeout) - return "stale-first-output" - } catch is CancellationError { - env.firstCancelledExpectation.fulfill() - throw CancellationError() - } - } - env.secondStartedExpectation.fulfill() - try? await env.secondReleaseExpectation.await(nanoseconds: env.timeout) - return "replacement-output" - } - } - } - - static func output(state: State, event: Event) -> String { "" } - } - - let timeout: UInt64 = 500_000_000_000 - let env = WorkerEnv(timeout: timeout) - - var capturedInput: EffectViewInput? - - try await testView(initialState: T.State()) { binding in - EffectView( - of: T.self, - state: binding, - initialEnv: env - ) { _, input in - Color.clear.onAppear { capturedInput = input } - } - } expect: { - guard let input = capturedInput else { Issue.record("Input not captured"); return } - - let firstWaiter = Task { - do { - return try await input.request(.first) - } catch { - print(error.localizedDescription) - throw error - } - } - try await env.firstStartedExpectation.await(nanoseconds: timeout) - - let secondWaiter = Task { try await input.request(.second) } - try await env.firstCancelledExpectation.await(nanoseconds: timeout) - try await env.secondStartedExpectation.await(nanoseconds: timeout) - env.secondReleaseExpectation.fulfill() - - await #expect(throws: Never.self, "first waiter requires to have a value") { - let firstOutput = try await firstWaiter.value - #expect(firstOutput == "replacement-output") - } - // let firstOutput = try await firstWaiter.value - let secondOutput = try await secondWaiter.value - #expect(secondOutput == "replacement-output") - #expect(await env.counter.count == 2, "switchToLatest should restart the active task") - } - - } - - @Test func switchToLatestReplacementFailureCancelsAllWaiters() async throws { - struct WorkerEnv: Sendable { - let counter: InvocationCounter = InvocationCounter() - let firstStartedExpectation: Expectation = .init() - let firstCancelledExpectation: Expectation = .init() - let secondStartedExpectation: Expectation = .init() - let secondReleaseExpectation: Expectation = .init() - let timeout: UInt64 - } - - enum ReplacementFailure: Error { - case boom - } - - enum T: Transducer { - struct State: Equatable {} - enum Event: Sendable { case first, second } - typealias Output = String - typealias Env = WorkerEnv - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .first: - return .task(id: "replaceable", option: .switchToLatest) { _, env in - let invocation = await env.counter.increment() - if invocation == 1 { - env.firstStartedExpectation.fulfill() - do { - try await Task.sleep(nanoseconds: env.timeout) - return "stale-first-output" - } catch is CancellationError { - env.firstCancelledExpectation.fulfill() - throw CancellationError() - } - } - env.secondStartedExpectation.fulfill() - try? await env.secondReleaseExpectation.await(nanoseconds: env.timeout) - throw ReplacementFailure.boom - } - case .second: - return .task(id: "replaceable", option: .switchToLatest) { _, env in - let invocation = await env.counter.increment() - if invocation == 1 { - env.firstStartedExpectation.fulfill() - do { - try await Task.sleep(nanoseconds: env.timeout) - return "stale-first-output" - } catch is CancellationError { - env.firstCancelledExpectation.fulfill() - throw CancellationError() - } - } - env.secondStartedExpectation.fulfill() - try? await env.secondReleaseExpectation.await(nanoseconds: env.timeout) - throw ReplacementFailure.boom - } - } - } - - static func output(state: State, event: Event) -> String { "" } - } - - let timeout: UInt64 = 5_000_000_000 - var capturedInput: EffectViewInput? - - let env = WorkerEnv(timeout: timeout) - - try await testView(initialState: T.State()) { binding in - EffectView( - of: T.self, - state: binding, - initialEnv: env - ) { _, input in - Color.clear.onAppear { capturedInput = input } - } - } expect: { - guard let input = capturedInput else { Issue.record("Input not captured"); return } - - let firstWaiter = Task { try await input.request(.first) } - try await env.firstStartedExpectation.await(nanoseconds: timeout) - - let secondWaiter = Task { try await input.request(.second) } - try await env.firstCancelledExpectation.await(nanoseconds: timeout) - try await env.secondStartedExpectation.await(nanoseconds: timeout) - env.secondReleaseExpectation.fulfill() - - do { - _ = try await firstWaiter.value - Issue.record("Expected first waiter to throw the replacement task failure") - } catch let error as ReplacementFailure { - #expect(error == .boom) - } catch { - Issue.record("Unexpected first waiter error: \(error)") - } - - do { - _ = try await secondWaiter.value - Issue.record("Expected second waiter to throw the replacement task failure") - } catch let error as ReplacementFailure { - #expect(error == .boom) - } catch { - Issue.record("Unexpected second waiter error: \(error)") - } - - #expect(await env.counter.count == 2, "switchToLatest should restart the active task") - } - } - - @Test("anonymous request task completes as an unshared task") - func anonymousRequestTaskCompletesAsUnsharedTask() async throws { - enum T: Transducer { - struct State: Equatable {} - enum Event: Sendable { case load } - typealias Output = String - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .load: - return .task(id: nil, option: .subscribe) { _, _ in - "anonymous-output" - } - } - } - - static func output(state: State, event: Event) -> String { "" } - } - - var capturedInput: EffectViewInput? - - try await testView(initialState: T.State()) { binding in - EffectView(of: T.self, state: binding) { _, input in - Color.clear.onAppear { capturedInput = input } - } - } expect: { - guard let input = capturedInput else { Issue.record("Input not captured"); return } - - let output = try await input.request(.load) - #expect(output == "anonymous-output") - } - } -} - -#else -import Testing - -@Suite("Task subscription (SwiftUI unavailable)") -struct TaskSubscriptionTests { - @Test func skipped() {} -} - -#endif diff --git a/Tests/EffectComponents/Utilities/Expectation.swift b/Tests/EffectComponents/Utilities/Expectation.swift deleted file mode 100644 index c2a460a..0000000 --- a/Tests/EffectComponents/Utilities/Expectation.swift +++ /dev/null @@ -1,339 +0,0 @@ -import Mutex - -// Possibly use: https://github.com/swhitty/swift-mutex/blob/main/Sources/Mutex.swift - -final class Expectation: Sendable { - - enum Error: Swift.Error { - case deinitalized - case timeout - case alreadyAwaited - } - - typealias Continuation = CheckedContinuation - - enum State { - case start(minFulfillCount: Int) - case partiallyFulfilled( - minFulfillCount: Int, - fulfillCount: Int - ) - case pending( - Continuation, - minFulfillCount: Int, - fulfillCount: Int, - timeoutTask: Task? - ) - case fulfilled(minFulfillCount: Int, fulfillCount: Int) - case rejected(any Swift.Error) - } - - let lock: Mutex - - init(minFulfillCount: Int = 1) { - lock = .init(.start(minFulfillCount: minFulfillCount)) - } - - var isFulfilled: Bool { - return lock.withLock { state in - if case .fulfilled = state { - return true - } else { - return false - } - } - } - - func await( - nanoseconds: UInt64 - ) async throws { - try await withCheckedThrowingContinuation { (continuation: Continuation) in - self.lock.withLock { state in - switch state { - case .start(let minFulfillCount): - let timeoutTask = Task { [weak self] in - try await Task.sleep(nanoseconds: nanoseconds) - self?.fail(with: Error.timeout) - } - state = .pending( - continuation, - minFulfillCount: minFulfillCount, - fulfillCount: 0, - timeoutTask: timeoutTask - ) - - case .partiallyFulfilled(let minFulfillCount, let fulfillCount): - assert(fulfillCount < minFulfillCount) - let timeoutTask = Task { [weak self] in - try await Task.sleep(nanoseconds: nanoseconds) - self?.fail(with: Error.timeout) - } - state = .pending( - continuation, - minFulfillCount: minFulfillCount, - fulfillCount: fulfillCount, - timeoutTask: timeoutTask - ) - - case .rejected(let error): - continuation.resume(throwing: error) - - case .fulfilled(let minFulfillCount, let fulfillCount): - assert(fulfillCount >= minFulfillCount) - let newFulfillCount = fulfillCount - minFulfillCount - if newFulfillCount >= minFulfillCount { - state = .fulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: newFulfillCount - ) - } else { - state = .partiallyFulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: newFulfillCount - ) - } - continuation.resume() - - case .pending: - continuation.resume(throwing: Error.alreadyAwaited) - } - } - } - } - - @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) - func await( - timeout duration: C.Instant.Duration, - tolerance: C.Instant.Duration? = nil, - clock: C = ContinuousClock(), - ) async throws { - try await withCheckedThrowingContinuation { (continuation: Continuation) in - self.lock.withLock { state in - switch state { - case .start(let minFulfillCount): - let timeoutTask = Task { [weak self] in - try await Task.sleep( - for: duration, - tolerance: tolerance, - clock: clock - ) - self?.fail(with: Error.timeout) - } - state = .pending( - continuation, - minFulfillCount: minFulfillCount, - fulfillCount: 0, - timeoutTask: timeoutTask - ) - - case .partiallyFulfilled(let minFulfillCount, let fulfillCount): - assert(fulfillCount < minFulfillCount) - let timeoutTask = Task { [weak self] in - try await Task.sleep( - for: duration, - tolerance: tolerance, - clock: clock - ) - self?.fail(with: Error.timeout) - } - state = .pending( - continuation, - minFulfillCount: minFulfillCount, - fulfillCount: fulfillCount, - timeoutTask: timeoutTask - ) - - case .rejected(let error): - continuation.resume(throwing: error) - - case .fulfilled(let minFulfillCount, let fulfillCount): - assert(fulfillCount >= minFulfillCount) - let newFulfillCount = fulfillCount - minFulfillCount - if newFulfillCount >= minFulfillCount { - state = .fulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: newFulfillCount - ) - } else { - state = .partiallyFulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: newFulfillCount - ) - } - continuation.resume() - - case .pending: - continuation.resume(throwing: Error.alreadyAwaited) - } - } - } - } - - func await() async throws { - try await withCheckedThrowingContinuation { (continuation: Continuation) in - self.lock.withLock { state in - switch state { - case .start(let minFulfillCount): - state = .pending( - continuation, - minFulfillCount: minFulfillCount, - fulfillCount: 0, - timeoutTask: nil - ) - - case .partiallyFulfilled(let minFulfillCount, let fulfillCount): - assert(fulfillCount < minFulfillCount) - state = .pending( - continuation, - minFulfillCount: minFulfillCount, - fulfillCount: fulfillCount, - timeoutTask: nil - ) - - case .rejected(let error): - continuation.resume(throwing: error) - - case .fulfilled(let minFulfillCount, let fulfillCount): - assert(fulfillCount >= minFulfillCount) - let newFulfillCount = fulfillCount - minFulfillCount // consume minFulfillCount - if newFulfillCount >= minFulfillCount { - state = .fulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: newFulfillCount - ) - } else { - state = .partiallyFulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: newFulfillCount - ) - } - continuation.resume() - - case .pending: - continuation.resume(throwing: Error.alreadyAwaited) - } - } - } - } - - func fulfill() { - self.lock.withLock { state in - switch state { - case .start(let minFulfillCount): - let fulfillCount = 1 - if fulfillCount >= minFulfillCount { - state = .fulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: 1 - ) - } else { - state = .partiallyFulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: 1 - ) - } - - case .partiallyFulfilled(let minFulfillCount, let fulfillCount): - assert(fulfillCount < minFulfillCount) - let fulfillCount = fulfillCount + 1 - if fulfillCount >= minFulfillCount { - state = .fulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: fulfillCount - ) - } else { - state = .partiallyFulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: fulfillCount - ) - } - - case .pending(let continuation, let minFulfillCount, let fulfillCount, let timeoutTask): - var newFulfillCount = fulfillCount + 1 - if newFulfillCount >= minFulfillCount { - // fullfilled, we are going to resume the continuation - newFulfillCount -= minFulfillCount // consume minFulfillCount - timeoutTask?.cancel() - if newFulfillCount >= minFulfillCount { - state = .fulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: newFulfillCount - ) - } else { - // partially fulfilled - state = .partiallyFulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: newFulfillCount - ) - } - continuation.resume(returning: Void()) - } else { - // partially fulfilled - state = .pending( - continuation, - minFulfillCount: minFulfillCount, - fulfillCount: newFulfillCount, - timeoutTask: timeoutTask - ) - } - - case .fulfilled(let minFulfillCount, let fulfillCount): - assert(fulfillCount >= minFulfillCount) - state = .fulfilled( - minFulfillCount: minFulfillCount, - fulfillCount: fulfillCount + 1 - ) - - case .rejected: - return - } - } - } - - func fail(with error: any Swift.Error) { - self.lock.withLock { state in - switch state { - case .start: - state = .rejected(error) - case .partiallyFulfilled: - state = .rejected(error) - case .pending(let continuation, _, _, let timeoutTask): - timeoutTask?.cancel() - continuation.resume(throwing: error) - state = .rejected(error) - case .rejected, .fulfilled: - return - } - } - } - - deinit { - self.lock.withLock { state in - switch state { - case .pending(let continuation, _, _, let timeoutTask): - timeoutTask?.cancel() - continuation.resume(throwing: Error.deinitalized) - state = .rejected(Error.deinitalized) - default: - break - } - } - } -} - -extension Expectation: CustomStringConvertible { - public var description: String { - enum State { - case unitialized(fulfillCount: Int) - case pending(Continuation, fulfillCount: Int, timeoutTask: Task?) - case fulfilled - case rejected(any Swift.Error) - } - - let description = self.lock.withLock { state in - return "\(state)" - } - - return "Expectation <\(description)>" - } -} diff --git a/Tests/Transduce/BasicsTests.swift b/Tests/Transduce/BasicsTests.swift new file mode 100644 index 0000000..19c2bc2 --- /dev/null +++ b/Tests/Transduce/BasicsTests.swift @@ -0,0 +1,248 @@ +@testable import Transduce +import Testing +import Dispatch + +@Suite +enum BasicsTests { + @Suite struct LoggerTests {} + @Suite struct TransducerTypeTests {} + @Suite struct IsolationTests {} +} + +extension BasicsTests.LoggerTests { + enum MyTransducer: Transducer { + enum State { case start, idle(value: Int) } + enum Event { case start, tick } + static func transduce(_ state: inout State, event: Event) -> Effect { + return .none + } + static let initialState: State = .start + } + + @Test + func testDebugStrings() { + let runtime = GlobalActorRuntime(transducer: MyTransducer.self, on: MainActor.self) + print("runtime: \(runtime)") + print(runtime) + debugPrint(runtime) + } +} + +extension BasicsTests.TransducerTypeTests { + + @Test + func testNonisolatedTransducer() { + + enum T: Transducer { + struct State: DefaultInitializable {} + enum Event { case start } + static func transduce(_ state: inout State, event: Event) -> Effect { + return .none + } + } + + #expect(T.Response.self == Void.self) + #expect(T.Effect.self == TransducerEffect.self) + #expect(T.Env.self == Void.self) + } + + // @Test + // @MainActor + // func testMainActorIsolatedTransducer() { + // + // @MainActor enum T: @MainActor Transducer { + // struct State: DefaultInitializable {} + // enum Event { case start } + // static func transduce(_ state: inout State, event: Event) -> Effect { + // return .none + // } + // } + // #expect(T.Response.self == Void.self) + // #expect(T.Effect.self == BasicTransducerEffect.self) + // #expect(T.Env.self == Void.self) + // } + + + @Test + func testNonSendableStateNonisolatedTransducer() { + + enum T: Transducer { + final class State: DefaultInitializable { var value: Int = 0 } + enum Event { case start } + static func transduce(_ state: inout State, event: Event) -> Effect { + return .none + } + } + + #expect(T.Response.self == Void.self) + #expect(T.Effect.self == TransducerEffect.self) + #expect(T.Env.self == Void.self) + } + + // @Test + // func testNonSendableStateMainActorIsolatedTransducer() { + // + // @MainActor enum T: @MainActor Transducer { + // final class State: DefaultInitializable { var value: Int = 0 } + // enum Event { case start } + // static func transduce(_ state: inout State, event: Event) -> Effect { + // return .none + // } + // } + // #expect(T.Response.self == Void.self) + // // #expect(T.Effect.self == BasicTransducerEffect.self) + // // #expect(T.Env.self == Void.self) + // } +// + +} + +extension BasicsTests.IsolationTests { + + /// - Given: a Runtime isolated to the MainActor + /// - When: adding a task with an nonisolated(nonsending) operation + /// - Then: the operation executes on the MainActor + @Test func testIsolatedOperationWillBeCalledOnRuntimeSystemActor() async throws { + enum T: EffectTransducer { + struct State {} + enum Event { case start } + struct Env {} + static func transduce(_ state: inout State, event: Event) -> Effect { .none } + static let initialState: State = .init() + } + let runtime = BaseRuntime(systemActor: MainActor.shared, env: .init()) + var taskOwnership = runtime.makeRuntimeTaskOwnership() + try await runtime.taskManager.addTask( + systemActor: MainActor.shared, + identifier: "test", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { input, env in + let currentLabel = String(cString: __dispatch_queue_get_label(nil)) + let isMainThread = currentLabel.contains("com.apple.main-thread") + #expect(isMainThread, "Expected to be on the main thread, but was on: \(currentLabel)") + return nil + } + ) + // try await Task.sleep(nanoseconds: 100_000_000) + } + + /// - Given: a Runtime isolated to the MainActor + /// - When: adding a task with an operation with an unspecified global actor isolation + /// - Then: the operation executes on the global actor + @Test func testOperationWillBeExecutedOnConcurrentActor() async throws { + enum T: EffectTransducer { + struct State {} + enum Event { case start } + struct Env {} + static func transduce(_ state: inout State, event: Event) -> Effect { .none } + static let initialState: State = .init() + } + let runtime = BaseRuntime(systemActor: MainActor.shared, env: .init()) + var taskOwnership = runtime.makeRuntimeTaskOwnership() + try await runtime.taskManager.addTask( + systemActor: MainActor.shared, + identifier: "test", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { input, env in + let currentLabel = String(cString: __dispatch_queue_get_label(nil)) + let isMainThread = currentLabel.contains("com.apple.main-thread") + #expect(isMainThread, "Expected to be on the main thread, but was on: \(currentLabel)") + return nil + } + ) + // try await Task.sleep(nanoseconds: 100_000_000) + } + + /// - Given: a Runtime isolated to the MainActor + /// - When: adding a task with an operation with a specified global actor isolation + /// - Then: the operation executes on the that specified actor + @Test func testGlobalActorIsolatedOperationWillBeExecutedOnGlobalActor() async throws { + enum T: EffectTransducer { + struct State {} + enum Event { case start } + struct Env {} + static func transduce(_ state: inout State, event: Event) -> Effect { .none } + static let initialState: State = .init() + } + let runtime = BaseRuntime(systemActor: MainActor.shared, env: .init()) + var taskOwnership = runtime.makeRuntimeTaskOwnership() + try await runtime.taskManager.addTask( + systemActor: MainActor.shared, + identifier: "test", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { @TestGlobalActor input, env in + TestGlobalActor.shared.assertIsolated() + return nil + } + ) + } +} + +extension BasicsTests.IsolationTests { + + @Test + func testNonisolatedTransducerExecutesOnGivenIsolation() async throws { + enum T: Transducer { + struct State {} + enum Event { case start } + static func transduce(_ state: inout State, event: Event) -> Effect { + MainActor.shared.preconditionIsolated() + return .none + } + static func output(from state: State, event: Event) -> String { + "\(event)" + } + static let initialState: State = .init() + } + + // + // CAUTION: ReferenceRuntime in itself is NOT thread safe! + // + // A ReferenceRuntime *requires* a Host which ensures, that + // the identical system actor is passed as a parameter for all + // functions. + + let storage = LocalStorage(initialState: T.State()) + let runtime: BaseRuntime = .init( + systemActor: MainActor.shared, + storage: storage + ) + + @MainActor func test() async throws { + try await runtime.send(.start) + } + } +} + +extension BasicsTests.IsolationTests { + + /// - Given: a Runtime isolated to the MainActor + /// - When: sending an event + /// - Then: the transducer executes on the MainActor + @Test func testTransducerWillBeExecutedOnTheMainActor() async throws { + enum T: EffectTransducer { + struct State {} + enum Event { case start } + struct Env {} + static func transduce(_ state: inout State, event: Event) -> Effect { + let currentLabel = String(cString: __dispatch_queue_get_label(nil)) + let isMainThread = currentLabel.contains("com.apple.main-thread") + #expect(isMainThread, "Expected to be on the main thread, but was on: \(currentLabel)") + return .none + } + static let initialState: State = .init() + } + let runtime = GlobalActorRuntime(env: .init()) + try await runtime.send(.start) + } + +} diff --git a/Tests/EffectComponents/EffectViewTests.swift b/Tests/Transduce/EffectViewTests.swift similarity index 58% rename from Tests/EffectComponents/EffectViewTests.swift rename to Tests/Transduce/EffectViewTests.swift index 3900758..835a2ad 100644 --- a/Tests/EffectComponents/EffectViewTests.swift +++ b/Tests/Transduce/EffectViewTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing import SwiftUI -@testable import EffectComponents +@testable import Transduce #if canImport(Observation) import Observation #endif @@ -20,19 +20,20 @@ import AppKit // closure — the same pattern used in Oak's TransducerView tests. // Expectations synchronize with async state changes. -@Suite("EffectView") +@Suite("EffectView", .serialized) @MainActor -struct EffectViewTests { +struct EffectViewTests {} +extension EffectViewTests { + // MARK: - Lifecycle - + @Test func contentAppearsExactlyOnce() async throws { enum T: Transducer { - enum Event: Sendable { case dummy } - struct State: Equatable { var x = 0 } - static func update(_ state: inout State, event: Event) -> Effect? { - nil - } + static let initialState: State = .init() + enum Event { case dummy } + struct State { var x = 0 } + static func transduce(_ state: inout State, event: Event) -> Effect { .none } } var appearCount = 0 @@ -44,16 +45,15 @@ struct EffectViewTests { #expect(appearCount == 1, "content onAppear should fire exactly once on first render") } } - + @Test func initialStateIsPreserved() async throws { enum T: Transducer { - struct State: Equatable { var label: String } + static let initialState: State = .init() + struct State: Equatable { var label: String = "" } enum Event: Sendable { case dummy } - static func update(_ state: inout State, event: Event) -> Effect? { - nil - } + static func transduce(_ state: inout State, event: Event) -> Effect { .none } } - + var capturedLabel: String? try await testView(initialState: T.State(label: "custom")) { binding in EffectView(of: T.self, state: binding) { state, _ in @@ -63,25 +63,26 @@ struct EffectViewTests { #expect(capturedLabel == "custom") } } - + // MARK: - State updates - + @Test func updateIsCalledAndStatePropagates() async throws { enum T: Transducer { + static let initialState: State = .init() struct State: Equatable { var count = 0 } enum Event: Sendable { case increment } - static func update(_ state: inout State, event: Event) -> Effect? { + static func transduce(_ state: inout State, event: Event) -> Effect { state.count += 1 - return nil + return .none } } - - var capturedInput: EffectViewInput? + + var capturedInput: EffectViewInput? var observedValues: [Int] = [] - let expectation = Expectation() + let expectation = Promise() let timeout: UInt64 = 5_000_000_000 - + try await testView(initialState: T.State()) { binding in EffectView( of: T.self, @@ -101,27 +102,28 @@ struct EffectViewTests { #expect(observedValues == [0]) #expect(capturedInput != nil) try await capturedInput?.send(.increment) - try await expectation.await(nanoseconds: timeout) + try await expectation.await(timeout: timeout) #expect(observedValues == [0, 1]) } } - + @Test func stateChangeTriggersRerender() async throws { enum T: Transducer { + static let initialState: State = .off enum State: Equatable, Sendable { case off, on } enum Event: Sendable { case toggle } - static func update(_ state: inout State, event: Event) -> Effect? { - state = (state == .off ? .on : .off); return nil + static func transduce(_ state: inout State, event: Event) -> Effect { + state = (state == .off ? .on : .off); return .none } } - + class RenderCounter: @unchecked Sendable { var count = 0 } let counter = RenderCounter() - let expectation = Expectation() - var capturedInput: EffectViewInput? - + let expectation = Promise() + var capturedInput: EffectViewInput? + let timeout: UInt64 = 5_000_000_000 - + try await testView(initialState: T.State.off) { binding in EffectView( of: T.self, @@ -140,29 +142,30 @@ struct EffectViewTests { } expect: { let countAfterMount = counter.count try await capturedInput?.send(.toggle) - try await expectation.await(nanoseconds: timeout) + try await expectation.await(timeout: timeout) #expect(counter.count > countAfterMount, "View should re-render after state change") } } - + // MARK: - initialEvent - + @Test func initialEventFiresOnAppear() async throws { // The initial event fires synchronously inside EffectView's .task, in the same // run-loop pass as the input setup. SwiftUI batches both state mutations into a // single re-render, so onChange never sees a transition. We record every event // in State and assert on it after onAppear fires. enum T: Transducer { + static let initialState: State = .init() enum Event: Sendable, Equatable { case start } struct State: Equatable { var events: [Event] = [] } - static func update(_ state: inout State, event: Event) -> Effect? { + static func transduce(_ state: inout State, event: Event) -> Effect { // Note: update with the initial event will be called before // onAppear will be called state.events.append(event) - return nil + return .none } } - + try await testView(initialState: T.State()) { binding in EffectView(of: T.self, state: binding, initialEvent: .start) { state, _ in Color.clear.onAppear { @@ -172,29 +175,30 @@ struct EffectViewTests { } expect: { } } - + // MARK: - Effects - + @Test func taskEffectRunsAndMutatesState() async throws { enum T: Transducer { + static let initialState: State = .init() struct State: Equatable { var loaded = false } enum Event: Sendable { case load, didLoad } - static func update(_ state: inout State, event: Event) -> Effect? { + static func transduce(_ state: inout State, event: Event) -> Effect { switch event { case .load: return .task(id: "fetch") { input, _ in try input.post(Event.didLoad) } case .didLoad: state.loaded = true - return nil + return .none } } } - - var capturedInput: EffectViewInput? - let loadedExpectation = Expectation() - + + var capturedInput: EffectViewInput? + let loadedExpectation = Promise() + let timeout: UInt64 = 5_000_000_000 - + try await testView(initialState: T.State()) { binding in EffectView(of: T.self, state: binding) { state, input in Text(state.loaded ? "loaded" : "idle") @@ -205,49 +209,46 @@ struct EffectViewTests { } } expect: { try await capturedInput?.send(.load) - try await loadedExpectation.await(nanoseconds: timeout) + try await loadedExpectation.await(timeout: timeout) } } - - @Test func cancelEffectStopsRunningTask() async throws { + + @Test + func cancelEffectStopsRunningTask() async throws { enum T: Transducer { + static let initialState: State = .init() struct State: Equatable { var ticks = 0; var running = false } enum Event: Sendable { case start, tick, stop } - static func update(_ state: inout State, event: Event) -> Effect? { + static func transduce(_ state: inout State, event: Event) -> Effect { switch event { case .start: state.running = true - return .task(id: "ticker") { input, _ in - do { - // run indefinitely, or until the "ticker" task gets cancelled - while true { - try await Task.sleep(nanoseconds: 20_000_000) // 20 ms - try input.post(Event.tick) - } - } catch { - print("Error: \(error)") - /* task cancelled — exit cleanly */ + return .task(id: "ticker") { input, _ -> Void in + // run indefinitely, or until the "ticker" task gets cancelled + while true { + try await Task.sleep(nanoseconds: 10_000_000) // 10 ms + try input.post(Event.tick) } } case .tick: state.ticks += 1 - return nil + return .none case .stop: state.running = false return .cancel("ticker") } } } - + class TickCounter: @unchecked Sendable { var count = 0 } let tickCounter = TickCounter() - - var capturedInput: EffectViewInput? - let twoTicksExpectation = Expectation(minFulfillCount: 2) - let stoppedExpectation = Expectation() - + + var capturedInput: EffectViewInput? + let twoTicksExpectation = Promise.init(fulfilledAt: 2) + let stoppedExpectation = Promise() + let timeout: UInt64 = 5_000_000_000 - + try await testView(initialState: T.State()) { binding in EffectView(of: T.self, state: binding) { state, input in Text("\(state.ticks)") @@ -266,10 +267,10 @@ struct EffectViewTests { #expect(capturedInput != nil) try await capturedInput?.send(.start) - try await twoTicksExpectation.await(nanoseconds: timeout) + try await twoTicksExpectation.await(timeout: timeout) try await capturedInput?.send(.stop) - try await stoppedExpectation.await(nanoseconds: timeout) + try await stoppedExpectation.await(timeout: timeout) let countAtStop = tickCounter.count // Wait 3x the tick interval - any in-flight ticks would arrive within this window. @@ -277,24 +278,26 @@ struct EffectViewTests { #expect(tickCounter.count == countAtStop, "No ticks should arrive after cancel") } } - - @Test func actionEffectChainFiresSynchronously() async throws { + + @Test + func actionEffectChainFiresSynchronously() async throws { enum T: Transducer { + static let initialState: State = .init() struct State: Equatable { var phase = 0 } enum Event: Sendable { case begin, step, done } - static func update(_ state: inout State, event: Event) -> Effect? { + static func transduce(_ state: inout State, event: Event) -> Effect { switch event { - case .begin: state.phase = 1; return .action { _ in Event.step } - case .step: state.phase = 2; return .action { _ in Event.done } - case .done: state.phase = 3; return nil + case .begin: state.phase = 1; return .action { _ in .step } + case .step: state.phase = 2; return .action { _ in .done } + case .done: state.phase = 3; return .none } } } - - var capturedInput: EffectViewInput? - let readyExpectation = Expectation() - let doneExpectation = Expectation() - + + var capturedInput: EffectViewInput? + let readyExpectation = Promise() + let doneExpectation = Promise() + try await testView(initialState: T.State()) { binding in EffectView(of: T.self, state: binding) { state, input in Text("\(state.phase)") @@ -307,25 +310,26 @@ struct EffectViewTests { } } } expect: { - try await readyExpectation.await(nanoseconds: 5_000_000_000) + try await readyExpectation.await(timeout: 5_000_000_000) // request() awaits the entire synchronous chain: begin → step → done. try await capturedInput?.request(.begin) - try await doneExpectation.await(nanoseconds: 5_000_000_000) + try await doneExpectation.await(timeout: 5_000_000_000) } } - + @Test func sequenceEffectCancelsThenStartsTask() async throws { - struct WorkerEnv: Sendable { let cancelExpectation: Expectation; let timeout: UInt64 } + struct WorkerEnv: Sendable { let cancelExpectation: Promise; let timeout: UInt64 } enum T: Transducer { + static let initialState: State = .init() struct State: Equatable { var ticks = 0 } enum Event: Sendable { case startFirst, refresh, tick } typealias Env = WorkerEnv - static func update(_ state: inout State, event: Event) -> Effect? { + static func transduce(_ state: inout State, event: Event) -> Effect { switch event { case .startFirst: // Long-running task that never ticks on its own. - return .task(id: "worker") { input, env in + return .task(id: "worker") { input, env -> Void in do { try await Task.sleep(nanoseconds: env.timeout) } catch { @@ -334,23 +338,23 @@ struct EffectViewTests { } case .refresh: // Cancel stale worker, immediately start a fresh one that ticks. - return .sequence([ + return .sequence( .cancel("worker"), .task(id: "worker") { input, _ in try input.post(Event.tick) }, - ]) + ) case .tick: state.ticks += 1 - return nil + return .none } } } - - var capturedInput: EffectViewInput? - let tickExpectation = Expectation() - let cancelExpectation = Expectation() - + + var capturedInput: EffectViewInput? + let tickExpectation = Promise() + let cancelExpectation = Promise() + let timeout: UInt64 = 5_000_000_000 - + try await testView(initialState: T.State()) { binding in EffectView( of: T.self, @@ -368,26 +372,27 @@ struct EffectViewTests { } expect: { try await capturedInput?.send(.startFirst) try await capturedInput?.send(.refresh) // cancels first task, starts new one that ticks - try await cancelExpectation.await(nanoseconds: timeout) - try await tickExpectation.await(nanoseconds: timeout) + try await cancelExpectation.await(timeout: timeout) + try await tickExpectation.await(timeout: timeout) } } - + // MARK: - Identity reset - + @Test func identityResetRestoresInitialState() async throws { enum T: Transducer { + static let initialState: State = .init() struct State: Equatable { var count = 0 } enum Event: Sendable { case increment } - static func update(_ state: inout State, event: Event) -> Effect? { state.count += 1; return nil } + static func transduce(_ state: inout State, event: Event) -> Effect { state.count += 1; return .none } } - - var capturedInput: EffectViewInput? - let resetExpectation = Expectation() + + var capturedInput: EffectViewInput? + let resetExpectation = Promise() var resetCount: Int? - + let timeout: UInt64 = 5_000_000_000 - + let (_, window) = try await embedInWindowAndMakeKey( TestView(initialState: T.State()) { binding in EffectView(of: T.self, state: binding) { _, input in @@ -397,14 +402,14 @@ struct EffectViewTests { } } ) - + guard let input = capturedInput else { Issue.record("Input not captured"); return } - + try await input.request(.increment) try await input.request(.increment) - + cleanup(window) - + // Mount a fresh host instance and assert it starts from the initial state. let (_, resetWindow) = try await embedInWindowAndMakeKey( TestView(initialState: T.State()) { binding in @@ -416,21 +421,22 @@ struct EffectViewTests { } } ) - - try await resetExpectation.await(nanoseconds: timeout) + + try await resetExpectation.await(timeout: timeout) #expect(resetCount == 0, "Fresh EffectView should start at count 0") cleanup(resetWindow) } - + // MARK: - Env - + @Test func envIsForwardedToTaskOperation() async throws { struct TaskEnv: Sendable { var value: String } enum T: Transducer { + static let initialState: State = .init() struct State: Equatable { var result = "" } enum Event: Sendable { case fetch, loaded(String) } typealias Env = TaskEnv - static func update(_ state: inout State, event: Event) -> Effect? { + static func transduce(_ state: inout State, event: Event) -> Effect { switch event { case .fetch: return .task(id: "fetch") { input, env in @@ -438,16 +444,16 @@ struct EffectViewTests { } case .loaded(let value): state.result = value - return nil + return .none } } } - - var capturedInput: EffectViewInput? - let loadedExpectation = Expectation() - + + var capturedInput: EffectViewInput? + let loadedExpectation = Promise() + let timeout: UInt64 = 5_000_000_000 - + try await testView(initialState: T.State()) { binding in EffectView( of: T.self, @@ -462,13 +468,17 @@ struct EffectViewTests { } } expect: { try await capturedInput?.send(.fetch) - try await loadedExpectation.await(nanoseconds: timeout) + try await loadedExpectation.await(timeout: timeout) } } +} + - // MARK: - Observation +// MARK: - Observation + +#if canImport(Observation) - #if canImport(Observation) +extension EffectViewTests { // Shared observable type for observation tests. Defined at member scope because // @Observable (an extension macro) cannot be applied to local types. @@ -481,6 +491,7 @@ struct EffectViewTests { init(_ v: T) { self.object = v } } + #if false // not yet implememted @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) @Test func observeDoesNotRetainObservable() async throws { // Effect.observe(object:keyPath:) documents that the object is held weakly. @@ -488,11 +499,12 @@ struct EffectViewTests { // object even while the EffectView is still live. enum T: Transducer { struct State: Equatable { var latest = -1 } + static let initialState: State = .init() enum Event: Sendable { case watch(ObservableCounter), tick(Int) } - static func update(_ state: inout State, event: Event) -> TransducerEffect? { + static func transduce(_ state: inout State, event: Event) -> Effect { switch event { case .watch(let observable): - return .observe(observable, keyPath: \.value) { input, value, env in + return .observeWeak(observable, keyPath: \.value) { input, value, env in // Regarding: using observe with isolated action // Note: request is *nonisolated* for this Input. Thus we // cannot use `isolatedOperation` - we need to have @@ -502,14 +514,14 @@ struct EffectViewTests { } case .tick(let value): state.latest = value - return nil + return .none } } } var counter: ObservableCounter? = ObservableCounter() let weakBox = WeakBox(counter!) - var capturedInput: EffectViewInput? + var capturedInput: EffectViewInput? let firstTickExpectation = Expectation() let timeout: UInt64 = 5_000_000_000 @@ -540,7 +552,8 @@ struct EffectViewTests { "Effect.observe must not retain the observable beyond the initial task") } } - + #endif + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) @MainActor @Test func cancelObservationTaskStopsHandlerInvocations() async throws { @@ -549,8 +562,8 @@ struct EffectViewTests { struct ObsEnv: Sendable { let counter: ObservableCounter } final class TickProbe: @unchecked Sendable { - let firstTickExpectation = Expectation() - let secondTickExpectation = Expectation() + let firstTickExpectation = Promise() + let secondTickExpectation = Promise() private(set) var count = 0 func recordTick() { @@ -565,33 +578,37 @@ struct EffectViewTests { enum T: Transducer { struct State: Equatable { var latest = -1 - let probe: TickProbe + var probe: TickProbe = .init() static func == (lhs: Self, rhs: Self) -> Bool { lhs.latest == rhs.latest } } + static let initialState: State = .init() enum Event: Sendable { case start, stop, tick(Int) } typealias Env = ObsEnv - static func update(_ state: inout State, event: Event) -> Effect? { + static func transduce(_ state: inout State, event: Event) -> Effect { switch event { case .start: - return .observe(\.counter, keyPath: \.value, id: "observe") { input, value, env in - try? await input.request(.tick(value)) - print("request(.tick(\(value))) finished") + return .task(id: "observe") { @MainActor input, env -> Void in + try await observe() { + let value = env.counter.value + try input(.tick(value)) + } } + case .stop: return .cancel("observe") case .tick(let v): state.latest = v state.probe.recordTick() - return nil + return .none } } } let counter = ObservableCounter() - var capturedInput: EffectViewInput? + var capturedInput: EffectViewInput? let timeout: UInt64 = 10_000_000_000 try await testView(initialState: T.State(probe: probe)) { binding in @@ -609,12 +626,12 @@ struct EffectViewTests { // Start observing; the initial value (0) is delivered via state. // Caution: DO NOT use `request` for an observation task, because it will not finish before it gets cancelled. - input.post(.start) - try await probe.firstTickExpectation.await(nanoseconds: timeout) + try input.post(.start) + try await probe.firstTickExpectation.await(timeout: timeout) // Mutate the counter; the handler should fire once more. counter.value = 1 - try await probe.secondTickExpectation.await(nanoseconds: timeout) + try await probe.secondTickExpectation.await(timeout: timeout) let countAtCancel = probe.count // expected: 2 // Cancel the observation task. @@ -633,322 +650,133 @@ struct EffectViewTests { } } - #endif -} - -#else -import Testing - -@Suite("EffectView (SwiftUI unavailable)") -struct EffectViewTests { - @Test func skipped() { - // Hosted tests require SwiftUI + AppKit or UIKit. - // This placeholder passes so `swift test` does not report a failure on - // platforms where SwiftUI is unavailable (e.g. Linux). - } -} - -#endif - -// MARK: - Spy helper - -/// Records events dispatched via `Input` so tests can assert on them. -/// `@unchecked Sendable` is intentional: all accesses happen on `@MainActor` -/// (the Input closure is `@MainActor`; assertions run on `@MainActor` too). -private final class EventSpy: @unchecked Sendable { - var received: [Event] = [] -} - -private struct TaskInput: TransducerInput, Sendable { - let onEvent: @Sendable @MainActor (Event) -> Void - - func send(_ event: sending Event) async throws { - await MainActor.run { - onEvent(event) - } - } - - func post(_ event: sending Event) { - Task { @MainActor in - onEvent(event) - } - } + #if false + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @MainActor + @Test func observeEnvDeliversChangesAndCancels() async throws { + // Model observes a value in Env and updates state; verify delivery and cancellation. - func request(_ event: Event) async -> Output? { - await MainActor.run { - onEvent(event) - return nil + @MainActor + enum T: @MainActor Transducer { + struct State: Equatable { var latest = -1 } + enum Event: Sendable { case start, stop, tick(Int) } + struct Env: Sendable { var counter = ObservableCounter() } + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .start: + return .observeEnv(\.counter.value, id: "observeEnv") { input, value, _ in + try? await input.request(.tick(value)) + } + case .stop: + return .cancel("observeEnv") + case .tick(let v): + state.latest = v + return nil + } + } } - } -} - -// MARK: - Counter model (no Env) - -private struct CounterState: Equatable { - var count = 0 - var running = false -} - -private enum CounterEvent: Equatable, Sendable { - case increment, decrement, reset, start, stop, ticked -} - -private func counterUpdate( - state: inout CounterState, - event: CounterEvent -) -> TransducerEffect? { - switch event { - case .increment: - state.count += 1 - return nil - case .decrement: - state.count -= 1 - return nil - case .reset: - state = .init() - return nil - case .start: - state.running = true - return .init(._task(id: "ticker", priority: nil, option: .switchToLatest) { input, _ in - try input.post(.ticked) - }) - case .stop: - state.running = false - return .init(._cancel("ticker")) - case .ticked: - state.count += 1 - return nil - } -} - -// MARK: - Loader model (with Env) - -private struct LoaderState: Equatable { - var items: [String] = [] - var isLoading = false - var error: String? = nil -} -private enum LoaderEvent: Equatable, Sendable { - case load, loaded([String]), failed(String) -} - -private struct LoaderEnv: Sendable { - var fetch: @Sendable () async throws -> [String] -} - -private struct LoadFetchError: Error, LocalizedError { - let message: String - var errorDescription: String? { message } -} + var capturedInput: EffectViewInput? + let firstExpectation = Expectation() + let secondExpectation = Expectation() + let timeout: UInt64 = 10_000_000_000 -private func loaderUpdate( - state: inout LoaderState, - event: LoaderEvent -) -> TransducerEffect? { - switch event { - case .load: - state.isLoading = true - state.error = nil - return .init(._task(id: "fetch", priority: nil, option: .switchToLatest) { input, env in - do { - let items = try await env.fetch() - try input.post(.loaded(items)) - } catch { - try input.post(.failed(error.localizedDescription)) + let env = T.Env() + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding, initialEvent: .start, initialEnv: env) { state, input in + Color.clear + .onAppear { capturedInput = input } + .onChange(of: state.latest) { _ in + if !firstExpectation.isFulfilled { + firstExpectation.fulfill() + } else { + secondExpectation.fulfill() + } + } } - }) - case .loaded(let items): - state.isLoading = false - state.items = items - return nil - case .failed(let message): - state.isLoading = false - state.error = message - return nil - } -} - -// MARK: - Tests: pure state mutations - -@Suite("State mutations") -struct StateMutationTests { - - @Test func incrementAddsOne() { - var state = CounterState() - let effect = counterUpdate(state: &state, event: .increment) - #expect(state.count == 1) - #expect(effect == nil) - } - - @Test func decrementSubtractsOne() { - var state = CounterState(count: 3, running: false) - let effect = counterUpdate(state: &state, event: .decrement) - #expect(state.count == 2) - #expect(effect == nil) - } - - @Test func resetRestoresDefaultState() { - var state = CounterState(count: 5, running: true) - let effect = counterUpdate(state: &state, event: .reset) - #expect(state == CounterState()) - #expect(effect == nil) - } - - @Test func loadSetsIsLoadingFlag() { - var state = LoaderState() - _ = loaderUpdate(state: &state, event: .load) - #expect(state.isLoading == true) - #expect(state.error == nil) - } - - @Test func loadedClearsLoadingAndStoresItems() { - var state = LoaderState(items: [], isLoading: true, error: nil) - let effect = loaderUpdate(state: &state, event: .loaded(["A", "B"])) - #expect(state.isLoading == false) - #expect(state.items == ["A", "B"]) - #expect(effect == nil) - } - - @Test func failedClearsLoadingAndStoresError() { - var state = LoaderState(items: [], isLoading: true, error: nil) - let effect = loaderUpdate(state: &state, event: .failed("network error")) - #expect(state.isLoading == false) - #expect(state.error == "network error") - #expect(effect == nil) - } -} - -// MARK: - Tests: returned Effect cases - -@Suite("Effect types") -struct EffectTypeTests { - - @Test func startReturnsNamedTask() { - var state = CounterState() - let effect = counterUpdate(state: &state, event: .start) - #expect(state.running == true) - guard case ._task(id: let name, _, _, _) = effect?.type, name == "ticker" else { - Issue.record(#"Expected .task(name: "ticker")"#) - return - } - } - - @Test func stopReturnsCancelForTicker() { - var state = CounterState(count: 0, running: true) - let effect = counterUpdate(state: &state, event: .stop) - #expect(state.running == false) - guard case ._cancel(let name) = effect?.type else { - Issue.record("Expected .cancel") - return + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + try await firstExpectation.await(nanoseconds: timeout) // initial delivery (0) + env.counter.value = 1 + try await secondExpectation.await(nanoseconds: timeout) // change delivery (1) + let countAtCancel = env.counter.value + try await input.request(.stop) + env.counter.value = countAtCancel + 1 + try await Task.sleep(nanoseconds: 50_000_000) // allow any in-flight work to drain + #expect(env.counter.value == countAtCancel + 1) } - #expect(name == "ticker") } - - @Test func loadReturnsNamedFetchTask() { - var state = LoaderState() - let effect = loaderUpdate(state: &state, event: .load) - guard case ._task(id: let name, _, _, _) = effect?.type, name == "fetch" else { - Issue.record(#"Expected .task(name: "fetch")"#) - return + #endif + + #if false + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + @MainActor + @Test func observeObjectDeliversChangesAndCancels() async throws { + // Verify observeObject delivers changes from a provided observable and stops after cancel. + @MainActor + enum T: @MainActor Transducer { + struct State: Equatable { var latest = -1 } + enum Event: Sendable { case start(ObservableCounter), stop, tick(Int) } + static func update(_ state: inout State, event: Event) -> Effect? { + switch event { + case .start(let obj): + return .observeObject(obj, keyPath: \.value, id: "observeObject") { input, value, _ in + try? await input.request(.tick(value)) + } + case .stop: + return .cancel("observeObject") + case .tick(let v): + state.latest = v + return nil + } + } } - } - - @Test func actionEffectInvokesClosureAndReturnsEvent() { - enum Ev: Equatable, Sendable { case a, b } - let effect = TransducerEffect.init(._actionSync { _ in .b } ) - guard case ._actionSync(let run) = effect.type else { - Issue.record("Expected .action") - return - } - #expect(run(()) == .b) - } + let counter = ObservableCounter() + var capturedInput: EffectViewInput? + let firstExpectation = Expectation() + let secondExpectation = Expectation() + let timeout: UInt64 = 10_000_000_000 - @Test func actionEffectCanReturnNil() { - enum Ev: Equatable, Sendable { case a } - let effect = TransducerEffect.init(._actionSync { _ in nil } ) - guard case ._actionSync(let run) = effect.type else { - Issue.record("Expected .action") - return + try await testView(initialState: T.State()) { binding in + EffectView(of: T.self, state: binding) { state, input in + Color.clear + .onAppear { capturedInput = input } + .onChange(of: state.latest) { _ in + if !firstExpectation.isFulfilled { + firstExpectation.fulfill() + } else { + secondExpectation.fulfill() + } + } + } + } expect: { + guard let input = capturedInput else { Issue.record("Input not captured"); return } + input.post(.start(counter)) + try await firstExpectation.await(nanoseconds: timeout) // initial delivery (0) + counter.value = 1 + try await secondExpectation.await(nanoseconds: timeout) // change delivery (1) + let countAtCancel = counter.value + try await input.request(.stop) + counter.value = countAtCancel + 1 + try await Task.sleep(nanoseconds: 50_000_000) + #expect(counter.value == countAtCancel + 1) } - #expect(run(()) == nil) } + #endif - @Test func sequenceContainsOrderedEffects() { - enum Ev: Equatable, Sendable { case done } - let effect = TransducerEffect.init(._sequence([ - .init(._cancel("old")), - .init(._task(id: "new", priority: nil, option: .switchToLatest) { _, _ in }) - ])) - guard case ._sequence(let effects) = effect.type, effects.count == 2 else { - Issue.record("Expected .sequence with 2 effects") - return - } - guard case ._cancel("old") = effects[0].type else { - Issue.record(#"Expected effects[0] to be .cancel("old")"#) - return - } - guard case ._task(id: let name, _, _, _) = effects[1].type, name == "new" else { - Issue.record(#"Expected effects[1] to be .task(name: "new")"#) - return - } - } } +#endif -// MARK: - Tests: async task operations - -/// These tests extract the operation closure from a returned `.task` effect and -/// drive it directly — no SwiftUI hosting required. -/// -/// `post` schedules work on `@MainActor` via a child Task, so one `Task.yield()` -/// after `await operation(...)` is needed to let that task run before asserting. -@Suite("Task operations") -@MainActor -struct TaskOperationTests { - - @Test func fetchSuccessSendsLoadedEvent() async { - var state = LoaderState() - let effect = loaderUpdate(state: &state, event: .load) - guard case ._task(_, _, _, let operation) = effect?.type else { - Issue.record("Expected .task"); return - } - - let spy = EventSpy() - let input = TaskInput { [spy] event in spy.received.append(event) } - try? await operation(input, LoaderEnv(fetch: { ["X", "Y"] })) - await Task.yield() - - #expect(spy.received == [.loaded(["X", "Y"])]) - } - - @Test func fetchFailureSendsFailedEvent() async { - var state = LoaderState() - let effect = loaderUpdate(state: &state, event: .load) - guard case ._task(_, _, _, let operation) = effect?.type else { - Issue.record("Expected .task"); return - } - - let spy = EventSpy() - let input = TaskInput { [spy] event in spy.received.append(event) } - try? await operation(input, LoaderEnv(fetch: { throw LoadFetchError(message: "timed out") })) - await Task.yield() - - #expect(spy.received == [.failed("timed out")]) - } - - @Test func tickerTaskEnqueuesTickedEvent() async { - var state = CounterState() - let effect = counterUpdate(state: &state, event: .start) - guard case ._task(_, _, _, let operation) = effect?.type else { - Issue.record("Expected .task"); return - } - let spy = EventSpy() - let input = TaskInput { [spy] event in spy.received.append(event) } - try? await operation(input, ()) - await Task.yield() +#else // canImport(SwiftUI) && (canImport(UIKit) || canImport(AppKit)) - #expect(spy.received == [.ticked]) +@Suite("EffectView (SwiftUI unavailable)") +struct EffectViewTests { + @Test func skipped() { + // Hosted tests require SwiftUI + AppKit or UIKit. + // This placeholder passes so `swift test` does not report a failure on + // platforms where SwiftUI is unavailable (e.g. Linux). } } +#endif diff --git a/Tests/Transduce/ObservationTests.swift b/Tests/Transduce/ObservationTests.swift new file mode 100644 index 0000000..35d8d76 --- /dev/null +++ b/Tests/Transduce/ObservationTests.swift @@ -0,0 +1,337 @@ +#if canImport(Observation) + +import Transduce + +import Testing +import Observation +import Foundation + +// @Suite() +@Suite(.serialized) +struct ObservationTests { + + #if false + // @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) + @MainActor + @Observable + final class TestObservabel { + private(set) var value: Int = 0 + let timeInterval: TimeInterval + + @ObservationIgnored + private var timerTask: Task? = nil + + nonisolated init(timeInterval: TimeInterval = 1.0) { + self.timeInterval = timeInterval + } + deinit { self.timerTask?.cancel() } + func start() { + self.timerTask?.cancel() + self.timerTask = Task { @MainActor in + while true { + try? await Task.sleep(nanoseconds: UInt64(timeInterval * 1_000_000_000 + 0.5)) + self.value += 1 + } + } + } + } + #endif + +} + +extension ObservationTests { + @MainActor @Observable + final class ObservableValue { + init(value: Int = 0) { + self.ininitValue = value + self.value = value + } + let ininitValue: Int + var value: Int + func setValue(_ newValue: Int) { + self.value = newValue + } + } + + @MainActor @Observable + final class ManualCounter { + nonisolated init() {} + var value: Int = 0 + func tick() { value += 1 } + } + + @Test + @MainActor + func testObserveFreeFunctionSendsInitialValue() async throws { + let promiseCount = Promise(fulfilledAt: 1) + let observableValue = ObservableValue() + // Caution: allways cancel the observation task - otherwise it will leak! + let observationTask = Task { @MainActor [weak observableValue] in + try await observe { + guard let observableValue else { return } + let value = observableValue.value + #expect(value == observableValue.ininitValue) + promiseCount.fulfill() + } + } + defer { observationTask.cancel() } + await #expect(throws: Never.self) { + try await promiseCount.await(timeout: 10_000_000_000) + } + #expect(observableValue.value == 0) + } + + @Test + @MainActor + func testObserveFreeFunctionHandlesTaskCancellation() async throws { + let promiseCancellation = Promise() + let promiseDidObserve = Promise(fulfilledAt: 1) + let observableValue = ObservableValue() + // Caution: allways cancel the observation task - otherwise it will leak! + let observationTask = Task { @MainActor in + do { + try await observe { + let _ = observableValue.value + promiseDidObserve.fulfill() + } + } catch is CancellationError { + promiseCancellation.fulfill() + throw CancellationError() + } + } + try await promiseDidObserve.await(timeout: 10_000_000_000) + observationTask.cancel() + try await promiseCancellation.await(timeout: 10_000_000_000) + } + + @Test + @MainActor + func testObserveFreeFunctionSendsNchanges() async throws { + /// **Important** + /// **Pointer to Documentation & Official Guidance ** + /// 1. Swift Documentation (@Observable macro): Explicitly notes in the Observing Changes section that "Change notifications are delivered asynchronously and may be coalesced for performance." + /// 2. WWDC 2023 Session 10178 ("Meet Observability") & Session 10209: Detail how the Observation model batches events to maintain smooth run-loop pacing, warning developers not to depend on synchronous or per-mutation firing during rapid state transitions. + /// 3. Swift Forums / Bug Reports: Multiple threads and rdar:// links address "missing observations under stress" and clarify this is a design feature. Apple recommends using the Observation framework for UI state binding/decoupling, not for strict step-by-step event sequencing. + + let count = 10 + let promiseDidObserveChange = Promise(fulfilledAt: 1) // Expect at least 1 notification + let observableValue = ObservableValue() + // Caution: allways cancel the observation task - otherwise it will leak! + let observationTask = Task { @MainActor in + try await observe { + let value = observableValue.value + if value == count { + promiseDidObserveChange.fulfill() + } + } + } + defer { observationTask.cancel() } + let testTask = Task { + for i in 1...count { + observableValue.setValue(i) + try? await Task.sleep(nanoseconds: 1_000_000) + } + } + try await promiseDidObserveChange.await(timeout: 10_000_000_000) + + await testTask.value + } + + @Test + @MainActor + func testObserveFreeFunctionThrowsWhenApplyFailes() async throws { + struct ApplyError: Error {} + let promiseDidObserve = Promise(fulfilledAt: 1) + let observableValue = ObservableValue() + // Caution: allways cancel the observation task - otherwise it will leak! + let observationTask = Task { @MainActor in + do { + try await observe { + let _ = observableValue.value + promiseDidObserve.fulfill() + throw ApplyError() + } + } + } + defer { observationTask.cancel() } + try await promiseDidObserve.await(timeout: 10_000_000_000) + + await #expect(throws: ApplyError.self) { + try await observationTask.value + } + } + +} + +extension ObservationTests { + + /// - Given: A transducer that starts an Observation task watching a ticking @Observable timer + /// - When: The observation is started and the timer produces values on the main actor + /// - Then: The transducer emits tick events until at least 10 ticks are observed, and cancelling the observation stops further ticks + /// + @Test func testObservableHasSeen10Ticks() async throws { + enum T: EffectTransducer { + enum State { case idle, observing, cancelled } + enum Event { case observe, cancelObservation, tick(Int) } + struct Env { + let counter = ManualCounter() + let initialApplyClosure = Promise() + let tickCount = Promise(fulfilledAt: 1) + } + enum Response { case none } + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.idle, .observe): + state = .observing + return .task(id: "observe") { @MainActor input, env -> Void in + try await observe() { + let value = env.counter.value + if value == 0 { + env.initialApplyClosure.succeed() + } + // print("### observation: value: \(value)") + try? input(.tick(value)) + } + } + + case (.observing, .tick): + return .action { env in + env.tickCount.fulfill() // first fulfill happens at initial observation + } + + case (.observing, .cancelObservation): + state = .cancelled + // print("### cancel observation") + return .cancel("observe") + + case (.cancelled, .tick): + return .action { env in + Issue.record("unexpected tick after observation has been cancelled") + env.tickCount.fulfill() + } + + default: + return .none + } + } + static func response(state: State, event: Event) -> Response { .none } + static let initialState: State = .idle + } + + let env = T.Env() + let runtime = GlobalActorRuntime(env: env) + let input = runtime.input + + try await input.send(.observe) + + // Wait for the initial apply closure to be called, then send 10 ticks, + // while waiting of the change to be applied. + try await env.initialApplyClosure.await(timeout: 10_000_000_000) + for _ in 0..<10 { + try await env.tickCount.await(timeout: 100_000_000) + await MainActor.run { env.counter.tick() } + } + + // Cancel observation and verify no further ticks are observed + try await input.send(.cancelObservation) + + await MainActor.run { env.counter.tick() } + await #expect(throws: Error.self) { + try await env.tickCount.await(timeout: 50_000_000) + } + await runtime.cancel() + } + + /// - Given: An Observable observed via a Transducer + /// - When: Observation starts and later is cancelled + /// - Then: Ticks stop after cancellation + @Test func testObservableObservationCanBeCancelled() async throws { + enum T: EffectTransducer { + enum State { case idle, observing(count: Int), cancelled } + enum Event { case observe, cancelObservation, finishedObservation, tick(Int) } + struct Env { + let counter = ManualCounter() + let tickCount = Promise(fulfilledAt: 2) + let finished = Promise() + let unexpectedTick = Promise() + } + enum Response { case none } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.idle, .observe): + state = .observing(count: 0) + return .task(id: "observe") { @MainActor input, env -> Void in + do { + try await observe() { + let value = env.counter.value + try? input(.tick(value)) + } + } catch is CancellationError { + try? input(.finishedObservation) + } + } + + case (.observing(let count), .tick): + state = .observing(count: count + 1) + return .action { env in + env.tickCount.fulfill() + } + + case (.observing, .cancelObservation): + state = .cancelled + return .cancel("observe") + + case (.cancelled, .tick): + return .action { env in + env.unexpectedTick.fulfill() + } + + case (.cancelled, .finishedObservation): + return .action { env in + env.finished.fulfill() + } + + default: + return .none + } + } + + static func response(state: State, event: Event) -> Response { .none } + static let initialState: State = .idle + } + + let env = T.Env() + let runtime = GlobalActorRuntime(env: env) + let input = runtime.input + + try await input.send(.observe) + + // Produce two ticks deterministically + await MainActor.run { env.counter.tick() } + await MainActor.run { env.counter.tick() } + + try await env.tickCount.await(timeout: 1_000_000_000) + + // Cancel observation and wait for confirmation + try await input.send(.cancelObservation) + try await env.finished.await(timeout: 1_000_000_000) + + // Try to produce more ticks; they must not be observed + await MainActor.run { env.counter.tick() } + await MainActor.run { env.counter.tick() } + + // Ensure no unexpected tick was observed within a brief window + await #expect(throws: Error.self) { + try await env.unexpectedTick.await(timeout: 50_000_000) + } + await runtime.cancel() + } +} + +#else // No Observation Framework +@Suite(.disabled()) +enum ObservationTests {} +#endif + + diff --git a/Tests/Transduce/RemoveTestsGherkinSpec.md b/Tests/Transduce/RemoveTestsGherkinSpec.md new file mode 100644 index 0000000..9d10095 --- /dev/null +++ b/Tests/Transduce/RemoveTestsGherkinSpec.md @@ -0,0 +1,207 @@ +# TaskManager.remove(continuationWith:) — Gherkin Specifications (Per-Entry + Cross-Entry) + +## Scope + +Tests for `func remove(continuationWith: continuationId: Int) -> Bool` in `TaskManager`. +All tests use **direct waiter construction** via `@testable import Transduce`. No runtime abstractions. + +## Shared Test Helpers + +Each test creates a TaskManager, manually sets Tasks on the taskInfo dictionary, and calls remove(). + +Key types accessible via `@testable`: +- `Waiters` enum: `.anon`, `.unique(ownerId: Int, box: CB)`, `.shareable([CB])` +- `TaskValue` struct: `{ id: Int, task: Task, waiters: Waiters }` +- `TaskKey`: Hashable key from `TaskIdentifier?` +- `ContinuationBox`: holds `.id: Int` + +--- + +## Phase 1: Per-Entry States (single-task dicts) + +### E0 — Empty Dictionary + +```gherkin +GIVEN: tasks = {} (empty dict, no keys) +AND: state == .active +WHEN: remove(continuationWith: 999) is called +THEN: return false +AND: iteration count == 0 +``` + +### E1 — Anonymous Task (no subscribers) + +```gherkin +GIVEN: tasks = { "anon-task": TaskValue(id: 1, task: liveTask, waiters: .anon) } +AND: liveTask is running (not cancelled) +WHEN: remove(continuationWith: 1) is called +THEN: return false +AND: task.isCancelled == false (task untouched) +AND: waiter state unchanged (.anon) +``` + +### W1a — Unique Task, Continuation ID Matches Owner + +```gherkin +GIVEN: tasks = { "unique-task": TaskValue(id: 2, task: liveTask, waiters: .unique(ownerId: 42, box: cb(42))} +AND: cb(42).id == 42 +AND: liveTask is running (not cancelled) +WHEN: remove(continuationWith: 42) is called +THEN: return true +AND: liveTask.isCancelled == true +AND: Continuation resumed with CancellationError +AND: taskValue.waiters is unmodified: .unique(ownerId: 42, box: cb(42)) + (verify via taskInfo() snapshot after return) +``` + +### W1b — Unique Task, Continuation ID Does NOT Match Owner + +```gherkin +GIVEN: tasks = { "unique-task": TaskValue(id: 3, task: liveTask, waiters: .unique(ownerId: 42, box: cb(42))} +WHEN: remove(continuationWith: 99) is called (miss!) +THEN: return false +AND: task.isCancelled == false (task untouched) +AND: waiter state unchanged (.unique(ownerId: 42, ...)) +``` + +### P4 — Shareable with Multiple Subs (one removed) + +```gherkin +GIVEN: tasks = { "share-task": TaskValue(id: 5, task: liveTask, waiters: .shareable([cb(10), cb(20), cb(30)])} +WHEN: remove(continuationWith: 20) is called (remove the middle subscriber) +THEN: return true +AND: liveTask.isCancelled == false (task survives!) +AND: waiters mutated to .shareable([cb(10), cb(30)]) (count goes from 3 -> 2) +``` + +### P5 — Shareable with Single Sub Removed → Empty Waiters + +```gherkin +GIVEN: tasks = { "single-sub": TaskValue(id: 6, task: liveTask, waiters: .shareable([cb(77)])} +WHEN: remove(continuationWith: 77) is called (remove the only subscriber) +THEN: return true +AND: liveTask.isCancelled == false (task MUST survive per confirmed semantics!) +AND: waiters mutated to .shareable([]) (empty but dict entry still exists) +``` + +### R_empty — Shareable with Empty Waiter List + +```gherkin +GIVEN: tasks = { "empty-share": TaskValue(id: 7, task: liveTask, waiters: .shareable([])} +WHEN: remove(continuationWith: 999) is called +THEN: return false (no match in empty list) +``` + +--- + +## Phase 2: Cross-Entry Combinations (multi-task dicts) + +**Iteration order note**: `for (key, tv) in tasks` iterates dictionary entries in unspecified order. +We test scenarios where the target continuation could be in any position. + +### R0 — Target in First Entry (of 2) + +```gherkin +GIVEN: tasks = { + "first": TaskValue(id: 1, task: taskA, waiters: .shareable([cb(50)])), + "second": TaskValue(id: 2, task: taskB, waiters: .unique(ownerId: 60, box: cb(60))) + } +WHEN: remove(continuationWith: 50) is called (removing first entry's subscriber) +THEN: return true +AND: taskA.isCancelled == false +AND: "first" waiters becomes .shareable([]) +AND: "second" untouched +``` + +### R1 — Target in Second Entry (of 2) + +```gherkin +GIVEN: tasks = { + "first": TaskValue(id: 3, task: taskC, waiters: .unique(ownerId: 40, box: cb(40))), + "second": TaskValue(id: 4, task: taskD, waiters: .shareable([cb(80)])) + } +WHEN: remove(continuationWith: 80) is called (removing second entry's subscriber) +THEN: return true +AND: taskD.isCancelled == false +AND: "second" waiters becomes .shareable([]) +AND: "first" untouched +``` + +### R2 — No Match in Multi-Entry Dict + +```gherkin +GIVEN: tasks = { + "alpha": TaskValue(id: 10, task: tA, waiters: .anon), + "beta": TaskValue(id: 11, task: tB, waiters: .unique(ownerId: 99, box: cb(99))) + } +WHEN: remove(continuationWith: 42) is called (not in any entry) +THEN: return false +AND: BOTH tasks untouched (no cancellation, no waiter mutation) +``` + +### R3 — Multiple Shareable Subs Across Two Tasks (remove from first) + +```gherkin +GIVEN: tasks = { + "t1": TaskValue(id: 20, task: tX, waiters: .shareable([cb(1), cb(2)])), + "t2": TaskValue(id: 21, task: tY, waiters: .shareable([cb(3), cb(4)])) + } +WHEN: remove(continuationWith: 1) is called +THEN: return true +AND: "t1" waiters becomes .shareable([cb(2)]) +AND: "t2" untouched +``` + +### R4 — Multiple Shareable Subs Across Two Tasks (remove from second) + +```gherkin +GIVEN: tasks = { + "t1": TaskValue(id: 30, task: tP, waiters: .shareable([cb(5)])), + "t2": TaskValue(id: 31, task: tQ, waiters: .shareable([cb(6), cb(7)])) + } +WHEN: remove(continuationWith: 6) is called +THEN: return true +AND: "t2" waiters becomes .shareable([cb(7)]) +AND: "t1" untouched +``` + +### R5 — Triple Entry: Target in Third (last) Entry + +```gherkin +GIVEN: tasks = { + "a": TaskValue(id: 40, task: tA, waiters: .unique(ownerId: 100, box: cb(100))), + "b": TaskValue(id: 41, task: tB, waiters: .anon), + "c": TaskValue(id: 42, task: tC, waiters: .shareable([cb(42)])) + } +WHEN: remove(continuationWith: 42) is called (only in third entry!) +THEN: return true +AND: "c" waiters becomes .shareable([]) +AND: taskC.isCancelled == false +AND: "a", "b" completely untouched +``` + +--- + +## Phase 3: Edge Cases + +### EDGE-1 — ContinuationBox with non-Int ID? (skip — CB always has Int ID) + +### EDGE-2 — Call remove() twice on same continuation ID → second call returns false + +```gherkin +GIVEN: tasks = { "once": TaskValue(id: 50, task: tZt, waiters: .shareable([cb(7)]))} +WHEN: remove(continuationWith: 7) called → returns true, waiters becomes .shareable([]) +AND: remove(continuationWith: 7) called again on same dict entry +THEN: second call returns false (cb(7) no longer in the now-empty list) +``` + +--- + +## Summary: Test Count + +| Phase | Tests | Scenarios | +|-------|-------|-----------| +| Per-Entry | 6 | E0, E1, W1a, W1b, P4, P5 | +| Cross-Entry | 6 | R0-R5 | +| Edge Cases | 1 | EDGE-2 | +| **Total** | **13** | | diff --git a/Tests/Transduce/RuntimeTests.swift b/Tests/Transduce/RuntimeTests.swift new file mode 100644 index 0000000..9543295 --- /dev/null +++ b/Tests/Transduce/RuntimeTests.swift @@ -0,0 +1,2366 @@ +import Testing +import Foundation +@testable import Transduce + +// All Tests use a Reference implementation for the Transducer Runtime +@Suite(.serialized) +struct RuntimeTests { + @Suite struct EngineTests {} + @Suite struct TaskManagerTests {} + @Suite struct GateTests {} + @Suite struct RequestTests {} + @Suite struct ActionIsolationTests {} + @Suite struct CancellationTests {} + @Suite struct UnsubscribingTests {} + @Suite struct TaskReturnTests {} +} + +extension RuntimeTests.EngineTests { + + /// GIVEN: A transducer with a nonsendbale Env class that modifies env in a synchronous action. + /// WHEN: The runtime input sends `.start` + /// THEN: The transducer processes the event and returns an synchronous actions which increments env.value by one. + @Test func testNonsendableEnvCanBeModifiedInSyncAction() async throws { + enum T: Transducer { + enum State: DefaultInitializable { + case start + init() { self = .start } + } + enum Event { case start } + class Env { var value: Int = 0 } + static func transduce(_ state: inout State, event: Event) -> Effect { + .action { env in + env.value += 1 + } + } + } + let env = T.Env() + + let runtime = GlobalActorRuntime( + transducer: T.self, + on: MainActor.self, + env: env // note: env is sending + ) + let input = runtime.input + try await input.send(.start) + await MainActor.run { + #expect(runtime.env.value == 1) + } + } + + /// GIVEN: A transducer that responds to `.start` by returning `.event(.done)` + /// WHEN: The runtime input sends `.start` + /// THEN: The transducer processes `.done`, transitions to `.terminal`, and no further effects are produced + @Test func testInputSendInvokesTransducerWithSend() async throws { + enum T: Transducer { + enum State: DefaultInitializable { + case start, terminal + init() { self = .start } + } + enum Event { case start, done } + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.start, .start): + return .event(.done) + case (.start, .done), (.terminal, _): + state = .terminal + return .none + } + } + } + let runtime = GlobalActorRuntime( + transducer: T.self, + on: MainActor.self + ) + let input = runtime.input + try await input.send(.start) + await #expect(try runtime.state == .terminal) + } + + /// GIVEN: A transducer that responds to `.start` by returning an `.action` which mutates env and returns `.done` + /// WHEN: The runtime input sends `.start` + /// THEN: The action runs, env is updated, `.done` is processed, and the state transitions to `.terminal` + @Test func testInputSendInvokesTransducerWithAction() async throws { + enum T: Transducer { + enum State: DefaultInitializable { + case start, terminal + init() { self = .start } + } + enum Event { case start, done } + final class Env { var value = 0 } + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.start, .start): + return .action { env in + env.value = 1 + return .done + } + case (.start, .done), (.terminal, _): + state = .terminal + return .none + } + } + } + let env: T.Env = .init() + let runtime = GlobalActorRuntime( + transducer: T.self, + on: MainActor.self, + env: env + ) + let input = runtime.input + try await input.send(.start) + await #expect(try runtime.state == .terminal) + #expect(runtime.env.value == 1) + } + + + /// GIVEN: A transducer that transitions to `.terminal` on `.start` and performs an action that fulfills an expectation + /// WHEN: The runtime input sends `.start` + /// THEN: The expectation is fulfilled and the state is `.terminal` + @Test func testInputPostInvokesTransducerWithAction() async throws { + enum T: Transducer { + enum State: DefaultInitializable { + case start, terminal + init() { self = .start } + } + enum Event { case start, done } + struct Env { + let expect = Promise() + } + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.start, .start): + state = .terminal + return .action { env in + env.expect.fulfill() + } + case (.start, .done), (.terminal, _): + Issue.record("Unexpected state/event reached") + return .none + } + } + } + let env = T.Env() + let runtime = GlobalActorRuntime( + transducer: T.self, + on: MainActor.self, + env: env + ) + let input = runtime.input + try await input.send(.start) + try await env.expect.await(timeout: 100_000_000) + await #expect(try runtime.state == .terminal) + } + + /// GIVEN: A transducer that starts a named async task on .start. + /// The task uses Input.send(.done) to drive the machine to a terminal state, + /// where an action fulfills an expectation in Env. + /// WHEN: We send .start synchronously. + /// THEN: The task runs, sends .done, the transducer transitions to .terminal, + /// and the Env expectation is fulfilled. + @Test func testTaskOperationCanSendEventToComplete() async throws { + enum T: Transducer { + enum State: DefaultInitializable, Equatable { + case start, working, terminal + init() { self = .start } + } + enum Event: Sendable { case start, done } + struct Env: Sendable { let finished = Promise() } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.start, .start): + state = .working + return .task(id: "work") { @MainActor input, env -> Void in + try await input.send(.done) + } + case (.working, .done): + state = .terminal + return .action { env in + env.finished.fulfill() + } + default: + return .none + } + } + } + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + try await input.send(.start) + try await env.finished.await(timeout: 1_000_000_000) + let state = try await runtime.state + #expect(state == .terminal) + } + + /// GIVEN: A transducer that starts a nonsendable async task on .start. + /// The task uses Input.post(.done) to drive the machine to a terminal state, + /// where an action fulfills an expectation in Env. + /// WHEN: We send .start synchronously. + /// THEN: The task runs, posts .done, the transducer transitions to .terminal, + /// and the Env expectation is fulfilled. + @Test func testTaskNonsendingOperationCanPostEventToComplete() async throws { + enum T: Transducer { + enum State: DefaultInitializable, Equatable { + case start, working, terminal + init() { self = .start } + } + enum Event: Sendable { case start, done } + struct Env: Sendable { let finished = Promise() } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.start, .start): + state = .working + return .task(id: "work") { input, env -> Void in + try? input.post(.done) + } + case (.working, .done): + state = .terminal + return .action { env in + env.finished.fulfill() + } + default: + return .none + } + } + } + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + try await input.send(.start) + try await env.finished.await(timeout: 10_000_000_000) + await #expect(try runtime.state == .terminal) + } + + /// GIVEN: A transducer that schedules a task which immediately throws a custom error. + /// The TaskManager cancels the runtime with that error. + /// WHEN: We send .start to schedule the failing task, then attempt to send .start again + /// after the cancellation has latched. + /// THEN: The second send throws the same custom error that caused cancellation. + @Test func testTaskThrowCancelsRuntimeAndSubsequentSendThrows() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case start } + struct Env: Sendable { let started = Promise() } + enum Boom: Error, Equatable { case boom } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .task(id: "work") { _, env -> Void in + env.started.fulfill() + throw Boom.boom + } + } + } + } + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + try await input.send(.start) + try await env.started.await(timeout: 1_000_000_000) + do { + try await input.send(.start) + Issue.record("Expected second send to throw Boom.boom") + } catch let error as T.Boom { + #expect(error == .boom) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + /// GIVEN: A transducer that transitions to terminal on .start and fulfills an expectation. + /// WHEN: We call input.post(.start) instead of send. + /// THEN: The event is processed asynchronously; the expectation fulfills and the state is terminal. + @Test func testInputPostSchedulesEventAsynchronously() async throws { + enum T: Transducer { + enum State: DefaultInitializable, Equatable { + case start, terminal + init() { self = .start } + } + enum Event: Sendable { case start } + struct Env: Sendable { let finished = Promise() } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.start, .start): + state = .terminal + return .action { env in + env.finished.fulfill() + } + case (.terminal, _): + return .none + } + } + } + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + try? input.post(.start) + try await env.finished.await(timeout: 10_000_000_000) + await #expect(try runtime.state == .terminal) + } + + /// GIVEN: A transducer that chains partial actions (event-returning) several times, then terminates with a void action. + /// WHEN: We send `.start`. + /// THEN: All steps run within a single compute cycle and the terminal action fulfills a promise. + @Test func testPartialActionChainingUntilTerminal() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event { case start, step(Int) } + @MainActor final class Env { var count = 0; let finished = Promise() } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .action { @MainActor env async in + env.count += 1 + return .step(1) + } + case .step(let i) where i < 3: + return .action { env in + await MainActor.run { env.count += 1 } + return .step(i + 1) + } + case .step: + return .action { @MainActor env async in + env.count += 1 + env.finished.succeed() + } + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + try await input.send(.start) + try await env.finished.await(timeout: 1_000_000_000) + await MainActor.run { #expect(env.count == 4) } + } + + /// GIVEN: A sequence of effects where only the last effect returns an event `done`, and + /// an response function which returns ouptut value`ok` only for the transition `(_, .done)` + /// WHEN: We call `request(.start)`. + /// THEN: A request associates its continutation only to the last effect in the sequence, + /// and returns the response value `ok` (for the transition `(.start, .done)`) + @Test func testSequenceOnlyFinalStepCompletesRequest() async throws { + enum T: Transducer { + + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event { case start(String), first, second, done } + enum Response: Sendable, Equatable { case none, value(String) } + final class Env { let release = Promise() } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .sequence( + .action { _ in return .first }, + .action { _ in return .second }, + .action { env in + try? await env.release.await() + return .done + } + ) + case .first, .second, .done: + return .none + } + } + static func response(state: State, event: Event) -> Response { + switch event { + case .start(let value): return .value(value) + default: return .none + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + + let task = Task { + let response = try await input.request(.start("completed")) + #expect(response == .value("completed")) + return response + } + env.release.succeed() + let out = try await task.value + #expect(out == .value("completed")) + } + + /// GIVEN: A sequence that starts a long-running task and then emits `cancel(id:)`. + /// WHEN: We call `request(.start)`. + /// THEN: The request resumes with `nil` and the task observes cancellation. + @Test func testCancelEffectResumesRequestWithNilAndCancelsTasks() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case start, done } + @MainActor final class Env { let cancelled = Promise() } + + static func transduce(_ state: inout State, event: Event) -> Effect { + TestGlobalActor.assertIsolated() + switch event { + case .start: + return .sequence( + .task(id: "X", .switchToLatest) { _, env -> Void in + TestGlobalActor.assertIsolated() + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + Issue.record("Task did not cancel") + } catch { + env.cancelled.succeed() + } + }, + .cancel("X") + ) + case .done: + return .none + } + } + + enum Response { case ok, none } + + static func response(state: State, event: Event) -> Response { + switch event { + case .start: return .ok + case .done: return .none + } + } + } + + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: TestGlobalActor.self, env: env) + let input = runtime.input + + #expect(try await input.request(.start) == .ok) + try await env.cancelled.await(timeout: 1_000_000_000) + } +} + +extension RuntimeTests.TaskManagerTests { + + /// - GIVEN: Two sequential events that each start a task with the same id using .switchToLatest. + /// - WHEN: The second event arrives before the first task completes. + /// - THEN: The first task is cancelled and only the second task's action runs. + @Test func testSwitchToLatestCancelsPreviousTaskWithSameID() async throws { + + // Here, we declare `Env` on the MainActor. This may be useful if we + // want to mutate the environment from different transducers, or when + // it may be mutated from elsewhere. Please note, that a mutable env + // is very problemtatic for the reliability of a transducer. So, please + // avoid to mutate dependencies or other values during the life-cycle + // of a transducer. For utilising it as a testing harnsess, this is OK. + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case first, second, done(Int) } + @MainActor final class Env { + let firstStarted = Promise() + let finished = Promise() + var values: [Int] = [] + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .first: + // Since Env needs to be accessed exclusively on the MainActor, we + // use a global actor isolated closure and specifiy the MainActor: + return .task(id: "work", .switchToLatest) { @MainActor input, env -> Void in + // Signal we've started, then wait to be cancelled by the second task + env.firstStarted.fulfill() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 10_000_000) + } + // If not cancelled (unexpected), try to send a value that would fail the test + if !Task.isCancelled { + try await input.send(.done(1)) + } + } + case .second: + return .task(id: "work", .switchToLatest) { @MainActor input, env -> Void in + try await input.send(.done(2)) + } + case .done(let value): + // we need to return an action whose body is a global isolated closure, isolated to the MainActor + return .action { @MainActor env async in + env.values.append(value) + env.finished.fulfill() + } + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + try await input.send(.first) + try await env.firstStarted.await(timeout: 1_000_000_000) + try await input.send(.second) // should cancel the first + try await env.finished.await(timeout: 10_000_000_000) + await MainActor.run { + // Only the second should have completed + let values = runtime.env.values + #expect(values == [2]) + } + } + + /// GIVEN: Two tasks started with different ids. + /// WHEN: Both are scheduled nearly simultaneously. + /// THEN: Both run to completion and both actions fire. + @Test func testDifferentTaskIDsRunConcurrently() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case startA, startB, done(String) } + @MainActor final class Env { + let finishedA = Promise() + let finishedB = Promise() + var results: [String] = [] + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .startA: + return .task(id: "A", .switchToLatest) { @MainActor input, env -> Void in + try await input.send(.done("A")) + } + case .startB: + return .task(id: "B", .switchToLatest) { @MainActor input, env -> Void in + try await input.send(.done("B")) + } + case .done(let label): + return .action { @MainActor env async in + env.results.append(label) + if label == "A" { env.finishedA.fulfill() } else if label == "B" { env.finishedB.fulfill() } + return + } + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + try await input.send(.startA) + try await input.send(.startB) + try await env.finishedA.await(timeout: 10_000_000_000) + try await env.finishedB.await(timeout: 10_000_000_000) + await MainActor.run { + // Both ran; order is not guaranteed + #expect(Set(runtime.env.results) == Set(["A", "B"])) + } + } + + /// GIVEN: A task scheduled with a specific TaskPriority. + /// WHEN: The operation executes. + /// THEN: The current task priority matches the requested one. + @Test func testTaskReceivesRequestedPriority() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case start } + struct Env: Sendable { let checked = Promise() } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .task(id: "prio", priority: .background, .switchToLatest) { @MainActor input, env -> Void in + #expect(Task.currentPriority == .background) + env.checked.fulfill() + } + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + try await input.send(.start) + try await env.checked.await(timeout: 10_000_000_000) + } + + /// GIVEN: A long-running task and a subsequent event that schedules a failing task. + /// WHEN: The failing task throws, the runtime cancels. + /// THEN: The long-running task observes cancellation. + @Test func testInFlightTaskIsCancelledWhenRuntimeCancels() async throws { + let timeout: UInt64 = 10_000_000_000 + + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case startLong, cancelWithBoom } + struct Env: Sendable { let longStarted = Promise(); let longCancelled = Promise() } + enum Boom: Error { case boom } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .startLong: + return .task(id: "long", .switchToLatest) { @MainActor input, env -> Void in + var tick = 0 + env.longStarted.fulfill() + do { + while true { + try await Task.sleep(nanoseconds: 1_000_000_000) + tick += 1 + } + } catch { + env.longCancelled.fulfill() + throw error + } + } + case .cancelWithBoom: + return .task(id: "killer", .switchToLatest) { @MainActor _, env -> Void in + throw Boom.boom + } + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + try await input.send(.startLong) + + try await env.longStarted.await(timeout: timeout) + do { + try await input.send(.cancelWithBoom) + } catch { + print("*** sending event `cancelWithBoom` failed: \(error)") + // The send that triggers cancellation may rethrow; that's fine for this test + } + try await env.longCancelled.await(timeout: timeout) + } + + /// GIVEN: Two tasks with different IDs `A` and `B`. + /// WHEN: We emit a `cancel(id: "A")` effect. + /// THEN: Task `A` is cancelled and `B` completes normally. + @Test func testCancelSpecificIDDoesNotAffectOthers() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case startA, startB, cancelA, doneA, doneB } + @MainActor final class Env { + let aStarted = Promise() + let bStarted = Promise() + let aCancelled = Promise() + let bFinished = Promise() + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .startA: + return .task(id: "A", .switchToLatest) { @MainActor input, env -> Void in + env.aStarted.succeed() + do { + try await Task.sleep(nanoseconds: 2_000_000_000) + try await input.send(.doneA) + } catch { + env.aCancelled.succeed() + } + } + case .startB: + return .task(id: "B", .switchToLatest) { @MainActor input, env -> Void in + env.bStarted.succeed() + try? await Task.sleep(nanoseconds: 5_000_000) + try await input.send(.doneB) + } + case .cancelA: + return .cancel("A") + case .doneA: + return .action { @MainActor env async in + // unexpected for this test; we assert on aCancelled + } + case .doneB: + return .action { @MainActor env async in + env.bFinished.succeed() + } + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + + try await input.send(.startA) + try await input.send(.startB) + try await env.aStarted.await(timeout: 10_000_000_000) + try await env.bStarted.await(timeout: 10_000_000_000) + + try await input.send(.cancelA) + + try await env.aCancelled.await(timeout: 10_000_000_000) + try await env.bFinished.await(timeout: 10_000_000_000) + } + + /// GIVEN: A transducer running a task with id "request" . Two sends that schedule a task + /// with `.shareable` for the same id. + /// WHEN: Both sends occur while the task is in-flight. + /// THEN: The existing task instance is kept running and the other are ignored, and + /// `send(.request)` returns immediately when the event has been processed - not + /// when the task completes. + @Test func testSubscribeSharesTaskForSends() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case start, request, done } + @MainActor final class Env { + var startedCount = 0 + let workStarted = Promise() + let continueWork = Promise() + let finished = Promise() + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .task(id: "request", .switchToLatest) { @MainActor input, env -> Void in + env.workStarted.succeed() + env.startedCount += 1 + try await env.continueWork.await() + try await input.send(.done) + } + case .request: + return .task(id: "request", .shareable) { @MainActor input, env -> Void in + // should never be entered + env.startedCount += 1 + Issue.record("should not be entered") + } + + case .done: + return .action { @MainActor env async in + env.finished.fulfill() + } + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + try await input.send(.start) // start the task and resume + try await env.workStarted.await() + + async let s1: Void = try await input.send(.request) + async let s2: Void = try await input.send(.request) + + try await Task.sleep(nanoseconds: 1_000_000) + env.continueWork.succeed() + try await env.finished.await(timeout: 10_000_000_000) + await MainActor.run { #expect(env.startedCount == 1) } + _ = try await (s1, s2) + await runtime.cancel() + } + + /// GIVEN: A shared task is started via `.task(id, .shareable)`. Two additional callers + /// dipatching an event with `request` shareable for the same id while the first is in-flight. + /// WHEN: The underlying operation completes and sends an event to the transducer. + /// THEN: All waiter paths receive the completion — each can observe its own done event. + @Test func testSubscribeSharedTaskAllWaitersReceiveCompletion() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case start, gotOne, gotTwo, gotThree, done } + @MainActor final class Env { + let workStarted = Promise() + let continueWork = Promise() + var doneOrder: [String] = [] + let doneOrdered = Promise() + } + + enum Response { case one, two, three, finished, none } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .task(id: "shared", .shareable) { @MainActor input, env -> Void in + env.workStarted.succeed() + try await env.continueWork.await() + } + case .gotOne: + // First waiter receives completion — it continues watching. + return .action { @MainActor env async in + env.doneOrder.append("one") + if env.doneOrder.contains("one"), env.doneOrder.contains("two"), env.doneOrder.contains("three") { + env.doneOrdered.succeed() + } + } + case .gotTwo: + return .action { @MainActor env async in + env.doneOrder.append("two") + if env.doneOrder.contains("one"), env.doneOrder.contains("two"), env.doneOrder.contains("three") { + env.doneOrdered.succeed() + } + } + case .gotThree: + return .action { @MainActor env async in + env.doneOrder.append("three") + if env.doneOrder.contains("one"), env.doneOrder.contains("two"), env.doneOrder.contains("three") { + env.doneOrdered.succeed() + } + } + case .done: + return .none + } + } + + static func response(state: State, event: Event) -> Response { + switch event { + case .gotOne: .one + case .gotTwo: .two + case .gotThree: .three + case .done: .finished + case .start: .none + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + + try await input.send(.start) + try await env.workStarted.await(timeout: 1_000_000_000) + + // Add two more subscribers — they share the same in-flight task. + async let subOne = try await input.request(.gotOne) + async let subTwo = try await input.request(.gotTwo) + async let subThree = try await input.request(.gotThree) + + // Release; all three should eventually receive their event. + env.continueWork.succeed() + try await env.doneOrdered.await(timeout: 10_000_000_000) + await MainActor.run { #expect(env.doneOrder.count == 3) } + + let (responseOne, responseTwo, responseThree) = try await (subOne, subTwo, subThree) + #expect(responseOne == .one) + #expect(responseTwo == .two) + #expect(responseThree == .three) + } + + /// GIVEN: A shared task with id `"work"` is running without any waiters. + /// WHEN: A second `.switchToLatest` add provides no continuation for the same id. + /// THEN: The old task is cancelled, a new one starts independently. + @Test func testSwitchToLatestWithoutExistingWaitersStartsNewTask() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case startWork, newWork, done(String) } + @MainActor final class Env { + let oldCancelled = Promise() + let newFinished = Promise() + var results: [String] = [] + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .startWork: + return .task(id: "work", .switchToLatest) { @MainActor input, env -> Void in + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + // Should be cancelled before reaching here + Issue.record("Old task should have been cancelled") + } catch is CancellationError { + env.oldCancelled.succeed() + } + } + + case .newWork: + return .task(id: "work", .switchToLatest) { @MainActor input, env -> Void in + try await Task.sleep(nanoseconds: 10_000) + try await input.send(.done("new")) + } + case .done(let value): + return .action { @MainActor env async in + env.results.append(value) + env.newFinished.succeed() + } + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + + try await input.send(.startWork) + try await Task.sleep(nanoseconds: 10_000_000) // allow old task to reach sleep + + try await input.send(.newWork) + + try await env.oldCancelled.await(timeout: 1_000_000_000) + try await env.newFinished.await(timeout: 10_000_000_000) + await MainActor.run { #expect(env.results == ["new"]) } + } + + /// GIVEN: Two `.task` effects that use the same logical id `X` but with a mechanism + /// that makes them independent (simulating caller-owned unique via sequential events). + /// WHEN: One completes or is cancelled. + /// THEN: The other continues unaffected, observing independence at the behavior level. + @Test func testCallerOwnedUniqueTasksRemainIndependent() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case startA, startB, doneA, doneB } + @MainActor final class Env { + let aDone = Promise() + let bDone = Promise() + var results: [String] = [] + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .startA: + // Use unique internal id (via different effect — sequence with cancel prevents sharing). + return .task(id: "X_a", .switchToLatest) { @MainActor input, env -> Void in + try await Task.sleep(nanoseconds: 10_000) + try await input.send(.doneA) + } + case .startB: + // Use different internal id to force independence. + return .task(id: "X_b", .switchToLatest) { @MainActor input, env -> Void in + try await Task.sleep(nanoseconds: 10_000) + try await input.send(.doneB) + } + case .doneA: + return .action { @MainActor env async in + env.results.append("A") + env.aDone.succeed() + } + case .doneB: + return .action { @MainActor env async in + env.results.append("B") + env.bDone.succeed() + } + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + + // Start both sequentially — they should NOT share (different logical id in the effect). + try await input.send(.startA) + try await input.send(.startB) + + try await env.aDone.await(timeout: 10_000_000_000) + try await env.bDone.await(timeout: 10_000_000_000) + await MainActor.run { #expect(Set(env.results) == Set(["A", "B"])) } + } +} + +extension RuntimeTests.GateTests { + + /// GIVEN: Two concurrent sends to the same runtime, where the first event's effect suspends. + /// WHEN: The first compute invocation is active and awaiting release. + /// THEN: The second send does not enter compute until the first leaves (no re-entrancy), + /// and only starts after the test releases the first. + @Test func testComputeGateSerializesConcurrentSends() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case first, second } + struct Env: Sendable { + let firstStarted = Promise() + let secondStarted = Promise() + let releaseFirst = Promise() + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .first: + // Suspend inside compute via an async action until test releases. + return .action { env in + env.firstStarted.fulfill() + // Await release signal; use try? to avoid surfacing cancellation if it occurs. + try? await env.releaseFirst.await(timeout: 1_000_000_000) + } + case .second: + return .action { env -> Void in + env.secondStarted.fulfill() + } + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + + let t1 = Task { try await input.send(.first) } + try await Task.sleep(nanoseconds: 10_000_000) + let t2 = Task { try await input.send(.second) } + + // First effect should start promptly and suspend. + try await env.firstStarted.await(timeout: 10_000_000_000) + + // Second must NOT start while the first is still suspended. + do { + try await env.secondStarted.await(timeout: 100_000_000) + Issue.record("Second started too early; compute gate did not serialize sends") + } catch { + // print("Expected timeout or similar since second should not have started yet.") + } + + // Release the first; now the second may proceed. + env.releaseFirst.fulfill() + + do { + try await env.secondStarted.await(timeout: 100_000_000_000) + } catch { + print("ERROR: \(error)") + } + + // Ensure both sends complete without error. + _ = try await t1.value + _ = try await t2.value + } + + + /// GIVEN: Three concurrent requests. + /// WHEN: Each step is released in order. + /// THEN: Requests enter compute in FIFO order. + @Test(.disabled()) + func testGateEnforcesFIFOForThreeRequests() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event { case go } + enum Response: Sendable { case none, ok } + @MainActor final class Env { + var startedOrder: [Int] = [] + var counter = 0 + let started1 = Promise() + let started2 = Promise() + let started3 = Promise() + let release1 = Promise() + let release2 = Promise() + let release3 = Promise() + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .go: + return .action { @MainActor env in + env.counter += 1 + let idx = env.counter + env.startedOrder.append(idx) + switch idx { + case 1: env.started1.succeed(); try? await env.release1.await() + case 2: env.started2.succeed(); try? await env.release2.await() + case 3: env.started3.succeed(); try? await env.release3.await() + default: break + } + } + } + } + static func response(state: State, event: Event) -> Response { .ok } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + + let r1 = Task { try await input.request(.go) } + let r2 = Task { try await input.request(.go) } + let r3 = Task { try await input.request(.go) } + + try await env.started1.await(timeout: 1_000_000_000) + do { try await env.started2.await(timeout: 100_000_000); Issue.record("Second started too early") } catch {} + do { try await env.started3.await(timeout: 100_000_000); Issue.record("Third started too early") } catch {} + + env.release1.succeed() + try await env.started2.await(timeout: 1_000_000_000) + do { try await env.started3.await(timeout: 100_000_000); Issue.record("Third started too early") } catch {} + + env.release2.succeed() + try await env.started3.await(timeout: 1_000_000_000) + env.release3.succeed() + + _ = try await (r1.value, r2.value, r3.value) + await MainActor.run { #expect(env.startedOrder == [1, 2, 3]) } + } +} + +extension RuntimeTests.RequestTests { + + /// - GIVEN: A transducer that returns a synchronous terminal action on transition (State, Event) == `(_, .start)` + /// , and an response function which returns `start` on event `start` + /// - WHEN: A `request(.start)` is called + /// - THEN: The request completes with return value (`Response`) == `start` – which corresponds + /// to the response value returned from the transducer for (State, Event) == `(_, .start`). + @Test func testRequestReturnsOutputForCorrespondingTerminalTransition1() async throws { + enum T: Transducer { + enum State { case start } + enum Event { case start, next } + enum Response: Sendable, Equatable { case none, start, next } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + if case .start = event { + .action { env -> Void in } // empty terminal action + } else { .none } + } + static func response(state: State, event: Event) -> Response { + switch event { + case .start: .start + case .next: .next + } + } + static let initialState: State = .start + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + async let request = input.request(.start) + let output = try await request + #expect(output == .start) + } + + /// GIVEN: A transducer that returns an asynchronous terminal action on transition (State, Event) == `(_, .start)` + /// , and an response function which returns `start` on event `start` + /// WHEN: A `request(.start)` is called + /// THEN: The request completes with return value (`Response`) == `start` – which corresponds + /// to the response value returned from the transducer for (State, Event) == `(_, .start`). + @Test func testRequestReturnsOutputForCorrespondingTerminalTransition2() async throws { + enum T: Transducer { + enum State { case start } + enum Event { case start, next } + enum Response: Sendable, Equatable { case none, start, next } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + if case .start = event { + .action { env in + try? await Task.sleep(nanoseconds: 10_000) + } // empty terminal action + } else { .none } + } + static func response(state: State, event: Event) -> Response { + switch event { + case .start: .start + case .next: .next + } + } + static let initialState: State = .start + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + async let request = input.request(.start) + let output = try await request + #expect(output == .start) + } + + /// GIVEN: A transducer that returns a synchronous action on transition (State, Event) == `(_, .start)` returning another event `next`, + /// and a transition `(_, .next)` which returns a terminal action, + /// and an response function which returns `next` on event `next` + /// WHEN: A `request(.start)` is called + /// THEN: The request completes with return value (`Response`) == `next` – which corresponds + /// to the response value returned from the transducer for (State, Event) == `(_, .next`). + @Test func testRequestReturnsOutputForCorrespondingTerminalTransition3() async throws { + enum T: Transducer { + enum State { case start } + enum Event { case start, next } + enum Response: Sendable, Equatable { case none, start, next } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .action { _ in .next } + case .next: + return .action { _ -> Void in } + } + } + static func response(state: State, event: Event) -> Response { + switch event { + case .start: .start + case .next: .next + } + } + static let initialState: State = .start + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + async let request = input.request(.start) + let output = try await request + #expect(output == .next) + } + + /// - GIVEN: A transducer that returns an asynchronous action on transition (State, Event) == `(_, .start)` returning another event `next`, + /// and a transition `(_, .next)` which returns an async terminal action, + /// and an response function which returns `next` on event `next` + /// - WHEN: A `request(.start)` is called + /// - THEN: The request completes with return value (`Response`) == `next` – which corresponds + /// to the response value returned from the transducer for (State, Event) == `(_, .next`). + @Test func testRequestReturnsOutputForCorrespondingTerminalTransition4() async throws { + enum T: Transducer { + enum State { case start } + enum Event { case start, next } + enum Response: Sendable, Equatable { case none, start, next } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .action { _ in + try? await Task.sleep(nanoseconds: 10_000) + return .next + } + case .next: + return .action { _ in + try? await Task.sleep(nanoseconds: 10_000) + } + } + } + static func response(state: State, event: Event) -> Response { + switch event { + case .start: .start + case .next: .next + } + } + static let initialState: State = .start + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + async let request = input.request(.start) + let output = try await request + #expect(output == .next) + } + + /// - GIVEN: An transducer which implementes an "initialise once" pattern using actions + /// - WHEN: Two concurrent `request(.start)` calls are made. + /// - THEN: Both requests complete with the expected response `idle. + @Test func testInitialiseOncePattern() async throws { + enum T: Transducer { + enum State { case start, idle } + enum Event { case start, initialized, some } + enum Response: Sendable, Equatable { case none, idle, ok, error } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.start, .start): + return .action { _ in + try? await Task.sleep(nanoseconds: 1_000_000) + return .initialized + } + case (.start, .initialized): + state = .idle + return .none + case (.idle, _): + return .none + case (.start, .some): + return .none + } + } + static func response(state: State, event: Event) -> Response { + switch (state, event) { + case (.start, .start): return .error + case (.start, .initialized): return .error + case (.start, .some): return .error + case (.idle, .start): return .idle + case (.idle, .initialized): return .idle + case (.idle, .some): return .ok + } + } + static let initialState: State = .start + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + // Note: since the initialisation once pattern uses actions (not tasks) + // `input.send(.start)` can be used as well. However, it's recommended + // to use `request(_:)` since it also works for initialisation *tasks*. + async let request1 = input.request(.start) + async let request2 = input.request(.start) + let response1 = try await request1 // returns response(.idle, .initialized) + let response2 = try await request2 // returns response(.idle, .start) + #expect(response1 == .idle) + #expect(response2 == .idle) + } + + + /// - GIVEN: A transducer that returns a task with id "work" on transition (State, Event) == `(_, .request)` that returns an Response value + /// - WHEN: A `request(.request)` is called + /// - THEN: The request completes with the return value of the task, i.e. `response("result")` + @Test func testRequestAwaitTaskReturningOutput() async throws { + enum T: Transducer { + enum State { case idle } + enum Event { case request, response(String) } + enum Response: Sendable, Equatable { case none, response(String) } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + if case .request = event { + .task(id: "work", .shareable) { input, env -> Event in + try await Task.sleep(nanoseconds: 10_000_000) + return .response("result") + } + } else { .none } + } + static func response(state: State, event: Event) -> Response { + if case .response(let response) = event { + return .response(response) + } else { return .none } + } + static let initialState: State = .idle + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + async let request = input.request(.request) + let response = try await request + #expect(response == .response("result")) + } + + /// - GIVEN: A transducer that on transition (State, Event) == `(_, .request)` + /// returns a task with id "work", with option `subscribing`, returning an Response value `response(String)` + /// - WHEN: Multiple `request(.request)` are called, while the task is inflight + /// - THEN: The requests completes with the identical return value of the task, i.e. `response()` + @Test func testRequestSubcribingToSharedTaskReturningOutput() async throws { + enum T: Transducer { + enum State { case idle } + enum Event { case request, response(String) } + enum Response: Sendable, Equatable { case none, response(String) } + final class Env: Sendable { let continueWork = Promise() } + static func transduce(_ state: inout State, event: Event) -> Effect { + if case .request = event { + .task(id: "work", .shareable) { input, env in + try await env.continueWork.await(timeout: 10_000_000_000) + return .response(UUID().uuidString) + } + } else { .none } + } + static func response(state: State, event: Event) -> Response { + if case .response(let value) = event { + .response(value) + } else { .none } + } + static let initialState: State = .idle + } + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + async let request1 = input.request(.request) + async let request2 = input.request(.request) + async let request3 = input.request(.request) + env.continueWork.fulfill() + let response1 = try await request1 + let response2 = try await request2 + let response3 = try await request3 + #expect(response1 == response2) + #expect(response1 == response3) + } + + /// - GIVEN: A transducer that returns a task with id "work", with `.switchToLatest`, on transition (State, Event) == `(_, .request)` that returns an Response value + /// - WHEN: A `request(.request)` is called + /// - THEN: The request completes with the return value of the task, i.e. `response("result")` + @Test func testRequestAwaitTaskWithSwiftToLatestReturningOutput() async throws { + enum T: Transducer { + enum State { case idle } + enum Event { case request(value: Int), response(value: Int) } + enum Response: Sendable, Equatable { case none, response(value: Int) } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + if case .request(let value) = event { + .task(id: "work", .switchToLatest) { input, env in + try await Task.sleep(nanoseconds: 10_000_000) + return .response(value: value) + } + } else { .none } + } + static func response(state: State, event: Event) -> Response { + if case .response(let value) = event { + .response(value: value) + } else { .none } + } + static let initialState: State = .idle + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + async let request = input.request(.request(value: 1)) + let response = try await request + #expect(response == .response(value: 1)) + } + + + /// - GIVEN: A transducer that on transition (State, Event) == `(_, .request(value:)`, + /// returns a task with id "work", with option `switchToLatest`, returning an Response value `response(value:)` + /// - WHEN: Multiple `request(.request(value:))` are called, while the task is inflight + /// - THEN: The requests completes returning an Response value `response(value:)` where the parameter value + /// is set to the latest scheduled task, i.e. `response(value: )` + @Test func testRequestToSharedSwitchToLastedTaskReturningOutput() async throws { + enum T: Transducer { + enum State { case idle } + enum Event { case request(value: Int), response(Int) } + enum Response: Sendable, Equatable { case none, response(value: Int) } + final class Env: Sendable { let continueWork = Promise() } + static func transduce(_ state: inout State, event: Event) -> Effect { + if case .request(value: let value) = event { + .task(id: "work", .switchToLatest) { input, env in + try await env.continueWork.await(timeout: 100_000_000_000) + return .response(value) + } + } else { .none } + } + static func response(state: State, event: Event) -> Response { + if case .response(let value) = event { + .response(value: value) + } else { .none } + } + static let initialState: State = .idle + } + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + async let request1 = input.request(.request(value: 1)) + try await Task.sleep(nanoseconds: 1_000_000) + async let request2 = input.request(.request(value: 2)) + try await Task.sleep(nanoseconds: 1_000_000) + async let request3 = input.request(.request(value: 3)) + try await Task.sleep(nanoseconds: 1_000_000) + env.continueWork.fulfill() + let response1 = try await request1 + let response2 = try await request2 + let response3 = try await request3 + #expect(response1 == .response(value: 3)) + #expect(response2 == .response(value: 3)) + #expect(response3 == .response(value: 3)) + await runtime.cancel() + } + + + /// - GIVEN: A transducer with a transition `(_, .work(Int)` that starts a task "work" with `.switchToLatest`. + /// - WHEN: a call `input.request(.work(0)` is made, and a second call is made `input.request(.work(1)`while task (0) is inflight. + /// - THEN: + /// - The second request cancels the task "work " (0) and starts a task "work" (1), and + /// - upon completion of task (1), both requests resume and return the result of operation (1). + @Test func testSwitchToLatestTransfersWaitersBetweenTasks() async throws { + enum T: Transducer { + enum State { case idle } + enum Event: Sendable { case work(Int), finish(Int) } + enum Response: Sendable, Equatable { case none, value(Int), error } + final class Env: Sendable { + let workStarted: [Promise] = [.init(), .init()] + let workCancelled: [Promise] = [.init(), .init()] + let continueWork: [Promise] = [.init(), .init()] + } + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .work(let value): + return .task(id: "work", .switchToLatest) { [value] input, env -> Event in + // Note: work(value) returns `Event.finish(value)`. + func work(id value: Int) async throws -> Event { + guard value == 0 || value == 1 else { fatalError("bad test harness") } + do { + env.workStarted[value].fulfill() + try await env.continueWork[value].await(timeout: 10_000_000_000) + } catch is CancellationError { + env.workCancelled[value].fulfill() + throw CancellationError() + } + return .finish(value) + } + return try await work(id: value) + } + case .finish: + return .none + } + } + static func response(state: State, event: Event) -> Response { + if case .finish(let value) = event { return .value(value) } + return .none + } + static let initialState: State = .idle + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + + // start first task: + async let r0 = try await input.request(.work(0)) + // wait until managed task[0] started: + try await env.workStarted[0].await(timeout: 10_000_000_000) + // start second task - which should cancel the first: + async let r1 = try await input.request(.work(1)) + // wait until task[0] has been cancelled: + try await env.workCancelled[0].await(timeout: 10_000_000_000) + // wait until task[1] has been started: + try await env.workStarted[1].await(timeout: 10_000_000_000) + + // Now allow the replacement task to finish and satisfy both waiters: + env.continueWork[1].fulfill() + + let v0 = try await r0 // .value + let v1 = try await r1 // .value + #expect(v0 == .value(1)) + #expect(v1 == .value(1)) + } + + /// GIVEN: A transducer that schedules a task which throws a custom error. + /// WHEN: We call `request(.start)`. + /// THEN: The request throws the same custom error that cancels the runtime. + @Test func testRequestThrowsOnSystemError() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event { case start } + enum Boom: Error { case boom } + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .task(id: "work") { _, _ -> Void in + throw Boom.boom + } + } + } + } + + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + await #expect(throws: T.Boom.self) { + _ = try await input.request(.start) + } + } + + @Test func testRequestWithIntermediateEventPreservesContinuation() async throws { + enum T: Transducer { + enum State { case s0, s1 } + enum Event { case start, next } + enum Response: Sendable, Equatable { case none, started, finished } + typealias Env = Void + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.s0, .start): + state = .s1 + return .event(.next) // pure event: must preserve continuation + case (.s1, .next): + // terminal step + return .action { _ -> Void in } + default: + return .none + } + } + + static let initialState: State = .s0 + static func response(state: State, event: Event) -> Response { + switch event { + case .start: return .started + case .next: return .finished + } + } + } + + let runtime = GlobalActorRuntime() + let input = runtime.input + let response = try await input.request(.start) + #expect(response == .finished) + } + + @Test func testRequestCompletesOnNoneEffect() async throws { + // Edge case: .none should return (nil, continuation) so compute can settle and resume. + enum T: Transducer { + enum State { case s0 } + enum Event { case start } + enum Response: Sendable, Equatable { case none, started } + typealias Env = Void + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .none + } + } + + static let initialState: State = .s0 + static func response(state: State, event: Event) -> Response { + switch event { + case .start: return .started + } + } + } + + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + let out = try await input.request(.start) + #expect(out == .started) + } + + @Test func testRequestCompletesOnSynchronousTerminalActionEffect() async throws { + enum T: Transducer { + enum State { case s0 } + enum Event { case start } + enum Response: Sendable, Equatable { case none, started } + typealias Env = Void + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .action { env in } + } + } + + static let initialState: State = .s0 + + static func response(state: State, event: Event) -> Response { + switch event { + case .start: return .started + } + } + } + + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + let out = try await input.request(.start) + #expect(out == .started) + } + + @Test func testRequestCompletesOnGlobalActorIsolatedAsynchronousTermainalActionEffect() async throws { + enum T: Transducer { + enum State { case s0 } + enum Event { case start } + enum Response: Sendable, Equatable { case none, started } + typealias Env = Void + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .action { env in + try? await Task.sleep(nanoseconds: 10_000) + } + } + } + + static let initialState: State = .s0 + + static func response(state: State, event: Event) -> Response { + switch event { + case .start: return .started + } + } + } + + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + let out = try await input.request(.start) + #expect(out == .started) + } + + @Test func testRequestCompletesOnNonsendingAsynchronousTermainalActionEffect() async throws { + enum T: Transducer { + enum State { case s0 } + enum Event { case start } + enum Response: Sendable, Equatable { case none, started } + typealias Env = Void + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .action { env in } + } + } + + static let initialState: State = .s0 + + static func response(state: State, event: Event) -> Response { + switch event { + case .start: return .started + } + } + } + + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + let out = try await input.request(.start) + #expect(out == .started) + } +} + +extension RuntimeTests.ActionIsolationTests { + + /// - Given: a Runtime isolated to the MainActor + /// - When: sending an event + /// - Then: the transition returns an action with a synchronous closure + /// - The closure executes in the system actor (MainActor) + @Test func testSyncActionWillBeExecutedOnTheMainActor() async throws { + enum T: EffectTransducer { + struct State {} + enum Event { case start } + struct Env {} + static func transduce(_ state: inout State, event: Event) -> Effect { + return .action { env -> Void in + let currentLabel = String(cString: __dispatch_queue_get_label(nil)) + let isMainThread = currentLabel.contains("com.apple.main-thread") + #expect(isMainThread, "Expected to be on the main thread, but was on: \(currentLabel)") + } + } + static let initialState: State = .init() + } + let runtime = GlobalActorRuntime(env: .init()) + try await runtime.send(.start) + } + + /// - Given: a Runtime isolated to the MainActor + /// - When: sending an event + /// - Then: the transition returns an action with a @concurrent asynchronous closure + /// - The closure executes in the concurrent isolation + @Test func testAsyncActionWillBeExecutedOnConcurrentQueue() async throws { + enum T: EffectTransducer { + struct State {} + enum Event { case start } + struct Env {} + static func transduce(_ state: inout State, event: Event) -> Effect { + MainActor.shared.assertIsolated() + return .action { @concurrent env in + try? await Task.sleep(nanoseconds: 10_000_000) + let currentLabel = String(cString: __dispatch_queue_get_label(nil)) + let isOnCooperativePool = currentLabel.contains("com.apple.root.") && currentLabel.contains(".cooperative") + #expect(isOnCooperativePool, "Expected to be on the global concurrent cooperative pool, but was on: \(currentLabel)") + } + } + static let initialState: State = .init() + } + let runtime = GlobalActorRuntime(env: .init()) + try await runtime.send(.start) + } + + /// - Given: a Runtime isolated to the MainActor + /// - When: sending an event + /// - Then: the transition returns an action with a nonisolated(nonsending) asynchronous closure + /// - And: The closure executes on the system actor (i.e. MainActor) + @Test func testNonsendingAsyncActionWillBeExecutedOnTheSystemActor() async throws { + enum T: EffectTransducer { + struct State {} + enum Event { case start } + class Env { var value: Int = 0 } + static func transduce(_ state: inout State, event: Event) -> Effect { + return .action { env in + try? await Task.sleep(nanoseconds: 10_000_000) + let currentLabel = String(cString: __dispatch_queue_get_label(nil)) + let isMainThread = currentLabel.contains("com.apple.main-thread") + #expect(isMainThread, "Expected to be on the main thread, but was on: \(currentLabel)") + } + } + static let initialState: State = .init() + } + let runtime = GlobalActorRuntime(env: .init()) + try await runtime.send(.start) + } + +} + +extension RuntimeTests.CancellationTests { + + /// - Given: A Transducer with a long running task, and a suspended request waiter + /// - When: cancelling the runtime via (`cancel(with: MyError()`) + /// - Then: The task will be cancelled, and + /// the request waiter's `request()` will throw error `MyError()`. + @Test func requestWaiterWillThrowLatchedError() async throws { + enum T: EffectTransducer { + enum State { case idle } + enum Event { case request, response(String) } + struct Env { var workStarted = Promise(); var taskCancelled = Promise() } + enum Response { case none, response(String) } + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .request: + return .task(id: "request") { input, env in + env.workStarted.succeed() + do { + try await Task.sleep(nanoseconds: 10_000_000_000) // long running + return .response("result") + } catch is CancellationError { + env.taskCancelled.succeed() + throw CancellationError() + } + } + case .response: return .none + } + } + static func response(state: State, event: Event) -> Response { + if case .response(let result) = event { .response(result) } else { .none } + } + static let initialState: State = .idle + } + + struct MyError: Swift.Error {} + let env = T.Env() + let runtime = GlobalActorRuntime(env: env) + + let givenTask = Task { + await #expect(throws: MyError.self) { + try await runtime.request(.request) + } + } + + _ = await Task { @MainActor in + await #expect(throws: Never.self) { + try await env.workStarted.await(timeout: 10_000_000_000) + await runtime.cancel(with: MyError()) + try await env.taskCancelled.await(timeout: 10_000_000_000) + _ = await givenTask.value // should throw with MyError + } + }.value + } + + /// - Given: A Transducer with a long running task, and a suspended request waiter + /// - When: cancelling the runtime via (`cancel()`) + /// - Then: The task will be cancelled, and + /// the request waiter's `request()` will throw error `RuntimeCancellationError()`. + @Test func requestWaiterWillThrowRuntimeCancellationError() async throws { + enum T: EffectTransducer { + enum State { case idle } + enum Event { case request, response(String) } + struct Env { var workStarted = Promise(); var taskCancelled = Promise() } + enum Response { case none, response(String) } + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .request: + return .task(id: "request") { input, env in + env.workStarted.succeed() + do { + try await Task.sleep(nanoseconds: 10_000_000_000) // long running + return .response("result") + } catch is Swift.CancellationError { + env.taskCancelled.succeed() + throw Swift.CancellationError() + } + } + case .response: return .none + } + } + static func response(state: State, event: Event) -> Response { + if case .response(let result) = event { .response(result) } else { .none } + } + static let initialState: State = .idle + } + + let env = T.Env() + let runtime = GlobalActorRuntime(env: env) + + let givenTask = Task { + await #expect(throws: RuntimeCancellationError.self) { + try await runtime.request(.request) + } + } + + _ = await Task { @MainActor in + await #expect(throws: Never.self) { + try await env.workStarted.await(timeout: 10_000_000_000) + await runtime.cancel() + try await env.taskCancelled.await(timeout: 10_000_000_000) + _ = await givenTask.value // should throw with MyError + } + }.value + } +} + + +// MARK: - UnsubscribingTests (Behavioral Runtime Tests) + +/// R1-R5 test subscriber-lifecycle behavior at the **public API boundary**. +/// They observe effect starts/finishes/throws, request return values, and inspect +/// `baseRuntime.taskManager` only for **consistency verification** — NOT direct waiter +/// manipulation. +/// +/// Direct `removeContinuation` unit tests live in Gherkin spec / TaskManagerTests.RemoveContinuationTests. + +extension RuntimeTests.UnsubscribingTests { + + /// Tracks a subscriber's continuation ID, passed through Env for observability. + struct SubscriberInfo { + let id: Int + let joined = Promise() // signals when subscriber actually attached to task + let completed = Promise() // signals when detached/subscriber's request completed + } + + // MARK: R0 — Original subscriber completes after late joiner attaches + + /// When the original (fire-and-forget via send + .subscribe) finishes, a late joiner + /// via request() should find no running work and handle it gracefully. + /// + /// ```gherkin + /// GIVEN: A transducer with .shareable task that completes instantly (or very quickly) + /// WHEN: send(.start) fires the subscriber + /// AND: Wait for effect chain to complete + /// CHECK: - workStarted signal is observed + /// - workDone signal is observed + /// - TaskManager.hasNoActiveTask(for: "work") == true OR tasks.isEmpty + /// - No stale task entries remain + /// ``` + @MainActor + @Test func testR1_LateJoinerFindsCompletedWork() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event { case start, done } + struct Env { let workStarted = Promise(); let continueWork = Promise(); let workDone = Promise() } + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .task(id: "work", .shareable) { _, env in + env.workStarted.fulfill() + try await env.continueWork.await(timeout: 10_000_000_000) + env.workDone.fulfill() + return .done + } + case .done: return .none + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + + // Original subscriber fires work (fire-and-forget — no await) + try await runtime.send(.start) + try await env.workStarted.await(timeout: 5_000_000_000) + + // Work is in-flight — verify task exists + let preDone = runtime.baseRuntime.taskManager.taskInfo(for: .init("work")) + #expect(preDone != nil, "Task must exist while work is in-flight") + + // Work completes + env.continueWork.succeed() + try await env.workDone.await(timeout: 5_000_000_000) + + // Task should be removed or have zero waiters after completion + let postDone = runtime.baseRuntime.taskManager.taskInfo(for: .init("work")) + #expect(postDone == nil || postDone!.waiters.continuations.isEmpty, + "Task removed or has no waiters after work completes") + } + + // MARK: R1 — Subscriber cancelled mid-flight + + /// When the runtime cancels via control event, the subscriber's in-flight work gets + /// CancellationError. We verify by: (1) confirming cancel completes within timeout (any + /// hang = bug), and (2) taskInfo no longer shows active task (it was cleaned up). + /// + /// ```gherkin + /// GIVEN: A transducer with a long-running .shareable task (duration >> cancel latency) + /// WHEN: send(.start) fires the subscriber + /// AND: Wait for workStarted signal + /// AND: call control(.cancel) + /// CHECK: - TaskManager.cancel completes without hanging + /// - Underlying subscriber receives cancellation (indirectly via no hung tasks) + /// - No task entries remain after cancel completes + /// + /// NOTE: At runtime layer we verify NO HANG. We CANNOT observe env.cancellationCaught + /// because the effect closure captures its own `env` copy, and modifying it + /// inside a fire-and-forget subscriber is not observable at public boundary. + /// ``` + @MainActor + @Test func testR2_CancellationPropagatesToAttachedSubscriber() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event { case start, done } + + struct Env { + let workStarted = Promise() // signals subscriber started sleep + let continueWork = Promise() + let hasbeenCancelled = Promise() + } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .task(id: "work", .shareable) { input, env -> Event? in + env.workStarted.fulfill() // test signal: we're running + do { + try await env.continueWork.await(timeout: 1000_000_000_000) // long running - will be cancelled + Issue.record("work completed unexpectedly") + return .done + } catch is CancellationError { + env.hasbeenCancelled.fulfill() + throw CancellationError() + } + } + case .done: + return .none + } + } + } + + let env = T.Env() // uses custom factory for parameterized init + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + + // Start long-running subscriber and confirm it's actually running + try await runtime.send(.start) + try await env.workStarted.await(timeout: 5_000_000_000) + + // Verify task exists with proper state before cancel + let preCancelTask = runtime.baseRuntime.taskManager.taskInfo(for: .init("work")) + #expect(preCancelTask != nil, "Task must exist while work is in-flight") + + // control(.cancel) does not throw - it's a terminal operation + await #expect(throws: Never.self) { try await runtime.control(.cancel) } + try await env.hasbeenCancelled.await(timeout: 100_000_000_000) + + // The task will be removed only after it actually finishes which is + // not determinable. But we try by waiting a few ms: + for i in 0...1000 { + try await Task.sleep(nanoseconds: 1_000_000) + let taskRemoved = runtime.baseRuntime.taskManager.taskInfo().isEmpty + if taskRemoved || i >= 100 { break } + } + #expect(runtime.baseRuntime.taskManager.taskInfo().isEmpty) + } + + + // MARK: R2 — Detach from completed/no-waiter task (no-op) + + /// Detaching a fake continuation ID from a completed/no-waiter task should be safe. + /// + /// ```gherkin + /// GIVEN: Active subscription to a task (send + .shareable) + /// WHEN: Attempt detach with non-existent continuation ID + /// THEN: Returns false (no crash, no throw, clean handling) + /// + /// GIVEN: Completed/fresh runtime with no in-flight tasks + /// WHEN: Attempt detach with negative/zero continuation ID + /// THEN: Returns false (same semantics) + /// ``` + @MainActor + @Test func testR3_NoOpDetachFromCompletedTask() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event { case start, done } + struct Env {} + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: return .task(id: "work", .shareable) { _, _ in return .done } + case .done: return .none + } + } + } + + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: T.Env()) + + // Fire-and-forget subscriber starts task and it immediately completes + try await runtime.send(.start) + try await Task.sleep(nanoseconds: 50_000) // allow completion + + // Any fake detach should be safe — no throws possible + for fakeID in [1000, -1] { + let result = runtime.baseRuntime.taskManager.cancelContinuation( + systemActor: MainActor.shared, + withId: fakeID + ) + #expect(result == false, "Detach from completed/no-waiter task returns false") + } + } + + // MARK: R3 — Multiple subscribers, one detaches before completion + + /// When one subscriber detaches from a multi-waiter task, the original keeps running. + /// + /// ```gherkin + ///GIVEN: A .shareable task with one active subscriber (via original send) + ///WHEN: A second caller calls request(.start) for the same task id — joins as share waiter + ///AND: Wait for workStarted signal from subscriber ONE + ///AND: Detach subscriber TWO (the late joining waiter) + ///CHECK: - Detach returns false (no matching continuation found after fire-and-forget send, + /// since .shareable via send does NOT add continuations to waiters) + /// - Task still exists in TaskManager (survives the no-op detach) + /// + /// NOTE: At runtime layer, .shareable + send() creates a task with NO continuation waiters. + /// Only explicit request(.start) for joinable tasks adds waiter continuations. + ///``` + @MainActor + @Test func testR4_MultipleSubscribersDetach() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event { case start, done } + final class Env { + let workStarted = Promise() + var iterationsComplete: Int = 0 + } + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: + return .task(id: "work", .shareable) { _, env in + env.workStarted.fulfill() + for i in 1...5 { + try await Task.sleep(nanoseconds: 300_000_000) + if !Task.isCancelled { + env.iterationsComplete = i + } + } + return .done + } + case .done: return .none + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + + // Original subscriber fires work (fire-and-forget — one waiter attached) + try await runtime.send(.start) + try await env.workStarted.await(timeout: 5_000_000_000) + + // Verify task exists (fire-and-forget via send doesn't add continuations, only request does) + let preDetach = runtime.baseRuntime.taskManager.taskInfo(for: .init("work")) + #expect(preDetach != nil, "Task must exist while work is in-flight") + + // Detach a hypothetical second subscriber that was never attached — no-op + let detached = runtime.baseRuntime.taskManager.cancelContinuation( + systemActor: MainActor.shared, + withId: 9999 + ) + #expect(detached == false, "No-op detach returns false for non-existent continuation") + + // Verify original subscriber unaffected + let postDetach = runtime.baseRuntime.taskManager.taskInfo(for: .init("work")) + #expect(postDetach != nil, "Original task survives no-op detach") + } + + // MARK: R4 — Subscriber joins after work fully completes + + /// When the entire runtime has finished (no active tasks), attaching a late subscriber + /// should not create stale entries or cause errors. + /// + /// ```gherkin + /// GIVEN: A transducer that completes its effect chain instantly (sleep 100ns → return .done) + /// WHEN: send(.start) fires the subscriber + /// AND: Wait longer than completion time + scheduling latency + /// AND: Request task completion inspection + /// CHECK: - taskInfo() returns empty array OR all entries have zero continuations + /// - No active tasks with waiters remain + /// + /// GIVEN: Completed runtime + /// WHEN: Call cancelContinuation with any ID (positive or negative) + /// THEN: All return false, no crash, clean handling + /// ``` + @MainActor + @Test func testR5_SubscriberJoinsAfterCompleteRuntime() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event { case start, done } + struct Env {} + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: return .task(id: "work", .shareable) { _, _ in return .done } + case .done: return .none + } + } + } + + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: T.Env()) + + // Fire-and-forget — task completes immediately + try await runtime.send(.start) + try await Task.sleep(nanoseconds: 100_000) // ensure completion + + // Verify all tasks completed cleanly + let allTasks = runtime.baseRuntime.taskManager.taskInfo() + #expect(allTasks.isEmpty || allTasks.allSatisfy({ + $0.waiters.continuations.isEmpty + }), "No active tasks with waiters after complete") + + // Detach a fake ID — no-op + let result = runtime.baseRuntime.taskManager.cancelContinuation( + systemActor: MainActor.shared, + withId: -1 + ) + #expect(result == false, "Late detach from completed runtime returns false") + } +} + + +// MARK: TaskReturn Tests +extension RuntimeTests.TaskReturnTests { + + // Test the overload of task effect's whose closure returns a `TaskReturn`. + + /// - GIVEN: A transducer that returns a task with id "work" on transition (State, Event) == `(_, .request)` that returns an TaskReturn value + /// - WHEN: A `request(.request)` is called + /// - THEN: The request completes with the return value of the task, i.e. `response("result")` + @Test func testRequestAwaitTaskReturningOutput() async throws { + enum T: Transducer { + enum State { case idle } + enum Event { case request, response(String) } + enum Response: Sendable, Equatable { case none, response(String) } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + if case .request = event { + .task(id: "work", .shareable) { input, env -> TaskReturn in + try await Task.sleep(nanoseconds: 10_000_000) + return .response(.response("result")) + } + } else { .none } + } + static func response(state: State, event: Event) -> Response { + if case .response(let response) = event { + return .response(response) + } else { return .none } + } + static let initialState: State = .idle + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + async let request = input.request(.request) + let response = try await request + #expect(response == .response("result")) + } + + /// - GIVEN: A transducer with a task that returns `.request(event)` from the operation + /// - WHEN: A `request(.start)` is called + /// - THEN: The event is dispatched via input.request() and the outer request completes with inner request's response + @Test func testRequestAwaitTaskWithRequest() async throws { + enum T: Transducer { + enum State { case idle } + enum Event { case start, innerRequest } + enum Response: Sendable, Equatable { case none, outer(String), inner(String) } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.idle, .start): + return .task(id: "work", .shareable) { input, env -> TaskReturn in + try await Task.sleep(nanoseconds: 10_000_000) + return .request(.innerRequest) + } + case (.idle, .innerRequest): + return .none + } + } + static func response(state: State, event: Event) -> Response { + switch event { + case .start: return .outer("from_outer") + case .innerRequest: return .inner("from_inner") + } + } + static let initialState: State = .idle + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + async let request = input.request(.start) + let response = try await request + #expect(response == .inner("from_inner")) + } + + /// - GIVEN: A transducer with a task that returns `.uniqueRequest(event)` from the operation + /// - WHEN: A `request(.start)` is called + /// - THEN: The event is dispatched via input.uniqueRequest() and the outer request completes with unique request's response + @Test func testRequestAwaitTaskWithUniqueRequest() async throws { + enum T: Transducer { + enum State { case idle } + enum Event { case start, uniqueWork } + enum Response: Sendable, Equatable { case none, result(String) } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.idle, .start): + return .task(id: "work", .shareable) { input, env -> TaskReturn in + try await Task.sleep(nanoseconds: 10_000_000) + return .uniqueRequest(.uniqueWork) + } + case (.idle, .uniqueWork): + return .none + } + } + static func response(state: State, event: Event) -> Response { + if case .uniqueWork = event { return .result("from_unique") } + else { return .none } + } + static let initialState: State = .idle + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + async let request = input.request(.start) + let response = try await request + #expect(response == .result("from_unique")) + } + + /// - GIVEN: A transducer with a task that returns `.response(event)` from the operation + /// - WHEN: A `request(.start)` is called + /// - THEN: The request completes directly with response(state, event) without any dispatch + @Test func testRequestAwaitTaskWithResponse() async throws { + enum T: Transducer { + enum State { case idle } + enum Event { case start, complete } + enum Response: Sendable, Equatable { case none, value(Int) } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.idle, .start): + return .task(id: "work", .shareable) { input, env -> TaskReturn in + try await Task.sleep(nanoseconds: 10_000_000) + return .response(.complete) + } + default: + return .none + } + } + static func response(state: State, event: Event) -> Response { + if case .complete = event { return .value(42) } + else { return .none } + } + static let initialState: State = .idle + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + async let request = input.request(.start) + let response = try await request + #expect(response == .value(42)) + } + + /// - GIVEN: A transducer with a shareable task that returns `.response(event)` + /// - WHEN: Two `request(.start)` calls are made concurrently + /// - THEN: Both requests complete with the same response value (task is shared) + @Test func testShareableTaskWithResponseMultipleWaiters() async throws { + enum T: Transducer { + enum State { case idle } + enum Event { case start, complete } + enum Response: Sendable, Equatable { case none, result(String) } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.idle, .start): + return .task(id: "work", .shareable) { input, env -> TaskReturn in + try await Task.sleep(nanoseconds: 100_000_000) + return .response(.complete) + } + default: + return .none + } + } + static func response(state: State, event: Event) -> Response { + if case .complete = event { return .result("shared") } + else { return .none } + } + static let initialState: State = .idle + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + async let request1 = input.request(.start) + async let request2 = input.request(.start) + let (response1, response2) = try await (request1, request2) + #expect(response1 == .result("shared")) + #expect(response2 == .result("shared")) + } + + /// - GIVEN: A transducer with a task that returns `.response(event)` + /// - WHEN: The runtime is cancelled while the task is in flight + /// - THEN: The request throws TaskManagerCancellationError + @Test func testRequestCancelledWhileTaskInFlight() async throws { + enum T: Transducer { + enum State { case idle } + enum Event { case start, complete } + enum Response: Sendable, Equatable { case none, result } + typealias Env = Void + static func transduce(_ state: inout State, event: Event) -> Effect { + switch (state, event) { + case (.idle, .start): + return .task(id: "work", .shareable) { input, env -> TaskReturn in + try await Task.sleep(nanoseconds: 1_000_000_000) + return .response(.complete) + } + default: + return .none + } + } + static func response(state: State, event: Event) -> Response { + .result + } + static let initialState: State = .idle + } + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self) + let input = runtime.input + let task = Task { + try await input.request(.start) + } + try await Task.sleep(nanoseconds: 10_000_000) + await runtime.cancel() + await #expect(throws: RuntimeCancellationError.self) { + _ = try await task.value + } + } + +} diff --git a/Tests/Transduce/SubscriberVerificationSpec.md b/Tests/Transduce/SubscriberVerificationSpec.md new file mode 100644 index 0000000..6baa314 --- /dev/null +++ b/Tests/Transduce/SubscriberVerificationSpec.md @@ -0,0 +1,135 @@ +# B3 — Subscriber Verification Spec (Runtime Layer) + +## Scope + +These tests verify **subscriber lifecycle behaviors at the public API boundary** of `GlobalActorRuntime`. +They work through observable signals (Promise in Env), return values from request/uniqueRequest, and consistency inspection via `baseRuntime.taskManager`. + +### What these tests CAN observe +- Effect start/finish (via Promise fulfilled at effect entry) +- Request/uniqueRequest return values +- Runtime control events (send, post, cancel) +- TaskManager state via `@testable` inspection (`taskInfo()`, `taskInfo(for:)`) + +### What these tests CANNOT observe (unit-test scope) +- Direct waiter object mutations (`.shareable([cb(10), cb(20)]) → .shareable([cb(10)])`) +- ContinuationBox identity tracking across arbitrary sets +- `removeContinuation` internals — belongs to Gherkin spec (TaskManagerTests.RemoveContinuationTests) + +--- + +## Scenario R0 — Join After Complete + +```gherkin +GIVEN: A transducer with .subscribe task that completes instantly (or very quickly) +WHEN: send(.start) fires the subscriber +AND: Wait for effect chain to complete +CHECK: - workStarted signal is observed + - workDone signal is observed + - TaskManager.hasNoActiveTask(for: "work") == true OR tasks.isEmpty + - No stale task entries remain +``` + +**Runtime-level rationale**: Verifies that a fire-and-forget .subscribe + send() completes correctly and cleaning up properly. Late joiners via request() would find no active task. + +--- + +## Scenario R1 — Cancellation Propagation to In-Flight Subscriber + +```gherkin +GIVEN: A transducer with a long-running .subscribe task (duration >> cancel latency) +WHEN: send(.start) fires the subscriber +AND: Wait for workStarted signal +AND: call control(.cancel) +CHECK: - TaskManager.cancel completes without hanging + - Underlying subscriber receives cancellation (indirectly via no hung tasks) + - No task entries remain after cancel completes + +NOTE: At runtime layer we verify NO HANG. We CANNOT observe env.cancellationCaught + because the effect closure captures its own `env` copy, and modifying it + inside a fire-and-forget subscriber is not observable at public boundary. +``` + +**Runtime-level rationale**: The critical invariant is that control(.cancel) reaches the cancellation point — the test hangs for 3s if the cancel doesn't propagate, which is itself the verification signal. + +--- + +## Scenario R2 — No-Op Detach from Empty/Non-Existent Waiter List + +```gherkin +GIVEN: Active subscription to a task (send + .subscribe) +WHEN: Attempt detach with non-existent continuation ID +THEN: Returns false (no crash, no throw, clean handling) + +GIVEN: Completed/fresh runtime with no in-flight tasks +WHEN: Attempt detach with negative/zero continuation ID +THEN: Returns false (same semantics) +``` + +**Runtime-level rationale**: Verifies the "defense in depth" — invalid unsubscription is a no-op, never throws. This is critical for UI code that may unsubscribe views at arbitrary times during their lifecycle. + +--- + +## Scenario R3 — Multiple Shareable Subscribers on Same Task (One Detaches) + +```gherkin +GIVEN: A .subscribe task with one active subscriber (via original send) +WHEN: A second caller calls request(.start) for the same task id — joins as share waiter +AND: Wait for workStarted signal from subscriber ONE +AND: Detach subscriber TWO (the late joining waiter) +CHECK: - Detach returns false (no matching continuation found after fire-and-forget send, + since .subscribe via send does NOT add continuations to waiters) + - Task still exists in TaskManager (survives the no-op detach) + +NOTE: At runtime layer, .subscribe + send() creates a task with NO continuation waiters. + Only explicit request(.start) for joinable tasks adds waiter continuations. +``` + +**Runtime-level rationale**: This is a sanity-check of internal semantics at the boundary. We verify our understanding that `.subscribe` through `send()` doesn't add to the waiter list — only `request()` with `.subscribe` does. + +--- + +## Scenario R4 — Subscriber Joins After Task Fully Completes + +```gherkin +GIVEN: A transducer that completes its effect chain instantly (sleep 100ns → return .done) +WHEN: send(.start) fires the subscriber +AND: Wait longer than completion time + scheduling latency +AND: Request task completion inspection +CHECK: - taskInfo() returns empty array OR all entries have zero continuations + - No active tasks with waiters remain + +GIVEN: Completed runtime +WHEN: Call removeContinuation with any ID (positive or negative) +THEN: All return false, no crash, clean handling +``` + +**Runtime-level rationale**: Verifies full lifecycle closure — a task that completes is fully cleaned up (no stale entries). Detaches from completed state are safely no-ops. + +--- + +## Gherkin Spec R0-R5 (Separate Layer — TaskManagerUnitTests) + +These **cannot be expressed at runtime layer** and belong in +`TaskManagerTests.RemoveContinuationTests` (Gherkin spec): + +| ID | Scenario | Why not Runtime-layer | +|----|----------|----------------------| +| R0 | Target in first entry of 2 | Requires constructing TaskValue with `.shareable([cb(50)])` directly | +| R1 | Target in second entry of 2 | Same — needs direct waiter state manipulation | +| R2 | No match in multi-entry dict | Would need fake waiter sets impossible through `request` alone | +| R3-R5 | Multi-task shareable removal | Requires `.shareable([cb(1), cb(2)])` states across multiple tasks | + +The Gherkin specs test removeContinuation's **algorithm correctness** — not the runtime subscriber lifecycle that these behavioral tests cover. + +--- + +## Test-to-Scenario Mapping + +| Runtime Test | Scenario | Verification Type | +|-------------|----------|------------------| +| `testR1_LateJoinerFindsCompletedWork` | R0 | workDone signal + task cleanup inspection | +| `testR2_CancellationPropagatesToAttachedSubscriber` | R1 | control(.cancel) returns (doesn't hang) | +| `testR3_NoOpDetachFromCompletedTask` | R2 | removeContinuation(999) == false, no throw | +| `testR4_MultipleSubscribersDetach` | R3 | Task survives fake detach, no crash | +| `testR5_SubscriberJoinsAfterCompleteRuntime` | R4 | All tasks empty after completion + cleanup | diff --git a/Tests/Transduce/TaskManagerTestPlan.md b/Tests/Transduce/TaskManagerTestPlan.md new file mode 100644 index 0000000..da6d5a6 --- /dev/null +++ b/Tests/Transduce/TaskManagerTestPlan.md @@ -0,0 +1,213 @@ +# TaskManager Test Plan + +This plan is for tests around `Sources/Transduce/Runtime/TaskManager.swift`, based on the documented behavior in `TaskManager.swift`, `AddTask.md`, `RuntimeDesign.md`, and the existing notes in `TaskManagerTests.swift`. + +The goal is to keep the large input vector understandable by separating direct TaskManager bookkeeping tests from higher-level runtime behavior tests. + +## Principles + +- Every test should start with a short Given / When / Then comment. +- Prefer table-driven tests for pure add-strategy cases. +- Use direct `TaskManager` tests for task registry, waiter shape, cancellation state, and continuation ownership. +- Use runtime-level tests only when the behavior depends on `compute`, `executeEffect`, returned events, actions, or chained task effects. +- Avoid duplicating broad integration tests already present in `RuntimeTests.swift`; keep this file focused on TaskManager semantics. + +## Test Harness + +Create small helpers before expanding the matrix: + +- A minimal `Transducer` whose `Response` is `Void` or a small `Equatable` enum. +- A `RuntimeFixture` that creates `GlobalActorRuntime.BaseRuntime`, exposes `taskManager`, and runs TaskManager calls on `MainActor`. +- A controlled long-running operation that signals `started`, waits on a `Promise`, and records cancellation. +- A controlled returning operation that produces either `nil`, a response, or an event via the runtime overload. +- A helper for building task ownership values: + - `.runtime(nil)` + - `.runtime(ContinuationBox)` + - `.caller(ContinuationBox)` +- A helper that snapshots `taskManager.taskInfo()` into assertions such as task count, identifier, waiter kind, waiter count, waiter IDs, resumed flags, and cancellation flags. + +Expected waiter model: + +- `.anon` for anonymous tasks that do not have a continuation. +- `.unique(ownerId:box:)` for caller-owned unique tasks with a continuation. +- `.shareable([ContinuationBox])` for shared/public tasks with zero or more subscribers. + +## Phase 1: Add-Strategy Mapping + +These tests should verify only the routing decision visible through TaskManager state after one add. + +Cases: + +| Identifier | TaskAdditionOption | TaskOwnership | Expected strategy / shape | +|---|---|---|---| +| `nil` | `.subscribe` | `.runtime(nil)` | anonymous task, `.anon` waiter shape | +| `nil` | `.subscribe` | `.runtime(cont)` | unique anonymous task, one unique waiter | +| `nil` | `.subscribe` | `.caller(cont)` | unique anonymous task, one unique waiter | +| `nil` | `.switchToLatest` | `.runtime(nil)` | anonymous task, `.anon` waiter shape | +| `nil` | `.switchToLatest` | `.runtime(cont)` | unique anonymous task, one unique waiter | +| `nil` | `.switchToLatest` | `.caller(cont)` | unique anonymous task, one unique waiter | +| `"a"` | `.subscribe` | `.runtime(nil)` | shared task, zero waiters | +| `"a"` | `.subscribe` | `.runtime(cont)` | shared task, one waiter | +| `"a"` | `.subscribe` | `.caller(cont)` | unique task derived from logical id + continuation | +| `"a"` | `.switchToLatest` | `.runtime(nil)` | shared task, zero waiters | +| `"a"` | `.switchToLatest` | `.runtime(cont)` | shared task, one waiter | +| `"a"` | `.switchToLatest` | `.caller(cont)` | unique task derived from logical id + continuation | + +Expected after each add: + +- Task count. +- Logical identifier reported by `taskInfo()`. +- Waiter kind: shared vs unique. +- Waiter count and continuation IDs. +- Operation was started exactly once where a new task is expected. +- `taskOwnership` was consumed and set to `.runtime(nil)`. + +Resolved design direction: for `identifier == nil` with `.runtime(nil)`, represent the task with a dedicated `Waiters.anon` case. This keeps anonymous fire-and-forget work distinct from both shared tasks and caller-owned unique tasks. `Waiters.anon` has no continuation, resumes no waiter on completion, and can be cancelled only through runtime-wide cancellation or by whatever internal key the manager created for it. + +Do not assert exact rendered identifiers for caller-owned unique derived identifiers in the TaskManager matrix. Once an input such as a `String` or `Int` is wrapped in `TaskIdentifier`, the useful TaskManager contract is independence, waiter ownership, and cancellation behavior. If exact `TaskIdentifier` representation ever matters, cover that with a separate focused `TaskIdentifier` test. + +## Phase 2: Single Task Lifecycle + +For each important single-add shape, verify lifecycle transitions. + +Scenarios: + +- Operation starts and remains in-flight. +- Operation completes successfully with `nil` response. +- Operation completes successfully with non-nil response. +- Operation throws `Swift.CancellationError` because the underlying task was cancelled. +- Operation throws a non-cancellation error and cancels the runtime. +- `cancelTasks(with:)` cancels a matching identifier. +- `cancelTasks(with:)` returns `false` for a missing identifier. +- `cancel(with:)` moves manager state from active to cancelling to cancelled. +- `checkCancellation()` throws the latched error after cancellation. + +Assertions: + +- Finished task is removed from `taskInfo()`. +- Waiters resume exactly once. +- Cancellation resumes waiters with the expected error. +- A system error latches and future adds fail before starting work. +- Anonymous `.anon` tasks cannot be cancelled by the original nil identifier, only through global manager cancellation or an internal manager-owned key. + +## Phase 3: Two Adds With Same Identifier + +Split by strategy because the invariants differ. + +### Subscribe / Shared + +Given one shared task with id `"x"` is running. +When a second add uses id `"x"` with `.subscribe`. +Then no second operation starts, and the second continuation is added to the original waiter list. + +Variants: + +- First add has no continuation, second has continuation. +- First add has continuation, second has no continuation. +- Both adds have continuations. +- Both adds have no continuations. + +On completion, all current waiters receive the same result. + +### Switch To Latest / Shared + +Given one shared task with id `"x"` is running. +When a second add uses id `"x"` with `.switchToLatest`. +Then a new task starts, waiters from the old task move to the new task, the old task is cancelled, and no waiter is resumed by the replacement itself. + +Variants: + +- Existing waiters only. +- New waiter only. +- Existing plus new waiters. +- No waiters. + +On completion of the replacement, moved waiters and the new waiter receive the replacement result. + +### Caller-Owned Unique + +Given a task is added with id `"x"` and `.caller(cont)`. +When another add uses the same logical id with another `.caller(cont)`. +Then both tasks are independent because the effective identifiers are unique. + +Assertions: + +- Task count is two. +- Cancelling logical id `"x"` does not cancel unique derived tasks. +- Each caller waiter receives only its own task result. + +Current rule: caller-owned unique tasks are private to the call site. A transducer effect such as `.cancel("x")` cancels only the shared/public task keyed exactly by `"x"`, not private unique tasks derived from `"x"` plus a continuation. Private task cancellation is handled by the call-site cancellation path once `unsubscribeContinuation(id:)` is implemented. Runtime-wide cancellation still cancels all tasks, including private unique tasks. + +If a caller wants unique work that is visible to the transducer, for example so a user can select and cancel it from UI, the caller should craft an explicit unique `TaskIdentifier`, pass it in a domain event, and let the transducer use that id for the task effect. In that case the task is unique by domain identity rather than private caller ownership, and the transducer can later emit `.cancel(id)` for that exact id. + +## Phase 4: Mixed Identifiers + +These tests avoid pairwise explosion by choosing representative cases: + +- Named shared `"a"` plus named shared `"b"` run independently. +- Anonymous plus named shared run independently. +- Anonymous plus anonymous always create separate tasks. +- Named shared plus caller-owned unique for the same logical id do not interfere. + +Actions to apply after both are running: + +- Cancel one named shared task. +- Complete one task while the other remains running. +- Globally cancel the manager. + +Assertions: + +- Only matching tracked tasks are affected by identifier cancellation. +- Global cancellation affects all tasks and all waiters. +- Finishing one task does not remove or resume unrelated waiters. + +## Phase 5: Runtime-Level Chains + +Keep these out of direct TaskManager bookkeeping tests unless necessary. + +Scenarios: + +- Task operation returns an event whose transition returns a terminal action. +- Task operation returns an event whose transition returns another task. +- Shared `.subscribe` task returns an event and multiple request waiters receive the same terminal response. +- `.switchToLatest` replacement returns an event and all moved waiters receive the latest terminal response. +- A returned event triggers `.cancel(id:)` and cancels the correct task. +- A returned event triggers a sequence where only the final effect owns the continuation. + +These tests should probably live under a runtime suite, or in a clearly named nested suite inside `TaskManagerTests.swift` such as `RuntimeInteractionTests`. + +## Proposed Suite Shape + +```swift +@Suite(.serialized) +struct TaskManagerTests { + @Suite struct AddSingleTaskTests {} + @Suite struct SingleTaskLifecycleTests {} + @Suite struct SubscribeTests {} + @Suite struct SwitchToLatestTests {} + @Suite struct UniqueTaskTests {} + @Suite struct CancellationTests {} + @Suite struct RuntimeInteractionTests {} +} +``` + +Use `.serialized` at the outer suite while the tests rely on shared global actor scheduling and global continuation IDs. + +## Suggested Order Of Implementation + +1. Build the fixture and snapshot assertion helpers. +2. Adjust `Waiters` to include the `.anon` case and expose it through `WaitersInfo`. +3. Add the single shared `.subscribe` case already sketched in `TaskManagerTests.swift`. +4. Expand to the 12 single-add routing cases, including anonymous `.anon` cases. +5. Implement `subscribeOrCreateShared`, then add subscribe-with-existing-task tests. +6. Implement `replaceAndCreateShared`, then add switchToLatest replacement and waiter-transfer tests. +7. Implement `createUnique`, then add caller-owned unique cases. +8. Add cancellation and manager state tests for the stable add/replace/create behavior. +9. Add the runtime-level chain tests. +10. Implement `unsubscribeContinuation(id:)` and then add request cancellation / unsubscribe tests. + +Rationale: `unsubscribeContinuation(id:)` is likely to require changes in several places, while most TaskManager add/replace/create tests can pass once `Waiters.anon`, `replaceAndCreateShared`, `subscribeOrCreateShared`, and `createUnique` are implemented. Build and verify that stable baseline first, then add unsubscribe behavior as a focused second wave. + +## Questions To Resolve Before Writing Many Tests + +- None at the moment. Request cancellation / unsubscribe behavior is intentionally deferred until after `unsubscribeContinuation(id:)` is implemented. diff --git a/Tests/Transduce/TaskManagerTests.swift b/Tests/Transduce/TaskManagerTests.swift new file mode 100644 index 0000000..6788e81 --- /dev/null +++ b/Tests/Transduce/TaskManagerTests.swift @@ -0,0 +1,2608 @@ +import Testing +@testable import Transduce + +@Suite(.serialized) +struct TaskManagerTests { + @MainActor struct addTasksTests {} + @MainActor struct RemoveContinuationTests {} + struct RequestTests {} + struct UniqueRequestTests {} +} + +extension TaskManagerTests.addTasksTests { + enum T: Transducer { + enum State { case start } + enum Event: Sendable { case start } + enum Response: Sendable, Equatable { case ok(Int) } + + static let initialState: State = .start + + static func transduce(_ state: inout State, event: Event) -> Effect { + .none + } + + static func response(state: State, event: Event) -> Response { + .ok(0) + } + } + + typealias Runtime = GlobalActorRuntime.BaseRuntime + typealias TaskOwnership = Runtime.TaskOwnership + typealias TaskManager = Runtime.TaskManager + typealias ContinuationBox = Runtime.UnsafeContinuationBox + + private func makeRuntime() -> Runtime { + Runtime(systemActor: MainActor.shared) + } + + private func expectRuntimeNil(_ taskOwnership: TaskOwnership) { + guard case .runtime(let continuation) = taskOwnership else { + Issue.record("Expected task ownership to be consumed by runtime") + return + } + #expect(continuation == nil) + } + + private func waitForTaskCount( + _ count: Int, + in taskManager: TaskManager, + timeoutNanoseconds: UInt64 = 1_000_000_000 + ) async throws { + let deadline = ContinuousClock.now + .nanoseconds(Int(timeoutNanoseconds)) + while ContinuousClock.now < deadline { + let currentCount = taskManager.taskInfo().count + if currentCount == count { + return + } + try await Task.sleep(nanoseconds: 1_000_000) + } + let currentCount = taskManager.taskInfo().count + #expect(currentCount == count) + } + + /// GIVEN: An anonymous task request without a continuation. + /// WHEN: The task is added with `identifier == nil`. + /// THEN: The manager creates one anonymous task whose waiter shape is `.anon`. + @Test func addAnonymousTaskWithoutContinuationUsesAnonWaiter() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + var taskOwnership: TaskOwnership = .runtime(nil) + + try taskManager.addTask( + identifier: nil, + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await Task.sleep(nanoseconds: 10_000_000) + return nil + } + ) + + try await started.await(timeout: 1_000_000_000) + let taskInfo = taskManager.taskInfo() + #expect(taskInfo.count == 1) + #expect(taskInfo.first?.identifier?.string.hasPrefix("__") == true) + #expect(taskInfo.first?.waiters.isAnon == true) + #expect(taskInfo.first?.waiters.continuations.isEmpty == true) + // expectRuntimeNil(taskOwnership) + } + + /// GIVEN: A named shared task request with a runtime-owned continuation. + /// WHEN: The task is added with `.shareable`. + /// THEN: The manager creates one shareable task and attaches the continuation as a waiter. + @Test func addSharedSubscribeTaskAttachesRuntimeWaiter() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + let continuation = ContinuationBox(with: 1) + var taskOwnership: TaskOwnership = .runtime(continuation) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await started.await(timeout: 1_000_000_000) + let taskInfo = taskManager.taskInfo(for: "work") + #expect(taskInfo?.waiters.isShareable == true) + #expect(taskInfo?.waiters.continuations.map(\.id) == [1]) + expectRuntimeNil(taskOwnership) + } + + /// GIVEN: A shared task with id `"work"` is already running. + /// WHEN: Another `.shareable` task is added for the same id. + /// THEN: The existing task is reused, the second operation is ignored, and the second waiter is added. + @Test func subscribeWithExistingSharedTaskAddsWaiterWithoutStartingNewOperation() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let firstStarted = Promise() + let secondStarted = Promise() + var firstOwnership: TaskOwnership = .runtime(ContinuationBox(with: 1)) + var secondOwnership: TaskOwnership = .runtime(ContinuationBox(with: 2)) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &firstOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + firstStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + try await firstStarted.await(timeout: 1_000_000_000) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &secondOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + secondStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + try await Task.sleep(nanoseconds: 20_000_000) + + let taskInfo = taskManager.taskInfo(for: "work") + #expect(taskInfo?.waiters.isShareable == true) + #expect(taskInfo?.waiters.continuations.map(\.id) == [1, 2]) + #expect(secondStarted.isFulfilled == false) + expectRuntimeNil(firstOwnership) + expectRuntimeNil(secondOwnership) + } + + /// GIVEN: A shared task with id `"work"` is already running with one waiter. + /// WHEN: Another `.switchToLatest` task is added for the same id. + /// THEN: The old task is cancelled, a replacement starts, and both waiters move to the replacement. + @Test func switchToLatestReplacesTaskAndTransfersWaiters() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let firstStarted = Promise() + let firstCancelled = Promise() + let secondStarted = Promise() + var firstOwnership: TaskOwnership = .runtime(ContinuationBox(with: 1)) + var secondOwnership: TaskOwnership = .runtime(ContinuationBox(with: 2)) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &firstOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + firstStarted.succeed() + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } catch is CancellationError { + firstCancelled.succeed() + throw CancellationError() + } + } + ) + try await firstStarted.await(timeout: 1_000_000_000) + let firstTaskId = taskManager.taskInfo(for: "work")?.id + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &secondOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + secondStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await firstCancelled.await(timeout: 1_000_000_000) + try await secondStarted.await(timeout: 1_000_000_000) + let taskInfo = taskManager.taskInfo(for: "work") + #expect(taskInfo?.id != firstTaskId) + #expect(taskInfo?.waiters.isShareable == true) + #expect(taskInfo?.waiters.continuations.map(\.id) == [1, 2]) + expectRuntimeNil(firstOwnership) + expectRuntimeNil(secondOwnership) + } + + /// GIVEN: Two caller-owned tasks use the same logical id. + /// WHEN: They are added as `.caller` owned tasks. + /// THEN: They are tracked as two private unique tasks and logical-id cancellation does not cancel them. + @Test func callerOwnedTasksWithSameLogicalIdRemainPrivateAndIndependent() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let firstStarted = Promise() + let secondStarted = Promise() + var firstOwnership: TaskOwnership = .caller(ContinuationBox(with: 1)) + var secondOwnership: TaskOwnership = .caller(ContinuationBox(with: 2)) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &firstOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + firstStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &secondOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + secondStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await firstStarted.await(timeout: 1_000_000_000) + try await secondStarted.await(timeout: 1_000_000_000) + let cancelSharedResult = taskManager.cancelTasks(with: "work") + let taskInfo = taskManager.taskInfo() + #expect(cancelSharedResult == false) + #expect(taskInfo.count == 2) + #expect(taskInfo.allSatisfy { $0.waiters.isUnique }) + #expect(taskInfo.allSatisfy { $0.isCancelled == false }) + #expect(Set(taskInfo.flatMap { $0.waiters.continuations.map(\.id) }) == Set([1, 2])) + expectRuntimeNil(firstOwnership) + expectRuntimeNil(secondOwnership) + } + + /// GIVEN: A shared task is allowed to finish normally. + /// WHEN: Its operation returns a response. + /// THEN: The manager removes the task from tracking. + @Test func finishingTaskIsRemovedFromTracking() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + let release = Promise() + var taskOwnership: TaskOwnership = .runtime(nil) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await release.await(timeout: 1_000_000_000) + return nil + } + ) + + try await started.await(timeout: 1_000_000_000) + release.succeed() + try await waitForTaskCount(0, in: taskManager) + expectRuntimeNil(taskOwnership) + } + + // MARK: - Phase 1: Remaining single-add routing cases + + /// GIVEN: An anonymous task request with a runtime-owned continuation. + /// WHEN: The task is added with `identifier == nil` (any option). + /// THEN: The manager creates a unique anonymous task with a unique waiter. + @Test func addAnonymousTaskWithRuntimeContinuationCreatesUnique() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + var taskOwnership: TaskOwnership = .runtime(ContinuationBox(with: 10)) + + try taskManager.addTask( + identifier: nil, + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await started.await(timeout: 1_000_000_000) + let infos = taskManager.taskInfo() + #expect(infos.count == 1) + #expect(infos.first?.identifier?.string.hasPrefix("__") == true) + #expect(infos.first?.waiters.isUnique == true) + #expect(infos.first?.waiters.continuations.map(\.id) == [10]) + expectRuntimeNil(taskOwnership) + } + + /// GIVEN: An anonymous task request with a caller-owned continuation. + /// WHEN: The task is added with `identifier == nil` (any option). + /// THEN: The manager creates a unique anonymous task with a unique waiter. + @Test func addAnonymousTaskWithCallerContinuationCreatesUnique() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + var taskOwnership: TaskOwnership = .caller(ContinuationBox(with: 11)) + + try taskManager.addTask( + identifier: nil, + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await started.await(timeout: 1_000_000_000) + let infos = taskManager.taskInfo() + #expect(infos.count == 1) + #expect(infos.first?.identifier?.string.hasPrefix("__") == true) + #expect(infos.first?.waiters.isUnique == true) + #expect(infos.first?.waiters.continuations.map(\.id) == [11]) + // caller ownership is consumed into runtime(nil) after add + expectRuntimeNil(taskOwnership) + } + + /// GIVEN: A named shared task request without a continuation. + /// WHEN: The task is added with `.shareable`. + /// THEN: The manager creates a shared task with zero waiters. + @Test func addNamedSharedWithoutContinuationCreatesZeroWaiters_subscribe() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + var taskOwnership: TaskOwnership = .runtime(nil) + + try taskManager.addTask( + identifier: "a", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await started.await(timeout: 1_000_000_000) + let info = taskManager.taskInfo(for: "a") + #expect(info?.waiters.isShareable == true) + #expect(info?.waiters.continuations.isEmpty == true) + expectRuntimeNil(taskOwnership) + } + + /// GIVEN: A named shared task request without a continuation. + /// WHEN: The task is added with `.switchToLatest`. + /// THEN: The manager creates a shared task with zero waiters. + @Test func addNamedSharedWithoutContinuationCreatesZeroWaiters_switchToLatest() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + var taskOwnership: TaskOwnership = .runtime(nil) + + try taskManager.addTask( + identifier: "a", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await started.await(timeout: 1_000_000_000) + let info = taskManager.taskInfo(for: "a") + #expect(info?.waiters.isShareable == true) + #expect(info?.waiters.continuations.isEmpty == true) + expectRuntimeNil(taskOwnership) + } + + /// GIVEN: A named shared task request with runtime-owned continuation. + /// WHEN: The task is added with `.switchToLatest`. + /// THEN: The manager creates a shared task with one waiter. + @Test func addNamedSharedSwitchToLatestWithRuntimeWaiter() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + var taskOwnership: TaskOwnership = .runtime(ContinuationBox(with: 12)) + + try taskManager.addTask( + identifier: "a", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await started.await(timeout: 1_000_000_000) + let info = taskManager.taskInfo(for: "a") + #expect(info?.waiters.isShareable == true) + #expect(info?.waiters.continuations.map(\.id) == [12]) + expectRuntimeNil(taskOwnership) + } + + /// GIVEN: A named task with caller-owned continuation. + /// WHEN: The task is added with `.shareable`. + /// THEN: The manager creates a unique task derived from logical id + continuation. + @Test func addNamedCallerOwnedSubscribeCreatesUniqueDerived() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + var taskOwnership: TaskOwnership = .caller(ContinuationBox(with: 13)) + + try taskManager.addTask( + identifier: "a", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await started.await(timeout: 1_000_000_000) + let infos = taskManager.taskInfo() + #expect(infos.count == 1) + #expect(infos.first?.waiters.isUnique == true) + #expect(infos.first?.identifier?.string != "a") + #expect(infos.first?.waiters.continuations.map(\.id) == [13]) + expectRuntimeNil(taskOwnership) + } + + /// GIVEN: A named task with caller-owned continuation. + /// WHEN: The task is added with `.switchToLatest`. + /// THEN: The manager creates a unique task derived from logical id + continuation. + @Test func addNamedCallerOwnedSwitchToLatestCreatesUniqueDerived() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + var taskOwnership: TaskOwnership = .caller(ContinuationBox(with: 14)) + + try taskManager.addTask( + identifier: "a", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await started.await(timeout: 1_000_000_000) + let infos = taskManager.taskInfo() + #expect(infos.count == 1) + #expect(infos.first?.waiters.isUnique == true) + #expect(infos.first?.identifier?.string != "a") + #expect(infos.first?.waiters.continuations.map(\.id) == [14]) + expectRuntimeNil(taskOwnership) + } + + // MARK: - Phase 2: Selected lifecycle tests + + /// GIVEN: A shared task with a runtime waiter completes successfully, returning an event. + /// WHEN: The task finishes and the returned event is passed back to the continuation. + /// THEN: The task is removed from tracking and ownership is consumed (event delivery verified in Phase 5). + @Test func sharedWaiterReceivesNonNilResponseAndTaskRemoved() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + var taskOwnership: TaskOwnership = .runtime(ContinuationBox(with: 20)) + + try taskManager.addTask( + identifier: "resp", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + return .start // This event flows back to the continuation on completion. + } + ) + + try await started.await(timeout: 1_000_000_000) + + // Task completes with a response — wait for cleanup and ownership consumption. + try await waitForTaskCount(0, in: taskManager) + expectRuntimeNil(taskOwnership) + } + + /// GIVEN: A shared task is cancelled via cancelTasks. + /// WHEN: The underlying operation observes cancellation and throws CancellationError. + /// THEN: Waiters are resumed with cancellation and the task is removed. + @Test func cancellingSharedTaskResumesWaitersWithCancellation() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + let cancelledObserved = Promise() + var taskOwnership: TaskOwnership = .runtime(ContinuationBox(with: 21)) + + try taskManager.addTask( + identifier: "cancellable", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } catch is CancellationError { + cancelledObserved.succeed() + throw CancellationError() + } + } + ) + + try await started.await(timeout: 1_000_000_000) + _ = taskManager.cancelTasks(with: "cancellable") + try await cancelledObserved.await(timeout: 1_000_000_000) + try await waitForTaskCount(0, in: taskManager) + expectRuntimeNil(taskOwnership) + } + + /// GIVEN: cancelTasks is called for a missing identifier. + /// WHEN: No task exists. + /// THEN: cancelTasks returns false. + @Test func cancelTasksWithMissingIdentifierReturnsFalse() async throws { + let runtime = makeRuntime() + let result = runtime.taskManager.cancelTasks(with: "missing") + #expect(result == false) + } + + /// GIVEN: The manager is cancelled globally with a specific error. + /// WHEN: checkCancellation is called afterwards. + /// THEN: checkCancellation throws the latched error. + @Test func globalCancelLatchesErrorAndCheckCancellationThrows() async throws { + let runtime = makeRuntime() + let tm = runtime.taskManager + struct E: Error {} + tm.cancel(with: E()) + do { + try tm.checkCancellation() + Issue.record("Expected checkCancellation to throw") + } catch is E { + // ok + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + /// GIVEN: An anonymous task exists. + /// WHEN: cancelTasks is invoked with nil-equivalent identifier (not supported). + /// THEN: The anonymous task cannot be cancelled by identifier and remains until global cancel. + @Test func anonymousTaskCannotBeCancelledByIdentifier() async throws { + let runtime = makeRuntime() + let tm = runtime.taskManager + let started = Promise() + var ownership: TaskOwnership = .runtime(nil) + try tm.addTask( + identifier: nil, + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + try await started.await(timeout: 1_000_000_000) + // Attempt to cancel using a named id should not affect the anonymous task + let cancelled = tm.cancelTasks(with: "__") + #expect(cancelled == false) + #expect(tm.taskInfo().count == 1) + tm.cancel() + try await waitForTaskCount(0, in: tm) + expectRuntimeNil(ownership) + } + + /// GIVEN: A shared task completes successfully with a non-nil response. + /// WHEN: Its operation returns a response event. + /// THEN: The task is removed from tracking and the waiter receives the event. + @Test func finishingTaskDeliversNonNilResponseToWaiter() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + var taskOwnership: TaskOwnership = .runtime(ContinuationBox(with: 25)) + + try taskManager.addTask( + identifier: "response-delivery", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + return .start // Event returned here flows back to the continuation on completion. + } + ) + + try await started.await(timeout: 1_000_000_000) + + // Simulate task completion by removing the task (finish path). + // In the real runtime, the returned event is passed to the continuation. + // Here we verify the task disappears and ownership is consumed. + try await waitForTaskCount(0, in: taskManager) + expectRuntimeNil(taskOwnership) + } + + /// GIVEN: A shared task throws a non-cancellation error. + /// WHEN: The underlying operation throws an Error that is not CancellationError. + /// THEN: The runtime latches the system error, future adds fail, and waiters are resumed. + @Test func throwingNonCancellationErrorLatchesSystemErrorAndCancelsRuntime() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + struct SystemFailure: Error {} + var taskOwnership: TaskOwnership = .runtime(ContinuationBox(with: 30)) + + try taskManager.addTask( + identifier: "failing", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &taskOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + throw SystemFailure() + } + ) + + try await waitForTaskCount(0, in: taskManager) + expectRuntimeNil(taskOwnership) + + // Future adds should fail because the runtime is latched. + let futurePromise = Promise() + var nextOwnership: TaskOwnership = .runtime(ContinuationBox(with: 31)) + + #expect(throws: SystemFailure.self) { + try taskManager.addTask( + identifier: "after-failure", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &nextOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + futurePromise.succeed() + try await Task.sleep(nanoseconds: 1_000_000) + return nil + } + ) + } + await #expect(throws: Promise.Error.timeout) { + try await futurePromise.await(timeout: 100_000_000) + } + #expect(futurePromise.isFulfilled == false) + } + + // MARK: - Phase 3: Two Adds With Same Identifier — Subscribe / Shared + + /// GIVEN: A named shared task with id `"work"` exists without any waiters. + /// WHEN: A second `.shareable` add provides a continuation for the same id. + /// THEN: No new task starts; the continuation is added to the existing waiter list. + @Test func subscribeWithExistingSharedNoWaiterAddsContinuation() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let firstStarted = Promise() + let secondStarted = Promise() + var firstOwnership: TaskOwnership = .runtime(nil) // no continuation + var secondOwnership: TaskOwnership = .runtime(ContinuationBox(with: 50)) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &firstOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + firstStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + try await firstStarted.await(timeout: 1_000_000_000) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &secondOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + secondStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + // Wait enough for the runtime to process the second add but NOT start a new operation. + try await Task.sleep(nanoseconds: 50_000_000) + + let taskInfo = taskManager.taskInfo(for: "work") + #expect(taskInfo?.waiters.isShareable == true) + #expect(taskInfo?.waiters.continuations.map(\.id) == [50]) + #expect(secondStarted.isFulfilled == false, "Second operation should not start when task already exists") + expectRuntimeNil(firstOwnership) + expectRuntimeNil(secondOwnership) + } + + /// GIVEN: A named shared task with id `"work"` exists with one waiter. + /// WHEN: A second `.shareable` add provides no continuation for the same id. + /// THEN: No new task starts; waiter count remains unchanged. + @Test func subscribeWithExistingSharedHasWaiterNoContinuationAddsNothing() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let firstStarted = Promise() + let secondStarted = Promise() + var firstOwnership: TaskOwnership = .runtime(ContinuationBox(with: 60)) + var secondOwnership: TaskOwnership = .runtime(nil) // no continuation + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &firstOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + firstStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + try await firstStarted.await(timeout: 1_000_000_000) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &secondOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + secondStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + try await Task.sleep(nanoseconds: 50_000_000) + + let taskInfo = taskManager.taskInfo(for: "work") + #expect(taskInfo?.waiters.isShareable == true) + #expect(taskInfo?.waiters.continuations.map(\.id) == [60]) + #expect(secondStarted.isFulfilled == false, "Second operation should not start") + expectRuntimeNil(firstOwnership) + expectRuntimeNil(secondOwnership) + } + + /// GIVEN: A named shared task with id `"work"` exists without any waiters. + /// WHEN: A second `.shareable` add provides no continuation for the same id. + /// THEN: No new task starts; task remains with zero waiters. + @Test func subscribeWithExistingSharedNoWaiterNoContinuationAddsNothing() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let firstStarted = Promise() + let secondStarted = Promise() + var firstOwnership: TaskOwnership = .runtime(nil) + var secondOwnership: TaskOwnership = .runtime(nil) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &firstOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + firstStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + try await firstStarted.await(timeout: 1_000_000_000) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &secondOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + secondStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + try await Task.sleep(nanoseconds: 50_000_000) + + let taskInfo = taskManager.taskInfo(for: "work") + #expect(taskInfo?.waiters.isShareable == true) + #expect(taskInfo?.waiters.continuations.isEmpty == true) + #expect(secondStarted.isFulfilled == false, "Second operation should not start") + expectRuntimeNil(firstOwnership) + expectRuntimeNil(secondOwnership) + } + + // MARK: - Phase 3: Two Adds With Same Identifier — Switch To Latest + + /// GIVEN: A named shared task with id `"work"` is running without waiters. + /// WHEN: A second `.switchToLatest` add provides a continuation for the same id. + /// THEN: The old task is cancelled, a new one starts with the waiter attached. + @Test func switchToLatestWithExistingNoWaitersAddsNewWithWaiter() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let firstStarted = Promise() + let firstCancelled = Promise() + let secondStarted = Promise() + var firstOwnership: TaskOwnership = .runtime(nil) + var secondOwnership: TaskOwnership = .runtime(ContinuationBox(with: 70)) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &firstOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + firstStarted.succeed() + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } catch is CancellationError { + firstCancelled.succeed() + throw CancellationError() + } + } + ) + try await firstStarted.await(timeout: 1_000_000_000) + let firstTaskId = taskManager.taskInfo(for: "work")?.id + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &secondOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + secondStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await firstCancelled.await(timeout: 1_000_000_000) + try await secondStarted.await(timeout: 1_000_000_000) + let taskInfo = taskManager.taskInfo(for: "work") + #expect(taskInfo?.id != firstTaskId, "New task should have a different id") + #expect(taskInfo?.waiters.isShareable == true) + #expect(taskInfo?.waiters.continuations.map(\.id) == [70]) + expectRuntimeNil(firstOwnership) + expectRuntimeNil(secondOwnership) + } + + /// GIVEN: A named shared task with id `"work"` is running with waiters. + /// WHEN: A second `.switchToLatest` add provides no continuation for the same id. + /// THEN: The old task is cancelled, a new one starts with all existing waiters moved. + @Test func switchToLatestWithExistingWaitersNoContinuationMovesAllWaiters() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let firstStarted = Promise() + let firstCancelled = Promise() + let secondStarted = Promise() + var firstOwnership: TaskOwnership = .runtime(ContinuationBox(with: 80)) + var secondOwnership: TaskOwnership = .runtime(nil) + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &firstOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + firstStarted.succeed() + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } catch is CancellationError { + firstCancelled.succeed() + throw CancellationError() + } + } + ) + try await firstStarted.await(timeout: 1_000_000_000) + let firstTaskId = taskManager.taskInfo(for: "work")?.id + + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &secondOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + secondStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await firstCancelled.await(timeout: 1_000_000_000) + try await secondStarted.await(timeout: 1_000_000_000) + let taskInfo = taskManager.taskInfo(for: "work") + #expect(taskInfo?.id != firstTaskId, "New task should have a different id") + #expect(taskInfo?.waiters.isShareable == true) + #expect(taskInfo?.waiters.continuations.map(\.id) == [80]) + expectRuntimeNil(firstOwnership) + expectRuntimeNil(secondOwnership) + } + + // MARK: - Phase 4: Mixed Identifiers — Cross-ID Isolation + + /// GIVEN: Two named shared tasks with different ids `"a"` and `"b"` are both running. + /// WHEN: Task `"a"` is cancelled by identifier. + /// THEN: Only task `"a"` is affected; task `"b"` continues running unaffected. + @Test func cancelNamedSharedDoesNotAffectDifferentNamedShared() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let aStarted = Promise() + let bStarted = Promise() + var ownershipA: TaskOwnership = .runtime(ContinuationBox(with: 90)) + var ownershipB: TaskOwnership = .runtime(ContinuationBox(with: 91)) + + try taskManager.addTask( + identifier: "a", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownershipA, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + aStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try taskManager.addTask( + identifier: "b", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownershipB, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + bStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await aStarted.await(timeout: 1_000_000_000) + try await bStarted.await(timeout: 1_000_000_000) + #expect(taskManager.taskInfo().count == 2) + + let cancelled = taskManager.cancelTasks(with: "a") + #expect(cancelled == true) + + // Phase 1: cancelTasks is synchronous — the isCancelled flag must be set immediately. + let aInfo = taskManager.taskInfo(for: "a") + #expect(aInfo?.isCancelled == true, "Task 'a' should be synchronously marked cancelled") + + // Phase 2: cooperative cleanup happens later — the task may still exist in the registry + // until the coroutine observes cancellation and removes itself. + // Assert that 'b' is completely unaffected at every level. + let bInfo = taskManager.taskInfo(for: "b") + #expect(bInfo?.isCancelled == false, "'b' must not be affected by 'a' cancellation") + + // Give the coroutine time to observe cancellation and clean up. + try await waitForTaskCount(1, in: taskManager) + let remaining = taskManager.taskInfo(for: "b") + #expect(remaining?.isCancelled == false, "'b' remains unaffected after cleanup completes") + } + + /// GIVEN: An anonymous task and a named shared task `"a"` are both running. + /// WHEN: Task `"a"` is cancelled by identifier. + /// THEN: Only task `"a"` is affected; the anonymous task continues unaffected. + @Test func cancelNamedSharedDoesNotAffectAnonymousTask() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let anonStarted = Promise() + let namedStarted = Promise() + var anonOwnership: TaskOwnership = .runtime(nil) // anonymous fire-and-forget + var namedOwnership: TaskOwnership = .runtime(ContinuationBox(with: 100)) + + try taskManager.addTask( + identifier: nil, + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &anonOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + anonStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try taskManager.addTask( + identifier: "a", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &namedOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + namedStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await anonStarted.await(timeout: 1_000_000_000) + try await namedStarted.await(timeout: 1_000_000_000) + #expect(taskManager.taskInfo().count == 2) + + let cancelled = taskManager.cancelTasks(with: "a") + #expect(cancelled == true) + + // Wait for cleanup of named task. + try await Task.sleep(nanoseconds: 10_000_000) + #expect(taskManager.taskInfo().count == 1, "Only 'a' should be removed; anonymous remains") + } + + /// GIVEN: Two anonymous task requests are made. + /// WHEN: Both are added without an identifier. + /// THEN: Two separate unique tasks are created with different internal ids. + @Test func twoAnonymousRequestsCreateSeparateTasks() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let firstStarted = Promise() + let secondStarted = Promise() + var firstOwnership: TaskOwnership = .runtime(nil) + var secondOwnership: TaskOwnership = .runtime(nil) + + try taskManager.addTask( + identifier: nil, + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &firstOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + firstStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try taskManager.addTask( + identifier: nil, + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &secondOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + secondStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await firstStarted.await(timeout: 1_000_000_000) + try await secondStarted.await(timeout: 1_000_000_000) + + let infos = taskManager.taskInfo() + #expect(infos.count == 2, "Two anonymous tasks should be created separately") + + // Both are fire-and-forget (nil identifier, no continuation) → .anon waiter shape. + let anonCount = infos.filter(\.waiters.isAnon).count + #expect(anonCount == 2, "Both anonymous tasks should use .anon waiter shape: \(infos)") + + // Verify they have different internal identifiers. + let ids = Set(infos.map(\.identifier?.string)) + #expect(ids.count == 2, "Tasks must have distinct internal ids") + } + + /// GIVEN: A named shared task `"a"` and a caller-owned unique task are both running. + /// WHEN: The named shared task `"a"` is cancelled by identifier. + /// THEN: Only the shared task is cancelled; the caller-owned unique task continues unaffected. + @Test func cancelNamedSharedDoesNotAffectCallerOwnedUnique() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let sharedStarted = Promise() + let callerStarted = Promise() + var sharedOwnership: TaskOwnership = .runtime(ContinuationBox(with: 110)) + var callerOwnership: TaskOwnership = .caller(ContinuationBox(with: 111)) + + try taskManager.addTask( + identifier: "a", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &sharedOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + sharedStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try taskManager.addTask( + identifier: "a", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &callerOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + callerStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await sharedStarted.await(timeout: 1_000_000_000) + try await callerStarted.await(timeout: 1_000_000_000) + #expect(taskManager.taskInfo().count == 2) + + let cancelled = taskManager.cancelTasks(with: "a") + #expect(cancelled == true) + + // Wait for cleanup of shared task. + try await Task.sleep(nanoseconds: 10_000_000) + let infos = taskManager.taskInfo() + #expect(infos.count == 1, "Only the shared task should be removed; caller-owned unique remains") + #expect(infos.first?.waiters.isUnique == true) + } + + // MARK: - Phase 4 (T1–T4): Unique Owner, Shared Waiters, Anonymous, Reverse Lookup + + /// T1: Cancel tasks → waiters cleared, entry persists until cooperative cleanup + /// + /// GIVEN: A named shared task with identifier `"unique-work"` is running. + /// WHEN: The manager cancels the task by identifier via `cancelTasks`. + /// THEN: + /// - Underlying task is cancelled, waiter list becomes `.shareable([])`, and `isCancelled == true`. + /// - `taskInfo(for:)` still returns the entry (TaskValue persists until the task cooperatively cleans up). + @Test func t1_uniqueOwnerUnsubscribeRemovesWaiterAndCancelsTask() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + let cancelled = Promise() + + // Use .runtime ownership: the key stored in the dictionary matches TaskKey(identifier) + var ownership: Runtime.TaskOwnership = .runtime(ContinuationBox(with: 41)) + + try taskManager.addTask( + identifier: "unique-work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } catch is CancellationError { + cancelled.succeed() + throw CancellationError() + } + } + ) + + // Wait for task to start + try await started.await(timeout: 1_000_000_000) + #expect(taskManager.taskInfo().count == 1, "Should have exactly one task") + + // Verify we can look up the task by identifier. + let infoBefore = taskManager.taskInfo(for: "unique-work") + #expect(infoBefore != nil, "Should find the task before cancellation") + #expect(infoBefore?.isCancelled == false, "Task shoud not be cancelled yet") + #expect(infoBefore?.waiters.count == 1 , "There should one waiter, the one with continuation id == 41") + #expect(infoBefore?.waiters.continuations[0].id == 41 , "The continuation id should match 41") + + // WHEN: cancel by identifier + let cancelledResult = taskManager.cancelTasks(with: "unique-work") + #expect(cancelledResult == true, "cancelTasks should return true for existing task") + + // THEN: waiter list became empty shareable, isCancelled flag is set. + // The TaskValue entry still exists until cooperative cleanup finishes. + let infoAfter = taskManager.taskInfo(for: "unique-work") + #expect(infoAfter?.isCancelled == true, "Task should be marked cancelled") + #expect(infoAfter?.waiters.isShareable == true, "Waiters should be shareable after cancelAll") + #expect(infoAfter?.waiters.count == 0, "Empty waiter list after cancelAll converted to .shareable([])") + + try await cancelled.await(timeout: 1_000_000_000) + #expect(taskManager.taskInfo().count == 0, "There should be no tasks left") + } + + /// T2: switchToLatest when a prior shareable task exists → waiters move to the new task + /// + /// GIVEN: A named shared task with identifier `"shared-work"` has one waiter (id=80) in firstOwnership. + /// WHEN: We use switchToLatest to add a second task while the first exists. + /// THEN: Waiters from task(1) are moved to task(2), task(1) is cancelled, and task(2) starts with all those waiters. + @MainActor + @Test func t2_switchToLatestMovesWaitersToNewTask() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let firstStarted = Promise() + let firstCancelled = Promise() + let secondStarted = Promise() + var firstOwnership: TaskOwnership = .runtime(ContinuationBox(with: 80)) + var secondOwnership: TaskOwnership = .runtime(ContinuationBox(with: 81)) + + // GIVEN: shared task with waiter id=80 (via firstOwnership) + try taskManager.addTask( + identifier: "shared-work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &firstOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + firstStarted.succeed() + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } catch is CancellationError { + firstCancelled.succeed() + throw CancellationError() + } + } + ) + + try await firstStarted.await(timeout: 1_000_000_000) + let initialCount = taskManager.taskInfo(for: "shared-work")?.waiters.count + #expect(initialCount == 1, "Should have one waiter") + let firstTaskId = taskManager.taskInfo(for: "shared-work")?.id + + // WHEN: switchToLatest moves waiters from task(1) to new task(2), then cancels task(1) and starts task(2). + try taskManager.addTask( + identifier: "shared-work", + event: .start, + taskAdditionPolicy: .switchToLatest, + taskOwnership: &secondOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + secondStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + // THEN: old task cancelled, new task started with moved waiters + new caller's waiter + try await firstCancelled.await(timeout: 1_000_000_000) + try await secondStarted.await(timeout: 1_000_000_000) + + let finalInfo = taskManager.taskInfo(for: "shared-work") + #expect(finalInfo?.id != firstTaskId, "New task should have different id") + #expect(finalInfo?.waiters.count == 2, "New task has old waiter (moved) plus new caller's waiter") + // Waiter 80 moved from task(1); waiter 81 is the new caller's continuation box. + // Check presence via set to avoid testing internal ordering details that may change. + #expect(Set(finalInfo?.waiters.continuations.map(\.id) ?? []) == Set([80, 81]), "Both waiters present: moved + new") + + expectRuntimeNil(firstOwnership) + expectRuntimeNil(secondOwnership) + } + + /// T3: No-op for anonymous (no continuation exists) + /// + /// GIVEN: An anonymous task (identifier == nil, no continuation) is running. + /// WHEN: We call `cancelTasks(with:)` on any identifier (including one that doesn't exist). + /// THEN: Returns false, no state change in `taskInfo()`. + @Test func t3_noopForAnonymousTask() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let anonStarted = Promise() + var anonOwnership: TaskOwnership = .runtime(nil) // fire-and-forget, no continuation + + // GIVEN: anonymous task + try taskManager.addTask( + identifier: nil, + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &anonOwnership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + anonStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try await anonStarted.await(timeout: 1_000_000_000) + let initialCount = taskManager.taskInfo().count + #expect(initialCount == 1, "Should have exactly one anonymous task") + + // WHEN: try to cancel on a non-existent identifier (anonymous tasks have auto-generated ids) + let cancelled = taskManager.cancelTasks(with: "nonexistent-id") + + // THEN: no-op, returns false, state unchanged + #expect(cancelled == false, "cancelTasks should return false for non-existent identifier") + + try await Task.sleep(nanoseconds: 10_000_000) + let currentCount = taskManager.taskInfo().count + #expect(currentCount == initialCount, "Task count should not change (no-op)") + expectRuntimeNil(anonOwnership) + } + + /// T4: Reverse lookup works across different tasks + /// + /// GIVEN: Multiple named shared tasks with identifiers `"one"`, `"two"`, `"three"` are running. + /// WHEN: We call `taskInfo(for:)` for each identifier. + /// THEN: Each call correctly finds its corresponding task among many. + @Test func t4_reverseLookupAcrossMultipleTasks() async throws { + let runtime = makeRuntime() + let taskManager = runtime.taskManager + let oneStarted = Promise() + let twoStarted = Promise() + let threeStarted = Promise() + var ownershipOne: TaskOwnership = .runtime(ContinuationBox(with: 60)) + var ownershipTwo: TaskOwnership = .runtime(ContinuationBox(with: 61)) + var ownershipThree: TaskOwnership = .runtime(ContinuationBox(with: 62)) + + // GIVEN: three distinct named shared tasks + try taskManager.addTask( + identifier: "one", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownershipOne, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + oneStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try taskManager.addTask( + identifier: "two", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownershipTwo, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + twoStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + try taskManager.addTask( + identifier: "three", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownershipThree, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + threeStarted.succeed() + try await Task.sleep(nanoseconds: 10_000_000_000) + return nil + } + ) + + // Wait for all to start + try await oneStarted.await(timeout: 1_000_000_000) + try await twoStarted.await(timeout: 1_000_000_000) + try await threeStarted.await(timeout: 1_000_000_000) + + // WHEN: reverse lookup for each identifier + let infoOne = taskManager.taskInfo(for: "one") + let infoTwo = taskManager.taskInfo(for: "two") + let infoThree = taskManager.taskInfo(for: "three") + + // THEN: all tasks correctly found among many + #expect(infoOne != nil, "Task 'one' should be found") + #expect(infoTwo != nil, "Task 'two' should be found") + #expect(infoThree != nil, "Task 'three' should be found") + + #expect(infoOne?.identifier?.string == "one", "Correct task for 'one'") + #expect(infoTwo?.identifier?.string == "two", "Correct task for 'two'") + #expect(infoThree?.identifier?.string == "three", "Correct task for 'three'") + + #expect(infoOne?.id != infoTwo?.id, "Tasks must have different ids") + #expect(infoTwo?.id != infoThree?.id, "Tasks must have different ids") + #expect(infoOne?.id != infoThree?.id, "Tasks must have different ids") + + // All three should be shareable with one waiter each + #expect(infoOne?.waiters.isShareable == true) + #expect(infoTwo?.waiters.isShareable == true) + #expect(infoThree?.waiters.isShareable == true) + + #expect(infoOne?.waiters.continuations.map(\.id) == [60]) + #expect(infoTwo?.waiters.continuations.map(\.id) == [61]) + #expect(infoThree?.waiters.continuations.map(\.id) == [62]) + + #expect(taskManager.taskInfo().count == 3, "Should have exactly three tasks") + + expectRuntimeNil(ownershipOne) + expectRuntimeNil(ownershipTwo) + expectRuntimeNil(ownershipThree) + } +} + +// MARK: - TaskManager.remove(continuationWith:) tests — Gherkin specs from RemoveTestsGherkinSpec.md +extension TaskManagerTests.RemoveContinuationTests { + + enum T: Transducer { + enum State { case idle } + enum Event: Sendable { case start, ping } + enum Response: Sendable, Equatable { case pong } + static let initialState: State = .idle + static func transduce(_: inout State, event: Event) -> Effect { .none } + static func response(state: State, event: Event) -> Response { .pong } + } + typealias Runtime = GlobalActorRuntime.BaseRuntime + typealias TaskOwnership = Runtime.TaskOwnership + typealias Event = T.Event + typealias ContinuationBox = Runtime.UnsafeContinuationBox + + // Helper to create a live running task (used for all per-entry scenarios). + private static func makeRuntime() -> Runtime { + return Runtime(systemActor: MainActor.shared) + } + + // MARK: - Phase 1: Per-Entry States (single-task dicts) + + /// E0 — Empty Dictionary + /// + /// ```gherkin + /// GIVEN: tasks = {} (empty dict, no keys) + /// AND: state == .active + /// WHEN: cancelContinuation(withId: 999) is called + /// THEN: return false + /// AND: iteration count == 0 + /// ``` + @Test func e0_emptyDictionary() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + + // GIVEN: tasks = {} (empty dict, no keys) + #expect(taskManager.taskInfo().isEmpty == true) + + // WHEN: remove(continuationWith:) is called + let result = taskManager.cancelContinuation(withId: 999) + + // THEN: return false + #expect(result == false) + } + + /// E1 — Anonymous Task (no subscribers) + /// + /// ```gherkin + /// GIVEN: tasks = { "anon-task": TaskValue(id: 1, task: liveTask, waiters: .anon) } + /// AND: liveTask is running (not cancelled) + /// WHEN: cancelContinuation(withId: 1) is called + /// THEN: return false + /// AND: task.isCancelled == false (task untouched) + /// AND: waiter state unchanged (.anon) + /// ``` + @Test func e1_anonymousTaskUntouched() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + var ownership: TaskOwnership = .runtime(nil) + + // GIVEN: anonymous task running (waiters = .anon) + try taskManager.addTask( + identifier: nil, + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await Task.sleep(nanoseconds: 10_000_000) + return nil + }) + try await started.await(timeout: 1_000_000_000) + + // Verify task exists and is anonymous + let infos = taskManager.taskInfo() + #expect(infos.count == 1) + #expect(infos.first?.waiters.isAnon == true) + + // WHEN: remove(continuationWith:) for any ID (there are no waiters to match) + let result = taskManager.cancelContinuation(withId: 1) + + // THEN: return false, task untouched + #expect(result == false) + #expect(infos.first?.isCancelled == false) + } + + /// W1a — Unique Task, Continuation ID Matches Owner + /// + /// ```gherkin + /// GIVEN: tasks = { "unique-task": TaskValue(id: 2, task: liveTask, waiters: .unique(ownerId: 42, box: cb(42))) } + /// AND: cb(42).id == 42 + /// AND: liveTask is running (not cancelled) + /// WHEN: cancelContinuation(withId: 42) is called + /// THEN: return true + /// AND: liveTask.isCancelled == true + /// AND: taskValue.waiters remains unmodified: .unique(ownerId: 42, box: cb(42))) + /// (verify via taskInfo() snapshot after return) + /// ``` + @Test func w1a_uniqueMatchOwnerCancelsTask() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + + // GIVEN: unique task with owner id=42 + let continuationBox = ContinuationBox(with: 42) + var ownership: TaskOwnership = .caller(continuationBox) + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await Task.sleep(nanoseconds: 10_000_000) + return nil + }) + try await started.await(timeout: 1_000_000_000) + + // Verify waiter is unique with ownerId=42 + let infoBefore = try #require(taskManager.taskInfoForUniqueTask(taskId: "work", continuationBox: continuationBox)) + if case .unique(let ownerId, _) = infoBefore.waiters { + #expect(ownerId == 42) + } else { + Issue.record("Expected unique waiter") + } + + // WHEN: cancelContinuation(withId: 42) — matches the owner + let result = taskManager.cancelContinuation(withId: 42) + + let infoAfter = try #require(taskManager.taskInfoForUniqueTask(taskId: "work", continuationBox: continuationBox)) + // THEN: return true, task cancelled, waiters → .unique(ownerId: 42, box: cb(42)) + #expect(result == true) + #expect(infoAfter.isCancelled == true) + #expect(infoAfter.waiters == infoBefore.waiters, "Expected waiters unmodified") + + // The task was cancelled. When completed it cleans up the tasks dictionary. + for i in 0..<100 { + try await Task.sleep(nanoseconds: 10_000_000) // let the runtime process the Swift task completion. + let infoAfter2 = taskManager.taskInfoForUniqueTask(taskId: "work", continuationBox: continuationBox) + try #require(infoAfter2 == nil || i < 100) + } + } + + /// W1b — Unique Task, Continuation ID Does NOT Match Owner + /// + /// ```gherkin + /// GIVEN: tasks = { "unique-task": TaskValue(id: 3, task: liveTask, waiters: .unique(ownerId: 42, box: cb(42))) } + /// WHEN: cancelContinuation(withId: 99) is called (miss!) + /// THEN: return false + /// AND: task.isCancelled == false (task untouched) + /// AND: waiter state unchanged (.unique(ownerId: 42, ...)) + /// ``` + @Test func w1b_uniqueIdMismatchReturnsFalse() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + let continueWork = Promise() + + // GIVEN: unique task with ownerId=42 + let continuationBox = ContinuationBox(with: 42) + var ownership: TaskOwnership = .caller(continuationBox) + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + try await started.await(timeout: 1_000_000_000) + + #expect(taskManager.taskInfoForUniqueTask(taskId: "work", continuationBox: continuationBox)?.waiters.isUnique == true) + + // WHEN: cancelContinuation(withId: 99) — does NOT match owner id=42 + let result = taskManager.cancelContinuation(withId: 99) + + // THEN: return false, task untouched + #expect(result == false) + #expect(taskManager.taskInfoForUniqueTask(taskId: "work", continuationBox: continuationBox)?.isCancelled == false) + continueWork.succeed() + } + + /// P4 — Shareable with Multiple Subs (one removed) + /// + /// ```gherkin + /// GIVEN: tasks = { "share-task": TaskValue(id: 5, task: liveTask, waiters: .shareable([cb(10), cb(20), cb(30)])} + /// WHEN: cancelContinuation(withId: 20) is called (remove the middle subscriber) + /// THEN: return true + /// AND: liveTask.isCancelled == false (task survives!) + /// AND: waiters mutated to .shareable([cb(10), cb(30)]) (count goes from 3 -> 2) + /// ``` + @Test func p4_shareableRemoveMiddleSubscriber() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + let continueWork = Promise() + + // GIVEN: shareable task with 3 subscribers [10, 20, 30] + let continuationBox1 = ContinuationBox(with: 10) + var ownership1: TaskOwnership = .runtime(continuationBox1) + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownership1, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + try await started.await(timeout: 1_000_000_000) + + // Add a second subscriber by calling addTask again with same identifier. + var ownership2: TaskOwnership = .runtime(ContinuationBox(with: 20)) + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownership2, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + Issue.record("should NOT start because the task already exists") + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + + // Add a third subscriber by calling addTask again with same identifier. + var ownership3: TaskOwnership = .runtime(ContinuationBox(with: 30)) + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownership3, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + Issue.record("should NOT start because the task already exists") + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + + // Ownership has been consumed by the addTask function. Thus, we + // do not have a continuation anymore - and hence, id == `nil`: + #expect(ownership1.id == nil) + #expect(ownership2.id == nil) + #expect(ownership3.id == nil) + + // Verify all 3 waiters are attached + let infoBefore = taskManager.taskInfo(for: "work") + #expect(infoBefore?.waiters.isShareable == true) + #expect((infoBefore?.waiters.continuations.map(\.id).sorted()) == [10, 20, 30]) + + // WHEN: cancelContinuation(withId: 20) — remove middle subscriber + let result = taskManager.cancelContinuation(withId: 20) + + // THEN: return true, task survives, waiters → [10, 30] + let infoAfter = taskManager.taskInfo(for: "work") + #expect(result == true) + #expect(infoAfter?.isCancelled == false, "Task must survive — only one subscriber removed") + let remaining = infoAfter?.waiters.continuations.map(\.id).sorted() + #expect(remaining == [10, 30]) + + continueWork.succeed() + } + + /// P5 — Shareable with Single Sub Removed → Empty Waiters + /// + /// ```gherkin + /// GIVEN: tasks = { "single-sub": TaskValue(id: 6, task: liveTask, waiters: .shareable([cb(77)])} + /// WHEN: cancelContinuation(withId: 77) is called (remove the only subscriber) + /// THEN: return true + /// AND: liveTask.isCancelled == false (task MUST survive per confirmed semantics!) + /// AND: waiters mutated to .shareable([]) (empty but dict entry still exists) + /// ``` + @Test func p5_shareableSingleSubRemovedTaskIsNotCancelled() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + let continueWork = Promise() + + // GIVEN: shareable task with ONE subscriber [77] + var ownership: TaskOwnership = .runtime(ContinuationBox(with: 77)) + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + try await started.await(timeout: 1_000_000_000) + + let infoBefore = taskManager.taskInfo(for: "work") + #expect(infoBefore?.waiters.isShareable == true) + #expect(infoBefore?.waiters.continuations.map(\.id) == [77]) + + // WHEN: cancelContinuation(withId: 77) — remove the only subscriber + let result = taskManager.cancelContinuation(withId: 77) + + // THEN: return true, task MUST survive (per confirmed semantics), waiters → [] + let infoAfter = taskManager.taskInfo(for: "work") + #expect(result == true) + #expect(infoAfter?.isCancelled == false, "Task MUST survive per semantics") + if case .shareable(let boxes) = infoAfter?.waiters { + #expect(boxes.isEmpty == true, "Waiter list should be empty") + } else { + Issue.record("Expected shareable([])") + } + continueWork.succeed() + } + + /// R_empty — Shareable with Empty Waiter List + /// + /// ```gherkin + /// GIVEN: tasks = { "empty-share": TaskValue(id: 7, task: liveTask, waiters: .shareable([])} + /// WHEN: cancelContinuation(withId: 999) is called + /// THEN: return false (no match in empty list) + /// ``` + @Test func r_empty_shareableEmptyReturnsFalse() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + let continueWork = Promise() + + // GIVEN: shareable task with zero waiters (no continuation provided) + var ownership: TaskOwnership = .runtime(nil) + try taskManager.addTask( + identifier: "work", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &ownership, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + try await started.await(timeout: 1_000_000_000) + + // Verify zero waiters + let infoBefore = taskManager.taskInfo(for: "work") + #expect(infoBefore?.waiters.isShareable == true) + #expect(infoBefore?.waiters.continuations.isEmpty == true) + + // WHEN: cancelContinuation(withId: 999) — nothing to remove + let result = taskManager.cancelContinuation(withId: 999) + + // THEN: return false (no match in empty list) + #expect(result == false) + continueWork.succeed() + } + + // MARK: - Phase 2: Cross-Entry Combinations (multi-task dicts) + + /// R0 — Target in First Entry (of 2) + /// + /// ```gherkin + /// GIVEN: tasks = { "first": TaskValue(id: 1, taskA, waiters: .shareable([cb(50)])), + /// "second": TaskValue(id: 2, taskB, waiters: .unique(ownerId: 60, box: cb(60))) } + /// WHEN: cancelContinuation(withId: 50) is called (first entry's subscriber) + /// THEN: return true + /// AND: taskA.isCancelled == false + /// AND: "first" waiters → .shareable([]) + /// AND: "second" untouched + /// ``` + @Test func r0_targetInFirstOfTwo() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let firstStarted = Promise() + let secondStarted = Promise() + let continueWork = Promise() + + // GIVEN: two tasks — "first" has 1 sub, "second" has 1 sub (unique owner) + var firstOw: TaskOwnership = .runtime(ContinuationBox(with: 50)) + try taskManager.addTask( + identifier: "first", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &firstOw, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + firstStarted.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + try await firstStarted.await(timeout: 1_000_000_000) + + var secondOw: TaskOwnership = .runtime(ContinuationBox(with: 60)) + try taskManager.addTask( + identifier: "second", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &secondOw, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + secondStarted.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + } + ) + try await secondStarted.await(timeout: 1_000_000_000) + + let infoFirstBefore = taskManager.taskInfo(for: "first") + let infoSecondBefore = taskManager.taskInfo(for: "second") + #expect(infoFirstBefore?.waiters.continuations.map(\.id) == [50]) + #expect(infoSecondBefore?.waiters.continuations.map(\.id) == [60]) + + // WHEN: cancelContinuation(withId: 50) — target is in first entry + let result = taskManager.cancelContinuation(withId: 50) + + // THEN: return true, first task untouched (task survives), waiters → [] + #expect(result == true) + let infoFirstAfter = taskManager.taskInfo(for: "first") + #expect(infoFirstAfter?.isCancelled == false) + if case .shareable(let boxes) = infoFirstAfter?.waiters { + #expect(boxes.isEmpty == true) + } + // second untouched — still has its waiter + let infoSecondAfter = taskManager.taskInfo(for: "second") + #expect(infoSecondAfter?.waiters.continuations.map(\.id) == [60]) + + continueWork.succeed() + } + + /// R1 — Target in Second Entry (of 2) + /// + /// ```gherkin + /// GIVEN: tasks = { "first": TaskValue(id: 3, taskC, waiters: .unique(ownerId: 40, box: cb(40))), + /// "second": TaskValue(id: 4, taskD, waiters: .shareable([cb(80)])} + /// WHEN: cancelContinuation(withId: 80) is called (second entry's subscriber) + /// THEN: return true + /// AND: taskD.isCancelled == false + /// AND: "second" waiters → .shareable([]) + /// AND: "first" untouched + /// ``` + @Test func r1_targetInSecondOfTwo() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let firstStarted = Promise() + let secondStarted = Promise() + let continueWork = Promise() + + // GIVEN: "first" has unique owner 40, "second" has shareable [80] + var firstOw: TaskOwnership = .runtime(ContinuationBox(with: 40)) + try taskManager.addTask( + identifier: "first", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &firstOw, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + firstStarted.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + } + ) + try await firstStarted.await(timeout: 1_000_000_000) + + var secondOw: TaskOwnership = .runtime(ContinuationBox(with: 80)) + try taskManager.addTask( + identifier: "second", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &secondOw, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + secondStarted.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + } + ) + try await secondStarted.await(timeout: 1_000_000_000) + + // WHEN: remove(continuationWith: 80) — target is in second entry + let result = taskManager.cancelContinuation(withId: 80) + + // THEN: return true, second task survives (task not cancelled), waiters → [] + #expect(result == true) + let infoSecond = taskManager.taskInfo(for: "second") + #expect(infoSecond?.isCancelled == false) + if case .shareable(let boxes) = infoSecond?.waiters { + #expect(boxes.isEmpty == true) + } + // first untouched — still has its waiter + let stillFirst = taskManager.taskInfo(for: "first") + #expect(stillFirst?.waiters.continuations.map(\.id) == [40]) + continueWork.succeed() + } + + /// R2 — No Match in Multi-Entry Dict + /// + /// ```gherkin + /// GIVEN: tasks = { + /// "alpha": TaskValue(id: 10, task: tA, waiters: .anon), + /// "beta": TaskValue(id: 11, task: tB, waiters: .unique(ownerId: 99, box: cb(99))) + /// } + /// WHEN: cancelContinuation(withId: 42) is called (not in any entry) + /// THEN: return false + /// AND: BOTH tasks untouched (no cancellation, no waiter mutation) + /// ``` + @Test func r2_noMatchInMultiEntryDict() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let started1 = Promise() + let started2 = Promise() + let continueWork = Promise() + + // GIVEN: two tasks — "alpha" is anonymous, "beta" has unique owner 99 + var owo1: TaskOwnership = .runtime(nil) + try taskManager.addTask( + identifier: nil, + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &owo1, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started1.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + + var owo2: TaskOwnership = .runtime(ContinuationBox(with: 99)) + try taskManager.addTask( + identifier: "beta", + event: .start, + taskAdditionPolicy: .shareable, + taskOwnership: &owo2, + priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started2.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + try await started1.await(timeout: 500_000_000) + try await started2.await(timeout: 500_000_000) + + // WHEN: cancelContinuation(withId: 42) — not in any entry + let result = taskManager.cancelContinuation(withId: 42) + + // THEN: return false, BOTH tasks untouched + #expect(result == false) + #expect(taskManager.taskInfo().count == 2) + // Neither task should have been cancelled + for info in taskManager.taskInfo() { + #expect(info.isCancelled == false) + } + + continueWork.succeed() + } + + /// R3 — Multiple Shareable Subs Across Two Tasks (remove from first) + /// + /// ```gherkin + /// GIVEN: tasks = { + /// "t1": TaskValue(id: 20, task: tX, waiters: .shareable([cb(1), cb(2)])), + /// "t2": TaskValue(id: 21, task: tY, waiters: .shareable([cb(3), cb(4)])) + /// } + /// WHEN: cancelContinuation(withId: 1) is called + /// THEN: return true + /// AND: "t1" waiters becomes .shareable([cb(2)]) + /// AND: "t2" untouched + /// ``` + @Test func r3_removeFromFirstShareable() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let t1Started = Promise() + let t2Started = Promise() + let continueWork = Promise() + + // GIVEN: "t1" [1, 2], "t2" [3, 4] + var o1t1: TaskOwnership = .runtime(ContinuationBox(with: 1)) + try taskManager.addTask( + identifier: "t1", event: .start, taskAdditionPolicy: .shareable, + taskOwnership: &o1t1, priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + t1Started.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + try await t1Started.await(timeout: 500_000_000) + + // Add second waiter to "t1" → [1, 2] + var o2t1: TaskOwnership = .runtime(ContinuationBox(with: 2)) + try taskManager.addTask( + identifier: "t1", event: .start, taskAdditionPolicy: .shareable, + taskOwnership: &o2t1, priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + + var o1t2: TaskOwnership = .runtime(ContinuationBox(with: 3)) + try taskManager.addTask( + identifier: "t2", event: .start, taskAdditionPolicy: .shareable, + taskOwnership: &o1t2, priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + t2Started.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + try await t2Started.await(timeout: 500_000_000) + + var o2t2: TaskOwnership = .runtime(ContinuationBox(with: 4)) + try taskManager.addTask( + identifier: "t2", event: .start, taskAdditionPolicy: .shareable, + taskOwnership: &o2t2, priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + + let infoT1Before = taskManager.taskInfo(for: "t1") + let infoT2Before = taskManager.taskInfo(for: "t2") + #expect(infoT1Before?.waiters.continuations.map(\.id).sorted() == [1, 2]) + #expect(infoT2Before?.waiters.continuations.map(\.id).sorted() == [3, 4]) + + // WHEN: cancelContinuation(withId: 1) from first + let result = taskManager.cancelContinuation(withId: 1) + + // THEN: return true, t1 → [2], t2 untouched + let infoT1After = taskManager.taskInfo(for: "t1") + let infoT2After = taskManager.taskInfo(for: "t2") + #expect(result == true) + #expect(infoT1After?.isCancelled == false) + #expect(infoT1After?.waiters.continuations.map(\.id).sorted() == [2]) + #expect(infoT2After?.waiters.continuations.map(\.id) == [3, 4]) + + continueWork.succeed() + } + + /// R4 — Multiple Shareable Subs Across Two Tasks (remove from second) + /// + /// ```gherkin + /// GIVEN: tasks = { + /// "t1": TaskValue(id: 30, task: tP, waiters: .shareable([cb(5)])), + /// "t2": TaskValue(id: 31, task: tQ, waiters: .shareable([cb(6), cb(7)])) + /// } + /// WHEN: cancelContinuation(withId: 6) is called + /// THEN: return true + /// AND: "t2" waiters becomes .shareable([cb(7)]) + /// AND: "t1" untouched + /// ``` + @Test func r4_removeFromSecondShareable() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let t1Started = Promise() + let t2aStarted = Promise() + let t2bStarted = Promise() + let continueWork = Promise() + + + // GIVEN: "t1" [5], "t2" [6, 7] + var oT1: TaskOwnership = .runtime(ContinuationBox(with: 5)) + try taskManager.addTask( + identifier: "t1", event: .start, taskAdditionPolicy: .shareable, + taskOwnership: &oT1, priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + t1Started.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + try await t1Started.await(timeout: 500_000_000) + + var oT2a: TaskOwnership = .runtime(ContinuationBox(with: 6)) + try taskManager.addTask( + identifier: "t2", event: .start, taskAdditionPolicy: .shareable, + taskOwnership: &oT2a, priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + t2aStarted.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + try await t2aStarted.await(timeout: 500_000_000) + + var oT2b: TaskOwnership = .runtime(ContinuationBox(with: 7)) + try taskManager.addTask( + identifier: "t2", event: .start, taskAdditionPolicy: .shareable, + taskOwnership: &oT2b, priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + t2bStarted.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + + let infoT1Before = taskManager.taskInfo(for: "t1") + let infoT2Before = taskManager.taskInfo(for: "t2") + #expect(infoT1Before?.waiters.continuations.map(\.id) == [5]) + #expect(infoT2Before?.waiters.continuations.map(\.id).sorted() == [6, 7]) + + // WHEN: cancelContinuation(withId: 6) from second + let result = taskManager.cancelContinuation(withId: 6) + + // THEN: return true, t1 untouched → [5], t2 → [7] + let infoT1After = taskManager.taskInfo(for: "t1") + let infoT2After = taskManager.taskInfo(for: "t2") + #expect(result == true) + #expect(infoT1After?.waiters.continuations.map(\.id) == [5]) + #expect(infoT2After?.isCancelled == false) + #expect(infoT2After?.waiters.continuations.map(\.id) == [7]) + + continueWork.succeed() + } + + /// R5 — Triple Entry: Target in Third (last) Entry + /// + /// ```gherkin + /// GIVEN: tasks = { + /// "a": TaskValue(id: 40, task: tA, waiters: .unique(ownerId: 100, box: cb(100))), + /// anon: TaskValue(id: 41, task: tB, waiters: .anon), + /// "c": TaskValue(id: 42, task: tC, waiters: .shareable([cb(42)])) + /// } + /// WHEN: cancelContinuation(withId: 42) is called (only in third entry!) + /// THEN: return true + /// AND: "c" waiters becomes .shareable([]) + /// AND: taskC.isCancelled == false + /// AND: "a", "b" completely untouched + /// ``` + @Test func r5_targetInThirdEntryOfThree() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let aStarted = Promise() + let bStarted = Promise() + let cStarted = Promise() + let continueWork = Promise() + + // GIVEN: "a" unique-owner 100, "b" anonymous, "c" shareable [42] + var owa: TaskOwnership = .caller(ContinuationBox(with: 100)) + let owaId = taskManager.nextTaskId + try taskManager.addTask( + identifier: "a", event: .start, taskAdditionPolicy: .shareable, + taskOwnership: &owa, priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + aStarted.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + + var owb: TaskOwnership = .runtime(nil) + let owbId = taskManager.nextTaskId + try taskManager.addTask( + identifier: nil, event: .start, taskAdditionPolicy: .shareable, + taskOwnership: &owb, priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + bStarted.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + + var owc: TaskOwnership = .runtime(ContinuationBox(with: 42)) + let owcId = taskManager.nextTaskId + try taskManager.addTask( + identifier: "c", event: .start, taskAdditionPolicy: .shareable, + taskOwnership: &owc, priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + cStarted.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + }) + + // Wait for all three tasks to start + try await aStarted.await(timeout: 500_000_000) + try await bStarted.await(timeout: 500_000_000) + try await cStarted.await(timeout: 500_000_000) + + #expect(taskManager.taskInfo().count == 3) + + // Verify setups (a=unique, b=anon, c=shareable [42]) + let infoABefore = try #require(taskManager.taskInfo(id: owaId)) + let infoBBefore = try #require(taskManager.taskInfo(id: owbId)) + let infoCBefore = try #require(taskManager.taskInfo(id: owcId)) + #expect(infoABefore.waiters.isUnique == true) + #expect(infoBBefore.waiters.isAnon == true) + #expect(infoCBefore.waiters.continuations.map(\.id) == [42]) + + // WHEN: cancelContinuation(withId: 42) — only in third entry! + let result = taskManager.cancelContinuation(withId: 42) + + // THEN: return true, "c" waiters → [], c unaffected (task not cancelled) + let infoAAfter = try #require(taskManager.taskInfo(id: owaId)) + let infoBAfter = try #require(taskManager.taskInfo(id: owbId)) + let infoCAfter = try #require(taskManager.taskInfo(id: owcId)) + #expect(infoAAfter.isCancelled == false) + #expect(infoBAfter.isCancelled == false) + #expect(result == true) + if case .shareable(let boxes) = infoCAfter.waiters { + #expect(boxes.isEmpty == true) + } else { + Issue.record("Expected shareable([])") + } + + // "a", "b" completely untouched — still running, no cancellation + try await Task.sleep(nanoseconds: 100_000_000) + let infoAAfter2 = try #require(taskManager.taskInfo(id: owaId), "Task 'a' must still exist") + let infoBAfter2 = try #require(taskManager.taskInfo(id: owbId), "Task 'b' must still exist") + #expect(infoAAfter2.isCancelled == false) + #expect(infoBAfter2.isCancelled == false) + + // Count unchanged at 3 (entry for "c" persists with empty waiters) + #expect(taskManager.taskInfo().count == 3) + + continueWork.succeed() + } + + // MARK: - Phase 3: Edge Cases + + /// EDGE-2 — Call remove() twice on same continuation ID → second call returns false + /// + /// ```gherkin + /// GIVEN: tasks = { "once": TaskValue(id: 50, task: tZt, waiters: .shareable([cb(7)]))} + /// WHEN: cancelContinuation(withId: 7) called → returns true, waiters becomes .shareable([]) + /// AND: cancelContinuation(withId: 7) called again on same dict entry + /// THEN: second call returns false (cb(7) no longer in the now-empty list) + /// ``` + @Test func edge2_callRemoveTwiceReturnsFalseSecondTime() async throws { + let runtime = Self.makeRuntime() + let taskManager = runtime.taskManager + let started = Promise() + let continueWork = Promise() + + // GIVEN: shareable [7] + var ow: TaskOwnership = .runtime(ContinuationBox(with: 7)) + try taskManager.addTask( + identifier: "work", event: .start, taskAdditionPolicy: .shareable, + taskOwnership: &ow, priority: nil, + nonsendingOperationOptionalEvent: { _, _ in + started.succeed() + try await continueWork.await(timeout: 10_000_000_000) + return nil + } + ) + try await started.await(timeout: 500_000_000) + + let info = taskManager.taskInfo(for: "work") + #expect(info?.waiters.continuations.map(\.id) == [7]) + + // WHEN: first remove → true, waiters → [] + let result1 = taskManager.cancelContinuation(withId: 7) + #expect(result1 == true) + + // AND second remove on same ID → false (cb(7) no longer in now-empty list) + let result2 = taskManager.cancelContinuation(withId: 7) + + // THEN: second call returns false + #expect(result2 == false) + #expect(taskManager.taskInfo(for: "work")?.waiters.continuations.isEmpty == true, + "Waiter list must remain empty after first removal") + + continueWork.succeed() + } +} + +extension TaskManagerTests.RequestTests { + + // Early Cancellation + // Scenario: early cancellation of a request call will throw with CancellatinError + // GIVEN a Transducer with State=idle, Events={start, fetch, done}, Response={none, ok} + // and the transduce function: + // start -> .action { await short sleep; emit .done } + // done -> .none + // and the response function: event==.done → .ok + // WHEN send(.start) is called , and then request(.fetch) is called on the runtime input + // request task is immediately cancelled afterwards + // THEN the request throw a CanellationError + // AND the action's side-effect (env.finished) may or may not start, + // depending on whether the event could have been sent into the transducer. + // Note: `request` calls an unsructured task to dispatch the event to the + // transucer. It's not deterministic when this task will be called. + @Test func testEarlyCancellation() async throws { + enum T: Transducer { + enum State { case idle } + enum Event: Sendable { case start, fetch, done } + enum Response: Sendable, Equatable { case none, ok } + struct Env: Sendable { let didStart = Promise(); let continueWorkStart = Promise() } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: return .action { env in + env.didStart.succeed() + // blocks the computation in start + try await env.continueWorkStart.await(timeout: 10_000_000_000) + } + case .fetch: return .action { env in + return .done + } + case .done: return .none + } + } + static func response(state: State, event: Event) -> Response { + event == .done ? .ok : .none + } + static let initialState: State = .idle + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + // start the system + Task { + await #expect(throws: Never.self) { + // suspends until env.continueWorkStart is fulfilled + try await input.send(.start) + } + } + try await env.didStart.await(timeout: 10_000_000_000) + let requestTaskDidStart = Promise() + let requestTask = Task { + await #expect(throws: CancellationError.self) { + requestTaskDidStart.succeed() + let response = try await input.request(.start) + // should never reach here + print(response) + } + } + try await requestTaskDidStart.await(timeout: 10_000_000_000) + try await Task.sleep(nanoseconds: 10_000_000) // allow `try await input.request(.start)` to be called + requestTask.cancel() + env.continueWorkStart.succeed() + _ = await requestTask.value + } +} + +extension TaskManagerTests.UniqueRequestTests { + + /// Scenario: uniqueRequest drives effect chain to completion and returns response + /// GIVEN a Transducer with State=idle, Events={start, done}, Response={none, ok} + /// and the transduce function: + /// start -> .action { await short sleep; emit .done } + /// done -> .none + /// and the response function: event==.done → .ok + /// WHEN uniqueRequest(.start) is called on the runtime input + /// THEN the effect chain runs: start → action → done + /// AND the caller receives Response.ok + /// AND the action's side-effect (env.finished) completes + /// + /// validates that `uniqueRequest` successfully drives an effect chain to completion, + /// invokes the transducer's response function, and resolves the caller's await correctly. + @Test func testUniqueRequestCompletesChainAndReturnsResponse() async throws { + enum T: Transducer { + enum State { case idle } + enum Event: Sendable { case start, done } + enum Response: Sendable, Equatable { case none, ok } + struct Env: Sendable { let finished = Promise()} + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: return .action { env in + try? await Task.sleep(nanoseconds: 10_000) + env.finished.fulfill() + return .done + } + case .done: return .none + } + } + static func response(state: State, event: Event) -> Response { + event == .done ? .ok : .none + } + static let initialState: State = .idle + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + + let response = try await input.uniqueRequest(.start) + #expect(response == .ok) + try await env.finished.await(timeout: 1_000_000_000) + } + + /// Scenario: early uniqueRequest cancellation preserves runtime health + /// GIVEN a Transducer with State=idle, Events={start, done}, Env with workStarted=Promise + /// and transduce function: start → .task { fulfill workStarted; sleep(long) } + /// WHEN uniqueRequest(.start) is called inside a Task + /// AND the task's continuation has started (workStarted fulfilled) + /// AND requestTask.cancel() is called before the action completes + /// THEN the caller receives CancellationError + /// AND the continuation is unregistered via taskManager.remove() + /// AND the runtime remains in an active health state (not corrupted or hung) + /// + /// Validates that cancelling a `uniqueRequest` waiter before the effect chain settles + /// triggers the cancellation handler, properly unregisters (removes) the continuation + /// via `taskManager.remove()`, and leaves the runtime in a healthy, active state. + @Test func testUniqueRequestCancellationUnregistersContinuationAndPreservesRuntime() async throws { + enum T: Transducer { + enum State { case idle } + enum Event: Sendable { case start, done } + struct Env: Sendable { let workStarted = Promise() ; let waitContinue = Promise() } + enum Response { case none, done } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .start: return .task { input, env in + env.workStarted.fulfill() + await #expect(throws: Never.self) { + try await env.waitContinue.await(timeout: 10_000_000_000) // Prolonged work - should not throw + } + return .done + } + case .done: return .none + } + } + static func response(state: State, event: Event) -> Response { + switch event { + case .done: return .done + default: return .none + } + } + static let initialState: State = .idle + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + + let requestTask = Task { + try await input.uniqueRequest(.start) + } + + // Wait for work to actually start inside the isolated compute path + try await env.workStarted.await(timeout: 10_000_000_000) + + // Cancel the caller *after* it entered but *before* effects settle. + // This explicitly exercises the `onCancel` closure that calls taskManager.remove(). + requestTask.cancel() + env.waitContinue.succeed() + + do { + _ = try await requestTask.value + Issue.record("uniqueRequest should throw CancellationError") + } catch is CancellationError { + // Expected: cancellation propagated to the caller + } + + // THEN: verify unsubscription succeeded and did not corrupt runtime state or hang waiters + // 1. Runtime must remain active — cancellation cleanup should NOT disable it. + switch runtime.taskManager.state { + case .active: break // ✅ passes below + default: + Issue.record("Runtime task manager is not active (state: \(runtime.taskManager.state))") + } + } + + /// Validates that `uniqueRequest` establishes exclusive context isolation, verifying it + /// does not share waiters or continuations with standard `request` calls made concurrently. + @Test func testUniqueRequestMaintainsIsolationFromStandardRequestsWithAction() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case ping(Int), processed(Int) } + enum Response: Equatable { case none, pending(Int), pong(Int) } + struct Env { } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .ping(let value): return .action { env in + // print("ping(value: \(value))") + // try await Task.sleep(nanoseconds: 10_000) + return .processed(value) + } + case .processed: return .none + } + } + static func response(state: State, event: Event) -> Response { + switch event { + case .ping(let value): .pending(value) + case .processed(let value): .pong(value) + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + + // Note: action is serialized on compute! + + // Drive both request types concurrently to the same event + async let uniqueComp1Request = input.uniqueRequest(.ping(1)) + async let uniqueComp2Request = input.uniqueRequest(.ping(2)) + async let uniqueComp3Request = input.uniqueRequest(.ping(3)) + async let standardCompRequest = input.request(.ping(4)) + + let uniqueComp1Response = try await uniqueComp1Request + let uniqueComp2Response = try await uniqueComp2Request + let uniqueComp3Response = try await uniqueComp3Request + let standardCompResponse = try await standardCompRequest + + #expect(uniqueComp1Response == .pong(1)) + #expect(uniqueComp2Response == .pong(2)) + #expect(uniqueComp3Response == .pong(3)) + #expect(standardCompResponse == .pong(4)) + } + + + /// Validates that `uniqueRequest` establishes exclusive context isolation, verifying it + /// does not share waiters or continuations with standard `request` calls made concurrently. + @Test func testUniqueRequestMaintainsIsolationFromStandardRequestsWithTask() async throws { + enum T: Transducer { + enum State: DefaultInitializable { case idle; init() { self = .idle } } + enum Event: Sendable { case ping(Int), processed(Int) } + enum Response: Equatable { case none, pending(Int), pong(Int) } + struct Env { } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .ping(let value): return .task { input, env in + // print("ping(value: \(value))") + // try await Task.sleep(nanoseconds: 10_000_000) + return .processed(value) + } + case .processed: return .none + } + } + static func response(state: State, event: Event) -> Response { + switch event { + case .ping(let value): .pending(value) + case .processed(let value): .pong(value) + } + } + } + + let env = T.Env() + let runtime = GlobalActorRuntime(transducer: T.self, on: MainActor.self, env: env) + let input = runtime.input + + // Note: task run in parallel! + + // Drive both request types concurrently to the same event + async let uniqueComp1Request = input.uniqueRequest(.ping(1)) + async let uniqueComp2Request = input.uniqueRequest(.ping(2)) + async let uniqueComp3Request = input.uniqueRequest(.ping(3)) + async let standardCompRequest = input.request(.ping(4)) + + let uniqueComp1Response = try await uniqueComp1Request + let uniqueComp2Response = try await uniqueComp2Request + let uniqueComp3Response = try await uniqueComp3Request + let standardCompResponse = try await standardCompRequest + + #expect(uniqueComp1Response == .pong(1)) + #expect(uniqueComp2Response == .pong(2)) + #expect(uniqueComp3Response == .pong(3)) + #expect(standardCompResponse == .pong(4)) + } + +} + diff --git a/Tests/Transduce/TransducerHostTests.swift b/Tests/Transduce/TransducerHostTests.swift new file mode 100644 index 0000000..68172b4 --- /dev/null +++ b/Tests/Transduce/TransducerHostTests.swift @@ -0,0 +1,310 @@ +import Testing +@testable import Transduce + +private enum _TestTransducer: Transducer { + struct State: Equatable { var count: Int = 0 } + static let initialState: State = .init() + + enum Event { + case increment + case requestCount + } + + struct Env: Sendable { let prefix: String = "test" } + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .increment: + state.count += 1 + return .none + case .requestCount: + return .action { _ in } + } + } + + static func response(state: State, event: Event) -> Int { + return state.count + } +} + +@Suite +final class TransducerHostTests { + + // MARK: - Initialization Tests + + /// - Given: a new TransducerHost + /// - When: initialized with state and environment + /// - Then: should be properly created + @Test + func initialization() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + let state = try await host.peak + print(state) + + #expect(try await host.peak == _TestTransducer.initialState) + #expect(try await host.state == _TestTransducer.initialState) + } + + // MARK: - Type Alias Tests + + /// - Given: a TransducerHost + /// - When: accessing type aliases via the type itself + /// - Then: should match the underlying transducer types + @Test func typeAliases() async throws { + let _: _TestTransducer.Env = TransducerHost<_TestTransducer, MainActor>.Env() + let _: _TestTransducer.Event = TransducerHost<_TestTransducer, MainActor>.Event.increment + let _: _TestTransducer.State = TransducerHost<_TestTransducer, MainActor>.State() + let _: Int = TransducerHost<_TestTransducer, MainActor>.Response() + } + + // MARK: - Input Tests + + /// - Given: a TransducerHost + /// - When: accessing the input property + /// - Then: should return a valid Input + @Test func inputProperty() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + let input = host.input + try await input.send(.increment) + #expect(try await host.state == _TestTransducer.State(count: 1)) + } + + // MARK: - Send Tests + + /// - Given: a TransducerHost + /// - When: calling send with an event + /// - Then: should process the event and await completion + @Test func sendEvent() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + try await host.send(.increment) + #expect(try await host.peak == .init(count: 1)) + } + + /// - Given: a TransducerHost + /// - When: calling send multiple times + /// - Then: should process all events sequentially + @Test func sendMultipleEvents() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + try await host.send(.increment) + try await host.send(.increment) + try await host.send(.increment) + + let response = try await host.request(.requestCount) + #expect(response == 3) + } + + // MARK: - Post Tests + + /// - Given: a TransducerHost + /// - When: calling post with an event + /// - Then: should dispatch the event without awaiting completion + @Test func postEvent() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + try await host.post(.increment) + try await host.post(.increment) + + var response: Int = 0 + for _ in 0..<100 { + response = try await host.request(.requestCount) + if response == 2 { break } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(response == 2) + } + + // MARK: - Request Tests + + /// - Given: a TransducerHost with state + /// - When: calling request for a response + /// - Then: should return the correct response value + @Test func requestResponse() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + try await host.send(.increment) + try await host.send(.increment) + + let response = try await host.request(.requestCount) + #expect(response == 2) + } + + /// - Given: a TransducerHost + /// - When: calling request on initial state + /// - Then: should return the response for initial state + @Test func requestOnInitialState() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + let response = try await host.request(.requestCount) + #expect(response == 0) + } + + // MARK: - UniqueRequest Tests + + /// - Given: a TransducerHost + /// - When: calling uniqueRequest for a response + /// - Then: should return the correct response value + @Test func uniqueRequestResponse() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + try await host.send(.increment) + + let response = try await host.uniqueRequest(.requestCount) + #expect(response == 1) + } + + /// - Given: a TransducerHost + /// - When: calling uniqueRequest multiple times + /// - Then: each should return current state + @Test func multipleUniqueRequests() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + let r0 = try await host.uniqueRequest(.requestCount) + #expect(r0 == 0) + + try await host.send(.increment) + let r1 = try await host.uniqueRequest(.requestCount) + #expect(r1 == 1) + } + + // MARK: - Cancel Tests + + /// - Given: a TransducerHost + /// - When: calling cancel without error + /// - Then: should not crash + @Test func cancelWithoutError() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + try await host.send(.increment) + + // Cancel with no error - should not crash + await host.cancel() + } + + /// - Given: a TransducerHost + /// - When: calling cancel with an error + /// - Then: should handle the error gracefully + @Test func cancelWithError() async throws { + enum TestError: Error { case cancelled } + + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + try await host.send(.increment) + + // Cancel with error - should not crash + await host.cancel(with: TestError.cancelled) + } + + // MARK: - Peak Property Tests + + /// - Given: a TransducerHost with incremented state + /// - When: accessing the peak property + /// - Then: should return the current state immediately + @Test func peakProperty() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + try await host.send(.increment) + try await host.send(.increment) + + let currentState = try await host.peak + #expect(currentState.count == 2) + } + + /// - Given: a TransducerHost in initial state + /// - When: accessing the peak property on initial state + /// - Then: should return the initial state + @Test func peakOnInitialState() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + let currentState = try await host.peak + #expect(currentState.count == 0) + } + + // MARK: - State Property Tests + + /// - Given: a TransducerHost with state changes + /// - When: accessing the state property + /// - Then: should return the state after compute cycle completes + @Test func stateProperty() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + try await host.send(.increment) + try await host.send(.increment) + try await host.send(.increment) + + let currentState = try await host.state + #expect(currentState.count == 3) + } + + /// - Given: a TransducerHost in initial state + /// - When: accessing the state property on initial state + /// - Then: should return the initial state + @Test func stateOnInitialState() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + let currentState = try await host.state + #expect(currentState.count == 0) + } + + /// - Given: a TransducerHost with pending operations + /// - When: accessing state property after send + /// - Then: should reflect the updated state + @Test func stateAfterSend() async throws { + let host = try TransducerHost<_TestTransducer, MainActor>( + initialState: .init(), + env: _TestTransducer.Env() + ) + + try await host.send(.increment) + + let currentState = try await host.state + #expect(currentState.count == 1) + } +} diff --git a/Tests/Transduce/TransducerObservableTests.swift b/Tests/Transduce/TransducerObservableTests.swift new file mode 100644 index 0000000..f399066 --- /dev/null +++ b/Tests/Transduce/TransducerObservableTests.swift @@ -0,0 +1,228 @@ +import Testing +import Observation +@testable import Transduce + +// MARK: - Test Fixture (private to this file) + +/// Minimal fixture type for the SUT — no dependencies on runtime behavior. +private enum _FixtureT: Transducer { + + struct State { var value: Int = 0 } + static let initialState: State = .init(value: 0) + + enum Event { + case increment + case reset + case env(value: Int) + } + + final class Env { + init(value: Int = 0) { self.value = value } + var value: Int + } + + typealias Response = Int + + static func transduce(_ state: inout State, event: Event) -> Effect { + switch event { + case .increment: + state.value += 1 + return .none + case .reset: + state.value = 0 + return .none + case .env(value: let value): + return .action { + $0.value = value + } + } + } + + static func response(state: State, event: Event) -> Response { 99 } +} + +// MARK: - Lifecycle / Deinit Tests + +/// The lifecycle obligation of `TransducerObservable` is that its storage chain does NOT create a strong reference cycle. +/// - It storage stores an unowned or weak host link. +/// - This prevents the storage bridge from keeping the host (the observable) alive. +/// +/// Test: Observable is deallocated when its last strong reference is released. + +@Suite @MainActor +final class TransducerObservableLifecycleTests { + + /// - Given an observable with a strong reference + /// - When the strong reference goes out of scope + /// - Then the observable should be deallocated + @Test func observableTearsDownWhenDropped() async throws { + weak var weakRef: TransducerObservable<_FixtureT>? + + do { + let strong = TransducerObservable<_FixtureT>( + initialState: _FixtureT.State(value: 0), + initialEnv: .init() + ) + weakRef = strong + } + #expect(weakRef == nil, "Observable should be deallocated when its last strong reference is dropped") + } + + /// - Given an observable with a valid input handle inside scope + /// - When the scope exits while keeping only the input weak reference + /// - Then the observable should be deallocated while input remains valid + @Test func inputDoesNotKeepObservableAlive() async throws { + weak var weakRef: TransducerObservable<_FixtureT>? + var input: BaseTransducerInput<_FixtureT>? + + do { + let obs = TransducerObservable<_FixtureT>( + initialState: _FixtureT.State(value: 10), + initialEnv: .init(value: 1) + ) + weakRef = obs + input = obs.input + } + #expect(weakRef == nil, "Observable should deallocate when scope dropped") + #expect(input != nil, "Input handle should still exist after observable deallocated") + let _ = input // hold a reference to input after checking weakRef + } +} + +// MARK: - Smoke Tests for Members + +/// Validate `init` sets `state` and `env` correctly. +@Suite @MainActor +final class TransducerObservableSmokeTests { + + /// - Given an observable initialized with a specific state and environment + /// - When an event is processed without mutating the state + /// - Then the observable's state remains the initially provided value + @Test func initialStateIsSetCorrectly() async throws { + let env = _FixtureT.Env(value: 1) + let obs = TransducerObservable<_FixtureT>( + initialState: _FixtureT.State(value: 10), + initialEnv: env + ) + #expect(obs.state.value == 10) + #expect(env.value == 1) + } + + /// Given an observable with an initial state + /// When an event that mutates state is sent + /// Then the observable's state reflects the mutation + @Test func stateCanBeMutable() async throws { + let obs = TransducerObservable<_FixtureT>( + initialState: _FixtureT.State(value: 42), + initialEnv: .init() + ) + + #expect(obs.state.value == 42) + _ = try await obs.send(_FixtureT.Event.increment) + #expect(obs.state.value == 43) + } + + /// Given a newly created TransducerObservable + /// When accessing its input handle + /// Then a non-nil BaseTransducerInput is produced + @Test func inputIsNonNil() async throws { + let obs = TransducerObservable<_FixtureT>( + initialState: _FixtureT.State(value: 0), + initialEnv: .init() + ) + + _ = obs.input + } +} + +// MARK: - State Transition Tests (verify state changes flow through the storage bridge) + +/// Increment and reset events produce correct state transitions. +@Suite @MainActor +final class TransducerObservableStateTransitionTests { + + /// Given an observable with initial state and environment + /// When multiple events are sent (increment, increment, reset) + /// Then the state transitions cumulatively and finally resets to 0 + @Test func sendMultipleEventsProducesCorrectState() async throws { + let obs = TransducerObservable<_FixtureT>( + initialState: _FixtureT.State(value: 10), + initialEnv: .init() + ) + + // First increment — should transition via the storage bridge. + await #expect(throws: Never.self) { + try await obs.send(_FixtureT.Event.increment) + } + + #expect(obs.state.value == 11, "First increment produced unexpected state") + + // Second increment — verify cumulative effect. + await #expect(throws: Never.self) { + try await obs.send(_FixtureT.Event.increment) + } + #expect(obs.state.value == 12, "Second increment produced unexpected state") + + // Reset — should clear the accumulated changes via the storage bridge. + await #expect(throws: Never.self) { + try await obs.send(_FixtureT.Event.reset) + } + #expect(obs.state.value == 0, "Reset did not set state to initial value") + } + + /// Given an observable and its input handle + /// When events are sent via the input (increment, increment, reset) + /// Then the observable's state reflects the changes and ultimately resets + @Test func inputEventsProduceStateChanges() async throws { + let obs = TransducerObservable<_FixtureT>( + initialState: _FixtureT.State(value: 0), + initialEnv: .init() + ) + + await #expect(throws: Never.self) { + try await obs.input.send(.increment) + try await obs.input.send(.increment) + } + #expect(obs.state.value == 2, "Value incremented via input handle") + + await #expect(throws: Never.self) { + try await obs.input.send(.reset) + } + #expect(obs.state.value == 0, "Reset cleared values added by input handle") + } + + /// Given an observable that computes a response from state + /// When an event is sent via request(_:) + /// Then the returned response returns `T.response(state, event)` + @Test func sendReturnsCorrectResponse() async throws { + let obs = TransducerObservable<_FixtureT>( + initialState: _FixtureT.State(value: 42), + initialEnv: .init() + ) + + let response = try await obs.input.request(_FixtureT.Event.increment) + #expect(response == 99, "Response should match value 99") + } + + /// Given an observable instance + /// When an event is posted to the runtime + /// Then no crash occurs during processing + @Test func postDoesNotCrash() async throws { + var obs: TransducerObservable<_FixtureT>? = TransducerObservable<_FixtureT>( + initialState: _FixtureT.State(value: 0), + initialEnv: .init() + ) + // The "issue" with `post` is that it can potentially escape the life-time + // of the object, because `post` does not keep a strong reference to the + // object. That is, the object might already be deinitialized when + // it eventually is accessed by the engine. This may lead to a crash. + // There are two different implementations - one which causes a crash + // when attempting to access a deinitialised object, and the other + // performs a check if the object is still alive before accessing it. + try obs?.post(_FixtureT.Event.increment) + obs = nil + // we allow the object to deallocate + await Task.yield() + } +} + diff --git a/Tests/Transduce/Utilities/Promise.swift b/Tests/Transduce/Utilities/Promise.swift new file mode 100644 index 0000000..626e4bb --- /dev/null +++ b/Tests/Transduce/Utilities/Promise.swift @@ -0,0 +1,346 @@ +import Mutex + +/// A thread-safe, `Promise` that coordinates completion across multiple concurrent tasks. +/// +/// `Promise` is a reference-counted fulfillment coordinator: callers request completion by calling +/// ``fulfill(_:)`` or ``succeed()``, and downstream consumers wait via ``await(timeout:)``. +/// The promise becomes fulfilled when the total number of fulfillments reaches the threshold specified +/// at initialization (default: 1). After reaching that threshold, each additional fulfillment is "consumed" +/// by waking one waiter — enabling a handoff pattern where overflow work is distributed to pending tasks. +/// +/// **Example** +/// ```swift +/// let promise = Promise() // waits for 1 fulfillment +/// +/// Task { await promise.await() } // blocks until fulfilled +/// promise.fulfill() // wakes the waiter above +/// ``` +public final class Promise: Sendable { + + /// Internal synchronization state shared with ``CounterPromise``. + struct State { + var promise: CounterPromise + var waiters: Set = [] + } + + /// Errors that completion is guarded by ``timeout`` or ``cancelled`` cases may throw. + public enum Error: Swift.Error, Equatable, Sendable { + /// The operation was cancelled by the surrounding task. + case cancelled + /// The await exceeded its timeout duration. + case timeout + } + + private let mutex: Mutex + + /// Initialize a ``Promise`` that requires `fulfilledAt` total fulfillments before becoming fulfilled. + /// + /// - Parameter fulfilledAt: The number of ``fulfill(_:)`` / ``succeed()`` calls needed (default 1). + public init(fulfilledAt: Int = 1) { + precondition(fulfilledAt >= 1) + self.mutex = .init(.init(promise: .init(fulfilledAt: fulfilledAt))) + } + + /// Add `count` to the current fulfillment tally and wake waiters as needed. + /// + /// If the cumulative count reaches the threshold, **all** pending ``await(timeout:)`` tasks are resumed + /// and any excess fulfillments are "consumed" for subsequent waits (effectively distributing overflow work). + /// + /// - Parameter count: Number of fulfillments to add (default 1). + public func fulfill(_ count: Int = 1) { + precondition(count >= 0) + mutex.withLock { state in + state.promise.fulfill(count: count) + if state.promise.state.isFulfilled { + _checkFulfillment(&state) + } + } + } + + /// Instantly fulfill the ``Promise`` to its threshold. + /// + /// Unlike ``fulfill(_:)``, this method immediately sets the tally to the target value, regardless of prior + /// fulfillment attempts — it is a quick way to "close" a promise. After this call the ``Promise`` fulfills + /// and all waiters resume. + public func succeed() { + mutex.withLock { state in + state.promise.succeed() + if state.promise.state.isFulfilled { + _checkFulfillment(&state) + } + } + } + + /// Whether the ``Promise`` has reached its fulfillment threshold. + public var isFulfilled: Bool { + return mutex.withLock { state in + state.promise.isFulfilled + } + } + + /// The target number of fulfillments needed to reach the fulfilled state (set at initialization). + public var fulfilledAt: Int { + return mutex.withLock { state in + state.promise.fulfilledAt + } + } + + /// The current number of fulfillments that have occurred. + public var fulfillmentCount: Int { + return mutex.withLock { state in + state.promise.fulfillmentCount + } + } + + private func _checkFulfillment(_ state: inout State) { + guard !state.waiters.isEmpty else { + return + } + state.promise.state.consumeOnce() + let waiters = state.waiters + state.waiters.removeAll() + for cont in waiters { + cont.succeed() + } + } + + + // MARK: - Future + + /// Await until the ``Promise`` fulfills, optionally with a timeout. + /// + /// When the cumulative fulfillments reach the target threshold, this coroutine resumes and any excess + /// fulfillments are preserved for subsequent waiters (handoff pattern). + /// + /// **Timeout behavior**: If `timeout` is greater than 0 and the ``Promise`` does not fulfill within + /// that duration, a ``error/timeout`` is thrown. + /// + /// - Parameter timeout: Timeout in wall-clock seconds (default 0 = no timeout). + /// - Throws: ``cancelled`` (task cancelled) or ``timeout``. + public func await(timeout: UInt64 = 0) async throws { + let cont = ContinuationBox() + do { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + mutex.withLock { state in + _ = state.waiters.insert(cont) + cont.initialize( + with: continuation, + timeout: timeout + ) + if state.promise.state.isFulfilled { + _checkFulfillment(&state) + } + } + } + } onCancel: { + cont.fail(with: CancellationError()) + } + } catch { + mutex.withLock { state in + _ = state.waiters.remove(cont) + } + throw error + } + } +} + +/// A lightweight, `Sendable` fulfillment counter used internally by ``Promise``. +/// +/// Tracks how many calls to ``CounterPromise/fulfill(count:)`` (or ``succeed()``) have occurred and +/// compares the total against an initial `fulfilledAt` threshold. Transitioning from ``state/start(fulfilledAt:)`` +/// to ``state/maybeFulfilled(fulfilledAt:count:)`` happens on first fulfillment; further calls accumulate. +struct CounterPromise: Sendable { + /// Current progression through the fulfill lifecycle. + var state: State + + /// Create a `CounterPromise` that requires `value` total fulfillments before being considered fulfilled. + /// + /// - Parameter value: The number of ``fulfill(_:)`` / ``succeed()`` calls needed (default 1). + init(fulfilledAt value: Int = 1) { + state = .start(fulfilledAt: value) + } + + enum State { + case start(fulfilledAt: Int) + case maybeFulfilled(fulfilledAt: Int, count: Int) + + mutating func fulfillOnce() { + switch self { + case .start(let fulfilledAt): + self = .maybeFulfilled(fulfilledAt: fulfilledAt, count: 1) + case .maybeFulfilled(let fulfilledAt, let count): + self = .maybeFulfilled(fulfilledAt: fulfilledAt, count: count + 1) + } + } + + mutating func fulfill(_ count: Int) { + switch self { + case .start(let fulfilledAt): + self = .maybeFulfilled(fulfilledAt: fulfilledAt, count: count) + case .maybeFulfilled(let fulfilledAt, let currentCount): + self = .maybeFulfilled(fulfilledAt: fulfilledAt, count: currentCount + count) + } + } + + mutating func succeed() { + guard !isFulfilled else { return } + switch self { + case .start(let fulfilledAt): + self = .maybeFulfilled(fulfilledAt: fulfilledAt, count: fulfilledAt) + case .maybeFulfilled(let fulfilledAt, _): + self = .maybeFulfilled(fulfilledAt: fulfilledAt, count: fulfilledAt) + } + } + + mutating func consumeOnce() { + switch self { + case .maybeFulfilled(fulfilledAt: let fulfilledAt, count: let count): + if count >= fulfilledAt { + self = .maybeFulfilled(fulfilledAt: fulfilledAt, count: count - fulfilledAt) + } else { + return + } + case .start: return + } + } + + var isFulfilled: Bool { + switch self { + case .maybeFulfilled(fulfilledAt: let fulfilledAt, count: let count) where count >= fulfilledAt: + return true + default: return false + } + } + + var fulfilledAt: Int { + switch self { + case .maybeFulfilled(fulfilledAt: let fulfilledAt, count: _), + .start(fulfilledAt: let fulfilledAt): + return fulfilledAt + } + } + + var fulfillmentCount: Int { + switch self { + case .maybeFulfilled(fulfilledAt: _, count: let count): + return count + case .start: return 0 + } + } + } + + var isFulfilled: Bool { + state.isFulfilled + } + + var fulfillmentCount: Int { + state.fulfillmentCount + } + + var fulfilledAt: Int { + state.fulfilledAt + } + + mutating func fulfillOnce() { + state.fulfillOnce() + } + + mutating func fulfill(count: Int) { + state.fulfill(count) + } + + mutating func succeed() { + state.succeed() + } + + mutating func consume() { + state.consumeOnce() + } +} + +import Foundation + +/// Boxed `CheckedContinuation` with optional timeout support. +/// +/// `ContinuationBox` wraps a ``CheckedContinuation`` that is safe to pass across task boundaries, +/// adding timeout and cancellation handling. It must **not** be used after its continuation has been resumed. +final class ContinuationBox: Identifiable, Hashable, @unchecked Sendable { + + typealias Continuation = CheckedContinuation + + struct State { + var continuation: Continuation? = nil + var cancelTask: Task? = nil + } + + let mutex: Mutex + let id: UUID = .init() + + /// Create an uninitialized box (use ``initialize(with:timeout:)`` before awaiting). + init() { + self.mutex = .init(.init()) + } + + /// Initialize the box with an existing continuation. + /// + /// - Parameters: + /// - continuation: The continuation to resume when the associated ``Promise`` fulfills. + init(continuation: Continuation) { + self.mutex = .init(.init(continuation: continuation)) + } + + deinit {} + + func initialize( + with continuation: Continuation, + timeout nanoseconds: UInt64 = 0 + ) { + mutex.withLock { state in + guard state.continuation == nil else { + fatalError("continuation already set") + } + state.continuation = continuation + _setTimeout(nanoseconds: nanoseconds, &state) + } + } + + func succeed() { + mutex.withLock { state in + guard let cont = state.continuation else { return } + state.continuation = nil + cont.resume() + } + } + + func fail(with error: any Swift.Error) { + mutex.withLock { state in + guard let cont = state.continuation else { + return + } + state.continuation = nil + cont.resume(throwing: error) + } + } + + static func == (lhs: ContinuationBox, rhs: ContinuationBox) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + + func _setTimeout(nanoseconds: UInt64, _ state: inout State) { + guard nanoseconds > 0 else { return } + state.cancelTask?.cancel() + state.cancelTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: nanoseconds) + self?.fail(with: Promise.Error.timeout) + } catch { + // nothing + } + } + } +} diff --git a/Tests/Transduce/Utilities/PromiseTest.swift b/Tests/Transduce/Utilities/PromiseTest.swift new file mode 100644 index 0000000..d514d1f --- /dev/null +++ b/Tests/Transduce/Utilities/PromiseTest.swift @@ -0,0 +1,72 @@ +import Testing + +struct PromiseTest { + + @Test func test() async throws { + let expect = Promise() + + #expect(expect.isFulfilled == false) + #expect(expect.fulfillmentCount == 0) + + await #expect(throws: Promise.Error.timeout) { + try await expect.await(timeout: 1000) + } + } + + + @Test func test2() async throws { + let expect = Promise() + Task { + expect.fulfill() + } + await #expect(throws: Never.self) { + try await expect.await(timeout: 1_000_000_000) + } + } + + + @Test func test3() async throws { + let expect = Promise(fulfilledAt: 2) + #expect(expect.fulfilledAt == 2) + #expect(expect.isFulfilled == false) + #expect(expect.fulfillmentCount == 0) + + do { + try await expect.await(timeout: 1_000) + } catch Promise.Error.timeout { + expect.fulfill() + #expect(expect.fulfilledAt == 2) + #expect(expect.isFulfilled == false) + #expect(expect.fulfillmentCount == 1) + } + + do { + try await expect.await(timeout: 1_000) + } catch Promise.Error.timeout { + expect.fulfill() + #expect(expect.fulfilledAt == 2) + #expect(expect.isFulfilled == true) + #expect(expect.fulfillmentCount == 2) + } + + await #expect(throws: Never.self) { + try await expect.await(timeout: 1_000) + #expect(expect.fulfilledAt == 2) + #expect(expect.isFulfilled == false) + #expect(expect.fulfillmentCount == 0) + } + } + + + @Test func test4() async throws { + let expect = Promise() + let task = Task { + await #expect(throws: CancellationError.self) { + try await expect.await(timeout: 1_000_000_000_002) + } + } + try? await Task.sleep(nanoseconds: 1_000_000) + task.cancel() + _ = await task.value + } +} diff --git a/Tests/EffectComponents/Utilities/TestGlobalActor.swift b/Tests/Transduce/Utilities/TestGlobalActor.swift similarity index 100% rename from Tests/EffectComponents/Utilities/TestGlobalActor.swift rename to Tests/Transduce/Utilities/TestGlobalActor.swift diff --git a/Tests/EffectComponents/Utilities/TestView.swift b/Tests/Transduce/Utilities/TestView.swift similarity index 99% rename from Tests/EffectComponents/Utilities/TestView.swift rename to Tests/Transduce/Utilities/TestView.swift index 4a0e4a1..a90128b 100644 --- a/Tests/EffectComponents/Utilities/TestView.swift +++ b/Tests/Transduce/Utilities/TestView.swift @@ -2,7 +2,7 @@ import Foundation import Testing import SwiftUI -@testable import EffectComponents +@testable import Transduce #if canImport(Observation) import Observation #endif