From 3a515d17fa6f840c8e42c7973569eb8a7ec5775d Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 23 Jul 2026 12:41:14 -0500 Subject: [PATCH 01/19] refactor: isolate the core Workflow runtime to @MainActor Replaces the DispatchQueue.workflowExecution convention (dispatchPrecondition checks, dispatch-async deferral) with compiler-enforced @MainActor isolation and main-actor Tasks. Sink stays nonisolated; runtime entry goes through MainActor.assumeIsolated, preserving the crash-on-misuse contract. Co-Authored-By: Claude Fable 5 --- Workflow/Sources/DispatchQueue+Workflow.swift | 4 +++ Workflow/Sources/RuntimeConfiguration.swift | 5 +++- Workflow/Sources/SubtreeManager.swift | 27 +++++++++++++------ Workflow/Sources/WorkflowHost.swift | 9 ++++++- Workflow/Sources/WorkflowLogger.swift | 2 ++ Workflow/Sources/WorkflowNode.swift | 3 ++- Workflow/Tests/AnyWorkflowTests.swift | 1 + Workflow/Tests/ConcurrencyTests.swift | 1 + Workflow/Tests/HostContextTests.swift | 1 + .../Tests/RenderOnlyIfStateChangedTests.swift | 2 ++ Workflow/Tests/SinkEventHandlerTests.swift | 1 + Workflow/Tests/StateMutationSinkTests.swift | 1 + Workflow/Tests/SubtreeManagerTests.swift | 1 + Workflow/Tests/TestUtilities.swift | 5 ++-- Workflow/Tests/WorkflowHostTests.swift | 3 +++ Workflow/Tests/WorkflowNodeTests.swift | 1 + Workflow/Tests/WorkflowObserverTests.swift | 1 + WorkflowSwiftUI/Sources/Store.swift | 6 ++++- 18 files changed, 60 insertions(+), 14 deletions(-) diff --git a/Workflow/Sources/DispatchQueue+Workflow.swift b/Workflow/Sources/DispatchQueue+Workflow.swift index 00b119382..8b2f15312 100644 --- a/Workflow/Sources/DispatchQueue+Workflow.swift +++ b/Workflow/Sources/DispatchQueue+Workflow.swift @@ -17,6 +17,10 @@ import Foundation extension DispatchQueue { + /// The queue the Workflow runtime historically executed on. The runtime is + /// now `@MainActor`-isolated; this alias is retained for source + /// compatibility with existing schedulers (e.g. WorkflowReactiveSwift) and + /// will be formally deprecated in a future release. @_spi(WorkflowInternals) public static let workflowExecution: DispatchQueue = .main } diff --git a/Workflow/Sources/RuntimeConfiguration.swift b/Workflow/Sources/RuntimeConfiguration.swift index 75ed8d398..c5a25611b 100644 --- a/Workflow/Sources/RuntimeConfiguration.swift +++ b/Workflow/Sources/RuntimeConfiguration.swift @@ -41,9 +41,11 @@ public enum Runtime { _defaultConfiguration = config } + @MainActor private static var _defaultConfiguration = Configuration() /// The configuration active for the current task, falling back to the default configuration. + @MainActor package static var configuration: Configuration { _currentConfiguration ?? _defaultConfiguration } @@ -53,6 +55,7 @@ public enum Runtime { /// - Parameters: /// - override: An option block to reconfigure the current configuration value. /// - operation: The operation to perform with the customized configuration. + @MainActor public static func withConfiguration( override: ((inout Configuration) -> Void)? = nil, operation: () -> T @@ -71,7 +74,7 @@ public enum Runtime { extension Runtime { /// Configuration options for the Workflow runtime. - public struct Configuration: Equatable { + public struct Configuration: Equatable, Sendable { /// The default runtime configuration. static let `default` = Configuration() diff --git a/Workflow/Sources/SubtreeManager.swift b/Workflow/Sources/SubtreeManager.swift index 150376383..935cfde70 100644 --- a/Workflow/Sources/SubtreeManager.swift +++ b/Workflow/Sources/SubtreeManager.swift @@ -23,6 +23,7 @@ import IssueReporting extension WorkflowNode { /// Manages the subtree of a workflow. Specifically, this type encapsulates the logic required to update and manage /// the lifecycle of nested workflows across multiple render passes. + @MainActor final class SubtreeManager { var onUpdate: ((Output) -> Void)? @@ -199,6 +200,7 @@ extension WorkflowNode.SubtreeManager { extension WorkflowNode.SubtreeManager { /// The workflow context implementation used by the subtree manager. + @MainActor fileprivate final class Context: RenderContextType { private(set) var eventPipes: [EventPipe] @@ -328,6 +330,7 @@ extension WorkflowNode.SubtreeManager { // MARK: - Reusable Sink extension WorkflowNode.SubtreeManager { + @MainActor fileprivate struct SinkStore { private var previousSinks: [ObjectIdentifier: AnyReusableSink] private(set) var usedSinks: [ObjectIdentifier: AnyReusableSink] @@ -364,6 +367,7 @@ extension WorkflowNode.SubtreeManager { } /// Type-erased base class for reusable sinks. + @MainActor fileprivate class AnyReusableSink { /// The callback to invoke when an event is to be handled. let onSinkEvent: OnSinkEvent? @@ -376,7 +380,17 @@ extension WorkflowNode.SubtreeManager { } fileprivate final class ReusableSink: AnyReusableSink where Action.WorkflowType == WorkflowType { - func handle(action: Action) { + /// Nonisolated entry point: `Sink` closures are nonisolated by design + /// (they are captured into arbitrary consumer event handlers). Entering + /// the runtime requires the main actor; `assumeIsolated` preserves the + /// previous `dispatchPrecondition` crash-on-misuse contract. + nonisolated func handle(action: Action) { + MainActor.assumeIsolated { + handleIsolated(action: action) + } + } + + private func handleIsolated(action: Action) { if let onSinkEvent { handleWithSinkEventHandler(action: action, onSinkEvent: onSinkEvent) return @@ -392,7 +406,7 @@ extension WorkflowNode.SubtreeManager { if case .pending = eventPipe.validationState { // Workflow is currently processing an `event`. // Scheduling it to be processed after. - DispatchQueue.workflowExecution.async { [weak self] in + Task { @MainActor [weak self] in self?.eventPipe.handle(event: output) } return @@ -404,9 +418,6 @@ extension WorkflowNode.SubtreeManager { action: Action, onSinkEvent: OnSinkEvent ) { - // new `SinkEventHandler` logic - dispatchPrecondition(condition: .onQueue(DispatchQueue.workflowExecution)) - // If we can process now, forward through the `EventPipe` let immediatePerform: () -> Void = { let output = Output.update( @@ -420,7 +431,7 @@ extension WorkflowNode.SubtreeManager { // Otherwise, try to recurse again in the future let deferredPerform: () -> Void = { [weak self] in - self?.handle(action: action) + self?.handleIsolated(action: action) } onSinkEvent(immediatePerform, deferredPerform) @@ -431,6 +442,7 @@ extension WorkflowNode.SubtreeManager { // MARK: - EventPipe extension WorkflowNode.SubtreeManager { + @MainActor final class EventPipe { var validationState: ValidationState enum ValidationState { @@ -448,8 +460,6 @@ extension WorkflowNode.SubtreeManager { } func handle(event: Output) { - dispatchPrecondition(condition: .onQueue(DispatchQueue.workflowExecution)) - let isReentrantCall = isHandlingEvent isHandlingEvent = true defer { isHandlingEvent = isReentrantCall } @@ -529,6 +539,7 @@ extension WorkflowNode.SubtreeManager { extension WorkflowNode.SubtreeManager { /// Abstract base class for running children in the subtree. + @MainActor class AnyChildWorkflow { fileprivate var eventPipe: EventPipe diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index 813f5f6de..da9ababd4 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -32,6 +32,7 @@ public protocol WorkflowDebugger { } /// Manages an active workflow hierarchy. +@MainActor public final class WorkflowHost { private let outputSubject = PassthroughSubject() @@ -228,6 +229,7 @@ typealias OnSinkEvent = ( /// Handles events from 'Sinks' such that runtime-level event handling state is appropriately /// managed, and attempts to perform reentrant action handling can be detected and dealt with. +@MainActor final class SinkEventHandler { enum State { /// Ready to handle an event. @@ -259,7 +261,12 @@ final class SinkEventHandler { withEventHandlingSuspended(immediate) case .busy: - DispatchQueue.workflowExecution.async(execute: deferred) + // Main-actor Task preserves the previous DispatchQueue.main.async + // FIFO ordering; non-Sendable captures are legal because creation + // context and Task isolation are both MainActor (no region crossing). + Task { @MainActor in + deferred() + } } } diff --git a/Workflow/Sources/WorkflowLogger.swift b/Workflow/Sources/WorkflowLogger.swift index 4f75eb45b..6ec6fa1b6 100644 --- a/Workflow/Sources/WorkflowLogger.swift +++ b/Workflow/Sources/WorkflowLogger.swift @@ -160,6 +160,7 @@ enum WorkflowLogger { // MARK: Rendering + @MainActor static func logWorkflowStartedRendering( ref: WorkflowNode ) { @@ -178,6 +179,7 @@ enum WorkflowLogger { ) } + @MainActor static func logWorkflowFinishedRendering( ref: WorkflowNode ) { diff --git a/Workflow/Sources/WorkflowNode.swift b/Workflow/Sources/WorkflowNode.swift index ea934de02..98228f585 100644 --- a/Workflow/Sources/WorkflowNode.swift +++ b/Workflow/Sources/WorkflowNode.swift @@ -15,6 +15,7 @@ */ /// Manages a running workflow. +@MainActor final class WorkflowNode { /// The current `State` of the node's `Workflow`. private var state: WorkflowType.State @@ -35,7 +36,7 @@ final class WorkflowNode { var onOutput: ((Output) -> Void)? /// An optional `WorkflowObserver` instance - var observer: WorkflowObserver? { + nonisolated var observer: WorkflowObserver? { hostContext.observer } diff --git a/Workflow/Tests/AnyWorkflowTests.swift b/Workflow/Tests/AnyWorkflowTests.swift index 3c4faeb68..baef3eaf1 100644 --- a/Workflow/Tests/AnyWorkflowTests.swift +++ b/Workflow/Tests/AnyWorkflowTests.swift @@ -18,6 +18,7 @@ import Combine import XCTest @testable import Workflow +@MainActor public class AnyWorkflowTests: XCTestCase { func testRendersWrappedWorkflow() { let workflow = AnyWorkflow(SimpleWorkflow(string: "asdf")) diff --git a/Workflow/Tests/ConcurrencyTests.swift b/Workflow/Tests/ConcurrencyTests.swift index 400ca8743..856a699f3 100644 --- a/Workflow/Tests/ConcurrencyTests.swift +++ b/Workflow/Tests/ConcurrencyTests.swift @@ -18,6 +18,7 @@ import Combine import XCTest @testable import Workflow +@MainActor final class ConcurrencyTests: XCTestCase { // Applying an action from a sink must synchronously update the rendering. func test_sinkRenderLoopIsSynchronous() { diff --git a/Workflow/Tests/HostContextTests.swift b/Workflow/Tests/HostContextTests.swift index 846bbf87d..e9a22fa29 100644 --- a/Workflow/Tests/HostContextTests.swift +++ b/Workflow/Tests/HostContextTests.swift @@ -2,6 +2,7 @@ import XCTest @testable import Workflow +@MainActor final class HostContextTests: XCTestCase { func test_conditional_debug_info_no_debugger() { let subject = HostContext.testing(debugger: nil) diff --git a/Workflow/Tests/RenderOnlyIfStateChangedTests.swift b/Workflow/Tests/RenderOnlyIfStateChangedTests.swift index ba69c4a27..a70fc82f2 100644 --- a/Workflow/Tests/RenderOnlyIfStateChangedTests.swift +++ b/Workflow/Tests/RenderOnlyIfStateChangedTests.swift @@ -20,6 +20,7 @@ import XCTest @_spi(WorkflowRuntimeConfig) @testable import Workflow +@MainActor final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { override func invokeTest() { Runtime.withConfiguration { cfg in @@ -208,6 +209,7 @@ final class RenderOnlyIfStateChangedEnabledTests: XCTestCase { } } +@MainActor private func withRenderOnlyIfStateChangedDisabled( _ perform: () -> Void ) { diff --git a/Workflow/Tests/SinkEventHandlerTests.swift b/Workflow/Tests/SinkEventHandlerTests.swift index 68107e4f7..944ef21f9 100644 --- a/Workflow/Tests/SinkEventHandlerTests.swift +++ b/Workflow/Tests/SinkEventHandlerTests.swift @@ -18,6 +18,7 @@ import Testing @testable import Workflow +@MainActor struct SinkEventHandlerTests { @Test func initialState() async throws { diff --git a/Workflow/Tests/StateMutationSinkTests.swift b/Workflow/Tests/StateMutationSinkTests.swift index 06a376335..f6711acf7 100644 --- a/Workflow/Tests/StateMutationSinkTests.swift +++ b/Workflow/Tests/StateMutationSinkTests.swift @@ -18,6 +18,7 @@ import Combine import Workflow import XCTest +@MainActor final class StateMutationSinkTests: XCTestCase { var input: PassthroughSubject! diff --git a/Workflow/Tests/SubtreeManagerTests.swift b/Workflow/Tests/SubtreeManagerTests.swift index d4dad8a16..3a9b01269 100644 --- a/Workflow/Tests/SubtreeManagerTests.swift +++ b/Workflow/Tests/SubtreeManagerTests.swift @@ -19,6 +19,7 @@ import XCTest @testable import Workflow +@MainActor final class SubtreeManagerTests: XCTestCase { func test_maintainsChildrenBetweenRenderPasses() { let manager = WorkflowNode.SubtreeManager() diff --git a/Workflow/Tests/TestUtilities.swift b/Workflow/Tests/TestUtilities.swift index 226f7f5c7..8f20330a6 100644 --- a/Workflow/Tests/TestUtilities.swift +++ b/Workflow/Tests/TestUtilities.swift @@ -60,15 +60,16 @@ struct StateTransitioningWorkflow: Workflow { // MARK: - HostContext extension HostContext { + @MainActor static func testing( observer: WorkflowObserver? = nil, debugger: WorkflowDebugger? = nil, - runtimeConfig: Runtime.Configuration = Runtime.configuration + runtimeConfig: Runtime.Configuration? = nil ) -> HostContext { HostContext( observer: observer, debugger: debugger, - runtimeConfig: runtimeConfig, + runtimeConfig: runtimeConfig ?? Runtime.configuration, onSinkEvent: { perform, _ in perform() } ) } diff --git a/Workflow/Tests/WorkflowHostTests.swift b/Workflow/Tests/WorkflowHostTests.swift index a5147d5d4..7bc07f033 100644 --- a/Workflow/Tests/WorkflowHostTests.swift +++ b/Workflow/Tests/WorkflowHostTests.swift @@ -20,6 +20,7 @@ import XCTest @_spi(WorkflowRuntimeConfig) @testable import Workflow +@MainActor final class WorkflowHostTests: XCTestCase { func test_updatedInputCausesRenderPass() { let host = WorkflowHost(workflow: TestWorkflow(step: .first)) @@ -58,6 +59,7 @@ final class WorkflowHostTests: XCTestCase { // MARK: Event Emission Tests +@MainActor final class WorkflowHost_EventEmissionTests: XCTestCase { // Previous versions of Workflow would fatalError under this scenario func test_event_sent_to_invalidated_sink_during_action_handling() { @@ -396,6 +398,7 @@ extension WorkflowHost_EventEmissionTests { // MARK: Lifecycle Tests +@MainActor final class WorkflowHost_LifecycleTests: XCTestCase { func test_renderingPublisherCompletesWhenHostIsReleased() { var host: WorkflowHost? = WorkflowHost(workflow: OutputWorkflow()) diff --git a/Workflow/Tests/WorkflowNodeTests.swift b/Workflow/Tests/WorkflowNodeTests.swift index 96ad90d78..cc92dbd65 100644 --- a/Workflow/Tests/WorkflowNodeTests.swift +++ b/Workflow/Tests/WorkflowNodeTests.swift @@ -17,6 +17,7 @@ import XCTest @testable import Workflow +@MainActor final class WorkflowNodeTests: XCTestCase { func test_rendersSimpleWorkflow() { let node = WorkflowNode(workflow: SimpleWorkflow(string: "Foo")) diff --git a/Workflow/Tests/WorkflowObserverTests.swift b/Workflow/Tests/WorkflowObserverTests.swift index c6f210fe0..49943ce70 100644 --- a/Workflow/Tests/WorkflowObserverTests.swift +++ b/Workflow/Tests/WorkflowObserverTests.swift @@ -18,6 +18,7 @@ import XCTest @testable @_spi(WorkflowGlobalObservation) import Workflow +@MainActor final class WorkflowObserverTests: XCTestCase { private var observer: TestObserver! diff --git a/WorkflowSwiftUI/Sources/Store.swift b/WorkflowSwiftUI/Sources/Store.swift index dddb919cb..2b6f4fcfd 100644 --- a/WorkflowSwiftUI/Sources/Store.swift +++ b/WorkflowSwiftUI/Sources/Store.swift @@ -65,8 +65,12 @@ public final class Store: Perceptible { /// executes normally by default. private func withPerceptionCheckSuppressed(_ operation: () -> T) -> T { #if DEBUG && canImport(Observation) + // `Runtime.configuration` is main-actor-isolated. Store state is read + // from SwiftUI view bodies on the main actor in practice; if a debug + // read happens elsewhere, skip suppression rather than trap. if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *), - Runtime.configuration.suppressPerceptionCheckingWhenUsingObservation + Thread.isMainThread, + MainActor.assumeIsolated({ Runtime.configuration.suppressPerceptionCheckingWhenUsingObservation }) { return _PerceptionLocals.$skipPerceptionChecking.withValue(true, operation: operation) } From 4d1d8b4fea839c088fea4ebadaaf0ed82008a435 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 23 Jul 2026 13:04:43 -0500 Subject: [PATCH 02/19] refactor: isolate Workflow protocol requirements to @MainActor render/makeInitialState/workflowDidChange/apply are now @MainActor requirements, and RenderContext is a @MainActor type. Conforming types inherit isolation via protocol-conformance inference, so existing conformances continue to compile unchanged. Co-Authored-By: Claude Fable 5 --- Workflow/Sources/AnyWorkflow.swift | 3 +++ Workflow/Sources/AnyWorkflowConvertible.swift | 6 ++++++ Workflow/Sources/RenderContext.swift | 2 ++ Workflow/Sources/Workflow.swift | 5 +++++ Workflow/Sources/WorkflowAction.swift | 3 ++- Workflow/Tests/AnyWorkflowActionTests.swift | 1 + Workflow/Tests/AnyWorkflowTests.swift | 1 + Workflow/Tests/WorkflowNodeTests.swift | 2 ++ WorkflowCombine/Sources/Publisher+Extensions.swift | 1 + WorkflowCombine/Tests/PublisherTests.swift | 1 + WorkflowCombine/Tests/WorkerTests.swift | 1 + WorkflowConcurrency/Sources/Worker.swift | 2 +- WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift | 1 + WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift | 1 + WorkflowConcurrency/Tests/WorkerTests.swift | 1 + WorkflowReactiveSwift/Tests/SignalProducerTests.swift | 1 + WorkflowReactiveSwift/Tests/SignalTests.swift | 1 + WorkflowReactiveSwift/Tests/WorkerTests.swift | 1 + WorkflowRxSwift/Tests/ObservableTests.swift | 1 + WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift | 1 + WorkflowRxSwift/Tests/WorkerTests.swift | 1 + WorkflowTesting/Sources/Internal/RenderExpectations.swift | 2 ++ WorkflowTesting/Sources/WorkflowActionTester.swift | 1 + WorkflowTesting/Sources/WorkflowRenderTester.swift | 2 ++ .../Tests/TestingFrameworkCompatibilityTests.swift | 2 ++ WorkflowTesting/Tests/WorkflowActionTesterTests.swift | 2 ++ .../Tests/WorkflowRenderTesterFailureTests.swift | 1 + WorkflowTesting/Tests/WorkflowRenderTesterTests.swift | 1 + 28 files changed, 46 insertions(+), 2 deletions(-) diff --git a/Workflow/Sources/AnyWorkflow.swift b/Workflow/Sources/AnyWorkflow.swift index 852d318ac..7af165f92 100644 --- a/Workflow/Sources/AnyWorkflow.swift +++ b/Workflow/Sources/AnyWorkflow.swift @@ -95,6 +95,7 @@ extension AnyWorkflow { /// That type information *is* present in our storage object, however, so we /// pass the context down to that storage object which will ultimately call /// through to `context.render(workflow:key:reducer:)`. + @MainActor func render( context: RenderContext, key: String, @@ -111,6 +112,7 @@ extension AnyWorkflow { fileprivate class AnyStorage { var base: Any { fatalError() } + @MainActor func render( context: RenderContext, key: String, @@ -152,6 +154,7 @@ extension AnyWorkflow { T.self } + @MainActor override func render( context: RenderContext, key: String, diff --git a/Workflow/Sources/AnyWorkflowConvertible.swift b/Workflow/Sources/AnyWorkflowConvertible.swift index 2ff9c44b2..9aaa86b6a 100644 --- a/Workflow/Sources/AnyWorkflowConvertible.swift +++ b/Workflow/Sources/AnyWorkflowConvertible.swift @@ -37,6 +37,7 @@ extension AnyWorkflowConvertible { /// - Parameter key: A string that uniquely identifies this workflow. /// /// - Returns: The `Rendering` generated by the workflow. + @MainActor public func rendered( in context: RenderContext, key: String = "" @@ -46,6 +47,7 @@ extension AnyWorkflowConvertible { asAnyWorkflow().render(context: context, key: key, outputMap: { $0 }) } + @MainActor public func rendered( in context: RenderContext, key: String = "", @@ -56,6 +58,7 @@ extension AnyWorkflowConvertible { asAnyWorkflow().render(context: context, key: key, outputMap: { outputMap($0) }) } + @MainActor public func rendered( in context: RenderContext, key: String = "" @@ -76,6 +79,7 @@ extension AnyWorkflowConvertible where Output == Never { /// - Parameter key: A string that uniquely identifies this workflow. /// /// - Returns: The `Rendering` generated by the workflow. + @MainActor public func rendered(in context: RenderContext, key: String = "") -> Rendering { // Convenience for workflow that have no output allowing them to be rendered with any context @@ -89,6 +93,7 @@ extension AnyWorkflowConvertible where Output == Never { } extension AnyWorkflowConvertible where Rendering == Void { + @MainActor public func running( in context: RenderContext, key: String = "", @@ -101,6 +106,7 @@ extension AnyWorkflowConvertible where Rendering == Void { } extension AnyWorkflowConvertible where Rendering == Void, Output: WorkflowAction { + @MainActor public func running( in context: RenderContext, key: String = "" diff --git a/Workflow/Sources/RenderContext.swift b/Workflow/Sources/RenderContext.swift index 60fe2af4a..5db0e237c 100644 --- a/Workflow/Sources/RenderContext.swift +++ b/Workflow/Sources/RenderContext.swift @@ -46,6 +46,7 @@ import Foundation /// /// The infrastructure then performs a render pass on the child to obtain its /// `Rendering` value, which is then returned to the caller. +@MainActor public class RenderContext: RenderContextType { private(set) var isValid = true @@ -158,6 +159,7 @@ public class RenderContext: RenderContextType { } } +@MainActor protocol RenderContextType: AnyObject { associatedtype WorkflowType: Workflow diff --git a/Workflow/Sources/Workflow.swift b/Workflow/Sources/Workflow.swift index df4a3f99c..9d92f99c1 100644 --- a/Workflow/Sources/Workflow.swift +++ b/Workflow/Sources/Workflow.swift @@ -63,12 +63,14 @@ public protocol Workflow: AnyWorkflowConvertible { /// This method is invoked once when a workflow node comes into existence. /// /// - Returns: The initial state for the workflow. + @MainActor func makeInitialState() -> State /// Called when a new workflow is passed down from the parent to an existing workflow node. /// /// - Parameter previousWorkflow: The workflow before the update. /// - Parameter state: The current state. + @MainActor func workflowDidChange(from previousWorkflow: Self, state: inout State) /// Called by the internal Workflow infrastructure to "render" the current state into `Rendering`. @@ -78,16 +80,19 @@ public protocol Workflow: AnyWorkflowConvertible { /// - Parameter context: The workflow context is the composition point for the workflow tree. To use a nested /// workflow, instantiate it based on the current state, then call `rendered(in:key:outputMap:)`. /// This will return the child's `Rendering` type after creating or updating the nested workflow. + @MainActor func render(state: State, context: RenderContext) -> Rendering } extension Workflow { + @MainActor public func workflowDidChange(from previousWorkflow: Self, state: inout State) {} } /// When State is Void, provide empty `makeInitialState` and `workflowDidChange` /// implementations, making a “stateless workflow”. extension Workflow where State == Void { + @MainActor public func makeInitialState() -> State { () } diff --git a/Workflow/Sources/WorkflowAction.swift b/Workflow/Sources/WorkflowAction.swift index 6503df1db..f90612e99 100644 --- a/Workflow/Sources/WorkflowAction.swift +++ b/Workflow/Sources/WorkflowAction.swift @@ -30,6 +30,7 @@ public protocol WorkflowAction { /// the workflow hierarchy to this workflow's parent. /// > Warning: The `context` parameter should not escape from implementations of this requirement. /// Attempting to access the instance after `apply()` has returned is a client error and will crash. + @MainActor func apply( toState state: inout WorkflowType.State, context: ApplyContext @@ -38,7 +39,7 @@ public protocol WorkflowAction { extension WorkflowAction { /// Closure type signature matching `WorkflowAction`'s `apply()` method. - public typealias ActionApplyClosure = (inout WorkflowType.State, ApplyContext) -> WorkflowType.Output? + public typealias ActionApplyClosure = @MainActor (inout WorkflowType.State, ApplyContext) -> WorkflowType.Output? } /// A type-erased workflow action. diff --git a/Workflow/Tests/AnyWorkflowActionTests.swift b/Workflow/Tests/AnyWorkflowActionTests.swift index 6af882ebe..685ab23ff 100644 --- a/Workflow/Tests/AnyWorkflowActionTests.swift +++ b/Workflow/Tests/AnyWorkflowActionTests.swift @@ -17,6 +17,7 @@ import XCTest @testable import Workflow +@MainActor final class AnyWorkflowActionTests: XCTestCase { func testRetainsBaseActionTypeInfo() { let action = ExampleAction() diff --git a/Workflow/Tests/AnyWorkflowTests.swift b/Workflow/Tests/AnyWorkflowTests.swift index baef3eaf1..d723c196d 100644 --- a/Workflow/Tests/AnyWorkflowTests.swift +++ b/Workflow/Tests/AnyWorkflowTests.swift @@ -107,6 +107,7 @@ extension PassthroughWorkflow { State() } + @MainActor func render(state: State, context: RenderContext>) -> Rendering { child.rendered(in: context) } diff --git a/Workflow/Tests/WorkflowNodeTests.swift b/Workflow/Tests/WorkflowNodeTests.swift index cc92dbd65..35626b4fd 100644 --- a/Workflow/Tests/WorkflowNodeTests.swift +++ b/Workflow/Tests/WorkflowNodeTests.swift @@ -335,6 +335,7 @@ extension CompositeWorkflow { State() } + @MainActor func render(state: State, context: RenderContext>) -> Rendering { Rendering( aRendering: a @@ -415,6 +416,7 @@ extension EventEmittingWorkflow { case helloWorld } + @MainActor func render(state: State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Event.self) diff --git a/WorkflowCombine/Sources/Publisher+Extensions.swift b/WorkflowCombine/Sources/Publisher+Extensions.swift index b65cf7ea1..d582e4e33 100644 --- a/WorkflowCombine/Sources/Publisher+Extensions.swift +++ b/WorkflowCombine/Sources/Publisher+Extensions.swift @@ -16,6 +16,7 @@ import Workflow /// but was limited in the fact that rendering was only available to `AnyPublisher`s. /// this solutions makes it so that all publishers can render its view. extension Publisher where Failure == Never { + @MainActor public func running(in context: RenderContext, key: String = "") where Output == AnyWorkflowAction { diff --git a/WorkflowCombine/Tests/PublisherTests.swift b/WorkflowCombine/Tests/PublisherTests.swift index 5021ecfdd..25aeaad5d 100644 --- a/WorkflowCombine/Tests/PublisherTests.swift +++ b/WorkflowCombine/Tests/PublisherTests.swift @@ -21,6 +21,7 @@ import WorkflowCombineTesting import XCTest @testable import WorkflowCombine +@MainActor class PublisherTests: XCTestCase { func test_publisherWorkflow_usesSideEffectWithKey() { PublisherWorkflow(publisher: Just(1)) diff --git a/WorkflowCombine/Tests/WorkerTests.swift b/WorkflowCombine/Tests/WorkerTests.swift index be465c1af..3618949c1 100644 --- a/WorkflowCombine/Tests/WorkerTests.swift +++ b/WorkflowCombine/Tests/WorkerTests.swift @@ -19,6 +19,7 @@ import Workflow import XCTest @testable import WorkflowCombine +@MainActor class WorkerTests: XCTestCase { func testExpectedWorker() { PublisherTestWorkflow(key: "123") diff --git a/WorkflowConcurrency/Sources/Worker.swift b/WorkflowConcurrency/Sources/Worker.swift index c0695086d..8eea2695e 100644 --- a/WorkflowConcurrency/Sources/Worker.swift +++ b/WorkflowConcurrency/Sources/Worker.swift @@ -74,7 +74,7 @@ struct WorkerWorkflow: Workflow { } logger.logOutput() logger.logFinished(status: "Finished") - await send(output) + send(output) } lifetime.onEnded { task.cancel() diff --git a/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift b/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift index a4ea70830..c5432907e 100644 --- a/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift +++ b/WorkflowConcurrency/Tests/AsyncOperationWorkerTests.swift @@ -20,6 +20,7 @@ import WorkflowTesting import XCTest @testable import WorkflowConcurrency +@MainActor final class AsyncOperationWorkerTests: XCTestCase { func testWorkerOutput() { let host = WorkflowHost( diff --git a/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift b/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift index 716494e67..40ef77fc1 100644 --- a/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift +++ b/WorkflowConcurrency/Tests/AsyncSequenceWorkerTests.swift @@ -4,6 +4,7 @@ import WorkflowTesting import XCTest @testable import WorkflowConcurrency +@MainActor class AsyncSequenceWorkerTests: XCTestCase { func testWorkerOutput() { let host = WorkflowHost( diff --git a/WorkflowConcurrency/Tests/WorkerTests.swift b/WorkflowConcurrency/Tests/WorkerTests.swift index 2e180c130..328bb8d01 100644 --- a/WorkflowConcurrency/Tests/WorkerTests.swift +++ b/WorkflowConcurrency/Tests/WorkerTests.swift @@ -20,6 +20,7 @@ import WorkflowTesting import XCTest @testable import WorkflowConcurrency +@MainActor class WorkerTests: XCTestCase { func testWorkerOutput() { let host = WorkflowHost( diff --git a/WorkflowReactiveSwift/Tests/SignalProducerTests.swift b/WorkflowReactiveSwift/Tests/SignalProducerTests.swift index 8855f9769..b3d56f94a 100644 --- a/WorkflowReactiveSwift/Tests/SignalProducerTests.swift +++ b/WorkflowReactiveSwift/Tests/SignalProducerTests.swift @@ -21,6 +21,7 @@ import XCTest @testable import Workflow @testable import WorkflowReactiveSwift +@MainActor class SignalProducerTests: XCTestCase { func test_signalProducerWorkflow_usesSideEffectWithKey() { let signalProducer = SignalProducer(value: 1) diff --git a/WorkflowReactiveSwift/Tests/SignalTests.swift b/WorkflowReactiveSwift/Tests/SignalTests.swift index d61ed034e..0fa1ba623 100644 --- a/WorkflowReactiveSwift/Tests/SignalTests.swift +++ b/WorkflowReactiveSwift/Tests/SignalTests.swift @@ -20,6 +20,7 @@ import ReactiveSwift import XCTest @testable import Workflow +@MainActor class SignalTests: XCTestCase { func test_output() { let (signal, observer) = Signal.pipe() diff --git a/WorkflowReactiveSwift/Tests/WorkerTests.swift b/WorkflowReactiveSwift/Tests/WorkerTests.swift index f57fa9e97..c10576297 100644 --- a/WorkflowReactiveSwift/Tests/WorkerTests.swift +++ b/WorkflowReactiveSwift/Tests/WorkerTests.swift @@ -21,6 +21,7 @@ import WorkflowTesting import XCTest @testable import WorkflowReactiveSwift +@MainActor class WorkerTests: XCTestCase { func testExpectedWorker() { SignalProducerTestWorkflow(key: "123") diff --git a/WorkflowRxSwift/Tests/ObservableTests.swift b/WorkflowRxSwift/Tests/ObservableTests.swift index 7fa07b2a0..1f8b02153 100644 --- a/WorkflowRxSwift/Tests/ObservableTests.swift +++ b/WorkflowRxSwift/Tests/ObservableTests.swift @@ -21,6 +21,7 @@ import WorkflowTesting import XCTest @testable import WorkflowRxSwift +@MainActor class ObservableTests: XCTestCase { func test_observableWorkflow_usesSideEffectWithKey() { let observable = Observable.just(1) diff --git a/WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift b/WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift index 8e75c8ba7..ee6da5950 100644 --- a/WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift +++ b/WorkflowRxSwift/Tests/Rx+ReactiveWorkers.swift @@ -22,6 +22,7 @@ import Workflow import WorkflowReactiveSwift import XCTest +@MainActor class Rx_ReactiveWorkersTests: XCTestCase { func test_outputs_fromRxSwiftAndReactiveSwift() { let host = WorkflowHost( diff --git a/WorkflowRxSwift/Tests/WorkerTests.swift b/WorkflowRxSwift/Tests/WorkerTests.swift index 361f93b81..f4094611a 100644 --- a/WorkflowRxSwift/Tests/WorkerTests.swift +++ b/WorkflowRxSwift/Tests/WorkerTests.swift @@ -22,6 +22,7 @@ import WorkflowTesting import XCTest @testable import WorkflowRxSwift +@MainActor class WorkerTests: XCTestCase { func testExpectedWorker() { ObservableTestWorkflow(key: "123") diff --git a/WorkflowTesting/Sources/Internal/RenderExpectations.swift b/WorkflowTesting/Sources/Internal/RenderExpectations.swift index 061c4cb1c..9604a2a4a 100644 --- a/WorkflowTesting/Sources/Internal/RenderExpectations.swift +++ b/WorkflowTesting/Sources/Internal/RenderExpectations.swift @@ -59,6 +59,7 @@ extension RenderTester { self.line = line } + @MainActor func apply(context: ContextType) where ContextType: RenderContextType, ContextType.WorkflowType == WorkflowType {} } @@ -70,6 +71,7 @@ extension RenderTester { super.init(key: key, file: file, line: line) } + @MainActor override func apply(context: ContextType) where ContextType: RenderContextType, ContextType.WorkflowType == WorkflowType { let sink = context.makeSink(of: ActionType.self) sink.send(action) diff --git a/WorkflowTesting/Sources/WorkflowActionTester.swift b/WorkflowTesting/Sources/WorkflowActionTester.swift index e352a54eb..c1868ebe5 100644 --- a/WorkflowTesting/Sources/WorkflowActionTester.swift +++ b/WorkflowTesting/Sources/WorkflowActionTester.swift @@ -110,6 +110,7 @@ public struct WorkflowActionTester where A /// /// - returns: A new state tester containing the state and output (if any) after the update. @discardableResult + @MainActor public func send(action: Action) -> WorkflowActionTester where Action.WorkflowType == WorkflowType { diff --git a/WorkflowTesting/Sources/WorkflowRenderTester.swift b/WorkflowTesting/Sources/WorkflowRenderTester.swift index 7bf9b527b..ae8a63c3f 100644 --- a/WorkflowTesting/Sources/WorkflowRenderTester.swift +++ b/WorkflowTesting/Sources/WorkflowRenderTester.swift @@ -28,6 +28,7 @@ extension Workflow { } /// Returns a `RenderTester` with an initial state provided by `self.makeInitialState()` + @MainActor public func renderTester() -> RenderTester { renderTester(initialState: makeInitialState()) } @@ -257,6 +258,7 @@ public struct RenderTester { /// - assertions: A closure called with the produced rendering for verification /// - Returns: A `RenderTesterResult` that can be used to verify expected resulting state or outputs. @discardableResult + @MainActor public func render( file: StaticString = #file, line: UInt = #line, assertions: (WorkflowType.Rendering) throws -> Void diff --git a/WorkflowTesting/Tests/TestingFrameworkCompatibilityTests.swift b/WorkflowTesting/Tests/TestingFrameworkCompatibilityTests.swift index a13ee4f88..de0b909ab 100644 --- a/WorkflowTesting/Tests/TestingFrameworkCompatibilityTests.swift +++ b/WorkflowTesting/Tests/TestingFrameworkCompatibilityTests.swift @@ -20,6 +20,7 @@ import XCTest @testable import WorkflowTesting +@MainActor struct SwiftTestingCompatibilityTests { @Test func testInternalFailureRecordsExpectationFailure_swiftTesting() { @@ -32,6 +33,7 @@ struct SwiftTestingCompatibilityTests { } } +@MainActor final class XCTestCompatibilityTests: XCTestCase { func testInternalFailureRecordsExpectationFailure_xctest() { XCTExpectFailure { diff --git a/WorkflowTesting/Tests/WorkflowActionTesterTests.swift b/WorkflowTesting/Tests/WorkflowActionTesterTests.swift index 719f7dbcc..e98a9852f 100644 --- a/WorkflowTesting/Tests/WorkflowActionTesterTests.swift +++ b/WorkflowTesting/Tests/WorkflowActionTesterTests.swift @@ -19,6 +19,7 @@ import Testing import Workflow @testable import WorkflowTesting +@MainActor struct WorkflowActionTesterTests { @Test func stateTransitions() { TestAction @@ -144,6 +145,7 @@ extension WorkflowActionTesterTests { // that xcodebuild bug is resolved. import XCTest +@MainActor final class WorkflowActionTesterExpectedFailureTests: XCTestCase { func test_old_api_errors_accessing_optional_through_apply_context_without_proper_setup() { withExpectedIssue("reading optional value through context without workflow should fail but not crash") { diff --git a/WorkflowTesting/Tests/WorkflowRenderTesterFailureTests.swift b/WorkflowTesting/Tests/WorkflowRenderTesterFailureTests.swift index 90955f0e6..c1865ed9e 100644 --- a/WorkflowTesting/Tests/WorkflowRenderTesterFailureTests.swift +++ b/WorkflowTesting/Tests/WorkflowRenderTesterFailureTests.swift @@ -23,6 +23,7 @@ import XCTest /// WorkflowRenderTesterFailureTests does. /// /// Tests that the assertion failures actually assert failures. +@MainActor final class WorkflowRenderTesterFailureTests: XCTestCase { var expectedFailureStrings: [String] = [] diff --git a/WorkflowTesting/Tests/WorkflowRenderTesterTests.swift b/WorkflowTesting/Tests/WorkflowRenderTesterTests.swift index 7d48949a2..0d5b9f781 100644 --- a/WorkflowTesting/Tests/WorkflowRenderTesterTests.swift +++ b/WorkflowTesting/Tests/WorkflowRenderTesterTests.swift @@ -19,6 +19,7 @@ import Testing import Workflow import WorkflowTesting +@MainActor struct WorkflowRenderTesterTests { @Test func render() { let renderTester = TestWorkflow(initialText: "initial").renderTester() From a4206c71f788bbb586d1d498d0fb43c52fef1234 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 23 Jul 2026 13:14:29 -0500 Subject: [PATCH 03/19] feat: add internal AsyncMulticaster for async host observation Co-Authored-By: Claude Fable 5 --- Workflow/Sources/AsyncMulticaster.swift | 84 ++++++++++++++++++++++ Workflow/Tests/AsyncMulticasterTests.swift | 72 +++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 Workflow/Sources/AsyncMulticaster.swift create mode 100644 Workflow/Tests/AsyncMulticasterTests.swift diff --git a/Workflow/Sources/AsyncMulticaster.swift b/Workflow/Sources/AsyncMulticaster.swift new file mode 100644 index 000000000..ad8245ce1 --- /dev/null +++ b/Workflow/Sources/AsyncMulticaster.swift @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Fans values out to any number of independent `AsyncStream` consumers. +/// +/// Each call to `makeStream` returns an independent stream: every consumer +/// receives every value yielded after its stream was created (subject to the +/// requested buffering policy), optionally preceded by an initial value. +/// Streams finish when `finish()` is called or when the multicaster is +/// deallocated. +@MainActor +final class AsyncMulticaster { + private var continuations: [UUID: AsyncStream.Continuation] = [:] + private var isFinished = false + + nonisolated init() {} + + func makeStream( + bufferingPolicy: AsyncStream.Continuation.BufferingPolicy, + initial: Element? = nil + ) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream( + of: Element.self, + bufferingPolicy: bufferingPolicy + ) + + if let initial { + continuation.yield(initial) + } + + guard !isFinished else { + continuation.finish() + return stream + } + + let id = UUID() + continuations[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in + self?.continuations[id] = nil + } + } + + return stream + } + + func yield(_ element: Element) { + for continuation in continuations.values { + continuation.yield(element) + } + } + + func finish() { + isFinished = true + let existing = continuations + continuations.removeAll() + for continuation in existing.values { + continuation.finish() + } + } + + deinit { + // Continuations are Sendable; finishing them from a nonisolated + // deinit is safe. (Stored-property access is permitted in deinit.) + for continuation in continuations.values { + continuation.finish() + } + } +} diff --git a/Workflow/Tests/AsyncMulticasterTests.swift b/Workflow/Tests/AsyncMulticasterTests.swift new file mode 100644 index 000000000..5ae39cdff --- /dev/null +++ b/Workflow/Tests/AsyncMulticasterTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import Workflow + +@MainActor +final class AsyncMulticasterTests: XCTestCase { + func test_eachConsumerReceivesEveryValue() async { + let multicaster = AsyncMulticaster() + + let streamA = multicaster.makeStream(bufferingPolicy: .unbounded) + let streamB = multicaster.makeStream(bufferingPolicy: .unbounded) + + multicaster.yield(1) + multicaster.yield(2) + multicaster.finish() + + var receivedA: [Int] = [] + for await value in streamA { receivedA.append(value) } + var receivedB: [Int] = [] + for await value in streamB { receivedB.append(value) } + + XCTAssertEqual(receivedA, [1, 2]) + XCTAssertEqual(receivedB, [1, 2]) + } + + func test_initialValueIsYieldedFirst() async { + let multicaster = AsyncMulticaster() + let stream = multicaster.makeStream(bufferingPolicy: .unbounded, initial: 0) + + multicaster.yield(1) + multicaster.finish() + + var received: [Int] = [] + for await value in stream { received.append(value) } + XCTAssertEqual(received, [0, 1]) + } + + func test_bufferingNewestDropsStaleValues() async { + let multicaster = AsyncMulticaster() + let stream = multicaster.makeStream(bufferingPolicy: .bufferingNewest(1), initial: 0) + + // Consumer hasn't started; only the newest value should survive. + multicaster.yield(1) + multicaster.yield(2) + multicaster.yield(3) + multicaster.finish() + + var received: [Int] = [] + for await value in stream { received.append(value) } + XCTAssertEqual(received, [3]) + } + + func test_streamsMadeAfterFinishAreImmediatelyFinished() async { + let multicaster = AsyncMulticaster() + multicaster.finish() + + let stream = multicaster.makeStream(bufferingPolicy: .unbounded) + var received: [Int] = [] + for await value in stream { received.append(value) } + XCTAssertEqual(received, []) + } + + func test_deallocation_finishesStreams() async { + var multicaster: AsyncMulticaster? = AsyncMulticaster() + let stream = multicaster!.makeStream(bufferingPolicy: .unbounded) + multicaster!.yield(1) + multicaster = nil + + var received: [Int] = [] + for await value in stream { received.append(value) } + XCTAssertEqual(received, [1]) + } +} From 8d6c85acd25f3e84ae47f70c42493104c6ad1bc0 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 23 Jul 2026 13:21:28 -0500 Subject: [PATCH 04/19] fix: never yield the initial value from a finished AsyncMulticaster Streams created after finish() are immediately finished and empty, matching the documented contract. Co-Authored-By: Claude Fable 5 --- Workflow/Sources/AsyncMulticaster.swift | 8 ++++---- Workflow/Tests/AsyncMulticasterTests.swift | 10 ++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Workflow/Sources/AsyncMulticaster.swift b/Workflow/Sources/AsyncMulticaster.swift index ad8245ce1..43825e560 100644 --- a/Workflow/Sources/AsyncMulticaster.swift +++ b/Workflow/Sources/AsyncMulticaster.swift @@ -39,15 +39,15 @@ final class AsyncMulticaster { bufferingPolicy: bufferingPolicy ) - if let initial { - continuation.yield(initial) - } - guard !isFinished else { continuation.finish() return stream } + if let initial { + continuation.yield(initial) + } + let id = UUID() continuations[id] = continuation continuation.onTermination = { [weak self] _ in diff --git a/Workflow/Tests/AsyncMulticasterTests.swift b/Workflow/Tests/AsyncMulticasterTests.swift index 5ae39cdff..28bfe9b37 100644 --- a/Workflow/Tests/AsyncMulticasterTests.swift +++ b/Workflow/Tests/AsyncMulticasterTests.swift @@ -59,6 +59,16 @@ final class AsyncMulticasterTests: XCTestCase { XCTAssertEqual(received, []) } + func test_streamsMadeAfterFinishAreImmediatelyFinished_evenWithInitialValue() async { + let multicaster = AsyncMulticaster() + multicaster.finish() + + let stream = multicaster.makeStream(bufferingPolicy: .unbounded, initial: 42) + var received: [Int] = [] + for await value in stream { received.append(value) } + XCTAssertEqual(received, []) + } + func test_deallocation_finishesStreams() async { var multicaster: AsyncMulticaster? = AsyncMulticaster() let stream = multicaster!.makeStream(bufferingPolicy: .unbounded) From de0c545e1a7e6b31a5081df25fe22e28bed91c7b Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 23 Jul 2026 13:26:18 -0500 Subject: [PATCH 05/19] feat: add AsyncStream-based renderings/outputs to WorkflowHost Natively buffered streams (not Publisher.values, which drops events under backpressure). renderings replays the current value and conflates to the newest; outputs is unbounded and lossless. Co-Authored-By: Claude Fable 5 --- Workflow/Sources/WorkflowHost.swift | 35 +++++++- Workflow/Tests/WorkflowHostAsyncTests.swift | 99 +++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 Workflow/Tests/WorkflowHostAsyncTests.swift diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index da9ababd4..eef628455 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -41,6 +41,9 @@ public final class WorkflowHost { private let renderingSubject: CurrentValueSubject + private let renderingMulticaster = AsyncMulticaster() + private let outputMulticaster = AsyncMulticaster() + /// The current `Rendering` produced by the root workflow in the hierarchy. A new `Rendering` value is produced /// as state transitions occur within the hierarchy. public var rendering: WorkflowType.Rendering { @@ -54,6 +57,21 @@ public final class WorkflowHost { renderingSubject.eraseToAnyPublisher() } + /// An asynchronous sequence of the `Rendering` values produced by the root + /// workflow in the hierarchy. Yields the most recent `Rendering` when + /// iteration begins, followed by a new value after each subsequent render + /// pass. A slow consumer only ever observes the latest rendering; stale + /// intermediate values are dropped. + /// + /// Each access returns an independent stream. Obtain a fresh stream per + /// consumer; a single stream must not be iterated more than once. + public var renderings: AsyncStream { + renderingMulticaster.makeStream( + bufferingPolicy: .bufferingNewest(1), + initial: renderingSubject.value + ) + } + /// Context object to pass down to descendant nodes in the tree. let context: HostContext @@ -144,12 +162,15 @@ public final class WorkflowHost { private func handle(output: WorkflowNode.Output) { let shouldRender = !shouldSkipRenderForOutput(output) if shouldRender { - renderingSubject.send(rootNode.render()) + let rendering = rootNode.render() + renderingSubject.send(rendering) + renderingMulticaster.yield(rendering) } // Always emit an output, regardless of whether a render occurs if let outputEvent = output.outputEvent { outputSubject.send(outputEvent) + outputMulticaster.yield(outputEvent) } debugger?.didUpdate( @@ -169,6 +190,18 @@ public final class WorkflowHost { } } +extension WorkflowHost where WorkflowType.Output: Sendable { + /// An asynchronous sequence of the output events emitted by the root + /// workflow in the hierarchy. Every output emitted after the stream is + /// created is delivered, in order — the stream buffers without dropping. + /// + /// Each access returns an independent stream. Obtain a fresh stream per + /// consumer; a single stream must not be iterated more than once. + public var outputs: AsyncStream { + outputMulticaster.makeStream(bufferingPolicy: .unbounded) + } +} + // MARK: - Conditional Rendering Utilities extension WorkflowHost { diff --git a/Workflow/Tests/WorkflowHostAsyncTests.swift b/Workflow/Tests/WorkflowHostAsyncTests.swift new file mode 100644 index 000000000..09d4e73e4 --- /dev/null +++ b/Workflow/Tests/WorkflowHostAsyncTests.swift @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import XCTest +@testable import Workflow + +@MainActor +final class WorkflowHostAsyncTests: XCTestCase { + func test_renderings_conflatesToNewestForSlowConsumer() async { + let host = WorkflowHost(workflow: EchoWorkflow(value: 1)) + let renderings = host.renderings + + // No consumer is iterating yet; .bufferingNewest(1) keeps only the + // most recent value (updates overwrite the buffered initial value). + host.update(workflow: EchoWorkflow(value: 2)) + host.update(workflow: EchoWorkflow(value: 3)) + + var iterator = renderings.makeAsyncIterator() + let first = await iterator.next() + XCTAssertEqual(first, 3) + } + + func test_renderings_currentValueFirst_whenConsumedBeforeUpdates() async { + let host = WorkflowHost(workflow: EchoWorkflow(value: 1)) + var iterator = host.renderings.makeAsyncIterator() + + let first = await iterator.next() + XCTAssertEqual(first, 1) + } + + func test_outputs_deliversEveryOutputInOrder_noDrops() async { + let host = WorkflowHost(workflow: OutputEmittingWorkflow()) + let outputs = host.outputs + let sink = host.rendering // Rendering is the event-sending closure + + // Emit a burst BEFORE consuming: a .values-style bridge would drop + // these; the buffered stream must not. + for i in 0 ..< 100 { + sink(i) + } + + var received: [Int] = [] + var iterator = outputs.makeAsyncIterator() + for _ in 0 ..< 100 { + if let value = await iterator.next() { + received.append(value) + } + } + + XCTAssertEqual(received, Array(0 ..< 100)) + } + + func test_streams_finishWhenHostIsDeallocated() async { + var host: WorkflowHost? = WorkflowHost(workflow: EchoWorkflow(value: 1)) + let renderings = host!.renderings + host = nil + + var received: [Int] = [] + for await value in renderings { + received.append(value) + } + // Initial value was buffered; then the stream finished. + XCTAssertEqual(received, [1]) + } + + // MARK: - Fixtures + + fileprivate struct EchoWorkflow: Workflow { + var value: Int + typealias State = Void + typealias Rendering = Int + func render(state: State, context: RenderContext) -> Int { value } + } + + /// Renders a closure that emits its argument as an Output. + fileprivate struct OutputEmittingWorkflow: Workflow { + typealias State = Void + typealias Output = Int + typealias Rendering = (Int) -> Void + + func render(state: State, context: RenderContext) -> Rendering { + let sink = context.makeOutputSink() + return { sink.send($0) } + } + } +} From 8e736998bbda9925ff9f863be89f076bcc42f602 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 23 Jul 2026 13:37:32 -0500 Subject: [PATCH 06/19] feat: expose AsyncStream outputs on WorkflowHostingController Co-Authored-By: Claude Fable 5 --- .../Hosting/WorkflowHostingController.swift | 11 +++++++++++ .../Tests/WorkflowHostingControllerTests.swift | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift index 1d300df56..0c3dcea68 100644 --- a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift +++ b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift @@ -216,4 +216,15 @@ extension WorkflowHostingController: SingleScreenContaining { } } +// MARK: - Async Output Stream + +extension WorkflowHostingController where Output: Sendable { + /// An asynchronous sequence of output events from the bound workflow. + /// Each access returns an independent stream; obtain a fresh stream per + /// consumer. + public var outputs: AsyncStream { + workflowHost.outputs + } +} + #endif diff --git a/WorkflowUI/Tests/WorkflowHostingControllerTests.swift b/WorkflowUI/Tests/WorkflowHostingControllerTests.swift index 723feced1..8dccfb869 100644 --- a/WorkflowUI/Tests/WorkflowHostingControllerTests.swift +++ b/WorkflowUI/Tests/WorkflowHostingControllerTests.swift @@ -99,6 +99,20 @@ class WorkflowHostingControllerTests: XCTestCase { cancellable.cancel() } + func test_outputs_asyncStream_deliversWorkflowOutput() async { + let (signal, observer) = Signal.pipe() + let workflow = SubscribingWorkflow(subscription: signal) + let container = WorkflowHostingController(workflow: workflow) + + var iterator = container.outputs.makeAsyncIterator() + + observer.send(value: 3) + + let output = await iterator.next() + + XCTAssertEqual(3, output) + } + func test_container_with_anyworkflow() { let (signal, observer) = Signal.pipe() let workflow = SubscribingWorkflow(subscription: signal) From 532cafe4e7ae7a7d839ffccc6f2a2384f01e0ce9 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 23 Jul 2026 14:07:51 -0500 Subject: [PATCH 07/19] fix: annotate sample workflows for @MainActor protocol requirements Sample conformances with witnesses declared in separate extensions do not infer isolation from the Workflow protocol and need explicit annotations after the runtime's @MainActor migration. Co-Authored-By: Claude Fable 5 --- Samples/AsyncWorker/Sources/AsyncWorkerWorkflow.swift | 1 + Samples/SampleApp/Sources/DemoWorkflow.swift | 1 + Samples/SampleApp/Sources/RootWorkflow.swift | 1 + Samples/SampleApp/Sources/WelcomeWorkflow.swift | 1 + Samples/SplitScreenContainer/DemoApp/DemoWorkflow.swift | 2 ++ .../Sources/Authentication/AuthenticationWorkflow.swift | 1 + Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift | 1 + Samples/TicTacToe/Sources/Game/ConfirmQuitWorkflow.swift | 1 + Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift | 1 + Samples/TicTacToe/Sources/Game/TakeTurnsWorkflow.swift | 1 + Samples/TicTacToe/Sources/Main/MainWorkflow.swift | 1 + Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift | 1 + Samples/TicTacToe/Tests/ConfirmQuitWorkflowTests.swift | 1 + Samples/TicTacToe/Tests/LoginWorkflowTests.swift | 1 + Samples/TicTacToe/Tests/MainWorkflowTests.swift | 1 + Samples/TicTacToe/Tests/RunGameWorkflowTests.swift | 1 + Samples/TicTacToe/Tests/TakeTurnsWorkflowTests.swift | 1 + .../Tutorial1Complete/Sources/Welcome/WelcomeWorkflow.swift | 1 + .../Frameworks/Tutorial2Complete/Sources/RootWorkflow.swift | 1 + .../Tutorial2Complete/Sources/Welcome/WelcomeWorkflow.swift | 1 + .../Frameworks/Tutorial3Complete/Sources/RootWorkflow.swift | 1 + .../Tutorial3Complete/Sources/Todo/Edit/TodoEditWorkflow.swift | 1 + .../Tutorial3Complete/Sources/Todo/List/TodoListWorkflow.swift | 1 + .../Tutorial3Complete/Sources/Welcome/WelcomeWorkflow.swift | 1 + .../Frameworks/Tutorial4Complete/Sources/RootWorkflow.swift | 1 + .../Tutorial4Complete/Sources/Todo/Edit/TodoEditWorkflow.swift | 1 + .../Tutorial4Complete/Sources/Todo/List/TodoListWorkflow.swift | 1 + .../Tutorial4Complete/Sources/Todo/TodoWorkflow.swift | 1 + .../Tutorial4Complete/Sources/Welcome/WelcomeWorkflow.swift | 1 + .../Frameworks/Tutorial5Complete/Sources/RootWorkflow.swift | 1 + .../Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift | 1 + .../Tutorial5Complete/Sources/Todo/List/TodoListWorkflow.swift | 1 + .../Tutorial5Complete/Sources/Todo/TodoWorkflow.swift | 1 + .../Tutorial5Complete/Sources/Welcome/WelcomeWorkflow.swift | 1 + .../Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift | 1 + .../Tutorial5Complete/Tests/TodoEditWorkflowTests.swift | 1 + .../Tutorial5Complete/Tests/TodoListWorkflowTests.swift | 1 + .../Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift | 1 + .../Tutorial5Complete/Tests/WelcomeWorkflowTests.swift | 1 + .../WorkflowCombineSampleApp/DemoWorkflow.swift | 1 + .../WorkflowCombineSampleAppUnitTests/DemoWorkflowTests.swift | 1 + 41 files changed, 42 insertions(+) diff --git a/Samples/AsyncWorker/Sources/AsyncWorkerWorkflow.swift b/Samples/AsyncWorker/Sources/AsyncWorkerWorkflow.swift index 4b8e29c97..b00a56f25 100644 --- a/Samples/AsyncWorker/Sources/AsyncWorkerWorkflow.swift +++ b/Samples/AsyncWorker/Sources/AsyncWorkerWorkflow.swift @@ -55,6 +55,7 @@ extension AsyncWorkerWorkflow { extension AsyncWorkerWorkflow { typealias Rendering = MessageScreen + @MainActor func render(state: AsyncWorkerWorkflow.State, context: RenderContext) -> Rendering { NetworkRequestWorker() .mapOutput { result in diff --git a/Samples/SampleApp/Sources/DemoWorkflow.swift b/Samples/SampleApp/Sources/DemoWorkflow.swift index d022df9e8..d21a7b697 100644 --- a/Samples/SampleApp/Sources/DemoWorkflow.swift +++ b/Samples/SampleApp/Sources/DemoWorkflow.swift @@ -133,6 +133,7 @@ struct RefreshWorker: Worker { extension DemoWorkflow { typealias Rendering = DemoScreen + @MainActor func render(state: DemoWorkflow.State, context: RenderContext) -> Rendering { let color: UIColor = switch state.colorState { case .red: diff --git a/Samples/SampleApp/Sources/RootWorkflow.swift b/Samples/SampleApp/Sources/RootWorkflow.swift index 543c7ebfc..d82d18b5e 100644 --- a/Samples/SampleApp/Sources/RootWorkflow.swift +++ b/Samples/SampleApp/Sources/RootWorkflow.swift @@ -61,6 +61,7 @@ extension RootWorkflow { extension RootWorkflow { typealias Rendering = CrossFadeScreen + @MainActor func render(state: RootWorkflow.State, context: RenderContext) -> Rendering { switch state { case .welcome: diff --git a/Samples/SampleApp/Sources/WelcomeWorkflow.swift b/Samples/SampleApp/Sources/WelcomeWorkflow.swift index ae77ea767..dc8bde1ff 100644 --- a/Samples/SampleApp/Sources/WelcomeWorkflow.swift +++ b/Samples/SampleApp/Sources/WelcomeWorkflow.swift @@ -64,6 +64,7 @@ extension WelcomeWorkflow { extension WelcomeWorkflow { typealias Rendering = WelcomeScreen + @MainActor func render(state: WelcomeWorkflow.State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) return WelcomeScreen( diff --git a/Samples/SplitScreenContainer/DemoApp/DemoWorkflow.swift b/Samples/SplitScreenContainer/DemoApp/DemoWorkflow.swift index 2a6a05d89..a38fa147a 100644 --- a/Samples/SplitScreenContainer/DemoApp/DemoWorkflow.swift +++ b/Samples/SplitScreenContainer/DemoApp/DemoWorkflow.swift @@ -63,6 +63,7 @@ extension DemoWorkflow { private static let colors: [UIColor] = [.red, .blue, .green, .yellow] private static let complimentaryColors: [UIColor] = [.blue, .green, .yellow, .purple] + @MainActor func render(state: State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) @@ -75,6 +76,7 @@ extension DemoWorkflow { ) } + @MainActor private func leadingScreenFor(state: State, context: RenderContext) -> AnyScreen { let sink = context.makeSink(of: Action.self) diff --git a/Samples/TicTacToe/Sources/Authentication/AuthenticationWorkflow.swift b/Samples/TicTacToe/Sources/Authentication/AuthenticationWorkflow.swift index 7cee4000b..e777ee361 100644 --- a/Samples/TicTacToe/Sources/Authentication/AuthenticationWorkflow.swift +++ b/Samples/TicTacToe/Sources/Authentication/AuthenticationWorkflow.swift @@ -165,6 +165,7 @@ extension AuthenticationWorkflow { extension AuthenticationWorkflow { typealias Rendering = AlertContainerScreen>> + @MainActor func render(state: AuthenticationWorkflow.State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) diff --git a/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift b/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift index 55e7261a2..23e5c1d85 100644 --- a/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift +++ b/Samples/TicTacToe/Sources/Authentication/LoginWorkflow.swift @@ -71,6 +71,7 @@ extension LoginWorkflow { extension LoginWorkflow { typealias Rendering = LoginScreen + @MainActor func render(state: LoginWorkflow.State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) diff --git a/Samples/TicTacToe/Sources/Game/ConfirmQuitWorkflow.swift b/Samples/TicTacToe/Sources/Game/ConfirmQuitWorkflow.swift index a38a7ae06..9b0080872 100644 --- a/Samples/TicTacToe/Sources/Game/ConfirmQuitWorkflow.swift +++ b/Samples/TicTacToe/Sources/Game/ConfirmQuitWorkflow.swift @@ -77,6 +77,7 @@ extension ConfirmQuitWorkflow { extension ConfirmQuitWorkflow { typealias Rendering = (ConfirmQuitScreen, Alert?) + @MainActor func render(state: ConfirmQuitWorkflow.State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) var alert: Alert? diff --git a/Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift b/Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift index 7c10603ed..965f136c1 100644 --- a/Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift +++ b/Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift @@ -86,6 +86,7 @@ extension RunGameWorkflow { extension RunGameWorkflow { typealias Rendering = AlertContainerScreen>> + @MainActor func render(state: RunGameWorkflow.State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) var modals: [ModalContainerScreenModal] = [] diff --git a/Samples/TicTacToe/Sources/Game/TakeTurnsWorkflow.swift b/Samples/TicTacToe/Sources/Game/TakeTurnsWorkflow.swift index e8f27fc81..b250ff811 100644 --- a/Samples/TicTacToe/Sources/Game/TakeTurnsWorkflow.swift +++ b/Samples/TicTacToe/Sources/Game/TakeTurnsWorkflow.swift @@ -85,6 +85,7 @@ extension TakeTurnsWorkflow { extension TakeTurnsWorkflow { typealias Rendering = GamePlayScreen + @MainActor func render(state: TakeTurnsWorkflow.State, context: RenderContext) -> Rendering { let sink = context.makeSink(of: Action.self) diff --git a/Samples/TicTacToe/Sources/Main/MainWorkflow.swift b/Samples/TicTacToe/Sources/Main/MainWorkflow.swift index c3f214b70..dfb7fecb2 100644 --- a/Samples/TicTacToe/Sources/Main/MainWorkflow.swift +++ b/Samples/TicTacToe/Sources/Main/MainWorkflow.swift @@ -67,6 +67,7 @@ extension MainWorkflow { extension MainWorkflow { typealias Rendering = AlertContainerScreen>> + @MainActor func render(state: MainWorkflow.State, context: RenderContext) -> Rendering { switch state { case .authenticating: diff --git a/Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift b/Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift index e486f00e2..e3119ebec 100644 --- a/Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/AuthenticationWorkflowTests.swift @@ -21,6 +21,7 @@ import XCTest @testable import TicTacToe +@MainActor class AuthenticationWorkflowTests: XCTestCase { // MARK: Action Tests diff --git a/Samples/TicTacToe/Tests/ConfirmQuitWorkflowTests.swift b/Samples/TicTacToe/Tests/ConfirmQuitWorkflowTests.swift index 18595b1e3..6dcc68248 100644 --- a/Samples/TicTacToe/Tests/ConfirmQuitWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/ConfirmQuitWorkflowTests.swift @@ -20,6 +20,7 @@ import XCTest @testable import TicTacToe +@MainActor class ConfirmQuitWorkflowTests: XCTestCase { // MARK: Action Tests diff --git a/Samples/TicTacToe/Tests/LoginWorkflowTests.swift b/Samples/TicTacToe/Tests/LoginWorkflowTests.swift index e26df9902..8def01844 100644 --- a/Samples/TicTacToe/Tests/LoginWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/LoginWorkflowTests.swift @@ -20,6 +20,7 @@ import XCTest @testable import TicTacToe +@MainActor class LoginWorkflowTests: XCTestCase { // MARK: Action Tests diff --git a/Samples/TicTacToe/Tests/MainWorkflowTests.swift b/Samples/TicTacToe/Tests/MainWorkflowTests.swift index 491ad3f62..cf27ea3be 100644 --- a/Samples/TicTacToe/Tests/MainWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/MainWorkflowTests.swift @@ -22,6 +22,7 @@ import XCTest @testable import TicTacToe +@MainActor class MainWorkflowTests: XCTestCase { // MARK: Action Tests diff --git a/Samples/TicTacToe/Tests/RunGameWorkflowTests.swift b/Samples/TicTacToe/Tests/RunGameWorkflowTests.swift index 1339175bd..bc9bbcd3c 100644 --- a/Samples/TicTacToe/Tests/RunGameWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/RunGameWorkflowTests.swift @@ -21,6 +21,7 @@ import XCTest @testable import TicTacToe +@MainActor class RunGameWorkflowTests: XCTestCase { // MARK: Action Tests diff --git a/Samples/TicTacToe/Tests/TakeTurnsWorkflowTests.swift b/Samples/TicTacToe/Tests/TakeTurnsWorkflowTests.swift index 9244a6881..6c71f15ed 100644 --- a/Samples/TicTacToe/Tests/TakeTurnsWorkflowTests.swift +++ b/Samples/TicTacToe/Tests/TakeTurnsWorkflowTests.swift @@ -20,6 +20,7 @@ import XCTest @testable import TicTacToe +@MainActor class TakeTurnsWorkflowTests: XCTestCase { // MARK: Action Tests diff --git a/Samples/Tutorial/Frameworks/Tutorial1Complete/Sources/Welcome/WelcomeWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial1Complete/Sources/Welcome/WelcomeWorkflow.swift index 6691e9bd9..8650f927c 100644 --- a/Samples/Tutorial/Frameworks/Tutorial1Complete/Sources/Welcome/WelcomeWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial1Complete/Sources/Welcome/WelcomeWorkflow.swift @@ -80,6 +80,7 @@ extension WelcomeWorkflow { extension WelcomeWorkflow { typealias Rendering = WelcomeScreen + @MainActor func render(state: WelcomeWorkflow.State, context: RenderContext) -> Rendering { // Create a "sink" of type `Action`. A sink is what we use to send actions to the workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/RootWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/RootWorkflow.swift index a7c8add14..2fbbc8e09 100644 --- a/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/RootWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/RootWorkflow.swift @@ -90,6 +90,7 @@ extension RootWorkflow { extension RootWorkflow { typealias Rendering = BackStackScreen + @MainActor func render(state: RootWorkflow.State, context: RenderContext) -> Rendering { // Create a sink to handle the back action from the TodoListWorkflow to log out. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/Welcome/WelcomeWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/Welcome/WelcomeWorkflow.swift index 2bc3b6818..41f4db001 100644 --- a/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/Welcome/WelcomeWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial2Complete/Sources/Welcome/WelcomeWorkflow.swift @@ -87,6 +87,7 @@ extension WelcomeWorkflow { extension WelcomeWorkflow { typealias Rendering = WelcomeScreen + @MainActor func render(state: WelcomeWorkflow.State, context: RenderContext) -> Rendering { // Create a "sink" of type `Action`. A sink is what we use to send actions to the workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/RootWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/RootWorkflow.swift index 07273df9a..0610c7fbd 100644 --- a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/RootWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/RootWorkflow.swift @@ -88,6 +88,7 @@ extension RootWorkflow { extension RootWorkflow { typealias Rendering = BackStackScreen + @MainActor func render(state: RootWorkflow.State, context: RenderContext) -> Rendering { // Delete the `let sink = context.makeSink(of: ...) as we no longer need a sink. diff --git a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/Edit/TodoEditWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/Edit/TodoEditWorkflow.swift index ad9abb8ef..e93a51475 100644 --- a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/Edit/TodoEditWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/Edit/TodoEditWorkflow.swift @@ -109,6 +109,7 @@ extension TodoEditWorkflow { extension TodoEditWorkflow { typealias Rendering = BackStackScreen.Item + @MainActor func render(state: TodoEditWorkflow.State, context: RenderContext) -> Rendering { // The sink is used to send actions back to this workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/List/TodoListWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/List/TodoListWorkflow.swift index cdeb914dc..baa63156f 100644 --- a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/List/TodoListWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Todo/List/TodoListWorkflow.swift @@ -120,6 +120,7 @@ extension TodoListWorkflow { extension TodoListWorkflow { typealias Rendering = [BackStackScreen.Item] + @MainActor func render(state: TodoListWorkflow.State, context: RenderContext) -> Rendering { // Define a sink to be able to send actions. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Welcome/WelcomeWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Welcome/WelcomeWorkflow.swift index 2bc3b6818..41f4db001 100644 --- a/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Welcome/WelcomeWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial3Complete/Sources/Welcome/WelcomeWorkflow.swift @@ -87,6 +87,7 @@ extension WelcomeWorkflow { extension WelcomeWorkflow { typealias Rendering = WelcomeScreen + @MainActor func render(state: WelcomeWorkflow.State, context: RenderContext) -> Rendering { // Create a "sink" of type `Action`. A sink is what we use to send actions to the workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/RootWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/RootWorkflow.swift index e4e950a77..4b627680d 100644 --- a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/RootWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/RootWorkflow.swift @@ -90,6 +90,7 @@ extension RootWorkflow { extension RootWorkflow { typealias Rendering = BackStackScreen + @MainActor func render(state: RootWorkflow.State, context: RenderContext) -> Rendering { // Our list of back stack items. Will always include the "WelcomeScreen". var backStackItems: [BackStackScreen.Item] = [] diff --git a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/Edit/TodoEditWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/Edit/TodoEditWorkflow.swift index ad9abb8ef..e93a51475 100644 --- a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/Edit/TodoEditWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/Edit/TodoEditWorkflow.swift @@ -109,6 +109,7 @@ extension TodoEditWorkflow { extension TodoEditWorkflow { typealias Rendering = BackStackScreen.Item + @MainActor func render(state: TodoEditWorkflow.State, context: RenderContext) -> Rendering { // The sink is used to send actions back to this workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/List/TodoListWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/List/TodoListWorkflow.swift index 9b94a9e35..84baca1a8 100644 --- a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/List/TodoListWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/List/TodoListWorkflow.swift @@ -97,6 +97,7 @@ extension TodoListWorkflow { extension TodoListWorkflow { typealias Rendering = BackStackScreen.Item + @MainActor func render(state: TodoListWorkflow.State, context: RenderContext) -> Rendering { // Define a sink to be able to send actions. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/TodoWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/TodoWorkflow.swift index 7cc6a949a..bf9ff69f6 100644 --- a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/TodoWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Todo/TodoWorkflow.swift @@ -139,6 +139,7 @@ extension TodoWorkflow { extension TodoWorkflow { typealias Rendering = [BackStackScreen.Item] + @MainActor func render(state: TodoWorkflow.State, context: RenderContext) -> Rendering { let todoListItem = TodoListWorkflow(name: name, todos: state.todos) .mapOutput { output -> ListAction in diff --git a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Welcome/WelcomeWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Welcome/WelcomeWorkflow.swift index 2bc3b6818..41f4db001 100644 --- a/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Welcome/WelcomeWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial4Complete/Sources/Welcome/WelcomeWorkflow.swift @@ -87,6 +87,7 @@ extension WelcomeWorkflow { extension WelcomeWorkflow { typealias Rendering = WelcomeScreen + @MainActor func render(state: WelcomeWorkflow.State, context: RenderContext) -> Rendering { // Create a "sink" of type `Action`. A sink is what we use to send actions to the workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/RootWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/RootWorkflow.swift index 90ff592de..0cdd1d3c4 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/RootWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/RootWorkflow.swift @@ -88,6 +88,7 @@ extension RootWorkflow { extension RootWorkflow { typealias Rendering = BackStackScreen + @MainActor func render(state: RootWorkflow.State, context: RenderContext) -> Rendering { // Our list of back stack items. Will always include the "WelcomeScreen". var backStackItems: [BackStackScreen.Item] = [] diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift index ad9abb8ef..e93a51475 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/Edit/TodoEditWorkflow.swift @@ -109,6 +109,7 @@ extension TodoEditWorkflow { extension TodoEditWorkflow { typealias Rendering = BackStackScreen.Item + @MainActor func render(state: TodoEditWorkflow.State, context: RenderContext) -> Rendering { // The sink is used to send actions back to this workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/List/TodoListWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/List/TodoListWorkflow.swift index 9b94a9e35..84baca1a8 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/List/TodoListWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/List/TodoListWorkflow.swift @@ -97,6 +97,7 @@ extension TodoListWorkflow { extension TodoListWorkflow { typealias Rendering = BackStackScreen.Item + @MainActor func render(state: TodoListWorkflow.State, context: RenderContext) -> Rendering { // Define a sink to be able to send actions. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/TodoWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/TodoWorkflow.swift index 53ee93f44..ec1f4b80f 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/TodoWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Todo/TodoWorkflow.swift @@ -139,6 +139,7 @@ extension TodoWorkflow { extension TodoWorkflow { typealias Rendering = [BackStackScreen.Item] + @MainActor func render(state: TodoWorkflow.State, context: RenderContext) -> Rendering { let todoListItem = TodoListWorkflow(name: name, todos: state.todos) .mapOutput { output -> ListAction in diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Welcome/WelcomeWorkflow.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Welcome/WelcomeWorkflow.swift index f550aad42..ee6fc3eea 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Welcome/WelcomeWorkflow.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Sources/Welcome/WelcomeWorkflow.swift @@ -92,6 +92,7 @@ extension WelcomeWorkflow { extension WelcomeWorkflow { typealias Rendering = WelcomeScreen + @MainActor func render(state: WelcomeWorkflow.State, context: RenderContext) -> Rendering { // Create a "sink" of type `Action`. A sink is what we use to send actions to the workflow. let sink = context.makeSink(of: Action.self) diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift index 4fd2af96a..dffa0ade8 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/RootWorkflowTests.swift @@ -25,6 +25,7 @@ import XCTest // Import `WorkflowUI` as testable so that the wrappedScreen in `AnyScreen` can be accessed. @testable import WorkflowUI +@MainActor class RootWorkflowTests: XCTestCase { func testWelcomeRendering() throws { RootWorkflow() diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoEditWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoEditWorkflowTests.swift index f1e382883..9735847ca 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoEditWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoEditWorkflowTests.swift @@ -18,6 +18,7 @@ import WorkflowTesting import XCTest @testable import Tutorial5Complete +@MainActor class TodoEditWorkflowTests: XCTestCase { func testAction() throws { TodoEditWorkflow.Action diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoListWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoListWorkflowTests.swift index 60ae3dae8..ca72c0648 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoListWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoListWorkflowTests.swift @@ -18,6 +18,7 @@ import WorkflowTesting import XCTest @testable import Tutorial5Complete +@MainActor class TodoListWorkflowTests: XCTestCase { func testActions() throws { TodoListWorkflow.Action diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift index f18b93b72..07aa2d795 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/TodoWorkflowTests.swift @@ -21,6 +21,7 @@ import WorkflowUI import XCTest @testable import Tutorial5Complete +@MainActor class TodoWorkflowTests: XCTestCase { func testSelectingTodo() throws { let todos: [TodoModel] = [TodoModel(title: "Title", note: "Note")] diff --git a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/WelcomeWorkflowTests.swift b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/WelcomeWorkflowTests.swift index 795572990..e36d74e5b 100644 --- a/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/WelcomeWorkflowTests.swift +++ b/Samples/Tutorial/Frameworks/Tutorial5Complete/Tests/WelcomeWorkflowTests.swift @@ -18,6 +18,7 @@ import WorkflowTesting import XCTest @testable import Tutorial5Complete +@MainActor class WelcomeWorkflowTests: XCTestCase { func testNameUpdates() throws { WelcomeWorkflow.Action diff --git a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorkflow.swift b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorkflow.swift index 438c64a6b..70778995d 100644 --- a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorkflow.swift +++ b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorkflow.swift @@ -49,6 +49,7 @@ extension DemoWorkflow { extension DemoWorkflow { typealias Rendering = DemoScreen + @MainActor func render(state: DemoWorkflow.State, context: RenderContext) -> Rendering { // Combine-based worker example DemoWorker() diff --git a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleAppUnitTests/DemoWorkflowTests.swift b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleAppUnitTests/DemoWorkflowTests.swift index 95030f1f9..10f817680 100644 --- a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleAppUnitTests/DemoWorkflowTests.swift +++ b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleAppUnitTests/DemoWorkflowTests.swift @@ -11,6 +11,7 @@ import WorkflowTesting import XCTest @testable import WorkflowCombineSampleApp +@MainActor class DemoWorkflowTests: XCTestCase { func test_demoWorkflow_publishesNewDate() { let expectedDate = Date(timeIntervalSince1970: 0) From d7d1113c8153ee55ea710b1ace752446e2d43866 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 23 Jul 2026 14:08:04 -0500 Subject: [PATCH 08/19] fix: annotate partner-module TestingTests suites for @MainActor The Tests/ directories in each partner module were annotated during the @MainActor migration, but the parallel TestingTests/ directories (covering each module's -Testing helper library) were missed, leaving XCTestCase classes that call main actor-isolated renderTester()/ send(action:) APIs from a synchronous nonisolated context. This blocked a full tuist test --path Samples UnitTests run. Co-Authored-By: Claude Fable 5 --- WorkflowCombine/TestingTests/PublisherTests.swift | 1 + WorkflowCombine/TestingTests/TestingTests.swift | 1 + WorkflowConcurrency/TestingTests/TestingTests.swift | 1 + WorkflowReactiveSwift/TestingTests/SignalProducerTests.swift | 1 + WorkflowReactiveSwift/TestingTests/TestingTests.swift | 1 + WorkflowRxSwift/TestingTests/ObservableTests.swift | 1 + WorkflowRxSwift/TestingTests/TestingTests.swift | 1 + 7 files changed, 7 insertions(+) diff --git a/WorkflowCombine/TestingTests/PublisherTests.swift b/WorkflowCombine/TestingTests/PublisherTests.swift index dbe280cd8..e4d8e47e7 100644 --- a/WorkflowCombine/TestingTests/PublisherTests.swift +++ b/WorkflowCombine/TestingTests/PublisherTests.swift @@ -12,6 +12,7 @@ import WorkflowTesting import XCTest @testable import WorkflowCombineTesting +@MainActor class PublisherTests: XCTestCase { func testPublisherWorkflow() { TestWorkflow() diff --git a/WorkflowCombine/TestingTests/TestingTests.swift b/WorkflowCombine/TestingTests/TestingTests.swift index 1019d5149..497a317b0 100644 --- a/WorkflowCombine/TestingTests/TestingTests.swift +++ b/WorkflowCombine/TestingTests/TestingTests.swift @@ -21,6 +21,7 @@ import WorkflowCombineTesting import WorkflowTesting import XCTest +@MainActor class WorkflowCombineTestingTests: XCTestCase { func test_workers() { let renderTester = TestWorkflow() diff --git a/WorkflowConcurrency/TestingTests/TestingTests.swift b/WorkflowConcurrency/TestingTests/TestingTests.swift index e049eedb7..0fc3f27af 100644 --- a/WorkflowConcurrency/TestingTests/TestingTests.swift +++ b/WorkflowConcurrency/TestingTests/TestingTests.swift @@ -20,6 +20,7 @@ import WorkflowConcurrencyTesting import WorkflowTesting import XCTest +@MainActor class WorkflowConcurrencyTestingTests: XCTestCase { func test_workers() { let renderTester = TestWorkflow() diff --git a/WorkflowReactiveSwift/TestingTests/SignalProducerTests.swift b/WorkflowReactiveSwift/TestingTests/SignalProducerTests.swift index 7f3e098da..87dc77a74 100644 --- a/WorkflowReactiveSwift/TestingTests/SignalProducerTests.swift +++ b/WorkflowReactiveSwift/TestingTests/SignalProducerTests.swift @@ -20,6 +20,7 @@ import Workflow import WorkflowReactiveSwiftTesting import XCTest +@MainActor class SignalProducerTests: XCTestCase { func test_signalProducerWorkflow() { TestWorkflow() diff --git a/WorkflowReactiveSwift/TestingTests/TestingTests.swift b/WorkflowReactiveSwift/TestingTests/TestingTests.swift index 5c93791a3..e10f22e5c 100644 --- a/WorkflowReactiveSwift/TestingTests/TestingTests.swift +++ b/WorkflowReactiveSwift/TestingTests/TestingTests.swift @@ -21,6 +21,7 @@ import WorkflowReactiveSwiftTesting import WorkflowTesting import XCTest +@MainActor class WorkflowReactiveSwiftTestingTests: XCTestCase { func test_workers() { let renderTester = TestWorkflow() diff --git a/WorkflowRxSwift/TestingTests/ObservableTests.swift b/WorkflowRxSwift/TestingTests/ObservableTests.swift index 08b957425..5f0048f23 100644 --- a/WorkflowRxSwift/TestingTests/ObservableTests.swift +++ b/WorkflowRxSwift/TestingTests/ObservableTests.swift @@ -20,6 +20,7 @@ import Workflow import WorkflowRxSwiftTesting import XCTest +@MainActor class ObservableTests: XCTestCase { func testObservableWorkflow() { TestWorkflow() diff --git a/WorkflowRxSwift/TestingTests/TestingTests.swift b/WorkflowRxSwift/TestingTests/TestingTests.swift index 6ff6299fa..aa413ba99 100644 --- a/WorkflowRxSwift/TestingTests/TestingTests.swift +++ b/WorkflowRxSwift/TestingTests/TestingTests.swift @@ -21,6 +21,7 @@ import WorkflowRxSwiftTesting import WorkflowTesting import XCTest +@MainActor class WorkflowReactiveSwiftTestingTests: XCTestCase { func test_workers() { let renderTester = TestWorkflow() From 5a5a1226d88ed9cc60baf6632155bce5ef21cf5b Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 23 Jul 2026 14:15:55 -0500 Subject: [PATCH 09/19] feat: add structured async runSideEffect with cooperative cancellation Co-Authored-By: Claude Fable 5 --- Workflow/Sources/RenderContext.swift | 30 ++++++++ Workflow/Tests/AsyncSideEffectTests.swift | 92 +++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 Workflow/Tests/AsyncSideEffectTests.swift diff --git a/Workflow/Sources/RenderContext.swift b/Workflow/Sources/RenderContext.swift index 5db0e237c..c0b18d899 100644 --- a/Workflow/Sources/RenderContext.swift +++ b/Workflow/Sources/RenderContext.swift @@ -179,6 +179,36 @@ protocol RenderContextType: AnyObject { ) } +extension RenderContext { + /// Execute an asynchronous side-effect action. + /// + /// `action` runs in a `Task` owned by the workflow node the first time a + /// side-effect is run with a given `key`. Calls with the same `key` on + /// subsequent renders are ignored. If, after a render pass, a previously + /// used `key` is no longer used, the `Task` is cancelled — cancellation is + /// cooperative, so long-running work should check `Task.isCancelled` or + /// use cancellation-aware APIs. + /// + /// Prefer this over the `Lifetime`-based variant for new code. + /// + /// - Parameters: + /// - key: represents the block of work that needs to be executed. + /// - action: an async block of work to execute. + public func runSideEffect( + key: AnyHashable, + action: @escaping @Sendable () async -> Void + ) { + runSideEffect(key: key) { lifetime in + let task = Task { + await action() + } + lifetime.onEnded { + task.cancel() + } + } + } +} + extension RenderContext { public func makeSink( of eventType: Event.Type, diff --git a/Workflow/Tests/AsyncSideEffectTests.swift b/Workflow/Tests/AsyncSideEffectTests.swift new file mode 100644 index 000000000..8f4265791 --- /dev/null +++ b/Workflow/Tests/AsyncSideEffectTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import Workflow + +@MainActor +final class AsyncSideEffectTests: XCTestCase { + func test_asyncSideEffect_starts_and_isCancelledWhenKeyDisappears() async { + let started = expectation(description: "side effect started") + let cancelled = expectation(description: "side effect cancelled") + + let host = WorkflowHost( + workflow: ToggleWorkflow( + runEffect: true, + onStart: { started.fulfill() }, + onCancel: { cancelled.fulfill() } + ) + ) + + await fulfillment(of: [started], timeout: 1) + + // Re-render without the side effect: the key disappears, the Task + // must receive cooperative cancellation. + host.update(workflow: ToggleWorkflow(runEffect: false, onStart: {}, onCancel: {})) + + await fulfillment(of: [cancelled], timeout: 1) + } + + func test_asyncSideEffect_runsOnlyOncePerKey_acrossRenders() async { + let counter = StartCounter() + let ran = expectation(description: "side effect ran") + // Over-fulfillment (a second Task starting) fails the test. + ran.assertForOverFulfill = true + + let host = WorkflowHost( + workflow: CountingWorkflow(counter: counter, onRun: { ran.fulfill() }) + ) + host.update(workflow: CountingWorkflow(counter: counter, onRun: { ran.fulfill() })) + host.update(workflow: CountingWorkflow(counter: counter, onRun: { ran.fulfill() })) + + await fulfillment(of: [ran], timeout: 1) + XCTAssertEqual(counter.count, 1) + } + + // MARK: - Fixtures + + fileprivate final class StartCounter: @unchecked Sendable { + private let lock = NSLock() + private var _count = 0 + var count: Int { lock.withLock { _count } } + func increment() { lock.withLock { _count += 1 } } + } + + fileprivate struct ToggleWorkflow: Workflow { + var runEffect: Bool + var onStart: @Sendable () -> Void + var onCancel: @Sendable () -> Void + + typealias State = Void + typealias Rendering = Void + + func render(state: State, context: RenderContext) { + if runEffect { + let onStart = onStart + let onCancel = onCancel + context.runSideEffect(key: "effect") { + onStart() + do { + try await Task.sleep(nanoseconds: 10000000000) + } catch is CancellationError { + onCancel() + } catch {} + } + } + } + } + + fileprivate struct CountingWorkflow: Workflow { + var counter: StartCounter + var onRun: @Sendable () -> Void + + typealias State = Void + typealias Rendering = Void + + func render(state: State, context: RenderContext) { + let counter = counter + let onRun = onRun + context.runSideEffect(key: "count") { + counter.increment() + onRun() + } + } + } +} From 1669617b56b566484c712d37ab5f362b1e98ee5a Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 23 Jul 2026 15:13:19 -0500 Subject: [PATCH 10/19] chore: adopt Swift 6 language mode for the core Workflow target Bumps swift-tools-version to 6.0. Other targets remain in v5 mode with targeted strict concurrency. Co-Authored-By: Claude Fable 5 --- .mise.toml | 4 +- Package.swift | 12 +++- .../AuthenticationWorkflow.swift | 1 + .../Sources/Game/RunGameWorkflow.swift | 1 + Workflow/Sources/AnyWorkflow.swift | 20 +++---- Workflow/Sources/AnyWorkflowConvertible.swift | 2 +- Workflow/Sources/AsyncMulticaster.swift | 39 +++++++------ Workflow/Sources/Debugging.swift | 6 +- Workflow/Sources/RenderContext.swift | 6 +- Workflow/Sources/Sink.swift | 13 +++-- Workflow/Sources/StateMutationSink.swift | 2 + Workflow/Sources/SubtreeManager.swift | 24 +++----- Workflow/Sources/WorkflowAction.swift | 4 +- Workflow/Sources/WorkflowHost.swift | 56 ++++++++++++------- Workflow/Sources/WorkflowLogger.swift | 11 +++- Workflow/Sources/WorkflowNode.swift | 4 +- Workflow/Sources/WorkflowObserver.swift | 16 +++++- Workflow/Tests/AsyncMulticasterTests.swift | 28 +++++++--- Workflow/Tests/PerformanceTests.swift | 1 + Workflow/Tests/SubtreeManagerTests.swift | 1 + Workflow/Tests/WorkflowObserverTests.swift | 1 + .../Sources/Publisher+Extensions.swift | 2 +- WorkflowSwiftUI/Sources/Store.swift | 6 +- WorkflowSwiftUI/Tests/StoreTests.swift | 1 + .../Internal/RenderTester+TestContext.swift | 2 +- 25 files changed, 169 insertions(+), 94 deletions(-) diff --git a/.mise.toml b/.mise.toml index ad6f21f9c..4975e18d8 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,5 +1,7 @@ [tools] -tuist = "4.23.0" +# 4.27.0+ is required to parse swift-tools-version 6.0 package manifests +# (older versions fail decoding the manifest's build-settings format). +tuist = "4.27.0" swiftformat = "0.54.2" [settings] diff --git a/Package.swift b/Package.swift index 5ecff8d8c..8538a83f3 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import CompilerPluginSupport @@ -209,7 +209,7 @@ let package = Package( path: "ViewEnvironmentUI/Sources" ), ], - swiftLanguageVersions: [.v5] + swiftLanguageModes: [.v5] ) // MARK: Helpers @@ -222,8 +222,14 @@ extension PackageDescription.Product { } } +let swift6Targets: Set = ["Workflow"] + for target in package.targets { var settings = target.swiftSettings ?? [] - settings.append(.enableExperimentalFeature("StrictConcurrency=targeted")) + if swift6Targets.contains(target.name) { + settings.append(.swiftLanguageMode(.v6)) + } else { + settings.append(.enableExperimentalFeature("StrictConcurrency=targeted")) + } target.swiftSettings = settings } diff --git a/Samples/TicTacToe/Sources/Authentication/AuthenticationWorkflow.swift b/Samples/TicTacToe/Sources/Authentication/AuthenticationWorkflow.swift index e777ee361..9446699f5 100644 --- a/Samples/TicTacToe/Sources/Authentication/AuthenticationWorkflow.swift +++ b/Samples/TicTacToe/Sources/Authentication/AuthenticationWorkflow.swift @@ -236,6 +236,7 @@ extension AuthenticationWorkflow { ) } + @MainActor private func twoFactorScreen(error: AuthenticationService.AuthenticationError?, intermediateSession: String, sink: Sink) -> BackStackScreen.Item { let title: String = if let authenticationError = error { authenticationError.localizedDescription diff --git a/Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift b/Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift index 965f136c1..048b45d6b 100644 --- a/Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift +++ b/Samples/TicTacToe/Sources/Game/RunGameWorkflow.swift @@ -161,6 +161,7 @@ extension RunGameWorkflow { return AlertContainerScreen(baseScreen: modalContainerScreen, alert: alert) } + @MainActor private func newGameScreen(sink: Sink, playerX: String, playerO: String) -> NewGameScreen { NewGameScreen( playerX: playerX, diff --git a/Workflow/Sources/AnyWorkflow.swift b/Workflow/Sources/AnyWorkflow.swift index 7af165f92..56b783428 100644 --- a/Workflow/Sources/AnyWorkflow.swift +++ b/Workflow/Sources/AnyWorkflow.swift @@ -67,7 +67,7 @@ extension AnyWorkflow { /// /// - Returns: A type erased workflow with the new output type (the rendering type remains unchanged). public func mapOutput( - _ transform: @escaping (Output) -> NewOutput + _ transform: @escaping @MainActor (Output) -> NewOutput ) -> AnyWorkflow { let storage = storage.mapOutput(transform: transform) return AnyWorkflow(storage: storage) @@ -99,7 +99,7 @@ extension AnyWorkflow { func render( context: RenderContext, key: String, - outputMap: @escaping (Output) -> Action + outputMap: @escaping @MainActor (Output) -> Action ) -> Rendering where Action.WorkflowType == Parent { storage.render(context: context, key: key, outputMap: outputMap) } @@ -116,7 +116,7 @@ extension AnyWorkflow { func render( context: RenderContext, key: String, - outputMap: @escaping (Output) -> Action + outputMap: @escaping @MainActor (Output) -> Action ) -> Rendering where Action.WorkflowType == Parent { fatalError() } @@ -125,7 +125,7 @@ extension AnyWorkflow { fatalError() } - func mapOutput(transform: @escaping (Output) -> NewOutput) -> AnyWorkflow.AnyStorage { + func mapOutput(transform: @escaping @MainActor (Output) -> NewOutput) -> AnyWorkflow.AnyStorage { fatalError() } @@ -140,9 +140,9 @@ extension AnyWorkflow { fileprivate final class Storage: AnyStorage { let workflow: T let renderingTransform: (T.Rendering) -> Rendering - let outputTransform: (T.Output) -> Output + let outputTransform: @MainActor (T.Output) -> Output - init(workflow: T, renderingTransform: @escaping (T.Rendering) -> Rendering, outputTransform: @escaping (T.Output) -> Output) { + init(workflow: T, renderingTransform: @escaping (T.Rendering) -> Rendering, outputTransform: @escaping @MainActor (T.Output) -> Output) { self.workflow = workflow self.renderingTransform = renderingTransform self.outputTransform = outputTransform @@ -158,16 +158,16 @@ extension AnyWorkflow { override func render( context: RenderContext, key: String, - outputMap: @escaping (Output) -> Action + outputMap: @escaping @MainActor (Output) -> Action ) -> Rendering where Action.WorkflowType == Parent { - let outputMap: (T.Output) -> Action = { [outputTransform] output in + let outputMap: @MainActor (T.Output) -> Action = { [outputTransform] output in outputMap(outputTransform(output)) } let rendering = context.render(workflow: workflow, key: key, outputMap: outputMap) return renderingTransform(rendering) } - override func mapOutput(transform: @escaping (Output) -> NewOutput) -> AnyWorkflow.AnyStorage { + override func mapOutput(transform: @escaping @MainActor (Output) -> NewOutput) -> AnyWorkflow.AnyStorage { AnyWorkflow.Storage( workflow: workflow, renderingTransform: renderingTransform, @@ -190,7 +190,7 @@ extension AnyWorkflow { } extension AnyWorkflowConvertible { - public func mapOutput(_ transform: @escaping (Output) -> NewOutput) -> AnyWorkflow { + public func mapOutput(_ transform: @escaping @MainActor (Output) -> NewOutput) -> AnyWorkflow { asAnyWorkflow().mapOutput(transform) } diff --git a/Workflow/Sources/AnyWorkflowConvertible.swift b/Workflow/Sources/AnyWorkflowConvertible.swift index 9aaa86b6a..6cd9fda2d 100644 --- a/Workflow/Sources/AnyWorkflowConvertible.swift +++ b/Workflow/Sources/AnyWorkflowConvertible.swift @@ -122,7 +122,7 @@ extension AnyWorkflowConvertible { /// /// - Parameter apply: On `Output`, mutate `State` as necessary and return new `Output` (or `nil`). public func onOutput( - _ apply: @escaping (inout Parent.State, Output) -> Parent.Output? + _ apply: @escaping @MainActor (inout Parent.State, Output) -> Parent.Output? ) -> AnyWorkflow> { asAnyWorkflow() .mapOutput { output in diff --git a/Workflow/Sources/AsyncMulticaster.swift b/Workflow/Sources/AsyncMulticaster.swift index 43825e560..0629e8ada 100644 --- a/Workflow/Sources/AsyncMulticaster.swift +++ b/Workflow/Sources/AsyncMulticaster.swift @@ -30,6 +30,28 @@ final class AsyncMulticaster { nonisolated init() {} + func finish() { + isFinished = true + let existing = continuations + continuations.removeAll() + for continuation in existing.values { + continuation.finish() + } + } + + deinit { + // Continuations are Sendable; finishing them from a nonisolated + // deinit is safe. (Stored-property access is permitted in deinit.) + for continuation in continuations.values { + continuation.finish() + } + } +} + +// Yielding values into the fan-out streams requires `Element` to be `Sendable`: +// consumers may iterate their streams from arbitrary isolation domains, and a +// single value is delivered to every consumer. +extension AsyncMulticaster where Element: Sendable { func makeStream( bufferingPolicy: AsyncStream.Continuation.BufferingPolicy, initial: Element? = nil @@ -64,21 +86,4 @@ final class AsyncMulticaster { continuation.yield(element) } } - - func finish() { - isFinished = true - let existing = continuations - continuations.removeAll() - for continuation in existing.values { - continuation.finish() - } - } - - deinit { - // Continuations are Sendable; finishing them from a nonisolated - // deinit is safe. (Stored-property access is permitted in deinit.) - for continuation in continuations.values { - continuation.finish() - } - } } diff --git a/Workflow/Sources/Debugging.swift b/Workflow/Sources/Debugging.swift index f8159a42e..f6b6b50bf 100644 --- a/Workflow/Sources/Debugging.swift +++ b/Workflow/Sources/Debugging.swift @@ -18,7 +18,7 @@ import IssueReporting #endif -public struct WorkflowUpdateDebugInfo: Codable, Equatable { +public struct WorkflowUpdateDebugInfo: Codable, Equatable, Sendable { public var workflowType: String public var kind: Kind @@ -29,7 +29,7 @@ public struct WorkflowUpdateDebugInfo: Codable, Equatable { } extension WorkflowUpdateDebugInfo { - public indirect enum Kind: Equatable { + public indirect enum Kind: Equatable, Sendable { case didUpdate(source: Source) case childDidUpdate(WorkflowUpdateDebugInfo) } @@ -76,7 +76,7 @@ extension WorkflowUpdateDebugInfo.Kind: Codable { } extension WorkflowUpdateDebugInfo { - public indirect enum Source: Equatable { + public indirect enum Source: Equatable, Sendable { case external case worker case sideEffect diff --git a/Workflow/Sources/RenderContext.swift b/Workflow/Sources/RenderContext.swift index c0b18d899..ed633b3a4 100644 --- a/Workflow/Sources/RenderContext.swift +++ b/Workflow/Sources/RenderContext.swift @@ -67,7 +67,7 @@ public class RenderContext: RenderContextType { func render( workflow: Child, key: String, - outputMap: @escaping (Child.Output) -> Action + outputMap: @escaping @MainActor (Child.Output) -> Action ) -> Child.Rendering where WorkflowType == Action.WorkflowType { fatalError() } @@ -128,7 +128,7 @@ public class RenderContext: RenderContextType { override func render( workflow: Child, key: String, - outputMap: @escaping (Child.Output) -> Action + outputMap: @escaping @MainActor (Child.Output) -> Action ) -> Child.Rendering where WorkflowType == Action.WorkflowType { @@ -166,7 +166,7 @@ protocol RenderContextType: AnyObject { func render( workflow: Child, key: String, - outputMap: @escaping (Child.Output) -> Action + outputMap: @escaping @MainActor (Child.Output) -> Action ) -> Child.Rendering where Action.WorkflowType == WorkflowType func makeSink( diff --git a/Workflow/Sources/Sink.swift b/Workflow/Sources/Sink.swift index 1fd9ded0f..6468076a6 100644 --- a/Workflow/Sources/Sink.swift +++ b/Workflow/Sources/Sink.swift @@ -17,17 +17,22 @@ /// Sink is a type that receives incoming values (commonly events or `WorkflowAction`) /// /// Use `RenderContext.makeSink` to create instances. -public struct Sink { - private let onValue: (Value) -> Void +/// +/// Sinks deliver values into the Workflow runtime, which runs on the main +/// actor, so `send` is main-actor-isolated. A `Sink` value itself is +/// `Sendable` and may be captured and stored anywhere. +public struct Sink: Sendable { + private let onValue: @MainActor (Value) -> Void /// Initializes a new sink with the given closure. - public init(_ onValue: @escaping (Value) -> Void) { + public init(_ onValue: @escaping @MainActor (Value) -> Void) { self.onValue = onValue } /// Sends a new event into the sink. /// /// - Parameter event: The value to send into the sink. + @MainActor public func send(_ value: Value) { onValue(value) } @@ -52,7 +57,7 @@ public struct Sink { /// *input* types of its API. /// /// - Parameter transform: An escaping closure that transforms `T` into `Event`. - public func contraMap(_ transform: @escaping (NewValue) -> Value) -> Sink { + public func contraMap(_ transform: @escaping @MainActor (NewValue) -> Value) -> Sink { Sink { value in send(transform(value)) } diff --git a/Workflow/Sources/StateMutationSink.swift b/Workflow/Sources/StateMutationSink.swift index 3f9f6df5c..7b8be2c8d 100644 --- a/Workflow/Sources/StateMutationSink.swift +++ b/Workflow/Sources/StateMutationSink.swift @@ -42,6 +42,7 @@ public struct StateMutationSink { /// /// - Parameters: /// - update: The `State` mutation to perform. + @MainActor public func send(_ update: @escaping (inout WorkflowType.State) -> Void) { sink.send( AnyWorkflowAction { state, _ in @@ -56,6 +57,7 @@ public struct StateMutationSink { /// - Parameters: /// - keyPath: Key path of `State` whose value needs to be mutated. /// - value: Value to update `State` with. + @MainActor public func send(_ keyPath: WritableKeyPath, value: Value) { send { $0[keyPath: keyPath] = value } } diff --git a/Workflow/Sources/SubtreeManager.swift b/Workflow/Sources/SubtreeManager.swift index 935cfde70..8935e5b2a 100644 --- a/Workflow/Sources/SubtreeManager.swift +++ b/Workflow/Sources/SubtreeManager.swift @@ -242,7 +242,7 @@ extension WorkflowNode.SubtreeManager { func render( workflow: Child, key: String, - outputMap: @escaping (Child.Output) -> Action + outputMap: @escaping @MainActor (Child.Output) -> Action ) -> Child.Rendering where WorkflowType == Action.WorkflowType { @@ -380,17 +380,9 @@ extension WorkflowNode.SubtreeManager { } fileprivate final class ReusableSink: AnyReusableSink where Action.WorkflowType == WorkflowType { - /// Nonisolated entry point: `Sink` closures are nonisolated by design - /// (they are captured into arbitrary consumer event handlers). Entering - /// the runtime requires the main actor; `assumeIsolated` preserves the - /// previous `dispatchPrecondition` crash-on-misuse contract. - nonisolated func handle(action: Action) { - MainActor.assumeIsolated { - handleIsolated(action: action) - } - } - - private func handleIsolated(action: Action) { + /// Main-actor entry point: `Sink.send` is main-actor-isolated, so + /// actions always enter the runtime on the main actor. + func handle(action: Action) { if let onSinkEvent { handleWithSinkEventHandler(action: action, onSinkEvent: onSinkEvent) return @@ -431,7 +423,7 @@ extension WorkflowNode.SubtreeManager { // Otherwise, try to recurse again in the future let deferredPerform: () -> Void = { [weak self] in - self?.handleIsolated(action: action) + self?.handle(action: action) } onSinkEvent(immediatePerform, deferredPerform) @@ -558,11 +550,11 @@ extension WorkflowNode.SubtreeManager { fileprivate final class ChildWorkflow: AnyChildWorkflow { private let node: WorkflowNode - private var outputMap: (W.Output) -> any WorkflowAction + private var outputMap: @MainActor (W.Output) -> any WorkflowAction init( workflow: W, - outputMap: @escaping (W.Output) -> any WorkflowAction, + outputMap: @escaping @MainActor (W.Output) -> any WorkflowAction, eventPipe: EventPipe, key: String, hostContext: HostContext, @@ -593,7 +585,7 @@ extension WorkflowNode.SubtreeManager { func update( workflow: W, - outputMap: @escaping (W.Output) -> any WorkflowAction, + outputMap: @escaping @MainActor (W.Output) -> any WorkflowAction, eventPipe: EventPipe ) { self.outputMap = outputMap diff --git a/Workflow/Sources/WorkflowAction.swift b/Workflow/Sources/WorkflowAction.swift index f90612e99..06a769028 100644 --- a/Workflow/Sources/WorkflowAction.swift +++ b/Workflow/Sources/WorkflowAction.swift @@ -57,6 +57,7 @@ public struct AnyWorkflowAction: WorkflowAction { /// Creates a type-erased workflow action that wraps the given instance. /// /// - Parameter base: A workflow action to wrap. + @MainActor public init(_ base: E) where E.WorkflowType == WorkflowType { if let anyEvent = base as? AnyWorkflowAction { self = anyEvent @@ -90,7 +91,7 @@ public struct AnyWorkflowAction: WorkflowAction { /// - Parameter apply: the apply function for the resulting action. @_disfavoredOverload public init( - _ apply: @escaping (inout WorkflowType.State) -> WorkflowType.Output?, + _ apply: @escaping @MainActor (inout WorkflowType.State) -> WorkflowType.Output?, fileID: StaticString = #fileID, line: UInt = #line ) { @@ -121,6 +122,7 @@ extension AnyWorkflowAction { /// Creates a type-erased workflow action that simply sends the given output event. /// /// - Parameter output: The output event to send when this action is applied. + @MainActor public init(sendingOutput output: WorkflowType.Output) { self = AnyWorkflowAction { _, _ in output diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index eef628455..3c21b98b1 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -44,6 +44,12 @@ public final class WorkflowHost { private let renderingMulticaster = AsyncMulticaster() private let outputMulticaster = AsyncMulticaster() + /// Erased hooks into the multicasters, installed lazily by the `renderings` + /// and `outputs` accessors (whose extensions know the element types are + /// `Sendable`). `nil` until the corresponding stream is first requested. + private var yieldRendering: ((WorkflowType.Rendering) -> Void)? + private var yieldOutput: ((WorkflowType.Output) -> Void)? + /// The current `Rendering` produced by the root workflow in the hierarchy. A new `Rendering` value is produced /// as state transitions occur within the hierarchy. public var rendering: WorkflowType.Rendering { @@ -57,21 +63,6 @@ public final class WorkflowHost { renderingSubject.eraseToAnyPublisher() } - /// An asynchronous sequence of the `Rendering` values produced by the root - /// workflow in the hierarchy. Yields the most recent `Rendering` when - /// iteration begins, followed by a new value after each subsequent render - /// pass. A slow consumer only ever observes the latest rendering; stale - /// intermediate values are dropped. - /// - /// Each access returns an independent stream. Obtain a fresh stream per - /// consumer; a single stream must not be iterated more than once. - public var renderings: AsyncStream { - renderingMulticaster.makeStream( - bufferingPolicy: .bufferingNewest(1), - initial: renderingSubject.value - ) - } - /// Context object to pass down to descendant nodes in the tree. let context: HostContext @@ -126,7 +117,7 @@ public final class WorkflowHost { } } - deinit { + isolated deinit { renderingSubject.send(completion: .finished) outputSubject.send(completion: .finished) } @@ -164,13 +155,13 @@ public final class WorkflowHost { if shouldRender { let rendering = rootNode.render() renderingSubject.send(rendering) - renderingMulticaster.yield(rendering) + yieldRendering?(rendering) } // Always emit an output, regardless of whether a render occurs if let outputEvent = output.outputEvent { outputSubject.send(outputEvent) - outputMulticaster.yield(outputEvent) + yieldOutput?(outputEvent) } debugger?.didUpdate( @@ -190,6 +181,28 @@ public final class WorkflowHost { } } +extension WorkflowHost where WorkflowType.Rendering: Sendable { + /// An asynchronous sequence of the `Rendering` values produced by the root + /// workflow in the hierarchy. Yields the most recent `Rendering` when + /// iteration begins, followed by a new value after each subsequent render + /// pass. A slow consumer only ever observes the latest rendering; stale + /// intermediate values are dropped. + /// + /// Each access returns an independent stream. Obtain a fresh stream per + /// consumer; a single stream must not be iterated more than once. + public var renderings: AsyncStream { + if yieldRendering == nil { + yieldRendering = { [renderingMulticaster] in + renderingMulticaster.yield($0) + } + } + return renderingMulticaster.makeStream( + bufferingPolicy: .bufferingNewest(1), + initial: renderingSubject.value + ) + } +} + extension WorkflowHost where WorkflowType.Output: Sendable { /// An asynchronous sequence of the output events emitted by the root /// workflow in the hierarchy. Every output emitted after the stream is @@ -198,7 +211,12 @@ extension WorkflowHost where WorkflowType.Output: Sendable { /// Each access returns an independent stream. Obtain a fresh stream per /// consumer; a single stream must not be iterated more than once. public var outputs: AsyncStream { - outputMulticaster.makeStream(bufferingPolicy: .unbounded) + if yieldOutput == nil { + yieldOutput = { [outputMulticaster] in + outputMulticaster.yield($0) + } + } + return outputMulticaster.makeStream(bufferingPolicy: .unbounded) } } diff --git a/Workflow/Sources/WorkflowLogger.swift b/Workflow/Sources/WorkflowLogger.swift index 6ec6fa1b6..675cb393b 100644 --- a/Workflow/Sources/WorkflowLogger.swift +++ b/Workflow/Sources/WorkflowLogger.swift @@ -24,6 +24,7 @@ extension OSLog { /// The active log handle to use when logging. If `WorkflowLogging.osLoggingSupported` is /// `true`, defaults to the `workflow` handle, otherwise defaults to the shared `.disabled` /// handle. + @MainActor fileprivate static var active: OSLog = WorkflowLogging.isOSLoggingAllowed ? .workflow : .disabled } @@ -47,9 +48,9 @@ extension WorkflowLogging { } extension WorkflowLogging { - public struct Config { + public struct Config: Sendable { /// Configuration options to control logging during a render pass. - public enum RenderLoggingMode { + public enum RenderLoggingMode: Sendable { /// No data will be recorded for WorkflowNode render timings. case none @@ -81,6 +82,7 @@ extension WorkflowLogging { /// /// If you wish for more control over what the runtime will log, you may additionally specify /// a custom value for `WorkflowLogging.config`. + @MainActor public static var enabled: Bool { get { OSLog.active === OSLog.workflow } set { @@ -90,6 +92,7 @@ extension WorkflowLogging { } /// Configuration options used to determine which activities are logged. + @MainActor public static var config: Config = .rootRendersAndActions } @@ -114,6 +117,7 @@ final class SignpostRef { enum WorkflowLogger { // MARK: Workflows + @MainActor static func logWorkflowStarted(ref: WorkflowNode) { guard WorkflowLogging.isOSLoggingAllowed, @@ -131,6 +135,7 @@ enum WorkflowLogger { ) } + @MainActor static func logWorkflowFinished(ref: WorkflowNode) { guard WorkflowLogging.isOSLoggingAllowed, @@ -141,6 +146,7 @@ enum WorkflowLogger { os_signpost(.end, log: .active, name: "Alive", signpostID: signpostID) } + @MainActor static func logSinkEvent(ref: AnyObject, action: Action) { guard WorkflowLogging.isOSLoggingAllowed, @@ -193,6 +199,7 @@ enum WorkflowLogger { // MARK: - Utilities + @MainActor private static func shouldLogRenderTimings( isRootNode: @autoclosure () -> Bool ) -> Bool { diff --git a/Workflow/Sources/WorkflowNode.swift b/Workflow/Sources/WorkflowNode.swift index 98228f585..d1ac3f59f 100644 --- a/Workflow/Sources/WorkflowNode.swift +++ b/Workflow/Sources/WorkflowNode.swift @@ -36,7 +36,7 @@ final class WorkflowNode { var onOutput: ((Output) -> Void)? /// An optional `WorkflowObserver` instance - nonisolated var observer: WorkflowObserver? { + var observer: WorkflowObserver? { hostContext.observer } @@ -78,7 +78,7 @@ final class WorkflowNode { } } - deinit { + isolated deinit { observer?.sessionDidEnd(session) WorkflowLogger.logWorkflowFinished(ref: self) } diff --git a/Workflow/Sources/WorkflowObserver.swift b/Workflow/Sources/WorkflowObserver.swift index f6693e686..855fc99f0 100644 --- a/Workflow/Sources/WorkflowObserver.swift +++ b/Workflow/Sources/WorkflowObserver.swift @@ -104,14 +104,22 @@ public protocol WorkflowObserver { /// - An optional reference to a parent `WorkflowSession`, to differentiate root nodes. public struct WorkflowSession { public struct Identifier: Hashable { + @MainActor private static var _nextRawID: UInt64 = 0 + + @MainActor private static func _makeNextSessionID() -> UInt64 { let nextID = _nextRawID _nextRawID &+= 1 return nextID } - let rawIdentifier: UInt64 = Self._makeNextSessionID() + let rawIdentifier: UInt64 + + @MainActor + init() { + self.rawIdentifier = Self._makeNextSessionID() + } } /// As structs cannot contain stored properties of their own type, we use an indirect enum @@ -134,7 +142,7 @@ public struct WorkflowSession { public let renderKey: String - public let sessionID = Identifier() + public let sessionID: Identifier private let _indirectParent: IndirectParent public var parent: WorkflowSession? { @@ -152,6 +160,7 @@ public struct WorkflowSession { /// - workflow: The associated `Workflow` instance /// - renderKey: The string key used to render `workflow` /// - parent: The parent Workflow's session, if any + @MainActor init( workflow: WorkflowType, renderKey: String, @@ -159,6 +168,7 @@ public struct WorkflowSession { ) { self.workflowType = WorkflowType.self self.renderKey = renderKey + self.sessionID = Identifier() self._indirectParent = IndirectParent(parent) } } @@ -346,9 +356,11 @@ public protocol ObserversInterceptor { @_spi(WorkflowGlobalObservation) public enum WorkflowObservation { + @MainActor private static var _sharedInterceptorStorage: ObserversInterceptor = NoOpObserversInterceptor() /// The `DefaultObserversProvider` used by all runtimes. + @MainActor public static var sharedObserversInterceptor: ObserversInterceptor! { get { _sharedInterceptorStorage diff --git a/Workflow/Tests/AsyncMulticasterTests.swift b/Workflow/Tests/AsyncMulticasterTests.swift index 28bfe9b37..0b2f8bcfa 100644 --- a/Workflow/Tests/AsyncMulticasterTests.swift +++ b/Workflow/Tests/AsyncMulticasterTests.swift @@ -14,9 +14,13 @@ final class AsyncMulticasterTests: XCTestCase { multicaster.finish() var receivedA: [Int] = [] - for await value in streamA { receivedA.append(value) } + for await value in streamA { + receivedA.append(value) + } var receivedB: [Int] = [] - for await value in streamB { receivedB.append(value) } + for await value in streamB { + receivedB.append(value) + } XCTAssertEqual(receivedA, [1, 2]) XCTAssertEqual(receivedB, [1, 2]) @@ -30,7 +34,9 @@ final class AsyncMulticasterTests: XCTestCase { multicaster.finish() var received: [Int] = [] - for await value in stream { received.append(value) } + for await value in stream { + received.append(value) + } XCTAssertEqual(received, [0, 1]) } @@ -45,7 +51,9 @@ final class AsyncMulticasterTests: XCTestCase { multicaster.finish() var received: [Int] = [] - for await value in stream { received.append(value) } + for await value in stream { + received.append(value) + } XCTAssertEqual(received, [3]) } @@ -55,7 +63,9 @@ final class AsyncMulticasterTests: XCTestCase { let stream = multicaster.makeStream(bufferingPolicy: .unbounded) var received: [Int] = [] - for await value in stream { received.append(value) } + for await value in stream { + received.append(value) + } XCTAssertEqual(received, []) } @@ -65,7 +75,9 @@ final class AsyncMulticasterTests: XCTestCase { let stream = multicaster.makeStream(bufferingPolicy: .unbounded, initial: 42) var received: [Int] = [] - for await value in stream { received.append(value) } + for await value in stream { + received.append(value) + } XCTAssertEqual(received, []) } @@ -76,7 +88,9 @@ final class AsyncMulticasterTests: XCTestCase { multicaster = nil var received: [Int] = [] - for await value in stream { received.append(value) } + for await value in stream { + received.append(value) + } XCTAssertEqual(received, [1]) } } diff --git a/Workflow/Tests/PerformanceTests.swift b/Workflow/Tests/PerformanceTests.swift index cbd0ff6ee..022486b54 100644 --- a/Workflow/Tests/PerformanceTests.swift +++ b/Workflow/Tests/PerformanceTests.swift @@ -18,6 +18,7 @@ import XCTest @testable import Workflow +@MainActor class PerformanceTests: XCTestCase { var noOpObserver: WorkflowObserver { struct NoOpObserverImpl: WorkflowObserver {} diff --git a/Workflow/Tests/SubtreeManagerTests.swift b/Workflow/Tests/SubtreeManagerTests.swift index 3a9b01269..9daba0a4f 100644 --- a/Workflow/Tests/SubtreeManagerTests.swift +++ b/Workflow/Tests/SubtreeManagerTests.swift @@ -336,6 +336,7 @@ private struct TestWorkflow: Workflow { // MARK: Testing conveniences extension WorkflowSession { + @MainActor fileprivate static func testing() -> WorkflowSession { struct SessionTestWorkflow: Workflow { typealias State = Void diff --git a/Workflow/Tests/WorkflowObserverTests.swift b/Workflow/Tests/WorkflowObserverTests.swift index 49943ce70..1e99cdc92 100644 --- a/Workflow/Tests/WorkflowObserverTests.swift +++ b/Workflow/Tests/WorkflowObserverTests.swift @@ -654,6 +654,7 @@ private struct InjectableWorkflow: Workflow { extension WorkflowSession { fileprivate var workflowTypeString: String { String(describing: workflowType) } + @MainActor fileprivate static var testingSession: Self { WorkflowSession( workflow: Parent(), diff --git a/WorkflowCombine/Sources/Publisher+Extensions.swift b/WorkflowCombine/Sources/Publisher+Extensions.swift index d582e4e33..eb6f24baa 100644 --- a/WorkflowCombine/Sources/Publisher+Extensions.swift +++ b/WorkflowCombine/Sources/Publisher+Extensions.swift @@ -23,7 +23,7 @@ extension Publisher where Failure == Never { asAnyWorkflow().rendered(in: context, key: key, outputMap: { $0 }) } - public func mapOutput(_ transform: @escaping (Output) -> NewOutput) -> AnyWorkflow { + public func mapOutput(_ transform: @escaping @MainActor @Sendable (Output) -> NewOutput) -> AnyWorkflow { asAnyWorkflow().mapOutput(transform) } diff --git a/WorkflowSwiftUI/Sources/Store.swift b/WorkflowSwiftUI/Sources/Store.swift index 2b6f4fcfd..291312bcc 100644 --- a/WorkflowSwiftUI/Sources/Store.swift +++ b/WorkflowSwiftUI/Sources/Store.swift @@ -178,7 +178,11 @@ extension Store { readState(keyPath: state) } set { - model[keyPath: sink].send(action(newValue)) + // SwiftUI bindings are written on the main thread; `Sink.send` + // is main-actor-isolated. + MainActor.assumeIsolated { + model[keyPath: sink].send(action(newValue)) + } } } } diff --git a/WorkflowSwiftUI/Tests/StoreTests.swift b/WorkflowSwiftUI/Tests/StoreTests.swift index 4fde169df..a59d12f20 100644 --- a/WorkflowSwiftUI/Tests/StoreTests.swift +++ b/WorkflowSwiftUI/Tests/StoreTests.swift @@ -6,6 +6,7 @@ import SwiftUI import XCTest @testable import WorkflowSwiftUI +@MainActor final class StoreTests: XCTestCase { func test_stateRead() { var state = State() diff --git a/WorkflowTesting/Sources/Internal/RenderTester+TestContext.swift b/WorkflowTesting/Sources/Internal/RenderTester+TestContext.swift index 0970d2f24..91f6574b4 100644 --- a/WorkflowTesting/Sources/Internal/RenderTester+TestContext.swift +++ b/WorkflowTesting/Sources/Internal/RenderTester+TestContext.swift @@ -49,7 +49,7 @@ extension RenderTester { self.applyContext = applyContext } - func render(workflow: Child, key: String, outputMap: @escaping (Child.Output) -> Action) -> Child.Rendering where Action.WorkflowType == WorkflowType { + func render(workflow: Child, key: String, outputMap: @escaping @MainActor (Child.Output) -> Action) -> Child.Rendering where Action.WorkflowType == WorkflowType { let matchingTypes = expectedWorkflows.compactMap { $0 as? ExpectedWorkflow } guard let expectedWorkflow = matchingTypes.first(where: { $0.key == key }) else { let sameTypeDifferentKeys = matchingTypes.map(\.key) From 7d031fa6862059be3097c84b25497c6d53a72314 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 23 Jul 2026 15:28:13 -0500 Subject: [PATCH 11/19] docs: doc-comment updates for Swift Concurrency core APIs Co-Authored-By: Claude Fable 5 --- Workflow/Sources/StateMutationSink.swift | 6 ++++++ Workflow/Sources/WorkflowObserver.swift | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Workflow/Sources/StateMutationSink.swift b/Workflow/Sources/StateMutationSink.swift index 7b8be2c8d..75f973040 100644 --- a/Workflow/Sources/StateMutationSink.swift +++ b/Workflow/Sources/StateMutationSink.swift @@ -40,6 +40,9 @@ public struct StateMutationSink { /// Sends message to `StateMutationSink` to update `State`'s value using the provided closure. /// + /// Sinks deliver values into the Workflow runtime, which runs on the main actor, so `send` + /// is main-actor-isolated. + /// /// - Parameters: /// - update: The `State` mutation to perform. @MainActor @@ -54,6 +57,9 @@ public struct StateMutationSink { /// Sends message to `StateMutationSink` to update `State`'s value at `KeyPath` with `Value`. /// + /// Sinks deliver values into the Workflow runtime, which runs on the main actor, so `send` + /// is main-actor-isolated. + /// /// - Parameters: /// - keyPath: Key path of `State` whose value needs to be mutated. /// - value: Value to update `State` with. diff --git a/Workflow/Sources/WorkflowObserver.swift b/Workflow/Sources/WorkflowObserver.swift index 855fc99f0..ce663036a 100644 --- a/Workflow/Sources/WorkflowObserver.swift +++ b/Workflow/Sources/WorkflowObserver.swift @@ -154,8 +154,7 @@ public struct WorkflowSession { } } - /// Creates a new `WorkflowSession` instance. Note, construction of this type - /// is not safe to perform concurrently with respect to other instance initialization. + /// Creates a new `WorkflowSession` instance. /// - Parameters: /// - workflow: The associated `Workflow` instance /// - renderKey: The string key used to render `workflow` From 578e1e8f202232f910052f9e8c9ed778bc1397b2 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Thu, 23 Jul 2026 15:56:52 -0500 Subject: [PATCH 12/19] fix: address final review findings (ordering comment, mapOutput isolation note) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Softens an overstated FIFO-ordering claim in the sink-event deferral comment, and documents why WorkflowCombine's mapOutput spells @Sendable explicitly (implicit for @MainActor function types under the core module's Swift 6 mode, explicit under this module's Swift 5 mode — same type either way). Co-Authored-By: Claude Fable 5 --- Workflow/Sources/WorkflowHost.swift | 7 ++++--- WorkflowCombine/Sources/Publisher+Extensions.swift | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index 3c21b98b1..e9ab663cf 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -312,9 +312,10 @@ final class SinkEventHandler { withEventHandlingSuspended(immediate) case .busy: - // Main-actor Task preserves the previous DispatchQueue.main.async - // FIFO ordering; non-Sendable captures are legal because creation - // context and Task isolation are both MainActor (no region crossing). + // Delivery stays on the main actor; relative ordering with other + // main-queue work is best-effort per Task scheduling semantics. + // Non-Sendable captures are legal because creation context and + // Task isolation are both MainActor (no region crossing). Task { @MainActor in deferred() } diff --git a/WorkflowCombine/Sources/Publisher+Extensions.swift b/WorkflowCombine/Sources/Publisher+Extensions.swift index eb6f24baa..fcdd1dd10 100644 --- a/WorkflowCombine/Sources/Publisher+Extensions.swift +++ b/WorkflowCombine/Sources/Publisher+Extensions.swift @@ -23,6 +23,9 @@ extension Publisher where Failure == Never { asAnyWorkflow().rendered(in: context, key: key, outputMap: { $0 }) } + // Note: `@Sendable` is spelled explicitly here because this module compiles in Swift 5 mode; + // in the core module's Swift 6 mode, `@MainActor` function types are implicitly `Sendable`, + // so this is the same type as `AnyWorkflow.mapOutput`'s parameter. public func mapOutput(_ transform: @escaping @MainActor @Sendable (Output) -> NewOutput) -> AnyWorkflow { asAnyWorkflow().mapOutput(transform) } From 85bf6fc9abc1ec94a460f4baa7bfed2cc36d9c26 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Fri, 24 Jul 2026 03:17:14 -0500 Subject: [PATCH 13/19] fix: avoid isolated deinit optimizer crash in release builds Swift 6.3.2's performance inliner crashes (infinite recursion in its layout-constraint compatibility check) when optimizing the deallocating deinit of a generic class that declares an isolated deinit. Replace the isolated deinits on WorkflowNode and WorkflowHost with plain deinits that assume main-actor isolation, matching the runtime's guarantee that these references are released on the main actor. Co-Authored-By: Claude Fable 5 --- Workflow/Sources/WorkflowHost.swift | 12 +++++++++--- Workflow/Sources/WorkflowNode.swift | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index e9ab663cf..1ac603947 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -117,9 +117,15 @@ public final class WorkflowHost { } } - isolated deinit { - renderingSubject.send(completion: .finished) - outputSubject.send(completion: .finished) + deinit { + // Not an `isolated deinit`: the Swift 6.3.2 optimizer crashes when + // compiling isolated deinits of generic classes in release builds. + // The host is main-actor-isolated, so the last reference is expected + // to be released on the main actor. + MainActor.assumeIsolated { + renderingSubject.send(completion: .finished) + outputSubject.send(completion: .finished) + } } /// Update the input for the workflow. Will cause a render pass. diff --git a/Workflow/Sources/WorkflowNode.swift b/Workflow/Sources/WorkflowNode.swift index d1ac3f59f..eea4b8443 100644 --- a/Workflow/Sources/WorkflowNode.swift +++ b/Workflow/Sources/WorkflowNode.swift @@ -78,9 +78,15 @@ final class WorkflowNode { } } - isolated deinit { - observer?.sessionDidEnd(session) - WorkflowLogger.logWorkflowFinished(ref: self) + deinit { + // Not an `isolated deinit`: the Swift 6.3.2 optimizer crashes when + // compiling isolated deinits of generic classes in release builds. + // Node lifetimes are managed by the main-actor runtime, so the last + // reference is always released on the main actor. + MainActor.assumeIsolated { + observer?.sessionDidEnd(session) + WorkflowLogger.logWorkflowFinished(ref: self) + } } /// Handles an event produced by the subtree manager From dc1f50e6b9224a66e1a54438dd517bc02a7d0573 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Fri, 24 Jul 2026 14:27:12 -0500 Subject: [PATCH 14/19] fix: schedule deinit finalization instead of running it inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The isolated-deinit replacement (85bf6fc9) ran finalization inline via MainActor.assumeIsolated. That subtly changed runtime behavior: an isolated deinit enqueues its body onto the actor whenever the last release happens outside a main-actor task context (e.g. plain main-thread code), so observer callbacks and subject completions never interleaved with the main-actor operation that released the reference. Running them inline reordered those side effects relative to in-flight UI work. Store the finalization as a main-actor closure created at init (such closures are implicitly Sendable in Swift 6 mode) and have the plain deinit schedule it unconditionally. This is deterministic — unlike isolated deinit, which is inline-or-enqueued depending on the releasing context — avoids the Swift 6.3.2 optimizer crash, and removes two MainActor.assumeIsolated uses. The "Alive" signpost interval is now anchored to a SignpostRef owned by the node, since the node itself is gone by the time the scheduled finalization emits the end of the interval. Co-Authored-By: Claude Fable 5 --- Workflow/Sources/WorkflowHost.swift | 22 ++++++++++++---- Workflow/Sources/WorkflowLogger.swift | 10 +++++--- Workflow/Sources/WorkflowNode.swift | 30 +++++++++++++++++----- Workflow/Tests/WorkflowObserverTests.swift | 5 ++++ 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index 1ac603947..ad86f7680 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -72,6 +72,11 @@ public final class WorkflowHost { let sinkEventHandler: SinkEventHandler + /// Finalization work to perform when the host deinits. Stored as a + /// main-actor closure so the (nonisolated) deinit can schedule it on the + /// main actor without capturing the non-Sendable subjects directly. + private let finalizeOnMainActor: @MainActor () -> Void + /// Initializes a new host with the given workflow at the root. /// /// - Parameter workflow: The root workflow in the hierarchy @@ -108,6 +113,12 @@ public final class WorkflowHost { ) self.renderingSubject = CurrentValueSubject(rootNode.render()) + + self.finalizeOnMainActor = { [renderingSubject, outputSubject] in + renderingSubject.send(completion: .finished) + outputSubject.send(completion: .finished) + } + rootNode.enableEvents() debugger?.didEnterInitialState(snapshot: rootNode.makeDebugSnapshot()) @@ -120,11 +131,12 @@ public final class WorkflowHost { deinit { // Not an `isolated deinit`: the Swift 6.3.2 optimizer crashes when // compiling isolated deinits of generic classes in release builds. - // The host is main-actor-isolated, so the last reference is expected - // to be released on the main actor. - MainActor.assumeIsolated { - renderingSubject.send(completion: .finished) - outputSubject.send(completion: .finished) + // The finalization is enqueued (matching isolated-deinit semantics) + // rather than run inline so subject completions never interleave with + // whatever main-actor operation released the last reference. + let finalize = finalizeOnMainActor + Task { @MainActor in + finalize() } } diff --git a/Workflow/Sources/WorkflowLogger.swift b/Workflow/Sources/WorkflowLogger.swift index 675cb393b..8bd8368b1 100644 --- a/Workflow/Sources/WorkflowLogger.swift +++ b/Workflow/Sources/WorkflowLogger.swift @@ -117,8 +117,12 @@ final class SignpostRef { enum WorkflowLogger { // MARK: Workflows + /// Logs the start of a node's "Alive" interval. `ref` anchors the signpost + /// identity and must be the same object later passed to + /// `logWorkflowFinished(ref:)`; a `SignpostRef` owned by the node lets the + /// end of the interval be logged after the node itself is gone. @MainActor - static func logWorkflowStarted(ref: WorkflowNode) { + static func logWorkflowStarted(ref: AnyObject, workflowType: String) { guard WorkflowLogging.isOSLoggingAllowed, WorkflowLogging.config.logLifetimes @@ -131,12 +135,12 @@ enum WorkflowLogger { name: "Alive", signpostID: signpostID, "Workflow: %{public}@", - String(describing: WorkflowType.self) + workflowType ) } @MainActor - static func logWorkflowFinished(ref: WorkflowNode) { + static func logWorkflowFinished(ref: AnyObject) { guard WorkflowLogging.isOSLoggingAllowed, WorkflowLogging.config.logLifetimes diff --git a/Workflow/Sources/WorkflowNode.swift b/Workflow/Sources/WorkflowNode.swift index eea4b8443..25960e54f 100644 --- a/Workflow/Sources/WorkflowNode.swift +++ b/Workflow/Sources/WorkflowNode.swift @@ -42,6 +42,15 @@ final class WorkflowNode { lazy var hasVoidState: Bool = WorkflowType.State.self == Void.self + /// Anchors the node's "Alive" signpost interval to a pointer identity + /// that the deinit-scheduled finalization can safely outlive `self` with. + private let signpostRef = SignpostRef() + + /// Finalization work to perform when the node deinits. Stored as a + /// main-actor closure so the (nonisolated) deinit can schedule it on the + /// main actor without capturing `self` or its non-Sendable dependencies. + private let finalizeOnMainActor: @MainActor () -> Void + init( workflow: WorkflowType, key: String = "", @@ -65,13 +74,21 @@ final class WorkflowNode { self.state = workflow.makeInitialState() + self.finalizeOnMainActor = { [observer = hostContext.observer, session, signpostRef] in + observer?.sessionDidEnd(session) + WorkflowLogger.logWorkflowFinished(ref: signpostRef) + } + observer?.workflowDidMakeInitialState( workflow, initialState: state, session: session ) - WorkflowLogger.logWorkflowStarted(ref: self) + WorkflowLogger.logWorkflowStarted( + ref: signpostRef, + workflowType: String(describing: WorkflowType.self) + ) subtreeManager.onUpdate = { [weak self] output in self?.handle(subtreeOutput: output) @@ -81,11 +98,12 @@ final class WorkflowNode { deinit { // Not an `isolated deinit`: the Swift 6.3.2 optimizer crashes when // compiling isolated deinits of generic classes in release builds. - // Node lifetimes are managed by the main-actor runtime, so the last - // reference is always released on the main actor. - MainActor.assumeIsolated { - observer?.sessionDidEnd(session) - WorkflowLogger.logWorkflowFinished(ref: self) + // The finalization is enqueued (matching isolated-deinit semantics) + // rather than run inline so observer callbacks never interleave with + // whatever main-actor operation released the last reference. + let finalize = finalizeOnMainActor + Task { @MainActor in + finalize() } } diff --git a/Workflow/Tests/WorkflowObserverTests.swift b/Workflow/Tests/WorkflowObserverTests.swift index 1e99cdc92..b47114f3c 100644 --- a/Workflow/Tests/WorkflowObserverTests.swift +++ b/Workflow/Tests/WorkflowObserverTests.swift @@ -57,6 +57,11 @@ final class WorkflowObserverTests: XCTestCase { } XCTAssertNil(weakHost, "host expected to deallocate") + + // Deinit finalization (including `sessionDidEnd`) is scheduled onto + // the main actor rather than run inline; let it run. + drainMainQueueBySpinningRunLoop() + XCTAssertNotNil(beganSession) XCTAssertNotNil(beganSession?.sessionID) XCTAssertEqual(beganSession?.sessionID, endedSession?.sessionID) From 9936a3080860291e82faa020ca38c2916a7694b6 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Fri, 24 Jul 2026 14:50:01 -0500 Subject: [PATCH 15/19] fix: defer owned-subtree teardown with deinit finalization An isolated deinit defers releasing the object's stored properties along with its body, so a workflow tree released mid-render never tore down children, side-effect lifetimes, or event pipes while the render pass was still running. Scheduling only the finalization side effects left the deallocation cascade inline at the release point, where it can still interleave with in-flight main-actor work and perturb layout. Capture the node's subtree manager (and the host's root node) in the scheduled finalization closure so the entire teardown happens in the finalization job, matching isolated-deinit ordering deterministically. Co-Authored-By: Claude Fable 5 --- Workflow/Sources/WorkflowHost.swift | 11 ++++++++--- Workflow/Sources/WorkflowNode.swift | 12 +++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index ad86f7680..68bf7c452 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -114,9 +114,14 @@ public final class WorkflowHost { self.renderingSubject = CurrentValueSubject(rootNode.render()) - self.finalizeOnMainActor = { [renderingSubject, outputSubject] in - renderingSubject.send(completion: .finished) - outputSubject.send(completion: .finished) + self.finalizeOnMainActor = { [renderingSubject, outputSubject, rootNode] in + // Capturing the root node defers the workflow tree's teardown to + // this scheduled finalization, so it can't interleave with the + // main-actor work that released the host. + withExtendedLifetime(rootNode) { + renderingSubject.send(completion: .finished) + outputSubject.send(completion: .finished) + } } rootNode.enableEvents() diff --git a/Workflow/Sources/WorkflowNode.swift b/Workflow/Sources/WorkflowNode.swift index 25960e54f..7aec9e0ec 100644 --- a/Workflow/Sources/WorkflowNode.swift +++ b/Workflow/Sources/WorkflowNode.swift @@ -74,9 +74,15 @@ final class WorkflowNode { self.state = workflow.makeInitialState() - self.finalizeOnMainActor = { [observer = hostContext.observer, session, signpostRef] in - observer?.sessionDidEnd(session) - WorkflowLogger.logWorkflowFinished(ref: signpostRef) + self.finalizeOnMainActor = { [subtreeManager, observer = hostContext.observer, session, signpostRef] in + // Capturing the subtree manager defers the subtree's teardown + // (child deinits, side-effect terminations) to this scheduled + // finalization, so it can't interleave with the main-actor work + // that released the node — e.g. an in-progress render pass. + withExtendedLifetime(subtreeManager) { + observer?.sessionDidEnd(session) + WorkflowLogger.logWorkflowFinished(ref: signpostRef) + } } observer?.workflowDidMakeInitialState( From 17b95b55893ecd4481df5e01ab695c25dd7d88ed Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Fri, 24 Jul 2026 14:57:56 -0500 Subject: [PATCH 16/19] fix: keep host teardown inline; defer only node-level subtree teardown Deferring the host's whole tree through the finalization task holds the previous tree (including in-flight view state) alive across whatever the releasing code does next, which is its own source of interleaving. The node-level deferral is what protects an in-progress render pass from mid-pass subtree teardown; the host's root-node release can stay at the release point. Co-Authored-By: Claude Fable 5 --- Workflow/Sources/WorkflowHost.swift | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index 68bf7c452..ad86f7680 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -114,14 +114,9 @@ public final class WorkflowHost { self.renderingSubject = CurrentValueSubject(rootNode.render()) - self.finalizeOnMainActor = { [renderingSubject, outputSubject, rootNode] in - // Capturing the root node defers the workflow tree's teardown to - // this scheduled finalization, so it can't interleave with the - // main-actor work that released the host. - withExtendedLifetime(rootNode) { - renderingSubject.send(completion: .finished) - outputSubject.send(completion: .finished) - } + self.finalizeOnMainActor = { [renderingSubject, outputSubject] in + renderingSubject.send(completion: .finished) + outputSubject.send(completion: .finished) } rootNode.enableEvents() From 7eb839c2553b40a9b93d6f74837bad2e5fef9817 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Fri, 24 Jul 2026 16:29:08 -0500 Subject: [PATCH 17/19] Run deinit finalization inline when released from a main-actor task Matches the dispatch semantics of `isolated deinit` (SE-0371) instead of unconditionally enqueueing: a release from a main-actor task context finalizes inline, so callers that drop the last reference can observe teardown side effects (observer callbacks, side-effect terminations, subject completions) synchronously. Releases outside a task context still enqueue onto the main actor so finalization can't interleave with whatever main-actor operation released the reference. The inline path also restores single-job cascade teardown: once an enqueued finalization runs (inside a main-actor task), the child deinits it triggers take the inline path, so an entire subtree tears down within one main-actor job instead of one job per tree level with arbitrary main-queue work interleaved between levels. Co-Authored-By: Claude Fable 5 --- Workflow/Sources/DeinitFinalization.swift | 46 +++++++++++++++++++++++ Workflow/Sources/WorkflowHost.swift | 10 +---- Workflow/Sources/WorkflowNode.swift | 10 +---- 3 files changed, 48 insertions(+), 18 deletions(-) create mode 100644 Workflow/Sources/DeinitFinalization.swift diff --git a/Workflow/Sources/DeinitFinalization.swift b/Workflow/Sources/DeinitFinalization.swift new file mode 100644 index 000000000..bf5ad3c3d --- /dev/null +++ b/Workflow/Sources/DeinitFinalization.swift @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Runs main-actor finalization work from a (nonisolated) `deinit`, matching +/// the dispatch semantics of an `isolated deinit` (SE-0371) without using +/// one — the Swift 6.3.2 optimizer crashes when compiling isolated deinits +/// of generic classes in release builds. +/// +/// If the last reference was released from a main-actor task context, the +/// finalization runs inline, so callers that drop a reference can observe +/// teardown side effects synchronously. Otherwise — a release from the bare +/// main thread mid-operation, or from another thread — the finalization is +/// enqueued onto the main actor so it can't interleave with whatever +/// main-actor operation released the last reference (e.g. an in-progress +/// render pass). +/// +/// The inline path also gives enqueued finalizations single-job cascade +/// semantics: once an enqueued finalization runs (inside a main-actor task), +/// any deinits it triggers take the inline path, so an entire subtree tears +/// down within one main-actor job rather than one job per tree level. +func finalizeFromDeinit(_ finalize: @escaping @MainActor () -> Void) { + if Thread.isMainThread, withUnsafeCurrentTask(body: { $0 != nil }) { + // On the main thread and inside a task: the current task can only be + // executing here if it is isolated to the main actor. + MainActor.assumeIsolated(finalize) + } else { + Task { @MainActor in + finalize() + } + } +} diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index ad86f7680..a19d4e5c5 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -129,15 +129,7 @@ public final class WorkflowHost { } deinit { - // Not an `isolated deinit`: the Swift 6.3.2 optimizer crashes when - // compiling isolated deinits of generic classes in release builds. - // The finalization is enqueued (matching isolated-deinit semantics) - // rather than run inline so subject completions never interleave with - // whatever main-actor operation released the last reference. - let finalize = finalizeOnMainActor - Task { @MainActor in - finalize() - } + finalizeFromDeinit(finalizeOnMainActor) } /// Update the input for the workflow. Will cause a render pass. diff --git a/Workflow/Sources/WorkflowNode.swift b/Workflow/Sources/WorkflowNode.swift index 7aec9e0ec..b5c3abd8e 100644 --- a/Workflow/Sources/WorkflowNode.swift +++ b/Workflow/Sources/WorkflowNode.swift @@ -102,15 +102,7 @@ final class WorkflowNode { } deinit { - // Not an `isolated deinit`: the Swift 6.3.2 optimizer crashes when - // compiling isolated deinits of generic classes in release builds. - // The finalization is enqueued (matching isolated-deinit semantics) - // rather than run inline so observer callbacks never interleave with - // whatever main-actor operation released the last reference. - let finalize = finalizeOnMainActor - Task { @MainActor in - finalize() - } + finalizeFromDeinit(finalizeOnMainActor) } /// Handles an event produced by the subtree manager From 6de8764e36a5cf8b8e03f99633995d30f4f60802 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Fri, 24 Jul 2026 16:49:44 -0500 Subject: [PATCH 18/19] Base deinit finalization dispatch on runtime activity, not task context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-context detection was the wrong discriminator: synchronous code (e.g. sync XCTest methods) that drops the last reference to a workflow host has no current task, yet legitimately expects teardown side effects — side-effect terminations, observer callbacks — to be observable when the drop returns, exactly as a plain inline deinit behaves. The actual hazard is a release performed while the runtime is mid-operation: tearing down inline there interleaves observer callbacks, subject completions, and subtree teardown with the in-flight render pass or action cascade. So mark the runtime's main-actor operations with a re-entrancy counter (WorkflowRuntimeActivity) at every entry point — EventPipe.handle covers all event cascades; the host marks its initial render, update(workflow:), and output handling — and finalize inline only when on the main thread with the runtime quiescent, enqueueing onto the main actor otherwise. An enqueued finalization runs with the runtime quiescent, so the child deinits it triggers finalize inline: an entire subtree still tears down within one main-actor job rather than one job per tree level. Co-Authored-By: Claude Fable 5 --- Workflow/Sources/DeinitFinalization.swift | 74 +++++++++++++++++------ Workflow/Sources/SubtreeManager.swift | 10 +++ Workflow/Sources/WorkflowHost.swift | 23 +++++-- 3 files changed, 82 insertions(+), 25 deletions(-) diff --git a/Workflow/Sources/DeinitFinalization.swift b/Workflow/Sources/DeinitFinalization.swift index bf5ad3c3d..3261047b3 100644 --- a/Workflow/Sources/DeinitFinalization.swift +++ b/Workflow/Sources/DeinitFinalization.swift @@ -16,28 +16,64 @@ import Foundation -/// Runs main-actor finalization work from a (nonisolated) `deinit`, matching -/// the dispatch semantics of an `isolated deinit` (SE-0371) without using -/// one — the Swift 6.3.2 optimizer crashes when compiling isolated deinits -/// of generic classes in release builds. +/// Tracks re-entrancy into the workflow runtime's main-actor operations: +/// render passes, action application, and the output cascades they produce +/// (including any work Combine subscribers perform synchronously in +/// response). /// -/// If the last reference was released from a main-actor task context, the -/// finalization runs inline, so callers that drop a reference can observe -/// teardown side effects synchronously. Otherwise — a release from the bare -/// main thread mid-operation, or from another thread — the finalization is -/// enqueued onto the main actor so it can't interleave with whatever -/// main-actor operation released the last reference (e.g. an in-progress -/// render pass). +/// Deinit finalization consults this to decide whether teardown may run +/// inline. A reference released while the runtime is mid-operation must not +/// tear down inline: observer callbacks, subject completions, and subtree +/// teardown would interleave with the in-flight operation that performed +/// the release. +@MainActor +enum WorkflowRuntimeActivity { + private(set) static var depth = 0 + + /// Runs `operation` with the runtime marked as active. Nesting is + /// expected: event cascades re-enter through multiple entry points. + static func perform(_ operation: () throws -> T) rethrows -> T { + depth += 1 + defer { depth -= 1 } + return try operation() + } +} + +/// Runs main-actor finalization work from a (nonisolated) `deinit`. /// -/// The inline path also gives enqueued finalizations single-job cascade -/// semantics: once an enqueued finalization runs (inside a main-actor task), -/// any deinits it triggers take the inline path, so an entire subtree tears -/// down within one main-actor job rather than one job per tree level. +/// This exists because the natural tool — an `isolated deinit` (SE-0371) — +/// crashes the Swift 6.3.2 optimizer when compiling isolated deinits of +/// generic classes in release builds, and because isolated deinit's +/// dispatch rule (inline only from task contexts) doesn't match what +/// callers observe in practice: synchronous code that drops the last +/// reference to a workflow host expects teardown side effects (side-effect +/// terminations, observer callbacks) to have happened when the drop +/// returns, exactly as a plain inline `deinit` behaves. +/// +/// So the rule here is based on what the runtime is doing rather than on +/// task context: +/// - On the main thread with the runtime quiescent, finalize inline — +/// teardown is synchronously observable, matching a plain `deinit`. +/// - If the runtime is mid-operation (the release happened during a render +/// pass or action cascade) or the release is off the main thread, enqueue +/// onto the main actor so teardown can't interleave with the in-flight +/// operation. +/// +/// An enqueued finalization runs with the runtime quiescent, so the child +/// deinits it triggers take the inline path: an entire subtree tears down +/// within that one main-actor job rather than one job per tree level. func finalizeFromDeinit(_ finalize: @escaping @MainActor () -> Void) { - if Thread.isMainThread, withUnsafeCurrentTask(body: { $0 != nil }) { - // On the main thread and inside a task: the current task can only be - // executing here if it is isolated to the main actor. - MainActor.assumeIsolated(finalize) + if Thread.isMainThread { + // Dynamically safe: the main thread is the main actor's executor. + MainActor.assumeIsolated { + if WorkflowRuntimeActivity.depth == 0 { + finalize() + } else { + Task { @MainActor in + finalize() + } + } + } } else { Task { @MainActor in finalize() diff --git a/Workflow/Sources/SubtreeManager.swift b/Workflow/Sources/SubtreeManager.swift index 8935e5b2a..8d4a5a3ad 100644 --- a/Workflow/Sources/SubtreeManager.swift +++ b/Workflow/Sources/SubtreeManager.swift @@ -452,6 +452,16 @@ extension WorkflowNode.SubtreeManager { } func handle(event: Output) { + // Every event cascade (sink sends, worker outputs, child output + // bubbling) enters the runtime here, so this marks the whole + // cascade — action application through the resulting render — + // as active for deinit-finalization dispatch. + WorkflowRuntimeActivity.perform { + handleMarkedActive(event: event) + } + } + + private func handleMarkedActive(event: Output) { let isReentrantCall = isHandlingEvent isHandlingEvent = true defer { isHandlingEvent = isReentrantCall } diff --git a/Workflow/Sources/WorkflowHost.swift b/Workflow/Sources/WorkflowHost.swift index a19d4e5c5..545bca0eb 100644 --- a/Workflow/Sources/WorkflowHost.swift +++ b/Workflow/Sources/WorkflowHost.swift @@ -106,13 +106,16 @@ public final class WorkflowHost { onSinkEvent: sinkEventCallback ) - self.rootNode = WorkflowNode( + let rootNode = WorkflowNode( workflow: workflow, hostContext: context, parentSession: nil ) + self.rootNode = rootNode - self.renderingSubject = CurrentValueSubject(rootNode.render()) + self.renderingSubject = WorkflowRuntimeActivity.perform { + CurrentValueSubject(rootNode.render()) + } self.finalizeOnMainActor = { [renderingSubject, outputSubject] in renderingSubject.send(completion: .finished) @@ -134,12 +137,14 @@ public final class WorkflowHost { /// Update the input for the workflow. Will cause a render pass. public func update(workflow: WorkflowType) { - if context.runtimeConfig.useSinkEventHandler { - sinkEventHandler.withEventHandlingSuspended { + WorkflowRuntimeActivity.perform { + if context.runtimeConfig.useSinkEventHandler { + sinkEventHandler.withEventHandlingSuspended { + updateRootNode(workflow: workflow) + } + } else { updateRootNode(workflow: workflow) } - } else { - updateRootNode(workflow: workflow) } } @@ -161,6 +166,12 @@ public final class WorkflowHost { } private func handle(output: WorkflowNode.Output) { + WorkflowRuntimeActivity.perform { + handleMarkedActive(output: output) + } + } + + private func handleMarkedActive(output: WorkflowNode.Output) { let shouldRender = !shouldSkipRenderForOutput(output) if shouldRender { let rendering = rootNode.render() From f83d5cdc44fe5df215de5f6d9b186d6d513a3313 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Fri, 24 Jul 2026 17:47:52 -0500 Subject: [PATCH 19/19] Drain deferred deinit finalizations at operation exit, not via Task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deferring a mid-operation finalization to a Task pushed teardown to a later main-queue drain: unrelated main-queue work could interleave with it, and the extra runloop hop shifted the timing that snapshot tests (text-caret blink phase) and leak detectors observe. Queue the deferred finalizations on WorkflowRuntimeActivity instead and run them when the outermost operation exits — teardown still can't interleave with the in-flight render pass or action cascade, but it completes within the same runloop callout, matching pre-deferral timing at runloop granularity. Off-main-thread releases still hop onto the main actor. Co-Authored-By: Claude Fable 5 --- Workflow/Sources/DeinitFinalization.swift | 52 ++++++++++++++++++----- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/Workflow/Sources/DeinitFinalization.swift b/Workflow/Sources/DeinitFinalization.swift index 3261047b3..1cee7cd2a 100644 --- a/Workflow/Sources/DeinitFinalization.swift +++ b/Workflow/Sources/DeinitFinalization.swift @@ -30,13 +30,43 @@ import Foundation enum WorkflowRuntimeActivity { private(set) static var depth = 0 + private static var pendingFinalizations: [@MainActor () -> Void] = [] + /// Runs `operation` with the runtime marked as active. Nesting is /// expected: event cascades re-enter through multiple entry points. + /// When the outermost operation exits, finalizations deferred during it + /// run immediately — still within the same runloop callout, so teardown + /// timing stays at pre-deferral runloop granularity rather than slipping + /// to a later main-queue drain. static func perform(_ operation: () throws -> T) rethrows -> T { depth += 1 - defer { depth -= 1 } + defer { + depth -= 1 + if depth == 0 { + drainPendingFinalizations() + } + } return try operation() } + + /// Defers `finalize` until the outermost in-flight operation exits. + /// Must only be called while the runtime is active (`depth > 0`). + static func enqueueFinalization(_ finalize: @escaping @MainActor () -> Void) { + pendingFinalizations.append(finalize) + } + + private static func drainPendingFinalizations() { + // A finalization can release references whose deinits enqueue more + // finalizations (via a nested `perform`, drained there) or run + // inline (depth is 0 here); loop until no stragglers remain. + while !pendingFinalizations.isEmpty { + let pending = pendingFinalizations + pendingFinalizations.removeAll() + for finalize in pending { + finalize() + } + } + } } /// Runs main-actor finalization work from a (nonisolated) `deinit`. @@ -55,13 +85,17 @@ enum WorkflowRuntimeActivity { /// - On the main thread with the runtime quiescent, finalize inline — /// teardown is synchronously observable, matching a plain `deinit`. /// - If the runtime is mid-operation (the release happened during a render -/// pass or action cascade) or the release is off the main thread, enqueue -/// onto the main actor so teardown can't interleave with the in-flight -/// operation. +/// pass or action cascade), defer until the outermost operation exits, so +/// teardown can't interleave with the in-flight operation but still +/// completes within the same runloop callout. Deferring to a `Task` +/// instead would push teardown to a later main-queue drain, which lets +/// unrelated main-queue work interleave with it and shifts the runloop +/// timing that snapshot tests and leak detectors observe. +/// - If the release happened off the main thread, hop onto the main actor. /// -/// An enqueued finalization runs with the runtime quiescent, so the child -/// deinits it triggers take the inline path: an entire subtree tears down -/// within that one main-actor job rather than one job per tree level. +/// A deferred finalization runs with the runtime quiescent, so the child +/// deinits it triggers finalize inline: an entire subtree tears down at a +/// single point rather than one deferral hop per tree level. func finalizeFromDeinit(_ finalize: @escaping @MainActor () -> Void) { if Thread.isMainThread { // Dynamically safe: the main thread is the main actor's executor. @@ -69,9 +103,7 @@ func finalizeFromDeinit(_ finalize: @escaping @MainActor () -> Void) { if WorkflowRuntimeActivity.depth == 0 { finalize() } else { - Task { @MainActor in - finalize() - } + WorkflowRuntimeActivity.enqueueFinalization(finalize) } } } else {