BLOG-2906 1 traceback did not unwind completely is a fatal error that suggests invariants were violated when traversing a goroutine's stack — likely due to memory corruption. We first saw it sporadically on arm64 machines running a control plane service. It was an idle service where restarts had little impact, so we didn't prioritize it. Then it kept happening. Initial observations showed the fatal errors correlated with recovered panics. Our service had old code using panic/recover for error handling, and a related Go issue (#73259) reported an arm64 stack unwinding crash. We theorized that stack unwinding during panic recovery was triggering the corruption, so we removed panic/recover from our error paths. The crashes stopped — our mitigation seemed to work. A month later the same class of fatal panics returned, but at a much higher rate: up to 30 per day. This time there were no recovered panics to correlate with. We saw two crash classes: an explicit fatal error while unwinding in (*unwinder).next, and a segmentation fault while accessing invalid memory during the same unwind. The segfault was a dereference of the scheduler's m struct at a specific offset (0x118), which the unwinder touches when it assumes the goroutine is currently running. ### Scheduler context Go uses M:N scheduling: goroutines (g) run on kernel threads (m) which acquire processors (p). Each g holds a pointer to its current m when running, otherwise nil. If the stack unwinder reads a return address that's non-zero but isn't a function, it assumes the goroutine is running and dereferences the m pointer — crashing if that pointer is garbage. The crashes only happened during stack unwinding, and only on arm64. We suspected a corruption, but couldn't identify the source remotely from the crashes. Then we noticed every crash shared a common library: Go Netlink. Every segmentation fault was preempting NetlinkSocket.Receive. ### Preemption Since Go 1.14, scheduling isn't purely cooperative; sysmon sends SIGURG to preempt goroutines running longer than 10ms. The signal handler modifies the program counter and stack to mimic a call to asyncPreempt. This can happen at any instruction boundary. We had two theories: a bug in Go Netlink (possible due to its unsafe.Pointer usage) or a Go runtime bug we only triggered in that specific function. Code auditing Go Netlink went nowhere. ### The breakthrough A coredump from production revealed the key detail. The preempted goroutine was paused between two opcodes in a function epilogue: text ADD $80, RSP, RSP ADD $(16<<12), RSP, RSP The goroutine was stopped between adjusting the stack pointer in two separate instructions. Stack unwinding relies on a consistent stack frame, so preempting in the middle of adjusting the stack pointer is fatal. Logs confirmed this wasn't isolated — most crashes showed the same instruction boundary. ### Reproducer With this insight, we built a minimal standard-library-only reproducer. The theory: stack unwinding is triggered by garbage collection; async preemption between a split stack pointer adjustment causes the crash. We wrote a function with a stack frame just over 16 bits on arm64, forcing the compiler to split the stack adjustment into two opcodes:
package main

import (
	"runtime"
)

//go:noinline
func big_stack(val int) int {
	var big_buffer = make([]byte, 1 << 16)

	sum := 0
	// prevent the compiler from optimizing out the stack
	for i := 0; i < (1<<16); i++ {
		big_buffer[i] = byte(val)
	}
	for i := 0; i < (1<<16); i++ {
		sum ^= int(big_buffer[i])
	}
	return sum
}

func main() {
	go func() {
		for {
			runtime.GC()
		}
	}()
	for {
		_ = big_stack(1000)
	}
}
Running this in a loop crashed within minutes:
; epilogue for main.big_stack
ADD $8, RSP, R29
ADD $(16<<12), R29, R29
ADD $16, RSP, RSP
; preemption is problematic between these opcodes
ADD $(16<<12), RSP, RSP
RET
A reproducible, standard-library-only crash proved this was a runtime bug — not an issue in Go Netlink. The bug is an extremely narrow race: a one-instruction window where preemption corrupts the stack pointer mid-adjustment. It's so narrow that small changes matter: the reproducer, written against Go 1.23.4, doesn't crash when compiled with 1.23.9 — even though objdump shows the split ADD still present. Several unknown variables affect how often the race is hit, even with the exact bug present.

A one-instruction race

arm64 instructions are a fixed 4 bytes, which means immediates are heavily constrained. The add instruction, for instance, carries a 12-bit immediate with an optional shift-by-12 bit, so any constant up to 24 bits can be emitted as two back-to-back add opcodes. Other instructions must load their operands into registers first.

In the Go compiler, the last IR stage before code emission is the obj.Prog struct. This IR is deliberately unaware of immediate-size limits; the assembler in asm7.go handles that when translating to real arm64 machine code. It classifies each immediate in conclass based on bit width and decides whether extra instructions are necessary.

//https://github.com/golang/go/blob/fa2bb342d7b0024440d996c2d6d6778b7a5e0247/src/cmd/internal/obj/arm64/obj7.go#L856

// Pop stack frame.
// ADD $framesize, RSP, RSP
p = obj.Appendp(p, c.newprog)
p.As = AADD
p.From.Type = obj.TYPE_CONST
p.From.Offset = int64(c.autosize)
p.To.Type = obj.TYPE_REG
p.To.Reg = REGSP
p.Spadj = -c.autosize

The Go assembler favors a mov plus add pair for immediates that fit in 16 bits, and an add plus an add with a logical shift left of 12 for larger values. For a stack frame slightly bigger than 1<<15, the codegen looks like this:

; //go:noinline
; func big_stack() byte {
; 	var big_stack = make([]byte, 1<<15)
; 	return big_stack[0]
; }
MOVD $32776, R27
ADD R27, RSP, R29
MOVD $32784, R27
ADD R27, RSP, RSP
RET

For a frame of 1<<16 or more, the compiler emits two separate ADD opcodes that adjust RSP incrementally:

; //go:noinline
; func big_stack() byte {
; 	var big_stack = make([]byte, 1<<16)
; 	return big_stack[0]
; } 
ADD $8, RSP, R29
ADD $(16<<12), R29, R29
ADD $16, RSP, RSP
ADD $(16<<12), RSP, RSP
RET

The problem is in that gap. Between the two ADD instructions, the stack pointer is inconsistent—it points into the middle of the frame, not at its tip. The goroutine may already be in its epilogue, so corrupting data in that transient state is harmless; memory is being discarded anyway. What breaks is stack unwinding.

Why an invalid SP is fatal

The Go runtime walks the stack for many reasons: garbage collection scans for live heap references, panics need to find defer functions, and stack traces print the call chain. The unwinder reads the caller frame by dereferencing the current stack pointer. If that pointer is not exact, the unwinder will interpret random stack data as a parent frame's metadata, and the runtime will likely crash.

With async preemption, the runtime can force a function call onto the stack at any instruction boundary. If the preemption lands between the two stack-adjusting ADD opcodes, the parent frame it records is bogus. The failure sequence is:

  1. Preemption interrupts the goroutine after the first add x, rsp but before the second.
  2. Something triggers a stack walk, such as a GC liveness check.
  3. The unwinder follows the chain correctly until it hits the interrupted function.
  4. It reads the stack pointer to find the caller—and dereferences garbage.
  5. Crash.
//https://github.com/golang/go/blob/66536242fce34787230c42078a7bbd373ef8dcb0/src/runtime/traceback.go#L373

if innermost && frame.sp < frame.fp || frame.lr == 0 {
    lrPtr = frame.sp
    frame.lr = *(*uintptr)(unsafe.Pointer(lrPtr))
}

The faulting trace from our report ended in (*NetlinkSocket).Receive; the unwinder faulted while locating that function's parent. The stack walk itself was the victim.

BLOG-2906 3

We filed the bug with a minimal reproducer, and the fix landed quickly in go1.23.12, go1.24.6, and go1.25.0. The change is simple: the compiler no longer emits a single add x, rsp and lets the assembler split it. Instead, for stack frames larger than 1<<12, the offset is built in a temporary register and then added to rsp in one indivisible opcode. A goroutine can now be preempted before or after the stack pointer update—but never during it.

goroutine 90 gp=0x40042cc000 m=nil [preempted (scan)]:
runtime.asyncPreempt2()
/usr/local/go/src/runtime/preempt.go:306 +0x2c fp=0x40060a25d0 sp=0x40060a25b0 pc=0x55557e299dec
runtime.asyncPreempt()
/usr/local/go/src/runtime/preempt_arm64.s:47 +0x9c fp=0x40060a27c0 sp=0x40060a25d0 pc=0x55557e2dc94c
github.com/vishvananda/netlink/nl.(*NetlinkSocket).Receive(0xff48ce6e060b2848?)
/vendor/github.com/vishvananda/netlink/nl/nl_linux.go:779 +0x130 fp=0x40060b2820 sp=0x40060a27d0 pc=0x55557e9d2880

This was a rare race condition, one that only shows up at significant scale. It took weeks of debugging through runtime internals to pin the blame on the compiler, which is not a destination we reach often. The bug was subtle, but the fix is permanent.

LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET