Skip to content

bug: ARM64 handleHFAReturn crashes under -race (checkptr) — oversized array cast #67

Description

@kolkov

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: asmAssembly trampolines and ABIpriority: highImportant for next releasetype: bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions