Preserve VM throw sites in stack traces

This commit is contained in:
Sergey Chernov 2026-04-14 01:17:04 +03:00
parent fada848907
commit b42ceec686
11 changed files with 137 additions and 14 deletions

View File

@ -17,4 +17,5 @@
package net.sergeych.lyng.obj
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? = null
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? =
if (index < 0 || index >= size) "Index $index out of bounds for length $size" else null

View File

@ -33,6 +33,17 @@ data class Pos(val source: Source, val line: Int, val column: Int) {
if( end ) "EOF"
else if( line >= 0 ) source.lines[line] else "<no line information>"
val currentLineTrimmedStart: String get() = currentLine.trimStart()
val currentLineIndentWidth: Int
get() {
val lineText = currentLine
val firstNonWhitespace = lineText.indexOfFirst { !it.isWhitespace() }
return if (firstNonWhitespace >= 0) firstNonWhitespace else 0
}
val visualColumn: Int get() = (column - currentLineIndentWidth).coerceAtLeast(0)
val end: Boolean get() = line >= source.lines.size
companion object {

View File

@ -25,14 +25,15 @@ open class ScriptError(val pos: Pos, val errorMessage: String, cause: Throwable?
"""
$pos: Error: $errorMessage
${pos.currentLine}
${if( pos.column >= 0 ) "-".repeat(pos.column) + "^" else ""}
${pos.currentLineTrimmedStart}
${if( pos.column >= 0 ) "-".repeat(pos.visualColumn) + "^" else ""}
""".trimIndent(),
cause
)
class ScriptFlowIsNoMoreCollected: Exception()
class ExecutionError(val errorObject: Obj, pos: Pos, message: String) : ScriptError(pos, message)
class ExecutionError(val errorObject: Obj, pos: Pos, message: String, cause: Throwable? = null) :
ScriptError(pos, message, cause)
class ImportException(pos: Pos, message: String) : ScriptError(pos, message)
class ImportException(pos: Pos, message: String) : ScriptError(pos, message)

View File

@ -3650,11 +3650,14 @@ class BytecodeCompiler(
}
private fun compileIndexRef(ref: IndexRef): CompiledValue? {
val indexPos = refPosOrCurrent(ref.targetRef)
setPos(indexPos)
val receiver = compileRefWithFallback(ref.targetRef, null, Pos.builtIn) ?: return null
val elementSlotType = indexElementSlotType(receiver.slot, ref.targetRef)
val dst = allocSlot()
if (!ref.optionalRef) {
val index = compileRefWithFallback(ref.indexRef, null, Pos.builtIn) ?: return null
setPos(indexPos)
if (elementSlotType == SlotType.INT && index.type == SlotType.INT) {
builder.emit(Opcode.GET_INDEX_INT, receiver.slot, index.slot, dst)
updateSlotType(dst, SlotType.INT)

View File

@ -43,9 +43,10 @@ class CmdVm {
}
break
} catch (e: Throwable) {
if (!frame.handleException(e)) {
val throwable = frame.normalizeThrowable(e)
if (!frame.handleException(throwable)) {
frame.cancelIterators()
throw e
throw throwable
}
}
}
@ -4432,6 +4433,19 @@ class CmdFrame(
return scope
}
suspend fun normalizeThrowable(t: Throwable): Throwable {
if (t is ExecutionError || t is ReturnException || t is LoopBreakContinueException) return t
val parentScope = ensureScope()
val pos = (t as? ScriptError)?.pos ?: currentErrorPos() ?: parentScope.pos
val throwScope = parentScope.createChildScope(pos = pos)
val message = when (t) {
is ScriptError -> t.errorMessage
else -> t.message ?: t.toString()
}
val errorObject = ObjUnknownException(throwScope, message).apply { getStackTrace() }
return ExecutionError(errorObject, pos, message, t)
}
suspend fun handleException(t: Throwable): Boolean {
val handler = tryStack.lastOrNull() ?: return false
vmIterDebug {

View File

@ -106,16 +106,18 @@ open class ObjException(
val pos = s.pos
if (pos != lastPos && !pos.currentLine.isEmpty()) {
if (lastPos == null || (lastPos.source != pos.source || lastPos.line != pos.line)) {
val sourceLine = pos.currentLineTrimmedStart
val visualColumn = pos.visualColumn
val fallback =
ObjString("#${pos.source.objSourceName}:${pos.line+1}:${pos.column+1}: ${pos.currentLine}")
ObjString("#${pos.source.objSourceName}:${pos.line+1}:${visualColumn+1}: $sourceLine")
if (maybeCls != null) {
try {
result.list += maybeCls.callWithArgs(
scope,
pos.source.objSourceName,
ObjInt(pos.line.toLong()),
ObjInt(pos.column.toLong()),
ObjString(pos.currentLine)
ObjInt(visualColumn.toLong()),
ObjString(sourceLine)
)
} catch (e: Throwable) {
// Fallback textual entry if StackTraceEntry fails to instantiate

View File

@ -5261,6 +5261,35 @@ class ScriptTest {
)
}
@Test
fun testUnexpectedThrowablePreservesThrowSiteStackTrace() = runTest {
val caught = evalNamed(
"baderrorstack", """
fun boom() {
val arr = [10, 20, 30]
arr[10]
}
try {
boom()
"unreachable"
} catch (e) {
e
}
""".trimIndent()
)
val trace = caught.getLyngExceptionMessageWithStackTrace()
assertContains(trace, "\n at baderrorstack:3:1: arr[10]")
assertContains(trace, "\n at baderrorstack:6:1: boom()")
assertFalse(trace.contains("catch (e)"))
}
@Test
fun testScriptErrorMessageTrimsSourceIndent() = runTest {
val x = ScriptError(Pos(Source("trimraw", " arr[10]"), 0, 4), "boom")
assertContains(x.message!!, "\narr[10]\n")
assertFalse(x.message!!.contains("\n arr[10]\n"))
}
@Test
fun testMapIteralAmbiguity() = runTest {
eval(

View File

@ -22,6 +22,8 @@ import net.sergeych.lyng.bridge.globalBinder
import net.sergeych.lyng.obj.ObjVoid
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PrintlnOverrideTest {
@ -84,4 +86,46 @@ class PrintlnOverrideTest {
assertEquals(listOf("gb top level", "gb inside function"), output)
}
@Test
fun testExceptionPrintStackTraceFormatsPrimaryFrameBlock() = runTest {
val scope = Script.newScope()
val output = mutableListOf<String>()
scope.globalBinder().bindGlobalFun("println") {
val sb = StringBuilder()
for (i in 0 until args.size) {
if (i > 0) sb.append(" ")
sb.append(string(i))
}
output.add(sb.toString())
ObjVoid
}
scope.eval(
"""
fun boom() {
val arr = [10, 20, 30]
var a = 10
var b = arr[a]
b
}
try {
boom()
} catch (e) {
e.printStackTrace()
}
""".trimIndent()
)
assertTrue(output.isNotEmpty())
assertEquals(
"IndexOutOfBoundsException: Index 10 out of bounds for length 3 at eval:4:9:",
output[0]
)
assertEquals("var b = arr[a]", output[1])
assertEquals("--------^", output[2])
assertTrue(output.size >= 4)
assertFalse(output.any { it == " at eval:4:9: var b = arr[a]" })
}
}

View File

@ -17,4 +17,5 @@
package net.sergeych.lyng.obj
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? = null
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? =
if (index < 0 || index >= size) "Index $index out of bounds for length $size" else null

View File

@ -17,4 +17,5 @@
package net.sergeych.lyng.obj
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? = null
internal actual fun objListBoundsViolationMessageOrNull(size: Int, index: Int): String? =
if (index < 0 || index >= size) "Index $index out of bounds for length $size" else null

View File

@ -441,9 +441,25 @@ static fun List<T>.fill(size: Int, block: (Int)->T): List<T> {
/* Print this exception and its stack trace to standard output. */
fun Exception.printStackTrace(): void {
println(this)
if( stackTrace.size == 0 ) {
println(this)
return
}
val first = stackTrace[0] as StackTraceEntry
var arrow = "^"
for( i in 0..<first.column ) {
arrow = "-" + arrow
}
println("%s: %s at %s:"(this::class.className, message, first.at))
println(first.sourceString)
println(arrow)
var skipFirst = true
for( entry in stackTrace ) {
println("\tat "+entry.toString())
if( skipFirst ) {
skipFirst = false
} else {
println(" at " + entry.toString())
}
}
}