Skip to content

Commit 15e8c02

Browse files
authored
[refactor]: improve diagnostics for escaped context instances (#380)
### Issue the `{Render|Apply}Context` types should not escape from their respective methods, as this can allow library internals to 'leak out' and cause various issues[^1]. primarily this can result in lifetime discrepancies or surprising relationships in the object graph. historically we've enforced this invariant 'lazily' by setting a bit on the instances and checking that they are still 'valid' during access through their public API. however, it recently occurred to me that we could probably be a bit more proactive about this, and perform a unique reference check immediately after returning from the client callouts. ### Description - adds logic to detect escaping context instances (debug mode only) - adds the 'IssueReporting' dependency to Workflow so we can better test the logic in our unit tests - minor incidental refactoring of the Package file - updates unit tests appropriately [^1]: this is really more of an issue for RenderContext, as it doesn't yet zero-out its internal storage when invalidated. since ApplyContext is newer, we didn't make that mistake twice.
1 parent deb26f3 commit 15e8c02

File tree

6 files changed

+99
-22
lines changed

6 files changed

+99
-22
lines changed

Package.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,18 @@ 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-
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.0.0"),
64+
// 'xctest-dynamic-overlay' is actually for "IssueReporting"
65+
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"),
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(
@@ -126,7 +130,10 @@ let package = Package(
126130

127131
.target(
128132
name: "WorkflowReactiveSwift",
129-
dependencies: ["ReactiveSwift", "Workflow"],
133+
dependencies: [
134+
"Workflow",
135+
.product(name: "ReactiveSwift", package: "ReactiveSwift"),
136+
],
130137
path: "WorkflowReactiveSwift/Sources"
131138
),
132139
.target(
@@ -140,7 +147,10 @@ let package = Package(
140147

141148
.target(
142149
name: "WorkflowRxSwift",
143-
dependencies: ["RxSwift", "Workflow"],
150+
dependencies: [
151+
"Workflow",
152+
.product(name: "RxSwift", package: "RxSwift"),
153+
],
144154
path: "WorkflowRxSwift/Sources"
145155
),
146156
.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: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,18 @@
1414
* limitations under the License.
1515
*/
1616

17-
import Testing
17+
import IssueReporting
18+
import XCTest
1819

1920
@testable import Workflow
2021

2122
@MainActor
22-
struct ApplyContextTests {
23-
@Test
24-
func concreteApplyContextInvalidatedAfterUse() async throws {
23+
final class ApplyContextTests: XCTestCase {
24+
func testConcreteApplyContextInvalidatedAfterUse() async throws {
2525
var escapedContext: ApplyContext<EscapingContextWorkflow>?
2626
let onApply = { (context: ApplyContext<EscapingContextWorkflow>) in
27-
#expect(context[workflowValue: \.property] == 42)
28-
#expect(context.concreteStorage != nil)
27+
XCTAssert(context[workflowValue: \.property] == 42)
28+
XCTAssert(context.concreteStorage != nil)
2929
escapedContext = context
3030
}
3131

@@ -38,10 +38,13 @@ struct ApplyContextTests {
3838
let emitEvent = node.render()
3939
node.enableEvents()
4040

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

43-
#expect(escapedContext != nil)
44-
#expect(escapedContext?.concreteStorage == nil)
46+
XCTAssert(escapedContext != nil)
47+
XCTAssert(escapedContext?.concreteStorage == nil)
4548
}
4649
}
4750

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)