Skip to content

Commit 649c1ea

Browse files
committed
[refactor]: improve diagnostics for escaped context instances
1 parent 0061902 commit 649c1ea

File tree

6 files changed

+92
-13
lines changed

6 files changed

+92
-13
lines changed

Package.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,21 +61,26 @@ let package = Package(
6161
.package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.4.0"),
6262
.package(url: "https://github.com/pointfreeco/swift-perception", "1.5.0" ..< "3.0.0"),
6363
.package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"),
64+
// 'xctest-dynamic-overlay' is actually for "IssueReporting"
6465
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.0.0"),
6566
],
6667
targets: [
6768
// MARK: Workflow
6869

6970
.target(
7071
name: "Workflow",
71-
dependencies: ["ReactiveSwift"],
72+
dependencies: [
73+
.product(name: "IssueReporting", package: "xctest-dynamic-overlay"),
74+
.product(name: "ReactiveSwift", package: "ReactiveSwift"),
75+
],
7276
path: "Workflow/Sources"
7377
),
7478
.target(
7579
name: "WorkflowTesting",
7680
dependencies: [
7781
"Workflow",
7882
.product(name: "CustomDump", package: "swift-custom-dump"),
83+
.product(name: "IssueReporting", package: "xctest-dynamic-overlay"),
7984
],
8085
path: "WorkflowTesting/Sources",
8186
linkerSettings: [.linkedFramework("XCTest")]
@@ -125,7 +130,10 @@ let package = Package(
125130

126131
.target(
127132
name: "WorkflowReactiveSwift",
128-
dependencies: ["ReactiveSwift", "Workflow"],
133+
dependencies: [
134+
"Workflow",
135+
.product(name: "ReactiveSwift", package: "ReactiveSwift"),
136+
],
129137
path: "WorkflowReactiveSwift/Sources"
130138
),
131139
.target(
@@ -139,7 +147,10 @@ let package = Package(
139147

140148
.target(
141149
name: "WorkflowRxSwift",
142-
dependencies: ["RxSwift", "Workflow"],
150+
dependencies: [
151+
"Workflow",
152+
.product(name: "RxSwift", package: "RxSwift"),
153+
],
143154
path: "WorkflowRxSwift/Sources"
144155
),
145156
.target(

Workflow/Sources/Debugging.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
* limitations under the License.
1515
*/
1616

17+
#if DEBUG
18+
import IssueReporting
19+
#endif
20+
1721
public struct WorkflowUpdateDebugInfo: Codable, Equatable {
1822
public var workflowType: String
1923
public var kind: Kind
@@ -168,3 +172,30 @@ extension WorkflowUpdateDebugInfo {
168172
)
169173
}()
170174
}
175+
176+
// MARK: - Runtime Debugging Utilities
177+
178+
#if DEBUG
179+
/// Debug facility that checks if an instance of a reference type may have 'escaped' from a function.
180+
/// - Parameters:
181+
/// - object: The instance to test.
182+
/// - message: The message to log if not uniquely referenced. The ObjectIdentifier of the instance will be supplied as an argument.
183+
///
184+
/// If the instance is **not** known to be uniquely referenced it will:
185+
/// - Trigger a test failure if running in a testing context.
186+
/// - Cause an assertion failure otherwise.
187+
func diagnoseEscapedReference(
188+
to object: consuming some AnyObject,
189+
_ message: (ObjectIdentifier) -> String
190+
) {
191+
var maybeUniqueReference = consume object
192+
if !isKnownUniquelyReferenced(&maybeUniqueReference) {
193+
if let _ = TestContext.current {
194+
reportIssue(message(ObjectIdentifier(maybeUniqueReference)))
195+
} else {
196+
assertionFailure(message(ObjectIdentifier(maybeUniqueReference)))
197+
}
198+
}
199+
_ = consume maybeUniqueReference
200+
}
201+
#endif

Workflow/Sources/SubtreeManager.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616

1717
import Dispatch
1818

19+
#if DEBUG
20+
import IssueReporting
21+
#endif
22+
1923
extension WorkflowNode {
2024
/// Manages the subtree of a workflow. Specifically, this type encapsulates the logic required to update and manage
2125
/// the lifecycle of nested workflows across multiple render passes.
@@ -76,6 +80,16 @@ extension WorkflowNode {
7680

7781
wrapped.invalidate()
7882

83+
#if DEBUG
84+
// Try to ensure it didn't escape from the invocation.
85+
diagnoseEscapedReference(to: consume wrapped) { objectID in
86+
"Detected escape of a RenderContext<\(WorkflowType.self)> instance from a `render()` method. A RenderContext is only valid to use within render(); allowing it to escape may lead to incorrect behavior. Search for the address of '\(objectID)' in the memory graph debugger to determine why it may have escaped."
87+
}
88+
#else
89+
// For binding lifetime consistency
90+
_ = consume wrapped
91+
#endif
92+
7993
/// After the render is complete, assign children using *only the children that were used during the render
8094
/// pass.* This means that any pre-existing children that were *not* used during the render pass are removed
8195
/// as a result of this call to `render`.

Workflow/Sources/WorkflowNode.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,6 @@ extension WorkflowNode {
249249
do {
250250
// FIXME: can we avoid instantiating a class here somehow?
251251
let context = ConcreteApplyContext(storage: workflow)
252-
defer { context.invalidate() }
253252
let wrappedContext = ApplyContext.make(implementation: context)
254253

255254
let renderOnlyIfStateChanged = hostContext.runtimeConfig.renderOnlyIfStateChanged
@@ -302,6 +301,21 @@ extension WorkflowNode {
302301
} else {
303302
result = performSimpleActionApplication(markStateAsChanged: true)
304303
}
304+
305+
// N.B. This logic would perhaps be nicer to put in a defer
306+
// statement, but that seems to mess with the precise control
307+
// we need over binding lifetimes to perform these checks.
308+
_ = consume wrappedContext
309+
context.invalidate()
310+
#if DEBUG
311+
// Try to ensure it didn't escape from the invocation.
312+
diagnoseEscapedReference(to: consume context) { objectID in
313+
"Detected escaped reference to an ApplyContext<\(A.WorkflowType.self)> when applying action '\(action)'. An ApplyContext is only valid for the duration of its associated action application, and should not escape. Search for the address of '\(objectID)' in the memory graph debugger to investigate."
314+
}
315+
#else
316+
// For binding lifetime consistency
317+
_ = consume context
318+
#endif
305319
}
306320

307321
return result

Workflow/Tests/ApplyContextTests.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17+
import IssueReporting
1718
import Testing
1819

1920
@testable import Workflow
@@ -38,7 +39,10 @@ struct ApplyContextTests {
3839
let emitEvent = node.render()
3940
node.enableEvents()
4041

41-
emitEvent()
42+
// We're intentionally escaping the context
43+
withExpectedIssue {
44+
emitEvent()
45+
}
4246

4347
#expect(escapedContext != nil)
4448
#expect(escapedContext?.concreteStorage == nil)

Workflow/Tests/SubtreeManagerTests.swift

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
* limitations under the License.
1515
*/
1616

17+
import IssueReporting
1718
import ReactiveSwift
1819
import XCTest
20+
1921
@testable import Workflow
2022

2123
final class SubtreeManagerTests: XCTestCase {
@@ -126,14 +128,17 @@ final class SubtreeManagerTests: XCTestCase {
126128

127129
var escapingContext: RenderContext<ParentWorkflow>!
128130

129-
_ = manager.render { context -> TestViewModel in
130-
XCTAssertTrue(context.isValid)
131-
escapingContext = context
132-
return context.render(
133-
workflow: TestWorkflow(),
134-
key: "",
135-
outputMap: { _ in AnyWorkflowAction.noAction }
136-
)
131+
// We expect the context escape check to detect this
132+
withExpectedIssue {
133+
_ = manager.render { context -> TestViewModel in
134+
XCTAssertTrue(context.isValid)
135+
escapingContext = context
136+
return context.render(
137+
workflow: TestWorkflow(),
138+
key: "",
139+
outputMap: { _ in AnyWorkflowAction.noAction }
140+
)
141+
}
137142
}
138143
manager.enableEvents()
139144

0 commit comments

Comments
 (0)