-
Notifications
You must be signed in to change notification settings - Fork 47
[fix]: add host-level logic for reentrant event handling #381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ | |
| * limitations under the License. | ||
| */ | ||
|
|
||
| import Dispatch | ||
| import ReactiveSwift | ||
|
|
||
| /// Defines a type that receives debug information about a running workflow hierarchy. | ||
|
|
@@ -50,6 +51,8 @@ public final class WorkflowHost<WorkflowType: Workflow> { | |
| context.debugger | ||
| } | ||
|
|
||
| let eventHandler: SinkEventHandler | ||
|
|
||
| /// Initializes a new host with the given workflow at the root. | ||
| /// | ||
| /// - Parameter workflow: The root workflow in the hierarchy | ||
|
|
@@ -61,6 +64,13 @@ public final class WorkflowHost<WorkflowType: Workflow> { | |
| observers: [WorkflowObserver] = [], | ||
| debugger: WorkflowDebugger? = nil | ||
| ) { | ||
| self.eventHandler = SinkEventHandler() | ||
| assert( | ||
| eventHandler.state == .initializing, | ||
| "EventHandler must begin in the `.initializing` state" | ||
| ) | ||
jamieQ marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| defer { eventHandler.state = .ready } | ||
|
|
||
| let observer = WorkflowObservation | ||
| .sharedObserversInterceptor | ||
| .workflowObservers(for: observers) | ||
|
|
@@ -69,7 +79,8 @@ public final class WorkflowHost<WorkflowType: Workflow> { | |
| self.context = HostContext( | ||
| observer: observer, | ||
| debugger: debugger, | ||
| runtimeConfig: Runtime.configuration | ||
| runtimeConfig: Runtime.configuration, | ||
| onSinkEvent: eventHandler.makeOnSinkEventCallback() | ||
| ) | ||
|
|
||
| self.rootNode = WorkflowNode( | ||
|
|
@@ -158,14 +169,19 @@ struct HostContext { | |
| let debugger: WorkflowDebugger? | ||
| let runtimeConfig: Runtime.Configuration | ||
|
|
||
| /// Event handler to be plumbed through the runtime down to the Sinks | ||
| let onSinkEvent: OnSinkEvent | ||
|
|
||
| init( | ||
| observer: WorkflowObserver?, | ||
| debugger: WorkflowDebugger?, | ||
| runtimeConfig: Runtime.Configuration | ||
| runtimeConfig: Runtime.Configuration, | ||
| onSinkEvent: @escaping OnSinkEvent | ||
| ) { | ||
| self.observer = observer | ||
| self.debugger = debugger | ||
| self.runtimeConfig = runtimeConfig | ||
| self.onSinkEvent = onSinkEvent | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -176,3 +192,72 @@ extension HostContext { | |
| debugger != nil ? perform() : nil | ||
| } | ||
| } | ||
|
|
||
| // MARK: - EventHandler | ||
|
|
||
| /// Callback signature for the internal `ReusableSink` types to invoke when | ||
| /// they receive an event from the 'outside world'. | ||
| /// - Parameter perform: The event handler to invoke if the event can be processed immediately. | ||
| /// - Parameter enqueue: The event handler to invoke in the future if the event cannot currently be processed. | ||
| typealias OnSinkEvent = ( | ||
| _ perform: () -> Void, | ||
| _ enqueue: @escaping () -> Void | ||
jamieQ marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ) -> Void | ||
|
|
||
| /// 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. | ||
| final class SinkEventHandler { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I find this type's API and behavior very intuitive, I like it! |
||
| enum State { | ||
| /// The handler (and related components) are being | ||
| /// initialized, and are not yet ready to process events. | ||
| /// Attempts to do so in this state will fail with a fatal error. | ||
| case initializing | ||
|
|
||
| /// An event is currently being processed. | ||
| case processingEvent | ||
|
|
||
| /// Ready to handle an event. | ||
| case ready | ||
| } | ||
|
|
||
| fileprivate(set) var state: State = .initializing | ||
|
|
||
| /// Synchronously performs or enqueues the specified event handlers based on the current | ||
| /// event handler state. | ||
| /// - Parameters: | ||
| /// - perform: The event handling action to perform immediately if possible. | ||
| /// - enqueue: The event handling action to enqueue if the event handler is already processing an event. | ||
| func performOrEnqueueEvent( | ||
| perform: () -> Void, | ||
| enqueue: @escaping () -> Void | ||
| ) { | ||
| switch state { | ||
| case .initializing: | ||
| fatalError("Tried to handle event before finishing initialization.") | ||
|
|
||
| case .processingEvent: | ||
| DispatchQueue.workflowExecution.async(execute: enqueue) | ||
|
|
||
| case .ready: | ||
| state = .processingEvent | ||
| defer { state = .ready } | ||
| perform() | ||
| } | ||
| } | ||
|
|
||
| /// Creates the callback that should be invoked by Sinks to handle their event appropriately | ||
| /// given the `EventHandler`'s current state. | ||
jamieQ marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| /// - Returns: The callback that should be invoked. | ||
| func makeOnSinkEventCallback() -> OnSinkEvent { | ||
| // TODO: do we need the weak ref? | ||
| let onSinkEvent: OnSinkEvent = { [weak self] perform, enqueue in | ||
| guard let self else { | ||
| return // TODO: what's the appropriate handling? | ||
| } | ||
|
|
||
| performOrEnqueueEvent(perform: perform, enqueue: enqueue) | ||
| } | ||
|
|
||
| return onSinkEvent | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,7 +14,9 @@ | |
| * limitations under the License. | ||
| */ | ||
|
|
||
| import ReactiveSwift | ||
| import XCTest | ||
|
|
||
| @_spi(WorkflowRuntimeConfig) @testable import Workflow | ||
|
|
||
| final class WorkflowHostTests: XCTestCase { | ||
|
|
@@ -58,32 +60,84 @@ final class WorkflowHostTests: XCTestCase { | |
| final class WorkflowHost_EventEmissionTests: XCTestCase { | ||
| // Previous versions of Workflow would fatalError under this scenario | ||
| func test_event_sent_to_invalidated_sink_during_action_handling() { | ||
| let root = Parent() | ||
| let host = WorkflowHost(workflow: root) | ||
| let host = WorkflowHost(workflow: Parent()) | ||
| let (lifetime, token) = ReactiveSwift.Lifetime.make() | ||
| defer { _ = token } | ||
jamieQ marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| let initialRendering = host.rendering.value | ||
| var observedRenderCount = 0 | ||
|
|
||
| XCTAssertEqual(initialRendering.eventCount, 0) | ||
|
|
||
| let disposable = host.rendering.signal.observeValues { rendering in | ||
| XCTAssertEqual(rendering.eventCount, 1) | ||
| host | ||
| .rendering | ||
| .signal | ||
| .take(during: lifetime) | ||
| .observeValues { rendering in | ||
| XCTAssertEqual(rendering.eventCount, 1) | ||
|
|
||
| // emit another event using an old rendering | ||
| // while the first is still being processed, but | ||
| // the workflow that handles the event has been | ||
| // removed from the tree | ||
| if observedRenderCount == 0 { | ||
| initialRendering.eventHandler() | ||
| } | ||
|
|
||
| // emit another event using an old rendering | ||
| // while the first is still being processed, but | ||
| // the workflow that handles the event has been | ||
| // removed from the tree | ||
| if observedRenderCount == 0 { | ||
| initialRendering.eventHandler() | ||
| observedRenderCount += 1 | ||
| } | ||
|
|
||
| observedRenderCount += 1 | ||
| } | ||
| defer { disposable?.dispose() } | ||
|
|
||
| // send an event and cause a re-render | ||
| initialRendering.eventHandler() | ||
|
|
||
| XCTAssertEqual(observedRenderCount, 1) | ||
|
|
||
| drainMainQueueBySpinningRunLoop() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Helpful! |
||
|
|
||
| // Ensure the invalidated sink doesn't process the event | ||
| let nextRendering = host.rendering.value | ||
| XCTAssertEqual(nextRendering.eventCount, 1) | ||
| XCTAssertEqual(observedRenderCount, 1) | ||
| } | ||
|
|
||
| func test_reentrant_event_during_render() { | ||
| let host = WorkflowHost(workflow: ReentrancyWorkflow()) | ||
| let (lifetime, token) = ReactiveSwift.Lifetime.make() | ||
| defer { _ = token } | ||
| let initialRendering = host.rendering.value | ||
|
|
||
| var emitReentrantEvent = false | ||
|
|
||
| let renderExpectation = expectation(description: "render") | ||
| renderExpectation.expectedFulfillmentCount = 2 | ||
|
|
||
| host | ||
| .rendering | ||
| .signal | ||
| .take(during: lifetime) | ||
| .observeValues { val in | ||
| defer { renderExpectation.fulfill() } | ||
| defer { emitReentrantEvent = true } | ||
| guard !emitReentrantEvent else { return } | ||
|
|
||
| // In a prior implementation, this would check state local | ||
| // to the underlying EventPipe and defer event handling | ||
| // into the future. If the RunLoop was spun after that | ||
| // point, the action could attempt to be handled and an | ||
| // we'd hit a trap when sending a sink an action in an | ||
| // invalid state. | ||
| // | ||
| // 'Real world' code could hit this case as there are some | ||
| // UI bindings that fire when a rendering/output is updated | ||
| // that call into system API that do sometimes spin the | ||
| // RunLoop manually (e.g. stuff calling into WebKit). | ||
| initialRendering.sink.send(.event) | ||
| drainMainQueueBySpinningRunLoop() | ||
| } | ||
|
|
||
| // Send an event and cause a re-render | ||
| initialRendering.sink.send(.event) | ||
|
|
||
| waitForExpectations(timeout: 1) | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -115,6 +169,35 @@ extension WorkflowHostTests { | |
|
|
||
| // MARK: Utility Types | ||
|
|
||
| extension WorkflowHost_EventEmissionTests { | ||
| struct ReentrancyWorkflow: Workflow { | ||
| typealias State = Void | ||
| typealias Output = Never | ||
|
|
||
| struct Rendering { | ||
| var sink: Sink<Action>! | ||
| } | ||
|
|
||
| func render(state: Void, context: RenderContext<Self>) -> Rendering { | ||
| let sink = context.makeSink(of: Action.self) | ||
| return Rendering(sink: sink) | ||
| } | ||
|
|
||
| enum Action: WorkflowAction { | ||
| typealias WorkflowType = ReentrancyWorkflow | ||
|
|
||
| case event | ||
|
|
||
| func apply( | ||
| toState state: inout WorkflowType.State, | ||
| context: ApplyContext<WorkflowType> | ||
| ) -> WorkflowType.Output? { | ||
| nil | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| extension WorkflowHost_EventEmissionTests { | ||
| struct Parent: Workflow { | ||
| struct Rendering { | ||
|
|
@@ -182,3 +265,13 @@ extension WorkflowHost_EventEmissionTests { | |
| } | ||
| } | ||
| } | ||
|
|
||
| private func drainMainQueueBySpinningRunLoop(timeoutSeconds: UInt = 1) { | ||
| var done = false | ||
| DispatchQueue.main.async { done = true } | ||
|
|
||
| let deadline = ContinuousClock.now + .seconds(timeoutSeconds) | ||
| while !done, ContinuousClock.now < deadline { | ||
| RunLoop.current.run(until: .now.addingTimeInterval(0.01)) | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
trying to think through if we need this check anymore... the
pendingstate should only have been possible to be in after a node was rendered during a render pass, but before the rendering & output had finished being emitted. i guess it's maybe conceivable that a child node could do something that would trigger some side effect that sent a sink event to a parent node in thependingstate... 🤔. i think the 'happy path' case is that it would just be enqueued b/c we'd be re-rendering due to handling an event (and so would hit the 'enqueue' case in theonSinkEventcallback), but there is maybe an edge case to consider where the workflow host updates the root node independently (no event being handled) so maybe we should be a bit more cautious here...There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you've convinced me that it still makes sense to handle this possible edge case, what's the downside of leaving this? Does the dispatch approach not work with the new paradigm?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i agree, and think leaving something like this (at least for now1) makes sense. i think we do still need to change the logic that gets enqueued – the way the existing code works fails in the 'someone spun the run loop before you finished a render pass' case, because those manual run loop turns can run that enqueued block, but it doesn't recurse into the
ReusableSinkmethod itself and re-check the validation state; it just unconditionally forwards through to the event pipe and we hope for the best (which seems to often crash in that edge case).there's also an API design question here in my mind – who is responsible for making the check? i'm inclined to also move that logic out to the new
SinkEventHandlertype so the decision making is basically all in one place, but we'll need to pass the node-local state through to do that. it's a little awkward b/c the validation state enum is generic over various things, but we could just pass aisPendingflag in the callback i suppose.Footnotes
it's conceivable to me that much of the node-local state could probably be moved out to the 'tree level', but haven't thought of a compelling reason to work though that at the moment ↩
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i made a couple changes to address this:
withEventHandlingSuspended(better names welcome) that can be used by the workflow host to explicitly ensure no synchronous event handlers will be run when updating the root node of the tree