From d2055dfef254f2ec19a715a048f23b8dfe0cff4f Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:08:47 -0700 Subject: [PATCH] runtime: avoid heap allocs for plain errors --- builder/sizes_test.go | 6 ++-- main_test.go | 17 ++++++++++ src/internal/task/task_none.go | 12 +++---- src/runtime/chan.go | 8 ++--- src/runtime/error.go | 26 ++++++++++++++++ src/runtime/hashmap.go | 2 +- src/runtime/interface.go | 4 +-- src/runtime/panic.go | 38 +++++++++++++---------- src/runtime/runtime_tinygowasm_unknown.go | 2 +- src/runtime/runtime_unix.go | 10 +++--- src/runtime/runtime_wasmentry.go | 6 ++-- src/runtime/runtime_windows.go | 6 ++-- src/runtime/scheduler_none.go | 6 ++-- testdata/recover.go | 17 ++++++++++ testdata/recover.txt | 1 + 15 files changed, 113 insertions(+), 48 deletions(-) diff --git a/builder/sizes_test.go b/builder/sizes_test.go index 639c32b5c2..7a8131f1db 100644 --- a/builder/sizes_test.go +++ b/builder/sizes_test.go @@ -42,9 +42,9 @@ func TestBinarySize(t *testing.T) { // This is a small number of very diverse targets that we want to test. tests := []sizeTest{ // microcontrollers - {"hifive1b", "examples/echo", 4313, 323, 0, 2260}, - {"microbit", "examples/serial", 2838, 382, 8, 2256}, - {"wioterminal", "examples/pininterrupt", 8027, 1665, 132, 7488}, + {"hifive1b", "examples/echo", 4302, 346, 0, 2260}, + {"microbit", "examples/serial", 2829, 391, 8, 2256}, + {"wioterminal", "examples/pininterrupt", 8001, 1687, 132, 7488}, // TODO: also check wasm. Right now this is difficult, because // wasm binaries are run through wasm-opt and therefore the diff --git a/main_test.go b/main_test.go index 2dfd1683f6..1b43df9bc1 100644 --- a/main_test.go +++ b/main_test.go @@ -140,6 +140,23 @@ func TestBuild(t *testing.T) { runTestWithConfig("print.go", t, opts, nil, nil) }) + t.Run("gc=none-runtime-panic", func(t *testing.T) { + t.Parallel() + opts := optionsFromTarget("cortex-m-qemu", sema) + opts.GC = "none" + opts.Scheduler = "none" + config, err := builder.NewConfig(&opts) + if err != nil { + t.Fatal(err) + } + err = Build("testdata/trivialpanic.go", t.TempDir()+"/trivialpanic", config) + if err != nil { + w := &bytes.Buffer{} + diagnostics.CreateDiagnostics(err).WriteTo(w, "") + t.Fatal(w.String()) + } + }) + t.Run("ldflags", func(t *testing.T) { t.Parallel() opts := optionsFromTarget("", sema) diff --git a/src/internal/task/task_none.go b/src/internal/task/task_none.go index 7abd7a6bfc..15ab5d7984 100644 --- a/src/internal/task/task_none.go +++ b/src/internal/task/task_none.go @@ -7,11 +7,11 @@ import "unsafe" // There is only one goroutine so the task struct can be a global. var mainTask Task -//go:linkname runtimePanic runtime.runtimePanic -func runtimePanic(str string) +//go:linkname runtimePanicSchedulerDisabled runtime.runtimePanicSchedulerDisabled +func runtimePanicSchedulerDisabled() func Pause() { - runtimePanic("scheduler is disabled") + runtimePanicSchedulerDisabled() } func Current() *Task { @@ -22,13 +22,13 @@ func Current() *Task { //go:noinline func start(fn uintptr, args unsafe.Pointer, stackSize uintptr) { // The compiler will error if this is reachable. - runtimePanic("scheduler is disabled") + runtimePanicSchedulerDisabled() } type state struct{} func (t *Task) Resume() { - runtimePanic("scheduler is disabled") + runtimePanicSchedulerDisabled() } // OnSystemStack returns whether the caller is running on the system stack. @@ -39,7 +39,7 @@ func OnSystemStack() bool { func SystemStack() uintptr { // System stack is the current stack, so this shouldn't be called. - runtimePanic("scheduler is disabled") + runtimePanicSchedulerDisabled() return 0 // unreachable } diff --git a/src/runtime/chan.go b/src/runtime/chan.go index a85e9b6617..517aac205c 100644 --- a/src/runtime/chan.go +++ b/src/runtime/chan.go @@ -207,7 +207,7 @@ func (ch *channel) trySend(value unsafe.Pointer) (sent bool, wake *task.Task) { // Note: we cannot currently recover from this panic. // There's some state in the select statement especially that would be // corrupted if we allowed recovering from this panic. - runtimePanic("send on closed channel") + runtimePanic(errSendOnClosedChannel) } // There is no value in the buffer and we have a receiver available. Copy @@ -266,7 +266,7 @@ func chanSend(ch *channel, value unsafe.Pointer, op *channelOp) { // closed while sending). if t.DataUint32() == chanOperationClosed { // Oops, this channel was closed while sending! - runtimePanic("send on closed channel") + runtimePanic(errSendOnClosedChannel) } } @@ -349,7 +349,7 @@ func chanRecv(ch *channel, value unsafe.Pointer, op *channelOp) bool { func chanClose(ch *channel) { if ch == nil { // Not allowed by the language spec. - runtimePanic("close of nil channel") + runtimePanic(errCloseNilChannel) } mask := interrupt.Disable() @@ -359,7 +359,7 @@ func chanClose(ch *channel) { // Not allowed by the language spec. ch.lock.Unlock() interrupt.Restore(mask) - runtimePanic("close of closed channel") + runtimePanic(errCloseClosedChannel) } // Collect waiters and wake them after unlocking. diff --git a/src/runtime/error.go b/src/runtime/error.go index cd5429d1d3..0a05b85f68 100644 --- a/src/runtime/error.go +++ b/src/runtime/error.go @@ -12,3 +12,29 @@ type plainError string func (e plainError) Error() string { return string(e) } func (e plainError) RuntimeError() {} + +const ( + errNilPointer = plainError("nil pointer dereference") + errNilMap = plainError("assignment to entry in nil map") + errIndexOutOfRange = plainError("index out of range") + errSliceOutOfRange = plainError("slice out of range") + errSliceToArray = plainError("slice smaller than array") + errUnsafeSliceLength = plainError("unsafe.Slice/String: len out of range") + errChannelTooBig = plainError("new channel is too big") + errNegativeShift = plainError("negative shift") + errDivideByZero = plainError("divide by zero") + errBlockingExported = plainError("trying to do blocking operation in exported function") + errTimersUnsupported = plainError("timers not supported without a scheduler") + errIntegerOverflow = plainError("integer overflow") + errUnsupportedSignal = plainError("unsupported signal number") + errUnsupportedExit = plainError("unsupported: syscall.Exit") + errSendOnClosedChannel = plainError("send on closed channel") + errCloseNilChannel = plainError("close of nil channel") + errCloseClosedChannel = plainError("close of closed channel") + errWasmBeforeInit = plainError("//go:wasmexport function called before runtime initialization") + errWasmAfterMain = plainError("//go:wasmexport function called after main.main returned") + errWasmDidNotFinish = plainError("//go:wasmexport function did not finish") + errUncomparable = plainError("comparing un-comparable type") + errTypeAssert = plainError("type assert failed") + errSchedulerDisabled = plainError("scheduler is disabled") +) diff --git a/src/runtime/hashmap.go b/src/runtime/hashmap.go index ba864db305..4e3f4042f7 100644 --- a/src/runtime/hashmap.go +++ b/src/runtime/hashmap.go @@ -817,7 +817,7 @@ func hashmapInterfaceHash(itf interface{}, seed uintptr) uint32 { } return hash default: - runtimePanic("comparing un-comparable type") + runtimePanic(errUncomparable) return 0 // unreachable } } diff --git a/src/runtime/interface.go b/src/runtime/interface.go index e487c46c2f..3f772b40e0 100644 --- a/src/runtime/interface.go +++ b/src/runtime/interface.go @@ -77,7 +77,7 @@ func reflectValueEqual(x, y reflectlite.Value) bool { case reflectlite.Interface: return reflectValueEqual(x.Elem(), y.Elem()) default: - runtimePanic("comparing un-comparable type") + runtimePanic(errUncomparable) return false // unreachable } } @@ -86,7 +86,7 @@ func reflectValueEqual(x, y reflectlite.Value) bool { // returns false. func interfaceTypeAssert(ok bool) { if !ok { - runtimePanic("type assert failed") + runtimePanic(errTypeAssert) } } diff --git a/src/runtime/panic.go b/src/runtime/panic.go index ff4bb8af25..4dde4cb4bc 100644 --- a/src/runtime/panic.go +++ b/src/runtime/panic.go @@ -84,11 +84,11 @@ func panicOrGoexit(message interface{}, panicking panicState) { abort() } -// Cause a runtime panic, which is (currently) always a string. -func runtimePanic(msg string) { - // As long as this function is inined, llvm.returnaddress(0) will return +// Cause a recoverable runtime panic. +func runtimePanic(err Error) { + // As long as this function is inlined, llvm.returnaddress(0) will return // something sensible. - runtimePanicAt(returnAddress(0), msg) + runtimePanicAt(returnAddress(0), err) } // runtimeFatal terminates for runtime failures that cannot safely be @@ -103,7 +103,7 @@ func runtimeFatal(msg string) { abort() } -func runtimePanicAt(addr unsafe.Pointer, msg string) { +func runtimePanicAt(addr unsafe.Pointer, err Error) { if panicStrategy() == tinygo.PanicStrategyTrap { trap() } @@ -112,7 +112,7 @@ func runtimePanicAt(addr unsafe.Pointer, msg string) { if frame != nil { // Use the normal panic mechanism so that this runtime error // can be recovered with recover(). - frame.PanicValue = plainError(msg) + frame.PanicValue = err frame.Panicking = panicTrue | (frame.Panicking & panicGoexit) tinygo_longjmp(frame) // unreachable @@ -128,11 +128,15 @@ func runtimePanicAt(addr unsafe.Pointer, msg string) { } else { printstring("panic: runtime error: ") } - printstring(msg) + printstring(err.Error()) printnl() abort() } +func runtimePanicSchedulerDisabled() { + runtimePanicAt(returnAddress(0), errSchedulerDisabled) +} + // Called at the start of a function that includes a deferred call. // It gets passed in the stack-allocated defer frame and configures it. // Note that the frame is not zeroed yet, so we need to initialize all values @@ -220,54 +224,54 @@ func _recover(useParentFrame bool) interface{} { // Panic when trying to dereference a nil pointer. func nilPanic() { - runtimePanicAt(returnAddress(0), "nil pointer dereference") + runtimePanicAt(returnAddress(0), errNilPointer) } // Panic when trying to add an entry to a nil map func nilMapPanic() { - runtimePanicAt(returnAddress(0), "assignment to entry in nil map") + runtimePanicAt(returnAddress(0), errNilMap) } // Panic when trying to access an array or slice out of bounds. func lookupPanic() { - runtimePanicAt(returnAddress(0), "index out of range") + runtimePanicAt(returnAddress(0), errIndexOutOfRange) } // Panic when trying to slice a slice out of bounds. func slicePanic() { - runtimePanicAt(returnAddress(0), "slice out of range") + runtimePanicAt(returnAddress(0), errSliceOutOfRange) } // Panic when trying to convert a slice to an array pointer (Go 1.17+) and the // slice is shorter than the array. func sliceToArrayPointerPanic() { - runtimePanicAt(returnAddress(0), "slice smaller than array") + runtimePanicAt(returnAddress(0), errSliceToArray) } // Panic when calling unsafe.Slice() (Go 1.17+) or unsafe.String() (Go 1.20+) // with a len that's too large (which includes if the ptr is nil and len is // nonzero). func unsafeSlicePanic() { - runtimePanicAt(returnAddress(0), "unsafe.Slice/String: len out of range") + runtimePanicAt(returnAddress(0), errUnsafeSliceLength) } // Panic when trying to create a new channel that is too big. func chanMakePanic() { - runtimePanicAt(returnAddress(0), "new channel is too big") + runtimePanicAt(returnAddress(0), errChannelTooBig) } // Panic when a shift value is negative. func negativeShiftPanic() { - runtimePanicAt(returnAddress(0), "negative shift") + runtimePanicAt(returnAddress(0), errNegativeShift) } // Panic when there is a divide by zero. func divideByZeroPanic() { - runtimePanicAt(returnAddress(0), "divide by zero") + runtimePanicAt(returnAddress(0), errDivideByZero) } func blockingPanic() { - runtimePanicAt(returnAddress(0), "trying to do blocking operation in exported function") + runtimePanicAt(returnAddress(0), errBlockingExported) } //go:linkname fips_fatal crypto/internal/fips140.fatal diff --git a/src/runtime/runtime_tinygowasm_unknown.go b/src/runtime/runtime_tinygowasm_unknown.go index eac33a6063..8fa9acdd08 100644 --- a/src/runtime/runtime_tinygowasm_unknown.go +++ b/src/runtime/runtime_tinygowasm_unknown.go @@ -34,7 +34,7 @@ func syscall_Exit(code int) { // Because this is the "unknown" target we can't call an exit function. // But we also can't just return since the program will likely expect this // function to never return. So we panic instead. - runtimePanic("unsupported: syscall.Exit") + runtimePanic(errUnsupportedExit) } // There is not yet any support for any form of parallelism on WebAssembly, so these diff --git a/src/runtime/runtime_unix.go b/src/runtime/runtime_unix.go index 24b208c387..e1273059b7 100644 --- a/src/runtime/runtime_unix.go +++ b/src/runtime/runtime_unix.go @@ -151,9 +151,9 @@ func tinygo_sigpanic() { sig := tinygo_caught_signal switch sig { case sig_SIGSEGV, sig_SIGBUS: - runtimePanic("nil pointer dereference") + runtimePanic(errNilPointer) case sig_SIGFPE: - runtimePanic("divide by zero") + runtimePanic(errDivideByZero) default: runtimeFatal("signal") } @@ -382,7 +382,7 @@ func signal_enable(s uint32) { if s >= 32 { // TODO: to support higher signal numbers, we need to turn // receivedSignals into a uint32 array. - runtimePanicAt(returnAddress(0), "unsupported signal number") + runtimePanicAt(returnAddress(0), errUnsupportedSignal) } // This is intentonally a non-atomic store. This is safe, since hasSignals @@ -399,7 +399,7 @@ func signal_ignore(s uint32) { if s >= 32 { // TODO: to support higher signal numbers, we need to turn // receivedSignals into a uint32 array. - runtimePanicAt(returnAddress(0), "unsupported signal number") + runtimePanicAt(returnAddress(0), errUnsupportedSignal) } tinygo_signal_ignore(s) } @@ -409,7 +409,7 @@ func signal_disable(s uint32) { if s >= 32 { // TODO: to support higher signal numbers, we need to turn // receivedSignals into a uint32 array. - runtimePanicAt(returnAddress(0), "unsupported signal number") + runtimePanicAt(returnAddress(0), errUnsupportedSignal) } tinygo_signal_disable(s) } diff --git a/src/runtime/runtime_wasmentry.go b/src/runtime/runtime_wasmentry.go index 59cacb3b04..5e4d186ec3 100644 --- a/src/runtime/runtime_wasmentry.go +++ b/src/runtime/runtime_wasmentry.go @@ -67,9 +67,9 @@ var initializeCalled bool func wasmExportCheckRun() { switch { case !initializeCalled: - runtimePanic("//go:wasmexport function called before runtime initialization") + runtimePanic(errWasmBeforeInit) case mainExited: - runtimePanic("//go:wasmexport function called after main.main returned") + runtimePanic(errWasmAfterMain) } } @@ -82,7 +82,7 @@ func wasmExportCheckRun() { func wasmExportRun(done *bool) { scheduler(true) if !*done { - runtimePanic("//go:wasmexport function did not finish") + runtimePanic(errWasmDidNotFinish) } } diff --git a/src/runtime/runtime_windows.go b/src/runtime/runtime_windows.go index 51dcd543b0..804d3b7d89 100644 --- a/src/runtime/runtime_windows.go +++ b/src/runtime/runtime_windows.go @@ -315,11 +315,11 @@ func tinygo_init_exception_handler() func tinygo_sigpanic_windows(exceptionCode int32) { switch uint32(exceptionCode) { case _EXCEPTION_ACCESS_VIOLATION, _EXCEPTION_IN_PAGE_ERROR: - runtimePanic("nil pointer dereference") + runtimePanic(errNilPointer) case _EXCEPTION_INT_DIVIDE_BY_ZERO: - runtimePanic("divide by zero") + runtimePanic(errDivideByZero) case _EXCEPTION_INT_OVERFLOW: - runtimePanic("integer overflow") + runtimePanic(errIntegerOverflow) default: runtimeFatal("unknown exception") } diff --git a/src/runtime/scheduler_none.go b/src/runtime/scheduler_none.go index 7f75ef8e98..734b000e0a 100644 --- a/src/runtime/scheduler_none.go +++ b/src/runtime/scheduler_none.go @@ -60,15 +60,15 @@ func NumCPU() int { } func addTimer(tim *timerNode) { - runtimePanic("timers not supported without a scheduler") + runtimePanic(errTimersUnsupported) } func reAddTimer(tn *timerNode) { - runtimePanic("timers not supported without a scheduler") + runtimePanic(errTimersUnsupported) } func removeTimer(tim *timer) *timerNode { - runtimePanic("timers not supported without a scheduler") + runtimePanic(errTimersUnsupported) return nil } diff --git a/testdata/recover.go b/testdata/recover.go index bb90f0da62..03d50b0672 100644 --- a/testdata/recover.go +++ b/testdata/recover.go @@ -313,12 +313,29 @@ func recoverMustPanic(name string, f func()) { f() } +func recoverRuntimeErrorValue(f func()) { + defer func() { + r := recover() + err, ok := r.(runtime.Error) + if ok { + println(" recovered runtime error:", err.Error()) + } else { + println(" failed runtime error:", r) + } + }() + f() +} + // Test recovering from nil map assignment and closed channel send. func recoverNilMapAndChan() { recoverMustPanic("nil map", func() { var m map[string]int m["x"] = 1 }) + recoverRuntimeErrorValue(func() { + var m map[string]int + m["x"] = 1 + }) recoverMustPanic("closed chan", func() { ch := make(chan int) close(ch) diff --git a/testdata/recover.txt b/testdata/recover.txt index e61cf25fe6..634ae4af6d 100644 --- a/testdata/recover.txt +++ b/testdata/recover.txt @@ -52,6 +52,7 @@ outer recovered: repanic value # recover from nil map and closed channel recovered: nil map + recovered runtime error: assignment to entry in nil map recovered: closed chan recovered: close nil chan