Skip to content

Commit 77f10bc

Browse files
committed
Better crash reporter error grouping from RenderContext assertions
Normally crash reporters group errors by hashing against the elements of the top stack frame. This causes problems for bottleneck code that is enforcing invariants that should be implemented by client code: we wind up with "kitchen sink" groups that hold every crash of a particular type, rather than distinct groups for each specific way that mistake is made. We fix this for a few spots in `RenderContext` by inserting a fake top stack frame based on some hashable key provided by client code -- the child workflow, the "key" param for runningSideEffect, etc. So far this is a JVM-specific hack.
1 parent 81c5795 commit 77f10bc

File tree

8 files changed

+139
-14
lines changed

8 files changed

+139
-14
lines changed

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,15 @@ internal class RealRenderContext<out PropsT, StateT, OutputT>(
7272
key: String,
7373
handler: (ChildOutputT) -> WorkflowAction<PropsT, StateT, OutputT>
7474
): ChildRenderingT {
75-
checkNotFrozen { "renderChild(${child.identifier})" }
75+
checkNotFrozen(child) { "renderChild(${child.identifier})" }
7676
return renderer.render(child, props, key, handler)
7777
}
7878

7979
override fun runningSideEffect(
8080
key: String,
8181
sideEffect: suspend CoroutineScope.() -> Unit
8282
) {
83-
checkNotFrozen { "runningSideEffect($key)" }
83+
checkNotFrozen(key) { "runningSideEffect($key)" }
8484
sideEffectRunner.runningSideEffect(key, sideEffect)
8585
}
8686

@@ -90,15 +90,15 @@ internal class RealRenderContext<out PropsT, StateT, OutputT>(
9090
vararg inputs: Any?,
9191
calculation: () -> ResultT
9292
): ResultT {
93-
checkNotFrozen { "remember($key)" }
93+
checkNotFrozen(key) { "remember($key)" }
9494
return rememberStore.remember(key, resultType, inputs = inputs, calculation)
9595
}
9696

9797
/**
9898
* Freezes this context so that any further calls to this context will throw.
9999
*/
100100
fun freeze() {
101-
checkNotFrozen { "freeze" }
101+
checkNotFrozen("freeze") { "freeze" }
102102
frozen = true
103103
}
104104

@@ -109,7 +109,13 @@ internal class RealRenderContext<out PropsT, StateT, OutputT>(
109109
frozen = false
110110
}
111111

112-
private fun checkNotFrozen(reason: () -> String) = check(!frozen) {
113-
"RenderContext cannot be used after render method returns: ${reason()}"
114-
}
112+
/**
113+
* @param stackTraceKey ensures unique crash reporter error groups.
114+
*
115+
* @see checkWithKey
116+
*/
117+
private inline fun checkNotFrozen(stackTraceKey: Any, lazyMessage: () -> Any) =
118+
checkWithKey(!frozen, stackTraceKey) {
119+
"RenderContext cannot be used after render method returns: ${lazyMessage()}"
120+
}
115121
}

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ internal class SubtreeManager<PropsT, StateT, OutputT>(
126126
// Prevent duplicate workflows with the same key.
127127
workflowTracer.trace("CheckingUniqueMatches") {
128128
children.forEachStaging {
129-
require(!(it.matches(child, key, workflowTracer))) {
129+
requireWithKey(!(it.matches(child, key, workflowTracer)), stackTraceKey = child) {
130130
"Expected keys to be unique for ${child.identifier}: key=\"$key\""
131131
}
132132
}
Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,66 @@
11
package com.squareup.workflow1.internal
22

3-
import kotlinx.coroutines.CancellationException
3+
import kotlin.contracts.ExperimentalContracts
4+
import kotlin.contracts.contract
45

5-
internal tailrec fun Throwable.unwrapCancellationCause(): Throwable? {
6-
if (this !is CancellationException) return this
7-
return cause?.unwrapCancellationCause()
6+
/**
7+
* Like Kotlin's [require], but uses [stackTraceKey] to create a fake top element
8+
* on the stack trace, ensuring that crash reporter's default grouping will create unique
9+
* groups for unique keys.
10+
*
11+
* So far [stackTraceKey] is only effective on JVM, it has no effect in other languages.
12+
*
13+
* @see [withKey]
14+
*
15+
* @throws IllegalArgumentException if the [value] is false.
16+
*/
17+
@OptIn(ExperimentalContracts::class)
18+
internal inline fun requireWithKey(
19+
value: Boolean,
20+
stackTraceKey: Any,
21+
lazyMessage: () -> Any = { "Failed requirement." }
22+
) {
23+
contract {
24+
returns() implies value
25+
}
26+
if (!value) {
27+
val message = lazyMessage()
28+
val exception: Throwable = IllegalArgumentException(message.toString())
29+
throw exception.withKey(stackTraceKey)
30+
}
831
}
32+
33+
/**
34+
* Like Kotlin's [check], but uses [stackTraceKey] to create a fake top element
35+
* on the stack trace, ensuring that crash reporter's default grouping will create unique
36+
* groups for unique keys.
37+
*
38+
* So far [stackTraceKey] is only effective on JVM, it has no effect in other languages.
39+
*
40+
* @see [withKey]
41+
*
42+
* @throws IllegalStateException if the [value] is false.
43+
*/
44+
@OptIn(ExperimentalContracts::class)
45+
internal inline fun checkWithKey(
46+
value: Boolean,
47+
stackTraceKey: Any,
48+
lazyMessage: () -> Any = { "Check failed." }
49+
) {
50+
contract {
51+
returns() implies value
52+
}
53+
if (!value) {
54+
val message = lazyMessage()
55+
val exception: Throwable = IllegalStateException(message.toString())
56+
throw exception.withKey(stackTraceKey)
57+
}
58+
}
59+
60+
/**
61+
* Uses [stackTraceKey] to create a fake top element on the stack trace, ensuring
62+
* that crash reporter's default grouping will create unique groups for unique keys.
63+
*
64+
* So far only effective on JVM, this is a pass through in other languages.
65+
*/
66+
internal expect fun <T : Throwable> T.withKey(stackTraceKey: Any): T

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
163163
) {
164164
// Prevent duplicate side effects with the same key.
165165
sideEffects.forEachStaging {
166-
require(key != it.key) { "Expected side effect keys to be unique: \"$key\"" }
166+
requireWithKey(key != it.key, key) { "Expected side effect keys to be unique: \"$key\"" }
167167
}
168168

169169
sideEffects.retainOrCreate(
@@ -179,7 +179,10 @@ internal class WorkflowNode<PropsT, StateT, OutputT, RenderingT>(
179179
calculation: () -> ResultT
180180
): ResultT {
181181
remembered.forEachStaging {
182-
require(key != it.key || resultType != it.resultType || !inputs.contentEquals(it.inputs)) {
182+
requireWithKey(
183+
key != it.key || resultType != it.resultType || !inputs.contentEquals(it.inputs),
184+
stackTraceKey = key
185+
) {
183186
"Expected combination of key, inputs and result type to be unique: \"$key\""
184187
}
185188
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.squareup.workflow1.internal
2+
3+
actual fun <T : Throwable> T.withKey(stackTraceKey: Any): T = this
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.squareup.workflow1.internal
2+
3+
actual fun <T : Throwable> T.withKey(stackTraceKey: Any): T = this
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.squareup.workflow1.internal
2+
3+
internal actual fun <T : Throwable> T.withKey(stackTraceKey: Any): T = apply {
4+
val realTop = stackTrace[0]
5+
val fakeTop = StackTraceElement(
6+
// Real class name to ensure that we are still "in project".
7+
realTop.className,
8+
"fakeMethodForCrashGrouping",
9+
/* fileName = */ stackTraceKey.toString(),
10+
/* lineNumber = */ stackTraceKey.hashCode()
11+
)
12+
stackTrace = stackTrace.toMutableList().apply { add(0, fakeTop) }.toTypedArray()
13+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.squareup.workflow1.internal
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
6+
class ThrowablesTest {
7+
8+
@Test fun `requireWithKey throws IllegalArgumentException`() {
9+
try {
10+
requireWithKey(false, "requiredKey") { "message" }
11+
} catch (e: IllegalArgumentException) {
12+
assertEquals("message", e.message)
13+
e.assertIsKeyedException("requiredKey")
14+
return
15+
}
16+
}
17+
18+
@Test fun `checkWithKey throws IllegalStateException`() {
19+
try {
20+
checkWithKey(false, "checkedKey") { "message" }
21+
} catch (e: IllegalStateException) {
22+
assertEquals("message", e.message)
23+
e.assertIsKeyedException("checkedKey")
24+
return
25+
}
26+
}
27+
28+
@Test fun `Throwable withKey adds frame based on key`() {
29+
RuntimeException("cause").withKey("key").assertIsKeyedException("key")
30+
}
31+
32+
private fun RuntimeException.assertIsKeyedException(key: String) {
33+
val top = stackTrace[0]
34+
val topPlusOne = stackTrace[1]
35+
assertEquals(topPlusOne.className, top.className, "Uses real class name")
36+
assertEquals(key, top.fileName)
37+
assertEquals(key.hashCode(), top.lineNumber)
38+
}
39+
}

0 commit comments

Comments
 (0)