Skip to content
46 changes: 46 additions & 0 deletions objc/build_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The Ebitengine Authors

package objc_test

import (
"errors"
"fmt"
"os/exec"
"runtime"
"strings"
"testing"
)

// buildSharedLib compiles the Objective-C sources into a dynamic library at libFile.
// The library is built for GOARCH rather than the compiler's default, because
// macOS arm64 can run amd64 tests through Rosetta.
func buildSharedLib(tb testing.TB, libFile string, sources ...string) error {
tb.Helper()

out, err := exec.Command("go", "env", "CC").Output()
if err != nil {
return fmt.Errorf("go env CC error: %w", err)
}
compiler := strings.TrimSpace(string(out))
if compiler == "" {
return errors.New("compiler not found")
}

var arch string
switch runtime.GOARCH {
case "arm64":
arch = "arm64"
case "amd64":
arch = "x86_64"
default:
return fmt.Errorf("unknown macOS architecture %s", runtime.GOARCH)
}

args := []string{"-dynamiclib", "-Wall", "-Werror", "-arch", arch, "-framework", "Foundation", "-o", libFile}
cmd := exec.Command(compiler, append(args, sources...)...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("compile lib: %w\n%q\n%s", err, cmd, string(out))
}
return nil
}
96 changes: 96 additions & 0 deletions objc/objc_block_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package objc

