Skip to content

Commit b8e6bf0

Browse files
authored
Merge pull request #1302 from square/ray/throwables
Better crash reporter error grouping from RenderContext assertions
2 parents 81c5795 + 77f10bc commit b8e6bf0

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)