bug: ARM64 handleHFAReturn crashes under -race (checkptr) — oversized array cast
Summary
go test -race on ARM64 (Apple M1) crashes with checkptr: converted pointer straddles multiple allocations in handleHFAReturn (internal/arch/arm64/implementation.go:143). Any ObjC method returning a struct of 2-3 float64 fields (CGSize, CGPoint) triggers the crash.
Discovered by @jbunds while testing gogpu Metal backend on Apple M1, Go 1.25.0/1.26.5.
Root Cause
handleHFAReturn unconditionally casts the return value pointer to (*[4]float64) or (*[4]float32), regardless of the actual HFA element count:
// implementation.go:143 — hfaCount may be 2 or 3, but cast is always [4]
dest := (*[4]float64)(rvalue)
for idx := 0; idx < hfaCount; idx++ {
dest[idx] = math.Float64frombits(fret[idx])
}
The caller allocates rvalue buffer matching the actual struct size:
CGSize { Width, Height float64 } = 16 bytes
(*[4]float64) cast = 32 bytes → exceeds 16-byte allocation
checkptr detects the cast spans beyond the allocation boundary → fatal error.
The for loop is correctly bounded by hfaCount, so no actual memory corruption occurs at runtime. The bug is purely in the cast expression — checkptr validates the cast target size, not the loop bounds.
Same issue exists for float32 at line 136: (*[4]float32)(rvalue).
Affected Code
internal/arch/arm64/implementation.go:
Line 136: dest := (*[4]float32)(rvalue) // oversized for hfaCount < 4
Line 143: dest := (*[4]float64)(rvalue) // oversized for hfaCount < 4
hfaCount is determined by flags (lines 114-123):
switch {
case cif.Flags&types.ReturnHFA4 != 0:
hfaCount = 4 // [4] cast correct
case cif.Flags&types.ReturnHFA3 != 0:
hfaCount = 3 // [4] cast oversized by 8/4 bytes
case cif.Flags&types.ReturnHFA2 != 0:
hfaCount = 2 // [4] cast oversized by 16/8 bytes ← crash
default:
hfaCount = 1 // [4] cast oversized by 24/12 bytes ← crash
}
Reproduction
# macOS ARM64, Go 1.25+ with -race (enables checkptr)
# Any ObjC method returning CGSize, CGPoint, or similar 2-element float struct
go test -race ./...
# → fatal error: checkptr: converted pointer straddles multiple allocations
# internal/arch/arm64/implementation.go:143
Stack trace (from gogpu contributor @jbunds):
runtime.checkptrAlignment
runtime/checkptr.go:26
goffi/internal/arch/arm64.(*Implementation).handleHFAReturn
implementation.go:143
goffi/internal/arch/arm64.(*Implementation).handleReturn
implementation.go:63
goffi/internal/arch/arm64.(*Implementation).Execute
call_arm64.go:321
goffi/ffi.executeFunction
call.go:26
goffi/ffi.CallFunction
ffi.go:329
Recommended Fix
Write elements individually via unsafe.Add instead of casting to a fixed-size array. This keeps each access within the original allocation:
// float64 path (replaces line 143-146):
for idx := 0; idx < hfaCount; idx++ {
*(*float64)(unsafe.Add(rvalue, uintptr(idx)*8)) = math.Float64frombits(fret[idx])
}
// float32 path (replaces line 136-139):
for idx := 0; idx < hfaCount; idx++ {
*(*float32)(unsafe.Add(rvalue, uintptr(idx)*4)) = math.Float32frombits(uint32(fret[idx]))
}
This matches the existing single-element pattern at line 72: *(*float64)(rvalue) = ...
unsafe.Add preserves pointer provenance and stays within the allocation as long as the offset is within bounds (guaranteed by hfaCount matching the struct's field count).
Alternative: per-count switch
switch hfaCount {
case 2:
dest := (*[2]float64)(rvalue)
dest[0] = math.Float64frombits(fret[0])
dest[1] = math.Float64frombits(fret[1])
case 3:
dest := (*[3]float64)(rvalue)
// ...
case 4:
dest := (*[4]float64)(rvalue)
// ...
}
More verbose but avoids unsafe.Add. Both approaches are correct.
Impact
- Severity: P2 — any ARM64 macOS user running
go test -race on code that calls ObjC methods returning float structs
- Affected platforms: ARM64 only (HFA return convention is ARM64-specific)
- Affected types: CGSize, CGPoint, CGRect.origin/size, NSSize, NSPoint, any struct of 2-3 float32/float64 fields
- Not affected: x86_64 (different return convention), single float/double returns (line 72 — correct), HFA4 returns (cast happens to be correct size)
- Workaround:
GOFLAGS=-gcflags=all=-d=checkptr=0 or avoid -race flag
Context
bug: ARM64 handleHFAReturn crashes under
-race(checkptr) — oversized array castSummary
go test -raceon ARM64 (Apple M1) crashes withcheckptr: converted pointer straddles multiple allocationsinhandleHFAReturn(internal/arch/arm64/implementation.go:143). Any ObjC method returning a struct of 2-3 float64 fields (CGSize, CGPoint) triggers the crash.Discovered by @jbunds while testing gogpu Metal backend on Apple M1, Go 1.25.0/1.26.5.
Root Cause
handleHFAReturnunconditionally casts the return value pointer to(*[4]float64)or(*[4]float32), regardless of the actual HFA element count:The caller allocates
rvaluebuffer matching the actual struct size:CGSize { Width, Height float64 }= 16 bytes(*[4]float64)cast = 32 bytes → exceeds 16-byte allocationcheckptr detects the cast spans beyond the allocation boundary → fatal error.
The
forloop is correctly bounded byhfaCount, so no actual memory corruption occurs at runtime. The bug is purely in the cast expression — checkptr validates the cast target size, not the loop bounds.Same issue exists for float32 at line 136:
(*[4]float32)(rvalue).Affected Code
hfaCountis determined by flags (lines 114-123):Reproduction
Stack trace (from gogpu contributor @jbunds):
Recommended Fix
Write elements individually via
unsafe.Addinstead of casting to a fixed-size array. This keeps each access within the original allocation:This matches the existing single-element pattern at line 72:
*(*float64)(rvalue) = ...unsafe.Addpreserves pointer provenance and stays within the allocation as long as the offset is within bounds (guaranteed byhfaCountmatching the struct's field count).Alternative: per-count switch
More verbose but avoids
unsafe.Add. Both approaches are correct.Impact
go test -raceon code that calls ObjC methods returning float structsGOFLAGS=-gcflags=all=-d=checkptr=0or avoid-raceflagContext