import (
"errors"
"fmt"
"reflect"
"structs"
Expand Down Expand Up @@ -217,9 +218,93 @@ func (b Block) Copy() Block {
return _Block_copy(b)
}

// callForeign calls a block that was not created by [NewBlock] (for example one
// handed to us by Objective-C) through the Blocks ABI: the block is passed as the
// first argument followed by args. The signature is derived from the dynamic
// types of args and from resultType (nil for no result), and is checked against
// the block's own signature when it has one.
// See https://clang.llvm.org/docs/Block-ABI-Apple.html.
func (b Block) callForeign(resultType reflect.Type, args []any) ([]reflect.Value, error) {
if b == 0 {
return nil, errors.New("objc: cannot invoke a nil block")
}
invoke := (*(**blockLayout)(unsafe.Pointer(&b))).invoke
if invoke == 0 {
return nil, errors.New("objc: block has no invoke function")
}

in := make([]reflect.Type, len(args)+1)
layouts := make([]abiLayout, len(args))
reflectedArgs := make([]reflect.Value, len(args)+1)
in[0] = reflect.TypeFor[Block]()
reflectedArgs[0] = reflect.ValueOf(b)
for i, arg := range args {
if arg == nil {
return nil, fmt.Errorf("objc: argument %d to a block is nil; pass a typed value such as objc.ID(0)", i)
}
reflectedArgs[i+1] = reflect.ValueOf(arg)
in[i+1] = reflectedArgs[i+1].Type()
var err error
if layouts[i], err = goLayout(in[i+1]); err != nil {
return nil, err
}
}
var resultLayout abiLayout
var out []reflect.Type
if resultType != nil {
var err error
if resultLayout, err = goLayout(resultType); err != nil {
return nil, err
}
out = []reflect.Type{resultType}
}

if sig, ok := b.signature(); ok {
types, err := splitSignature(sig)
// types is the result, then the block itself, then the parameters.
if err != nil || len(types) < 2 {
return nil, fmt.Errorf("objc: malformed block signature %q", sig)
}
if len(types)-2 != len(args) {
return nil, fmt.Errorf("objc: block callback expects %d arguments, got %d", len(types)-2, len(args))
}
want, err := encodingLayout(types[0])
switch {
case resultType == nil && (err != nil || types[0][0] == '{'):
// The caller has to provide the result buffer for a struct returned in memory.
return nil, fmt.Errorf("objc: block returns %s; use InvokeBlock to receive it", types[0])
case resultType == nil:
// the result, if any, is discarded.
case err != nil:
return nil, fmt.Errorf("objc: block result %s: %w", types[0], err)
case !want.matches(resultLayout):
return nil, fmt.Errorf("objc: block returns %s, which does not match %s", types[0], resultType)
}
for i, got := range layouts {
want, err := encodingLayout(types[i+2])
if err != nil {
return nil, fmt.Errorf("objc: block argument %d %s: %w", i, types[i+2], err)
}
if !want.matches(got) {
return nil, fmt.Errorf("objc: block argument %d is %s, which does not match %s", i, types[i+2], in[i+1])
}
}
}

fn := reflect.New(reflect.FuncOf(in, out, false))
purego.RegisterFunc(fn.Interface(), invoke)
Comment thread
hajimehoshi marked this conversation as resolved.
return fn.Elem().Call(reflectedArgs), nil
}

// Invoke calls the implementation of a block.
func (b Block) Invoke(args ...any) {
fn := theBlocksCache.Functions.Load(b)
if !fn.IsValid() {
if _, err := b.callForeign(nil, args); err != nil {
panic(err)
}
return
}

reflectedArgs := make([]reflect.Value, len(args)+1)
reflectedArgs[0] = reflect.ValueOf(b)
Expand Down Expand Up @@ -251,6 +336,17 @@ func NewBlock(fn any) Block {
// InvokeBlock is a convenience method for calling the implementation of a block.
// The block implementation must return 1 value.
func InvokeBlock[T any](block Block, args ...any) (result T, err error) {
if !theBlocksCache.Functions.Load(block).IsValid() {
// not one of ours. The block may live on the caller's stack, so it must not be
// copied: the copy would be a different pointer and is unnecessary for a synchronous call.
out, err := block.callForeign(reflect.TypeFor[T](), args)
if err != nil {
return result, err
}
result, _ = reflect.TypeAssert[T](out[0])
return result, nil
}

block = block.Copy()
defer block.Release()

Expand Down
205 changes: 205 additions & 0 deletions objc/objc_block_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package objc_test

import (
"fmt"
"path/filepath"
"structs"
"testing"

Expand Down Expand Up @@ -154,3 +155,207 @@ func TestBlockCopyAndBlockRelease(t *testing.T) {
t.Fatalf("refCount: %d != 1", refCount)
}
}

// loadBlockFixture compiles testdata/block.m, which creates blocks in Objective-C.
func loadBlockFixture(t *testing.T) uintptr {
t.Helper()
library := filepath.Join(t.TempDir(), "block.dylib")
if err := buildSharedLib(t, library, filepath.Join("testdata", "block.m")); err != nil {
t.Fatal(err)
}
lib, err := purego.Dlopen(library, purego.RTLD_GLOBAL|purego.RTLD_NOW)
if err != nil {
t.Fatal(err)
}
return lib
}

func TestInvokeForeignBlock(t *testing.T) {
lib := loadBlockFixture(t)

check := func(name string, block objc.Block) {
t.Helper()
block.Invoke(int64(20), 3.5) // the result is discarded; this must not panic
// the block returns base + i + int64(f), where base is 100.
got, err := objc.InvokeBlock[int64](block, int64(20), 3.5)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if got != 123 {
t.Errorf("%s: InvokeBlock = %d, want 123", name, got)
}
}

t.Run("heap", func(t *testing.T) {
var heapBlock func(base int64) objc.Block
purego.RegisterLibFunc(&heapBlock, lib, "purego_heap_block")
block := heapBlock(100)
defer block.Release()
check("InvokeBlock", block)
})

t.Run("stack", func(t *testing.T) {
var withStackBlock func(base int64, cb uintptr)
purego.RegisterLibFunc(&withStackBlock, lib, "purego_with_stack_block")
called := false
cb := purego.NewCallback(func(block objc.Block) {
called = true
check("InvokeBlock", block)
})
withStackBlock(100, cb)
if !called {
t.Fatal("callback was not called")
}
})
}

func TestInvokeForeignBlockMismatch(t *testing.T) {
lib := loadBlockFixture(t)
var heapBlock func(base int64) objc.Block
purego.RegisterLibFunc(&heapBlock, lib, "purego_heap_block")
block := heapBlock(100)
defer block.Release()

if _, err := objc.InvokeBlock[int64](block, int64(20)); err == nil {
t.Error("missing argument: expected an error")
}
if _, err := objc.InvokeBlock[int64](block, int64(20), int64(3)); err == nil {
t.Error("integer for a double argument: expected an error")
}
if _, err := objc.InvokeBlock[float64](block, int64(20), 3.5); err == nil {
t.Error("wrong result type: expected an error")
}
if _, err := objc.InvokeBlock[any](block, int64(20), 3.5); err == nil {
t.Error("unsupported result type: expected an error")
}
if _, err := objc.InvokeBlock[int32](block, int64(20), 3.5); err == nil {
t.Error("smaller result type: expected an error")
}
if _, err := objc.InvokeBlock[int64](block, 20, 3.5); err != nil {
t.Errorf("int for an int64_t argument: %v", err)
}
if _, err := objc.InvokeBlock[int64](block, int32(20), 3.5); err == nil {
t.Error("int32 for an int64_t argument: expected an error")
}
if _, err := objc.InvokeBlock[int64](block, nil, 3.5); err == nil {
t.Error("nil argument: expected an error")
}
if got, err := objc.InvokeBlock[int64](block, int64(20), 3.5); err != nil || got != 123 {
t.Errorf("InvokeBlock = %d, %v; want 123, nil", got, err)
}
}

func TestInvokeForeignBlockStruct(t *testing.T) {
lib := loadBlockFixture(t)
var bigBlock func() objc.Block
purego.RegisterLibFunc(&bigBlock, lib, "purego_big_block")
block := bigBlock()
defer block.Release()

type big struct {
_ structs.HostLayout
a, b, c, d int64
}
got, err := objc.InvokeBlock[big](block, int64(1))
if err != nil {
t.Fatal(err)
}
if want := (big{a: 1, b: 2, c: 3, d: 4}); got != want {
t.Errorf("got %+v, want %+v", got, want)
}

type small struct {
_ structs.HostLayout
a, b int64
}
if _, err := objc.InvokeBlock[small](block, int64(1)); err == nil {
t.Error("smaller struct: expected an error")
}
type reordered struct {
_ structs.HostLayout
a, b, c int64
d float64
}
if _, err := objc.InvokeBlock[reordered](block, int64(1)); err == nil {
t.Error("struct with a different field: expected an error")
}

// Invoke cannot receive the result, so it must refuse rather than crash.
defer func() {
if recover() == nil {
t.Error("Invoke on a block returning a struct: expected a panic")
}
}()
block.Invoke(int64(1))
}

func TestInvokeForeignBlockStructPadding(t *testing.T) {
lib := loadBlockFixture(t)
var boolFloatBlock func() objc.Block
purego.RegisterLibFunc(&boolFloatBlock, lib, "purego_boolfloat_block")
block := boolFloatBlock()
defer block.Release()

// the padding is written out, as the RegisterFunc documentation asks for.
type boolFloat struct {
_ structs.HostLayout
b bool
_ [3]byte
f float32
}
got, err := objc.InvokeBlock[boolFloat](block, float32(1.5))
if err != nil {
t.Fatal(err)
}
if !got.b || got.f != 3 {
t.Errorf("got {%v %v}, want {true 3}", got.b, got.f)
}
}

func TestInvokeForeignBlockFuncArgument(t *testing.T) {
lib := loadBlockFixture(t)
var fnptrBlock func() objc.Block
purego.RegisterLibFunc(&fnptrBlock, lib, "purego_fnptr_block")
block := fnptrBlock()
defer block.Release()

// a func argument would consume a callback on every call; it must be refused.
if _, err := objc.InvokeBlock[objc.ID](block, func() {}); err == nil {
t.Error("expected an error for a func argument")
}

func() {
defer func() {
if recover() == nil {
t.Error("Invoke with a func argument: expected a panic")
}
}()
block.Invoke(func() {})
}()

called := 0
cb := purego.NewCallback(func() { called++ })
for range 5000 {
block.Invoke(cb)
}
if called != 5000 {
t.Errorf("called = %d, want 5000", called)
}
}

func TestInvokeForeignBlockBlockArgument(t *testing.T) {
lib := loadBlockFixture(t)
var blockArgBlock func() objc.Block
purego.RegisterLibFunc(&blockArgBlock, lib, "purego_blockarg_block")
block := blockArgBlock()
defer block.Release()

var got int64
handler := objc.NewBlock(func(_ objc.Block, x int64) { got = x })
defer handler.Release()

block.Invoke(handler, int64(41))
if got != 42 {
t.Errorf("got %d, want 42", got)
}
}
Loading
Loading