diff --git a/Sources/EffectComponents/EffectActor/ActorStateBinding.swift b/Sources/EffectComponents/EffectActor/ActorStateBinding.swift new file mode 100644 index 0000000..0dbd4d6 --- /dev/null +++ b/Sources/EffectComponents/EffectActor/ActorStateBinding.swift @@ -0,0 +1,85 @@ + +/// 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 new file mode 100644 index 0000000..5d2f95b --- /dev/null +++ b/Sources/EffectComponents/EffectActor/EffectActor.Input.swift @@ -0,0 +1,142 @@ +@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 new file mode 100644 index 0000000..7c92013 --- /dev/null +++ b/Sources/EffectComponents/EffectActor/EffectActor.swift @@ -0,0 +1,389 @@ +/// 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 index b01d1bd..bf1a0a8 100644 --- a/Sources/EffectComponents/EffectObservable/EffectObservable.Input.swift +++ b/Sources/EffectComponents/EffectObservable/EffectObservable.Input.swift @@ -23,7 +23,7 @@ /// - `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 { +extension EffectObservable where T.Event: Sendable, T.Output: Sendable { public struct Input: TransducerInput, Sendable { diff --git a/Sources/EffectComponents/EffectObservable/EffectObservable.swift b/Sources/EffectComponents/EffectObservable/EffectObservable.swift index 77a70ec..d78d2ad 100644 --- a/Sources/EffectComponents/EffectObservable/EffectObservable.swift +++ b/Sources/EffectComponents/EffectObservable/EffectObservable.swift @@ -14,20 +14,20 @@ import Observation /// transducer, and keeps event processing serialized on the `@MainActor`. public final class EffectObservable< T: Transducer, -> where +>: @MainActor TransducerHost where + T.Event: Sendable, T.Output: Sendable, T.Env: Sendable, - T.Effect == TransducerEffect, - T.Event: Sendable + T.Effect == TransducerEffect { public typealias State = T.State public typealias Event = T.Event - public typealias Env = T.Env public typealias Output = T.Output + public typealias Env = T.Env public typealias Effect = T.Effect typealias Storage = UnownedReferenceKeyPathStorage - typealias Send = EffectComponents::Send + typealias Send = EffectComponents::Send /// Current transducer state published through Swift Observation. @@ -66,6 +66,7 @@ public final class EffectObservable< self.initialEvent = initialEvent self.storage = .init(host: self, keyPath: \.state) let send = T.makeSend( + systemActor: MainActor.shared, with: Input.self, storage: storage, env: env @@ -115,7 +116,7 @@ public final class EffectObservable< /// - 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: Event) async throws { + public func send(_ event: sending Event) async throws { try checkRuntimeAvailability() guard let send = runtimeSend else { throw RuntimeError.actorCancelled @@ -180,13 +181,15 @@ public final class EffectObservable< /// 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 { + Task { [weak self] in + guard let self else { return } do { try await send.send(MainActor.shared, event, input, continuation) } catch { @@ -207,21 +210,23 @@ public final class EffectObservable< /// 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. + /// 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(with: RuntimeError.actorCancelled) + 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) { @@ -237,19 +242,25 @@ public final class EffectObservable< } } - private func cancelRuntime(with systemError: any Swift.Error) { + private func cancelRuntime(with systemError: (any Swift.Error)? = nil) { guard runtimeUnavailable == nil else { return } - runtimeUnavailable = .actorCancelled + runtimeUnavailable = systemError == nil ? .actorCancelled : .systemError guard let send = runtimeSend else { return } - Task { @MainActor [send] in - try? send.control(ControlEvent.systemError(systemError)) + 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 } } diff --git a/Sources/EffectComponents/EffectView/EffectView.swift b/Sources/EffectComponents/EffectView/EffectView.swift index ba849ad..c63fd98 100644 --- a/Sources/EffectComponents/EffectView/EffectView.swift +++ b/Sources/EffectComponents/EffectView/EffectView.swift @@ -66,23 +66,26 @@ import SwiftUI public struct EffectView< T: Transducer, Content: View ->: View where T.Output: Sendable, T.Env: Sendable, T.Effect == TransducerEffect, T.Event: Sendable { - +>: 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 Env = T.Env 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? + @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. /// @@ -152,6 +155,38 @@ public struct EffectView< } } } + + 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)) + } } diff --git a/Sources/EffectComponents/EffectView/EffectViewInput.swift b/Sources/EffectComponents/EffectView/EffectViewInput.swift index 136ea77..520f606 100644 --- a/Sources/EffectComponents/EffectView/EffectViewInput.swift +++ b/Sources/EffectComponents/EffectView/EffectViewInput.swift @@ -27,7 +27,7 @@ import Foundation public struct EffectViewInput: TransducerInput, Identifiable, Sendable { @MainActor - init(_ send: Send) { + init(_ send: Send) { self._send = { @MainActor (event, input, continuation) async throws -> Void in try await send(event, input: input, continuation: continuation) } @@ -143,9 +143,7 @@ public struct EffectViewInput: TransducerInput, Identifiable, Sen /// For usage patterns including `.refreshable`, `task(id:)`, and testing, /// see . @discardableResult - public func request( - _ event: Event - ) async throws-> Output? where Output: Sendable, Event: Sendable { + public func request(_ event: Event) async throws-> Output? where Event: Sendable { try await withCheckedThrowingContinuation { continuation in Task { @MainActor in do { diff --git a/Sources/EffectComponents/Transducer/Errors.swift b/Sources/EffectComponents/Transducer/Errors.swift index 654d251..c867d79 100644 --- a/Sources/EffectComponents/Transducer/Errors.swift +++ b/Sources/EffectComponents/Transducer/Errors.swift @@ -2,10 +2,16 @@ import Foundation /// Boundary error indicating that the runtime can no longer accept or complete work. public enum RuntimeError: LocalizedError, Equatable, Sendable { - /// The host cancelled the runtime before this call could proceed. + /// 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 host object was deallocated before this call could enter the runtime. + /// 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. @@ -16,14 +22,18 @@ public enum RuntimeError: LocalizedError, Equatable, Sendable { 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 runtime is unavailable because it has already been cancelled." + return "The effect actor is unavailable because it has already been cancelled." case .actorDeallocated: - return "The runtime is unavailable because it has already been deallocated." + 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 runtime is unavailable because it has been cancelled." + return "The effect actor is unavailable because it has been cancelled." } } } diff --git a/Sources/EffectComponents/Transducer/SendFunc.swift b/Sources/EffectComponents/Transducer/SendFunc.swift index bf0dcf9..3f5d18d 100644 --- a/Sources/EffectComponents/Transducer/SendFunc.swift +++ b/Sources/EffectComponents/Transducer/SendFunc.swift @@ -4,11 +4,11 @@ import Foundation /// /// Most feature code should use higher-level entry points such as /// ``EffectView/Input-swift.struct`` or ``EffectObservable/Input-swift.struct``. -public struct Send: Identifiable +public struct Send: Identifiable where Input: TransducerInput & Sendable { typealias TaggedEvent = EffectComponents::TaggedEvent - typealias SendFunc = (isolated any Actor, Event, Input?, Continuation?) async throws -> Void - typealias ControlFunc = (isolated any Actor, ControlEvent) throws -> Void + typealias SendFunc = (isolated SystemActor, Event, Input?, Continuation?) async throws -> Void + typealias ControlFunc = (isolated SystemActor, ControlEvent) throws -> Void let send: SendFunc let control: ControlFunc @@ -31,7 +31,7 @@ where Input: TransducerInput & Sendable { /// - Throws: Any runtime error raised while processing the event. @inline(__always) public func callAsFunction( - systemActor: isolated any Actor = #isolation, + systemActor: isolated SystemActor = #isolation, _ event: Event, input: Input, continuation: Continuation? = nil @@ -40,7 +40,7 @@ where Input: TransducerInput & Sendable { } func control( - systemActor: isolated any Actor = #isolation, + systemActor: isolated SystemActor = #isolation, _ controlEvent: ControlEvent ) throws { try control(systemActor, controlEvent) @@ -49,9 +49,10 @@ where Input: TransducerInput & Sendable { extension Transducer where Effect == TransducerEffect, Env: Sendable, Output: Sendable { - /// Creates the low-level `Send` handle used by runtime hosts. - /// NOTE: the implementation may work for global actors - but there could be issues with - /// actor instances since the compute function captures the instance in the closure. + /// 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. @@ -59,16 +60,16 @@ extension Transducer where Effect == TransducerEffect, Env: /// - 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 any Actor = #isolation, + public static func makeSend & Sendable, S: Storage>( + systemActor: isolated SystemActor = #isolation, with input: Input.Type = Input.self, storage: S, env: Env - ) -> Send + ) -> Send where S.Value == State { - typealias SendFunc = Send.SendFunc - typealias ControlFunc = Send.ControlFunc + 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 @@ -100,7 +101,7 @@ extension Transducer where Effect == TransducerEffect, Env: try control( systemActor: isolator, controlEvent: controlEvent, - storage: storage, + state: storage.value, taskManager: taskManager ) } @@ -116,6 +117,74 @@ extension Transducer where Effect == TransducerEffect, Env: 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 { diff --git a/Sources/EffectComponents/Transducer/Transducer.Effects.swift b/Sources/EffectComponents/Transducer/Transducer.Effects.swift index 81cc727..32ad978 100644 --- a/Sources/EffectComponents/Transducer/Transducer.Effects.swift +++ b/Sources/EffectComponents/Transducer/Transducer.Effects.swift @@ -119,6 +119,28 @@ extension Transducer where Effect == TransducerEffect { .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 + ) -> Effect { + .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. /// @@ -140,11 +162,21 @@ extension Transducer where Effect == TransducerEffect { /// - Returns: An effect. @inline(__always) public static func action( - _ action: @escaping @Sendable @isolated(any) (Env) async -> sending Event? + _ action: sending @escaping @isolated(any) (Env) async -> sending Event? ) -> Effect { .init(._actionAsync(action)) } - + + @inline(__always) + public static func action( + _ action: sending @escaping @isolated(any) (Env) async -> Void + ) -> Effect 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. /// @@ -171,6 +203,17 @@ extension Transducer where Effect == TransducerEffect { .init(._actionAsyncIsolated(action)) } + @inline(__always) + public static func action( + _ action: sending @escaping (Env, isolated any Actor) async -> Void + ) -> Effect { + .init(._actionAsyncIsolated( { env, isolated in + await action(env, isolated) + return nil + })) + } + + /// Returns an effect which when invoked feeds `event` back into `update` immediately, in /// the current synchronous turn. /// diff --git a/Sources/EffectComponents/Transducer/Transducer.swift b/Sources/EffectComponents/Transducer/Transducer.swift index 537bef2..f81f128 100644 --- a/Sources/EffectComponents/Transducer/Transducer.swift +++ b/Sources/EffectComponents/Transducer/Transducer.swift @@ -11,7 +11,7 @@ import Foundation /// `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`. -public protocol Transducer { +public protocol Transducer: SendableMetatype { /// Mutable feature state owned by the host runtime. associatedtype State @@ -92,49 +92,131 @@ enum SystemCompletion: Swift.Error { // Note: A Transducer requires an isolation in order to compile! extension Transducer where Effect == TransducerEffect { - // Caution: control should not mutate storage! - static func control( + /// 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, - controlEvent: ControlEvent, + event: Event, + continuation: Continuation?, storage: some Storage, - 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) + 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 + ) } - try taskManager.checkCancellation() } - /// Processes a regular event through `update` until the chain terminates. + /// 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. /// - /// > Important: On normal return, `continuation` has been fully consumed: it was either - /// resumed during synchronous processing or handed off to `taskManager` for - /// later completion. If this function throws, it does not resume the - /// continuation; the caller must handle the thrown error and decide how the - /// waiting request should complete. + /// 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. /// - /// - Throws: Throws when the task manager has been cancelled. + /// - 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?, - storage: some Storage, + 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(&storage.value, event: event) { + if let effect = update(&state, event: event) { (nextEvent, cont) = try await executeEffect( effect, continuation: cont, @@ -144,16 +226,131 @@ extension Transducer where Effect == TransducerEffect { ) } else { if let continuation { - let output = output(state: storage.value, event: event) + let output = output(state: state, event: event) continuation.resume(returning: output) } cont = nil } } assert(cont == nil) + #if DEBUG + print("*** end compute") + #endif } - // Throws when the task manager is cancelled + /// 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, @@ -259,3 +456,4 @@ extension Transducer where Effect == TransducerEffect { } } } + diff --git a/Sources/EffectComponents/Transducer/TransducerHost.swift b/Sources/EffectComponents/Transducer/TransducerHost.swift new file mode 100644 index 0000000..0c5e6a6 --- /dev/null +++ b/Sources/EffectComponents/Transducer/TransducerHost.swift @@ -0,0 +1,65 @@ + +/// 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 index 74cb88c..4a7f031 100644 --- a/Sources/EffectComponents/Transducer/TransducerInput.swift +++ b/Sources/EffectComponents/Transducer/TransducerInput.swift @@ -18,18 +18,24 @@ /// 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 { +public protocol TransducerInput: SendableMetatype { associatedtype Event associatedtype Output - /// Sends `event` without awaiting a result. + /// Schedules `event` without awaiting the resulting effect chain. /// - /// If `post` throws, that failure is local to the call site. It only becomes a - /// critical runtime error if the caller lets it escape from inside an effect - /// closure and the error is thereby fed back into the system. + /// `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 runtime entry failure if the implementation cannot accept the event. + /// - 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. diff --git a/Tests/EffectComponents/AsyncActionRuntimeTests.swift b/Tests/EffectComponents/AsyncActionRuntimeTests.swift index 9d0e514..7247972 100644 --- a/Tests/EffectComponents/AsyncActionRuntimeTests.swift +++ b/Tests/EffectComponents/AsyncActionRuntimeTests.swift @@ -63,44 +63,7 @@ struct AsyncActionRuntimeTests { } } - enum GatedAsyncActionTransducer: Transducer { - struct State: Equatable { - var events: [String] = [] - } - - enum Event: Sendable { - case first - case firstFinished - case second - } - - struct Env: Sendable { - let firstStarted: Expectation - let releaseFirst: AsyncGate - } - - static func update(_ state: inout State, event: Event) -> Effect? { - switch event { - case .first: - state.events.append("first") - return action { env in - env.firstStarted.fulfill() - await env.releaseFirst.wait() - return .firstFinished - } - - case .firstFinished: - state.events.append("firstFinished") - return nil - - case .second: - state.events.append("second") - return nil - } - } - } - - @Test func requestThrowsRuntimeUnavailableWhenCancelledDuringAsyncAction() async throws { + @Test func requestThrowsCancellationWhenCancelledDuringAsyncAction() async throws { guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { return } @@ -119,7 +82,6 @@ struct AsyncActionRuntimeTests { } try await started.await(nanoseconds: timeout) - #expect(observable.state.phases == ["start"]) observable.cancel() await Task.yield() @@ -128,9 +90,9 @@ struct AsyncActionRuntimeTests { do { _ = try await waiter.value - Issue.record("Expected accepted request to receive RuntimeUnavailable.actorCancelled") + Issue.record("Expected accepted request to receive RuntimeError.cancelled") } catch let error as RuntimeError { - #expect(error == .actorCancelled) + #expect(error == .cancelled) } catch { Issue.record("Unexpected waiter error: \(error)") } @@ -142,36 +104,71 @@ struct AsyncActionRuntimeTests { 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] = [] + } - let firstStarted = Expectation() - let releaseFirst = AsyncGate() - let timeout: UInt64 = 5_000_000_000 + 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 Self.event(.finished) + + case .finished: + return action { env in + env.finishedExpectation.fulfill() + return nil + } + } + } + } - let observable = EffectObservable( + let env: T.Env = .init() + let observable = EffectObservable( initialState: .init(), - env: .init(firstStarted: firstStarted, releaseFirst: releaseFirst) + env: env ) - let firstSend = Task { + let firstSender = Task { try await observable.send(.first) } - try await firstStarted.await(nanoseconds: timeout) + try await env.firstStartedExpectation.await(nanoseconds: 5_000_000_000) - let secondSend = Task { + let secondSender = Task { try await observable.send(.second) } - await Task.yield() - await Task.yield() - - #expect(observable.state.events == ["first"]) - - await releaseFirst.open() - - try await firstSend.value - try await secondSend.value - + 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"]) } } diff --git a/Tests/EffectComponents/EffectActorInputTests.swift b/Tests/EffectComponents/EffectActorInputTests.swift new file mode 100644 index 0000000..26d9de5 --- /dev/null +++ b/Tests/EffectComponents/EffectActorInputTests.swift @@ -0,0 +1,152 @@ +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 new file mode 100644 index 0000000..6b25216 --- /dev/null +++ b/Tests/EffectComponents/EffectActorTests.swift @@ -0,0 +1,320 @@ +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 Self.event(.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 new file mode 100644 index 0000000..f79328c --- /dev/null +++ b/Tests/EffectComponents/EffectObservableInputTests.swift @@ -0,0 +1,97 @@ +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/RuntimeUnavailableTests.swift b/Tests/EffectComponents/EffectObservableTests.swift similarity index 61% rename from Tests/EffectComponents/RuntimeUnavailableTests.swift rename to Tests/EffectComponents/EffectObservableTests.swift index 1d9285d..8135be2 100644 --- a/Tests/EffectComponents/RuntimeUnavailableTests.swift +++ b/Tests/EffectComponents/EffectObservableTests.swift @@ -3,15 +3,19 @@ import Testing #if canImport(Observation) -@Suite("Runtime unavailable") +@Suite("EffectObservable") @MainActor -struct RuntimeUnavailableTests { +struct EffectObservableTests { enum LatchedSystemError: Error, Equatable { case boom } - enum T: Transducer { + enum ExternalSystemFailure: Error, Equatable { + case boom + } + + enum IdleTransducer: Transducer { struct State: Equatable {} enum Event: Sendable { case ping } @@ -23,6 +27,7 @@ struct RuntimeUnavailableTests { enum RequestCancellationTransducer: Transducer { struct State: Equatable {} enum Event: Sendable { case start } + struct Env: Sendable { let started: Expectation let cancelled: Expectation @@ -72,36 +77,36 @@ struct RuntimeUnavailableTests { } } - @Test func observableSendThrowsRuntimeUnavailableWhenCancelled() async throws { + @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: ()) + let observable = EffectObservable(initialState: .init(), env: ()) observable.cancel() do { try await observable.send(.ping) - Issue.record("Expected RuntimeUnavailable.actorCancelled") + Issue.record("Expected RuntimeError.actorCancelled") } catch let error as RuntimeError { #expect(error == .actorCancelled) - #expect(error.errorDescription == "The runtime is unavailable because it has already been cancelled.") + #expect(error.errorDescription == "The effect actor is unavailable because it has already been cancelled.") } catch { Issue.record("Unexpected error: \(error)") } } - @Test func observableRequestThrowsRuntimeUnavailableWhenCancelled() async throws { + @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: ()) + let observable = EffectObservable(initialState: .init(), env: ()) observable.cancel() do { _ = try await observable.request(.ping) - Issue.record("Expected RuntimeUnavailable.actorCancelled") + Issue.record("Expected RuntimeError.actorCancelled") } catch let error as RuntimeError { #expect(error == .actorCancelled) } catch { @@ -109,84 +114,70 @@ struct RuntimeUnavailableTests { } } - @Test func inputSendThrowsRuntimeUnavailableWhenOwnerIsDeallocated() async throws { + @Test func acceptedRequestIsCancelledWhenObservableIsCancelled() 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 RuntimeUnavailable.actorDeallocated") - } catch let error as RuntimeError { - #expect(error == .actorDeallocated) - #expect(error.errorDescription == "The runtime is unavailable because it has already been deallocated.") - } catch { - Issue.record("Unexpected error: \(error)") - } - } + let started = Expectation() + let cancelled = Expectation() + let timeout: UInt64 = 5_000_000_000 + let observable = EffectObservable( + initialState: .init(), + env: .init(started: started, cancelled: cancelled) + ) - @Test func inputRequestThrowsRuntimeUnavailableWhenOwnerIsDeallocated() async throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return + let waiter = Task { + try await observable.request(.start) } - var observable: EffectObservable? = EffectObservable(initialState: .init(), env: ()) - let input = try #require(observable?.input) - observable = nil + try await started.await(nanoseconds: timeout) + observable.cancel() do { - _ = try await input.request(.ping) - Issue.record("Expected RuntimeUnavailable.actorDeallocated") - } catch let error as RuntimeError { - #expect(error == .actorDeallocated) + _ = try await waiter.value + Issue.record("Expected accepted request to receive CancellationError") + } catch is CancellationError { } catch { - Issue.record("Unexpected error: \(error)") + Issue.record("Unexpected waiter error: \(error)") } + + try await cancelled.await(nanoseconds: timeout) } - @Test func inputPostThrowsRuntimeUnavailableWhenOwnerIsDeallocated() throws { + @Test func acceptedRequestReceivesCustomSystemErrorWhenCancelled() 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 input.post(.ping) - Issue.record("Expected RuntimeUnavailable.actorDeallocated") - } catch let error as RuntimeError { - #expect(error == .actorDeallocated) - } catch { - Issue.record("Unexpected error: \(error)") - } - } + let started = Expectation() + let cancelled = Expectation() + let timeout: UInt64 = 5_000_000_000 + let observable = EffectObservable( + initialState: .init(), + env: .init(started: started, cancelled: cancelled) + ) - @Test func inputPostThrowsRuntimeUnavailableWhenRuntimeIsCancelled() throws { - guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { - return + let waiter = Task { + try await observable.request(.start) } - let observable = EffectObservable(initialState: .init(), env: ()) - let input = observable.input - observable.cancel() + try await started.await(nanoseconds: timeout) + observable.cancel(with: ExternalSystemFailure.boom) do { - try input.post(.ping) - Issue.record("Expected RuntimeUnavailable.actorCancelled") - } catch let error as RuntimeError { - #expect(error == .actorCancelled) + _ = 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 error: \(error)") + Issue.record("Unexpected waiter error: \(error)") } + + try await cancelled.await(nanoseconds: timeout) } - @Test func acceptedRequestIsCancelledWhenRuntimeIsCancelled() async throws { + @Test func laterEntryPointsThrowSystemErrorAfterCancelWithError() async throws { guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { return } @@ -204,19 +195,27 @@ struct RuntimeUnavailableTests { } try await started.await(nanoseconds: timeout) - - observable.cancel() + observable.cancel(with: ExternalSystemFailure.boom) do { _ = try await waiter.value - Issue.record("Expected accepted request to receive RuntimeUnavailable.actorCancelled") - } catch let error as RuntimeError { - #expect(error == .actorCancelled) + 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 { @@ -237,7 +236,7 @@ struct RuntimeUnavailableTests { do { _ = try await observable.request(.start) - Issue.record("Expected later request to throw the latched system error") + Issue.record("Expected later request to throw RuntimeError.systemError") } catch let error as RuntimeError { #expect(error == .systemError) } catch { @@ -245,7 +244,7 @@ struct RuntimeUnavailableTests { } } - @Test func observableSendThrowsRuntimeUnavailableWhenRuntimeHasFailed() async throws { + @Test func sendThrowsSystemErrorWhenObservableHasLatchedFailure() async throws { guard #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *) else { return } @@ -263,7 +262,7 @@ struct RuntimeUnavailableTests { do { try await observable.send(.start) - Issue.record("Expected later send to throw RuntimeUnavailable.runtimeFailed") + 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.") @@ -273,4 +272,4 @@ struct RuntimeUnavailableTests { } } -#endif +#endif \ No newline at end of file diff --git a/Tests/EffectComponents/EffectViewInputTests.swift b/Tests/EffectComponents/EffectViewInputTests.swift new file mode 100644 index 0000000..49ece3c --- /dev/null +++ b/Tests/EffectComponents/EffectViewInputTests.swift @@ -0,0 +1,279 @@ +#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 request(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 \ No newline at end of file diff --git a/Tests/EffectComponents/EffectViewTests.swift b/Tests/EffectComponents/EffectViewTests.swift index 6f5e1a3..59a0bc2 100644 --- a/Tests/EffectComponents/EffectViewTests.swift +++ b/Tests/EffectComponents/EffectViewTests.swift @@ -145,42 +145,6 @@ struct EffectViewTests { } } - @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) - } - } - // MARK: - initialEvent @Test func initialEventFiresOnAppear() async throws { @@ -209,190 +173,6 @@ struct EffectViewTests { } } - // MARK: - request - - @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 } - - // Each request() suspends until the update loop has processed the event. - // Three sequential requests must complete without deadlock or timeout. - 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]] = [] - - // request() guarantees each update completes before the next event is sent. - 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 request(id: "load") { input, _ in - // Simulate async work, aka a service function. If it was - // successful, fire a completion event which updates - // state. If it fails, send a corresponding failure event. - // Note: If we throw within an effect closure, we feed - // this error back into the system which is considered - // a "system error". It is preferable to handle the error or - // to send a corresponding error event back. - do { - try await Task.sleep(for: .milliseconds(1)) // simulate remote work - } catch { - // not handled here in the test. - // Production code should send a service error event, - // for example: `let output = try await input.request(Event.serviceFailed(error))` - // then return `output`. - } - let result = "hello" - let output = try? await input.request(Event.loaded(result)) // drives state; return discarded - return output // this becomes the Output? - } - case .loaded(let v): - state.value = v - 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 RuntimeUnavailable.runtimeFailed") - } 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 - } - } - // MARK: - Effects @Test func taskEffectRunsAndMutatesState() async throws { @@ -604,7 +384,7 @@ struct EffectViewTests { var capturedInput: EffectViewInput? let resetExpectation = Expectation() - var countsOnAppear: [Int] = [] + var resetCount: Int? let timeout: UInt64 = 5_000_000_000 @@ -623,12 +403,14 @@ struct EffectViewTests { try await input.request(.increment) try await input.request(.increment) - // Replace the root view with a fresh instance at initial state. - hostingController.rootView = AnyView( + 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 EffectView(of: T.self, state: binding) { state, _ in Color.clear.onAppear { - countsOnAppear.append(state.count) + resetCount = state.count resetExpectation.fulfill() } } @@ -636,8 +418,8 @@ struct EffectViewTests { ) try await resetExpectation.await(nanoseconds: timeout) - #expect(countsOnAppear.last == 0, "Fresh EffectView should start at count 0") - cleanup(window) + #expect(resetCount == 0, "Fresh EffectView should start at count 0") + cleanup(resetWindow) } // MARK: - Env @@ -765,11 +547,30 @@ struct EffectViewTests { // Verify that cancel("observe") prevents further handler calls. // After cancellation, mutating the observable must not deliver new values. struct ObsEnv: Sendable { let counter: ObservableCounter } - class InvocationLog: @unchecked Sendable { var count = 0 } - let log = InvocationLog() + + final class TickProbe: @unchecked Sendable { + let firstTickExpectation = Expectation() + let secondTickExpectation = Expectation() + private(set) var count = 0 + + func recordTick() { + count += 1 + if count == 1 { firstTickExpectation.fulfill() } + if count == 2 { secondTickExpectation.fulfill() } + } + } + + let probe = TickProbe() enum T: Transducer { - struct State: Equatable { var latest = -1 } + struct State: Equatable { + var latest = -1 + let probe: TickProbe + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.latest == rhs.latest + } + } enum Event: Sendable { case start, stop, tick(Int) } typealias Env = ObsEnv static func update(_ state: inout State, event: Event) -> Effect? { @@ -783,6 +584,7 @@ struct EffectViewTests { return cancel("observe") case .tick(let v): state.latest = v + state.probe.recordTick() return nil } } @@ -790,11 +592,9 @@ struct EffectViewTests { let counter = ObservableCounter() var capturedInput: EffectViewInput? - let firstTickExpectation = Expectation() - let secondTickExpectation = Expectation() - let timeout: UInt64 = 5_000_000_000 + let timeout: UInt64 = 10_000_000_000 - try await testView(initialState: T.State()) { binding in + try await testView(initialState: T.State(probe: probe)) { binding in EffectView( of: T.self, state: binding, @@ -802,11 +602,7 @@ struct EffectViewTests { ) { state, input in Color.clear .onAppear { capturedInput = input } - .onChange(of: state.latest) { newValue in - log.count += 1 - if log.count == 1 { firstTickExpectation.fulfill() } - if log.count == 2 { secondTickExpectation.fulfill() } - } + .onChange(of: state.latest) { _ in } } } expect: { guard let input = capturedInput else { Issue.record("Input not captured"); return } @@ -814,12 +610,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 firstTickExpectation.await(nanoseconds: timeout) + try await probe.firstTickExpectation.await(nanoseconds: timeout) // Mutate the counter; the handler should fire once more. counter.value = 1 - try await secondTickExpectation.await(nanoseconds: timeout) - let countAtCancel = log.count // expected: 2 + try await probe.secondTickExpectation.await(nanoseconds: timeout) + let countAtCancel = probe.count // expected: 2 // Cancel the observation task. try await input.request(.stop) @@ -832,7 +628,7 @@ struct EffectViewTests { counter.value = 3 try await Task.sleep(nanoseconds: 50_000_000) // 50 ms - #expect(log.count == countAtCancel, + #expect(probe.count == countAtCancel, "No handler calls expected after cancelling the observation task") } } @@ -843,7 +639,7 @@ struct EffectViewTests { #else import Testing -// @Suite("EffectView (SwiftUI unavailable)") +@Suite("EffectView (SwiftUI unavailable)") struct EffectViewTests { @Test func skipped() { // Hosted tests require SwiftUI + AppKit or UIKit. diff --git a/Tests/EffectComponents/TaskSubscriptionTests.swift b/Tests/EffectComponents/TaskSubscriptionTests.swift index acf5355..c71ddcf 100644 --- a/Tests/EffectComponents/TaskSubscriptionTests.swift +++ b/Tests/EffectComponents/TaskSubscriptionTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing import SwiftUI -import EffectComponents +@testable import EffectComponents #if canImport(UIKit) import UIKit @@ -30,6 +30,25 @@ struct TaskSubscriptionTests { @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 { @@ -39,23 +58,8 @@ extension TaskSubscriptionTests { let timeout: UInt64 } - final class RequestProbe: @unchecked Sendable { - let secondLoad: Expectation - - init(secondLoad: Expectation) { - self.secondLoad = secondLoad - } - } - enum T: Transducer { - struct State: Equatable { - var loadCount = 0 - let probe: RequestProbe - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.loadCount == rhs.loadCount - } - } + struct State: Equatable {} enum Event: Sendable { case load } typealias Output = String typealias Env = WorkerEnv @@ -63,10 +67,6 @@ extension TaskSubscriptionTests { static func update(_ state: inout State, event: Event) -> Effect? { switch event { case .load: - state.loadCount += 1 - if state.loadCount == 2 { - state.probe.secondLoad.fulfill() - } return request(id: "shared-load", option: .subscribe) { _, env in await env.counter.increment() env.started.fulfill() @@ -85,15 +85,12 @@ extension TaskSubscriptionTests { let counter = InvocationCounter() let startedExpectation = Expectation() - let secondLoadExpectation = Expectation() + let secondRequestEnqueuedExpectation = Expectation() let releaseExpectation = Expectation() let timeout: UInt64 = 10_000_000_000 var capturedInput: EffectViewInput? - let probe = RequestProbe(secondLoad: secondLoadExpectation) - - - try await testView(initialState: T.State(probe: probe)) { binding in + try await testView(initialState: T.State()) { binding in EffectView( of: T.self, state: binding, @@ -114,8 +111,12 @@ extension TaskSubscriptionTests { let firstWaiter = Task.detached { try await input.request(.load) } try await startedExpectation.await(nanoseconds: timeout) - let secondWaiter = Task.detached { try await input.request(.load) } - try await secondLoadExpectation.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 @@ -186,9 +187,13 @@ extension TaskSubscriptionTests { let firstWaiter = Task { try await input.request(.load) } try await startedExpectation.await(nanoseconds: timeout) - let secondWaiter = Task { try await input.request(.load) } - await Task.yield() - await Task.yield() + let secondRequestEnqueuedExpectation = Expectation() + let secondWaiter = startRequestAndWaitUntilEnqueued( + .load, + input: input, + enqueued: secondRequestEnqueuedExpectation + ) + try await secondRequestEnqueuedExpectation.await(nanoseconds: timeout) releaseExpectation.fulfill() do { @@ -310,6 +315,7 @@ extension TaskSubscriptionTests { 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? @@ -346,12 +352,15 @@ extension TaskSubscriptionTests { Issue.record("Unexpected first waiter error: \(error)") } - let secondWaiter = Task { try await input.request(.load) } - await Task.yield() + let secondWaiter = startRequestAndWaitUntilEnqueued( + .load, + input: input, + enqueued: secondRequestEnqueuedExpectation + ) + try await secondRequestEnqueuedExpectation.await(nanoseconds: timeout) releaseExpectation.fulfill() let secondOutput = try await secondWaiter.value - // TODO: May intermittently fail when executed in a test loop #expect(secondOutput == "late-output") #expect(await counter.count == 1, "subscribe should attach to the cancelled tracked task instead of starting fresh work") } @@ -409,6 +418,7 @@ extension TaskSubscriptionTests { let releaseExpectation = Expectation() let timeout: UInt64 = 5_000_000_000 var capturedInput: EffectViewInput? + let secondRequestEnqueuedExpectation = Expectation() try await testView(initialState: T.State()) { binding in EffectView( @@ -442,8 +452,12 @@ extension TaskSubscriptionTests { Issue.record("Unexpected first waiter error: \(error)") } - let secondWaiter = Task { try await input.request(.load) } - await Task.yield() + let secondWaiter = startRequestAndWaitUntilEnqueued( + .load, + input: input, + enqueued: secondRequestEnqueuedExpectation + ) + try await secondRequestEnqueuedExpectation.await(nanoseconds: timeout) releaseExpectation.fulfill() do { diff --git a/Tests/EffectComponents/Utilities/TestView.swift b/Tests/EffectComponents/Utilities/TestView.swift index fcc8995..4a0e4a1 100644 --- a/Tests/EffectComponents/Utilities/TestView.swift +++ b/Tests/EffectComponents/Utilities/TestView.swift @@ -48,7 +48,7 @@ func embedInWindowAndMakeKey(_ view: V, timeout: TimeInterval = 1.0) as try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let timeoutCancelTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(timeout * 100_000_000 + 0.5)) + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000 + 0.5)) guard isResumed == false else { return } isResumed = true continuation.resume(throwing: EmbedInWindowAndMakeKeyTimeoutError())