runtime: make divide-by-zero and nil dereference panics recoverable

Modify the Unix signal handler to redirect execution to a Go sigpanic
function instead of printing an error and re-raising the signal.
The C signal handler modifies the ucontext to make the faulting
instruction appear to have called tinygo_sigpanic, which then calls
runtimePanic with the appropriate message.

Supported on all architectures TinyGo targets on Linux and Darwin:
x86_64, i386, aarch64, ARM, and MIPS. Also registers SIGFPE, which
was previously not handled at all.
This commit is contained in:
Jake Bailey
2026-05-10 15:56:07 -07:00
committed by Ron Evans
parent 039f48f3d2
commit 29b4c6723f
12 changed files with 138 additions and 0 deletions
+1
View File
@@ -12,6 +12,7 @@ const callInstSize = 5 // "call someFunction" is 5 bytes
const (
linux_MAP_ANONYMOUS = 0x20
linux_SIGBUS = 7
linux_SIGFPE = 8
linux_SIGILL = 4
linux_SIGSEGV = 11
)
+1
View File
@@ -12,6 +12,7 @@ const callInstSize = 5 // "call someFunction" is 5 bytes
const (
linux_MAP_ANONYMOUS = 0x20
linux_SIGBUS = 7
linux_SIGFPE = 8
linux_SIGILL = 4
linux_SIGSEGV = 11
)
+1
View File
@@ -14,6 +14,7 @@ const callInstSize = 4 // "bl someFunction" is 4 bytes
const (
linux_MAP_ANONYMOUS = 0x20
linux_SIGBUS = 7
linux_SIGFPE = 8
linux_SIGILL = 4
linux_SIGSEGV = 11
)
+1
View File
@@ -12,6 +12,7 @@ const callInstSize = 4 // "bl someFunction" is 4 bytes
const (
linux_MAP_ANONYMOUS = 0x20
linux_SIGBUS = 7
linux_SIGFPE = 8
linux_SIGILL = 4
linux_SIGSEGV = 11
)
+1
View File
@@ -12,6 +12,7 @@ const callInstSize = 8 // "jal someFunc" is 4 bytes, plus a MIPS delay slot
const (
linux_MAP_ANONYMOUS = 0x800
linux_SIGBUS = 10
linux_SIGFPE = 8
linux_SIGILL = 4
linux_SIGSEGV = 11
)
+1
View File
@@ -12,6 +12,7 @@ const callInstSize = 8 // "jal someFunc" is 4 bytes, plus a MIPS delay slot
const (
linux_MAP_ANONYMOUS = 0x800
linux_SIGBUS = 10
linux_SIGFPE = 8
linux_SIGILL = 4
linux_SIGSEGV = 11
)
+1
View File
@@ -24,6 +24,7 @@ const (
// https://opensource.apple.com/source/xnu/xnu-7195.141.2/bsd/sys/signal.h.auto.html
const (
sig_SIGBUS = 10
sig_SIGFPE = 8
sig_SIGILL = 4
sig_SIGSEGV = 11
)
+1
View File
@@ -27,6 +27,7 @@ const (
const (
sig_SIGBUS = linux_SIGBUS
sig_SIGFPE = linux_SIGFPE
sig_SIGILL = linux_SIGILL
sig_SIGSEGV = linux_SIGSEGV
)
+91
View File
@@ -12,8 +12,98 @@
void tinygo_handle_fatal_signal(int sig, uintptr_t addr);
// tinygo_sigpanic is defined in Go. It turns a signal into a Go panic
// that can be recovered with recover(). The signal number is passed via
// the tinygo_caught_signal global.
void tinygo_sigpanic(void);
// Set by the signal handler before redirecting to tinygo_sigpanic.
int tinygo_caught_signal;
// Whether sigpanic-based recovery is supported for the current
// architecture. Set to 0 on architectures where we can't reliably
// modify the ucontext to redirect execution.
static int can_sigpanic(void) {
#if defined(__x86_64__) || defined(__i386__) || defined(__aarch64__) || defined(__arm64__) || defined(__arm__) || defined(__mips__)
return 1;
#else
return 0;
#endif
}
// Try to redirect execution from the signal handler to tinygo_sigpanic.
// Returns 1 on success, 0 if the architecture doesn't support it.
static int redirect_to_sigpanic(int sig, ucontext_t *uctx) {
if (!can_sigpanic()) {
return 0;
}
tinygo_caught_signal = sig;
#if __APPLE__
#if __arm64__
// ARM64: set LR to the faulting PC (as return address), set PC to sigpanic
uctx->uc_mcontext->__ss.__lr = uctx->uc_mcontext->__ss.__pc;
uctx->uc_mcontext->__ss.__pc = (uint64_t)&tinygo_sigpanic;
#elif __x86_64__
// x86_64: push the faulting PC onto the stack, set RIP to sigpanic
uintptr_t sp = uctx->uc_mcontext->__ss.__rsp;
sp -= sizeof(uintptr_t);
*(uintptr_t *)sp = uctx->uc_mcontext->__ss.__rip;
uctx->uc_mcontext->__ss.__rsp = sp;
uctx->uc_mcontext->__ss.__rip = (uint64_t)&tinygo_sigpanic;
#else
return 0;
#endif
#elif __linux__
#if __x86_64__
uintptr_t sp = uctx->uc_mcontext.gregs[REG_RSP];
sp -= sizeof(uintptr_t);
*(uintptr_t *)sp = uctx->uc_mcontext.gregs[REG_RIP];
uctx->uc_mcontext.gregs[REG_RSP] = sp;
uctx->uc_mcontext.gregs[REG_RIP] = (uintptr_t)&tinygo_sigpanic;
#elif __i386__
uintptr_t sp = uctx->uc_mcontext.gregs[REG_ESP];
sp -= sizeof(uintptr_t);
*(uintptr_t *)sp = uctx->uc_mcontext.gregs[REG_EIP];
uctx->uc_mcontext.gregs[REG_ESP] = sp;
uctx->uc_mcontext.gregs[REG_EIP] = (uintptr_t)&tinygo_sigpanic;
#elif __aarch64__
uctx->uc_mcontext.regs[30] = uctx->uc_mcontext.pc; // LR = faulting PC
uctx->uc_mcontext.pc = (uintptr_t)&tinygo_sigpanic;
#elif __arm__
uctx->uc_mcontext.arm_lr = uctx->uc_mcontext.arm_pc;
uctx->uc_mcontext.arm_pc = (uintptr_t)&tinygo_sigpanic;
#elif defined(__mips__)
// MIPS: set RA (gregs[31]) to the faulting PC, set PC to sigpanic.
uctx->uc_mcontext.gregs[31] = uctx->uc_mcontext.pc;
uctx->uc_mcontext.pc = (uintptr_t)&tinygo_sigpanic;
#else
return 0;
#endif
#else
return 0;
#endif
return 1;
}
static void signal_handler(int sig, siginfo_t *info, void *context) {
ucontext_t* uctx = context;
// Try to redirect to sigpanic for a recoverable panic.
if (redirect_to_sigpanic(sig, uctx)) {
// Re-register the signal handler since SA_RESETHAND cleared it.
// We need it active in case the sigpanic itself faults (e.g.,
// stack overflow during panic).
struct sigaction act;
memset(&act, 0, sizeof(act));
act.sa_flags = SA_SIGINFO | SA_RESETHAND;
act.sa_sigaction = &signal_handler;
sigaction(sig, &act, NULL);
return; // return from signal handler; execution resumes at sigpanic
}
// Fallback: extract the faulting address and call the fatal handler.
uintptr_t addr = 0;
#if __APPLE__
#if __arm64__
@@ -51,6 +141,7 @@ void tinygo_register_fatal_signals(void) {
// Register the signal handler for common issues. There are more signals,
// which can be added if needed.
sigaction(SIGBUS, &act, NULL);
sigaction(SIGFPE, &act, NULL);
sigaction(SIGILL, &act, NULL);
sigaction(SIGSEGV, &act, NULL);
}
+20
View File
@@ -139,6 +139,26 @@ func runMain() {
//export tinygo_register_fatal_signals
func tinygo_register_fatal_signals()
//go:extern tinygo_caught_signal
var tinygo_caught_signal int32
// tinygo_sigpanic is called when a signal (SIGSEGV, SIGFPE, etc.) is caught
// and the signal handler has redirected execution here. It turns the signal
// into a Go panic that can be recovered with recover().
//
//export tinygo_sigpanic
func tinygo_sigpanic() {
sig := tinygo_caught_signal
switch sig {
case sig_SIGSEGV, sig_SIGBUS:
runtimePanic("nil pointer dereference")
case sig_SIGFPE:
runtimePanic("divide by zero")
default:
runtimePanic("signal")
}
}
// Print fatal errors when they happen, including the instruction location.
// With the particular formatting below, `tinygo run` can extract the location
// where the signal happened and try to show the source location based on DWARF
+15
View File
@@ -44,6 +44,9 @@ func main() {
println("\n# recover from nil map and closed channel")
recoverNilMapAndChan()
println("\n# recover from hardware signals")
recoverSignals()
}
func recoverSimple() {
@@ -261,3 +264,15 @@ func recoverNilMapAndChan() {
close(ch)
})
}
// Test recovering from hardware signals (SIGFPE, SIGSEGV).
func recoverSignals() {
recoverMustPanic("divide by zero", func() {
var x int
println(1 / x)
})
recoverMustPanic("nil pointer dereference", func() {
var p *int
println(*p)
})
}
+4
View File
@@ -49,3 +49,7 @@ outer recovered: repanic value
recovered: nil map
recovered: closed chan
recovered: close nil chan
# recover from hardware signals
recovered: divide by zero
recovered: nil pointer dereference