compiler, runtime: make runtime panics recoverable

Emit fault checkpoints around compiler-generated runtime assertions so
runtimePanicAt can unwind through the existing defer/recover machinery
instead of aborting. This lets panics from bounds checks, type checks,
and other compiler-inserted runtime checks be recovered by deferred
functions.

Mark functions that call recover as noinline. Inlining such a function
into a deferred closure can make recover observe the wrong call context
and report success when it should return nil.

Addresses tinygo-org/tinygo issues 2759 and 3510.
This commit is contained in:
Jake Bailey
2026-05-26 05:33:38 -07:00
committed by Ron Evans
parent ce888e1042
commit eed4afda63
9 changed files with 214 additions and 34 deletions
+3
View File
@@ -252,6 +252,9 @@ func (b *builder) createRuntimeAssert(assert llvm.Value, blockPrefix, assertFunc
// Fail: the assert triggered so panic.
b.SetInsertPointAtEnd(faultBlock)
if b.hasDeferFrame() {
b.createFaultCheckpoint()
}
b.createRuntimeCall(assertFunc, nil, "")
b.CreateUnreachable()
+6
View File
@@ -1895,6 +1895,12 @@ func (b *builder) createBuiltin(argTypes []types.Type, argValues []llvm.Value, c
// not of the current function.
useParentFrame = 1
}
// Prevent inlining of functions that call recover(), matching the
// Go compiler's behavior. If this function were inlined into a
// deferred function, recover() would incorrectly succeed because
// the inlined code runs in the deferred function's context.
noinline := b.ctx.CreateEnumAttribute(llvm.AttributeKindID("noinline"), 0)
b.llvmFn.AddFunctionAttr(noinline)
return b.createRuntimeCall("_recover", []llvm.Value{llvm.ConstInt(b.ctx.Int1Type(), useParentFrame, false)}, ""), nil
case "ssa:wrapnilchk":
// TODO: do an actual nil check?
+11
View File
@@ -241,6 +241,17 @@ func (b *builder) createInvokeCheckpoint() {
b.currentBlockInfo.exit = continueBB
}
// createFaultCheckpoint is like createInvokeCheckpoint but for use in fault
// blocks (e.g., bounds check failures). Unlike createInvokeCheckpoint, it does
// not update currentBlockInfo.exit because the fault block is a dead-end that
// does not participate in phi node resolution.
func (b *builder) createFaultCheckpoint() {
isZero := b.createCheckpoint(b.deferFrame)
continueBB := b.insertBasicBlock("")
b.CreateCondBr(isZero, continueBB, b.landingpad)
b.SetInsertPointAtEnd(continueBB)
}
// isInLoop checks if there is a path from the current block to itself.
// Use Tarjan's strongly connected components algorithm to search for cycles.
// A one-node SCC is a cycle iff there is an edge from the node to itself.
+43 -27
View File
@@ -796,40 +796,56 @@ func (b *builder) createTypeAssert(expr *ssa.TypeAssert) llvm.Value {
prevBlock := b.GetInsertBlock()
okBlock := b.insertBasicBlock("typeassert.ok")
nextBlock := b.insertBasicBlock("typeassert.next")
b.currentBlockInfo.exit = nextBlock // adjust outgoing block for phi nodes
b.CreateCondBr(commaOk, okBlock, nextBlock)
// Retrieve the value from the interface if the type assert was
// successful.
b.SetInsertPointAtEnd(okBlock)
var valueOk llvm.Value
if _, ok := expr.AssertedType.Underlying().(*types.Interface); ok {
// Type assert on interface type. Easy: just return the same
// interface value.
valueOk = itf
} else {
// Type assert on concrete type. Extract the underlying type from
// the interface (but only after checking it matches).
valueOk = b.extractValueFromInterface(itf, assertedType)
}
b.CreateBr(nextBlock)
// Continue after the if statement.
b.SetInsertPointAtEnd(nextBlock)
phi := b.CreatePHI(assertedType, "typeassert.value")
phi.AddIncoming([]llvm.Value{llvm.ConstNull(assertedType), valueOk}, []llvm.BasicBlock{prevBlock, okBlock})
if expr.CommaOk {
nextBlock := b.insertBasicBlock("typeassert.next")
b.currentBlockInfo.exit = nextBlock
b.CreateCondBr(commaOk, okBlock, nextBlock)
// Retrieve the value from the interface if the type assert was
// successful.
b.SetInsertPointAtEnd(okBlock)
var valueOk llvm.Value
if _, ok := expr.AssertedType.Underlying().(*types.Interface); ok {
// Type assert on interface type. Easy: just return the same
// interface value.
valueOk = itf
} else {
// Type assert on concrete type. Extract the underlying type from
// the interface (but only after checking it matches).
valueOk = b.extractValueFromInterface(itf, assertedType)
}
b.CreateBr(nextBlock)
// Continue after the if statement.
b.SetInsertPointAtEnd(nextBlock)
phi := b.CreatePHI(assertedType, "typeassert.value")
phi.AddIncoming([]llvm.Value{llvm.ConstNull(assertedType), valueOk}, []llvm.BasicBlock{prevBlock, okBlock})
tuple := b.ctx.ConstStruct([]llvm.Value{llvm.Undef(assertedType), llvm.Undef(b.ctx.Int1Type())}, false) // create empty tuple
tuple = b.CreateInsertValue(tuple, phi, 0, "") // insert value
tuple = b.CreateInsertValue(tuple, commaOk, 1, "") // insert 'comma ok' boolean
return tuple
} else {
// This is kind of dirty as the branch above becomes mostly useless,
// but hopefully this gets optimized away.
b.createRuntimeCall("interfaceTypeAssert", []llvm.Value{commaOk}, "")
return phi
// Type assert without comma-ok. If it fails, panic.
faultBlock := b.ctx.AddBasicBlock(b.llvmFn, "typeassert.throw")
b.currentBlockInfo.exit = okBlock
b.CreateCondBr(commaOk, okBlock, faultBlock)
// Fault: emit a checkpoint (for recover) and panic.
b.SetInsertPointAtEnd(faultBlock)
if b.hasDeferFrame() {
b.createFaultCheckpoint()
}
b.createRuntimeCall("interfaceTypeAssert", []llvm.Value{llvm.ConstInt(b.ctx.Int1Type(), 0, false)}, "")
b.CreateUnreachable()
// OK: extract the value from the interface.
b.SetInsertPointAtEnd(okBlock)
if _, ok := expr.AssertedType.Underlying().(*types.Interface); ok {
return itf
}
return b.extractValueFromInterface(itf, assertedType)
}
}