Omit common stack frames in JS full stack trace

#KT-37603
This commit is contained in:
Ilya Gorbunov
2020-03-19 08:18:03 +03:00
parent 5fe8071d71
commit 641f1a55cb
2 changed files with 90 additions and 42 deletions
@@ -15,7 +15,7 @@ package kotlin
* - the detailed description of each throwable in the [Throwable.cause] chain. * - the detailed description of each throwable in the [Throwable.cause] chain.
*/ */
@SinceKotlin("1.4") @SinceKotlin("1.4")
public actual fun Throwable.toStringWithTrace(): String = buildString { dumpStackTraceTo("", "", this, arrayOf()) } public actual fun Throwable.toStringWithTrace(): String = ExceptionTraceBuilder().buildFor(this)
/** /**
* Adds the specified exception to the list of exceptions that were * Adds the specified exception to the list of exceptions that were
@@ -43,49 +43,87 @@ public actual val Throwable.suppressedExceptions: List<Throwable>
} }
private fun Array<Throwable>.hasSeen(exception: Throwable): Boolean = any { it === exception } private class ExceptionTraceBuilder {
private val target = StringBuilder()
private val visited = arrayOf<Throwable>()
private var topStack: String = ""
private var topStackStart: Int = 0
private fun Throwable.dumpStackTraceTo(indent: String, qualifier: String, target: StringBuilder, visited: Array<Throwable>) { fun buildFor(exception: Throwable): String {
this.dumpSelfTrace(indent, qualifier, target, visited) || return exception.dumpFullTrace("", "")
return target.toString()
var cause = this.cause
while (cause != null) {
// TODO: should skip common stack frames
cause.dumpSelfTrace(indent, "Caused by: ", target, visited)
cause = cause.cause
} }
}
private fun Throwable.dumpSelfTrace(indent: String, qualifier: String, target: StringBuilder, visited: Array<Throwable>): Boolean { private fun hasSeen(exception: Throwable): Boolean = visited.any { it === exception }
target.append(indent).append(qualifier)
if (visited.hasSeen(this)) { private fun Throwable.dumpFullTrace(indent: String, qualifier: String) {
target.append("[CIRCULAR REFERENCE, SEE ABOVE: ").append(this).append("]\n") this.dumpSelfTrace(indent, qualifier) || return
return false
var cause = this.cause
while (cause != null) {
cause.dumpSelfTrace(indent, "Caused by: ") || return
cause = cause.cause
}
} }
visited.asDynamic().push(this)
val stack = this.asDynamic().stack as String? private fun Throwable.dumpSelfTrace(indent: String, qualifier: String): Boolean {
if (stack != null) { target.append(indent).append(qualifier)
if (indent.isNotEmpty()) { if (hasSeen(this)) {
// indent stack, but avoid indenting exception message lines target.append("[CIRCULAR REFERENCE, SEE ABOVE: ").append(this).append("]\n")
val messageLines = 1 + (message?.count { c -> c == '\n' } ?: 0) return false
stack.lineSequence().forEachIndexed { index: Int, line: String -> }
if (index >= messageLines) target.append(indent) visited.asDynamic().push(this)
target.append(line).append("\n")
var stack = this.asDynamic().stack as String?
if (stack != null) {
if (topStack.isEmpty()) {
topStack = stack
topStackStart = this.toString().length
} else {
stack = dropCommonFrames(stack, this.toString().length)
}
if (indent.isNotEmpty()) {
// indent stack, but avoid indenting exception message lines
val messageLines = 1 + (message?.count { c -> c == '\n' } ?: 0)
stack.lineSequence().forEachIndexed { index: Int, line: String ->
if (index >= messageLines) target.append(indent)
target.append(line).append("\n")
}
} else {
target.append(stack).append("\n")
} }
} else { } else {
target.append(stack).append("\n") target.append(this.toString()).append("\n")
} }
} else {
target.append(this.toString()).append("\n") val suppressed = suppressedExceptions
if (suppressed.isNotEmpty()) {
val suppressedIndent = indent + " "
for (s in suppressed) {
s.dumpFullTrace(suppressedIndent, "Suppressed: ")
}
}
return true
} }
val suppressed = suppressedExceptions private fun dropCommonFrames(stack: String, stackStart: Int): String {
if (suppressed.isNotEmpty()) { var commonFrames: Int = 0
val suppressedIndent = indent + '\t' var lastBreak: Int = 0
for (s in suppressed) { var preLastBreak: Int = 0
s.dumpStackTraceTo(suppressedIndent, "Suppressed: ", target, visited) for (pos in 0 until minOf(topStack.length - topStackStart, stack.length - stackStart)) {
val c = stack[stack.lastIndex - pos]
if (c != topStack[topStack.lastIndex - pos]) break
if (c == '\n') {
commonFrames += 1
preLastBreak = lastBreak
lastBreak = pos
}
} }
if (commonFrames <= 1) return stack
while (preLastBreak > 0 && stack[stack.lastIndex - (preLastBreak - 1)] == ' ')
preLastBreak -= 1
// leave 1 common frame to ease matching with the top exception stack
return stack.dropLast(preLastBreak) + "... and ${commonFrames - 1} more common stack frames skipped"
} }
return true }
}
+17 -7
View File
@@ -97,7 +97,8 @@ class ExceptionTest {
try { try {
root() root()
} catch (e: Throwable) { } catch (e: Throwable) {
e.addSuppressed(suppressedError(1)) for (id in 0..1)
e.addSuppressed(suppressedError(id))
throw RuntimeException("Induced", e) throw RuntimeException("Induced", e)
} }
} }
@@ -123,8 +124,9 @@ class ExceptionTest {
if (supportsSuppressedExceptions) { if (supportsSuppressedExceptions) {
val topLevelSuppressed = e.suppressedExceptions.single() val topLevelSuppressed = e.suppressedExceptions.single()
assertInTrace(topLevelSuppressed) assertInTrace(topLevelSuppressed)
val causeSuppressed = cause.suppressedExceptions.single() cause.suppressedExceptions.forEach {
assertInTrace(causeSuppressed) assertInTrace(it)
}
} }
// fail(topLevelTrace) // to dump the entire trace // fail(topLevelTrace) // to dump the entire trace
@@ -136,6 +138,7 @@ class ExceptionTest {
// Testing an exception of the following structure // Testing an exception of the following structure
// e1 // e1
// -- suppressed: e0 (same stack as e1)
// -- suppressed: e3 // -- suppressed: e3
// -- suppressed: e1 // -- suppressed: e1
// Caused by: e2 // Caused by: e2
@@ -144,15 +147,22 @@ class ExceptionTest {
val e3 = Exception("e3") val e3 = Exception("e3")
val e2 = Error("e2", e3) val e2 = Error("e2", e3)
val e1 = RuntimeException("e1", e2) val (e1, e0) = listOf("e1", "e0").map { msg -> RuntimeException(msg, e2.takeIf { msg == "e1" }) }
e1.addSuppressed(e0)
e1.addSuppressed(e3) e1.addSuppressed(e3)
e3.addSuppressed(e1) e3.addSuppressed(e1)
e2.addSuppressed(e1) e2.addSuppressed(e1)
val topLevelTrace = e1.toStringWithTrace() val topLevelTrace = e1.toStringWithTrace()
assertEquals(3, Regex.fromLiteral(e1.toString()).findAll(topLevelTrace).count(), topLevelTrace) fun assertAppearsInTrace(value: Any, count: Int) {
assertEquals(1, Regex.fromLiteral(e2.toString()).findAll(topLevelTrace).count(), topLevelTrace) if (Regex.fromLiteral(value.toString()).findAll(topLevelTrace).count() != count) {
assertEquals(2, Regex.fromLiteral(e3.toString()).findAll(topLevelTrace).count(), topLevelTrace) fail("Expected to find $value $count times in $topLevelTrace")
}
}
assertAppearsInTrace(e1, 3)
assertAppearsInTrace(e0, 1)
assertAppearsInTrace(e2, 1)
assertAppearsInTrace(e3, 2)
// fail(topLevelTrace) // to dump the entire trace // fail(topLevelTrace) // to dump the entire trace
} }