Skip to content

Commit d5f33e4

Browse files
authored
[fix]: drop invalidated sink events instead of crashing (#234)
### Issue the issue manifests in the following scenario: 1. an action is sent to node A 1. the subsequent re-render caused by handling the action removes node A from the tree, invalidating its EventPipe in the process 1. an observer of the WorkflowHost's rendering signal synchronously sends another event that propagates to node A when the rendering signal updates 1. action handling of this second event hits a fatal assertion because A’s event pipe has been marked as ‘invalid’ ### Changes - removes the fatal assertion in favor of dropping the events and emitting debug console logging/regular assertions when this occurs - adds a reentrancy check to differentiate when a sink is being used multiple times in the same call stack, likely due to UIKit doing synchronous things when Renderings are being updated but before action handling has fully unwound from the stack - adds a regular assertion failure in the case an invalidated Sink is messaged when not in the re-entrant scenario - updates tests i asked around, and it seems that the Kotlin runtime has not enforced this hard assertion for some time, and i could not think of a compelling reason to continue doing it.
1 parent 9e40cde commit d5f33e4

File tree

3 files changed

+143
-5
lines changed

3 files changed

+143
-5
lines changed

Workflow/Sources/SubtreeManager.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ extension WorkflowNode {
9292
}
9393

9494
/// Enable the eventPipes for the previous rendering. The eventPipes are not valid until this has
95-
/// be called. If is an error to call this twice without generating a new rendering.
95+
/// be called. It is an error to call this twice without generating a new rendering.
9696
func enableEvents() {
9797
/// Enable all action pipes.
9898
for eventPipe in eventPipes {
@@ -331,13 +331,20 @@ extension WorkflowNode.SubtreeManager {
331331
case invalid
332332
}
333333

334+
/// Utility to detect reentrancy in `handle()`
335+
private var isHandlingEvent: Bool = false
336+
334337
init() {
335338
self.validationState = .preparing
336339
}
337340

338341
func handle(event: Output) {
339342
dispatchPrecondition(condition: .onQueue(DispatchQueue.workflowExecution))
340343

344+
let isReentrantCall = isHandlingEvent
345+
isHandlingEvent = true
346+
defer { isHandlingEvent = isReentrantCall }
347+
341348
switch validationState {
342349
case .preparing:
343350
fatalError("[\(WorkflowType.self)] Sink sent an action inside `render`. Sinks are not valid until `render` has completed.")
@@ -349,7 +356,26 @@ extension WorkflowNode.SubtreeManager {
349356
handler(event)
350357

351358
case .invalid:
352-
fatalError("[\(WorkflowType.self)] Sink sent an action after it was invalidated. Sinks can only be used for a single valid `Rendering`.")
359+
#if DEBUG
360+
// Reentrancy seems to often be due to UIKit behaviors over
361+
// which we have little control (e.g. synchronous resignation
362+
// of first responder after a new Rendering is assigned). Emit
363+
// some debug info in these cases.
364+
if isReentrantCall {
365+
print("[\(WorkflowType.self)]: ℹ️ Sink sent another action after it was invalidated but before its original action handling was resolved. This new action will be ignored. If this is unexpected, set a Swift error breakpoint on `\(InvalidSinkSentAction.self)` to debug.")
366+
}
367+
368+
do {
369+
throw InvalidSinkSentAction()
370+
} catch {}
371+
#endif
372+
373+
// If we're invalid and this is the first time `handle()` has
374+
// been called, then it's likely we've somehow been inadvertently
375+
// retained from the 'outside world'. Fail more loudly in this case.
376+
assert(isReentrantCall, """
377+
[\(WorkflowType.self)]: Sink sent an action after it was invalidated. This action will be ignored.
378+
""")
353379
}
354380
}
355381

@@ -499,3 +525,9 @@ extension WorkflowNode.SubtreeManager {
499525
}
500526
}
501527
}
528+
529+
// MARK: - Debugging Utilities
530+
531+
#if DEBUG
532+
private struct InvalidSinkSentAction: Error {}
533+
#endif

Workflow/Tests/ConcurrencyTests.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,11 @@ final class ConcurrencyTests: XCTestCase {
160160
let secondScreen = host.rendering.value
161161
XCTAssertEqual(1, secondScreen.count)
162162

163-
// MANUAL TEST CASE: Uncomment to validate this fatal errors.
164-
// Calling `update` uses the original sink. This will fail with a fatalError as the sink was not redeclared.
165-
// initialScreen.update()
163+
// Calling `update` uses the original sink. Historically this would be expected
164+
// to trigger a fatal error, but as of https://github.com/square/workflow-swift/pull/189
165+
// the internal event handling infrastructure is expected to have been
166+
// torn down by this point, so this should just no-op.
167+
initialScreen.update()
166168

167169
// If the sink *was* still valid, this would be correct. However, it should just fail and be `1` still.
168170
// XCTAssertEqual(2, secondScreen.count)

Workflow/Tests/WorkflowHostTests.swift

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,107 @@ final class WorkflowHostTests: XCTestCase {
5252
}
5353
}
5454
}
55+
56+
// MARK: Event Emission Tests
57+
58+
final class WorkflowHost_EventEmissionTests: XCTestCase {
59+
// Previous versions of Workflow would fatalError under this scenario
60+
func test_event_sent_to_invalidated_sink_during_action_handling() {
61+
let root = Parent()
62+
let host = WorkflowHost(workflow: root)
63+
let initialRendering = host.rendering.value
64+
var observedRenderCount = 0
65+
66+
XCTAssertEqual(initialRendering.eventCount, 0)
67+
68+
let disposable = host.rendering.signal.observeValues { rendering in
69+
XCTAssertEqual(rendering.eventCount, 1)
70+
71+
// emit another event using an old rendering
72+
// while the first is still being processed, but
73+
// the workflow that handles the event has been
74+
// removed from the tree
75+
if observedRenderCount == 0 {
76+
initialRendering.eventHandler()
77+
}
78+
79+
observedRenderCount += 1
80+
}
81+
defer { disposable?.dispose() }
82+
83+
// send an event and cause a re-render
84+
initialRendering.eventHandler()
85+
86+
XCTAssertEqual(observedRenderCount, 1)
87+
}
88+
}
89+
90+
// MARK: Utility Types
91+
92+
extension WorkflowHost_EventEmissionTests {
93+
struct Parent: Workflow {
94+
struct Rendering {
95+
var eventCount = 0
96+
var eventHandler: () -> Void
97+
}
98+
99+
typealias Output = Never
100+
101+
struct State {
102+
var renderFirst = true
103+
var eventCount = 0
104+
}
105+
106+
func makeInitialState() -> State { .init() }
107+
108+
func render(state: State, context: RenderContext<Parent>) -> Rendering {
109+
// swap which child is rendered
110+
let key = state.renderFirst ? "first" : "second"
111+
let handler = Child()
112+
.mapOutput { _ in
113+
ParentAction.childChanged
114+
}
115+
.rendered(in: context, key: key)
116+
117+
return Rendering(
118+
eventCount: state.eventCount,
119+
eventHandler: handler
120+
)
121+
}
122+
123+
enum ParentAction: WorkflowAction {
124+
typealias WorkflowType = Parent
125+
126+
case childChanged
127+
128+
func apply(toState state: inout Parent.State) -> Never? {
129+
state.eventCount += 1
130+
state.renderFirst.toggle()
131+
return nil
132+
}
133+
}
134+
}
135+
136+
struct Child: Workflow {
137+
typealias Rendering = () -> Void
138+
typealias State = Void
139+
enum Output {
140+
case eventOccurred
141+
}
142+
143+
func render(state: Void, context: RenderContext<Child>) -> () -> Void {
144+
let sink = context.makeSink(of: Action.self)
145+
return { sink.send(Action.eventOccurred) }
146+
}
147+
148+
enum Action: WorkflowAction {
149+
typealias WorkflowType = Child
150+
151+
case eventOccurred
152+
153+
func apply(toState state: inout Void) -> Child.Output? {
154+
return .eventOccurred
155+
}
156+
}
157+
}
158+
}

0 commit comments

Comments
 (0)