From c624ff7f0c579c2f300dec9bebcfd7717d0159a4 Mon Sep 17 00:00:00 2001 From: TotallyGamerJet Date: Wed, 30 Sep 2026 13:47:07 -0400 Subject: [PATCH 1/8] objc: let Invoke and InvokeBlock call blocks not created by NewBlock On a cache miss, call the block's invoke pointer through the Blocks ABI, marshalling via RegisterFunc with a signature derived from the arguments (and T for InvokeBlock). InvokeBlock no longer copies before the lookup, which missed for stack blocks. Fixes #536 Co-Authored-By: Claude Sonnet 5.5 --- objc/objc_block_darwin.go | 56 +++++++++++++++++++++++++ objc/objc_block_darwin_test.go | 74 ++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/objc/objc_block_darwin.go b/objc/objc_block_darwin.go index 224cd5e1..6f68615f 100644 --- a/objc/objc_block_darwin.go +++ b/objc/objc_block_darwin.go @@ -217,9 +217,54 @@ 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). +// See https://clang.llvm.org/docs/Block-ABI-Apple.html. +func (b Block) callForeign(resultType reflect.Type, args []any) []reflect.Value { + if b == 0 { + panic("objc: cannot invoke a nil block") + } + invoke := (*(**blockLayout)(unsafe.Pointer(&b))).invoke + if invoke == 0 { + panic("objc: block has no invoke function") + } + + in := make([]reflect.Type, len(args)+1) + 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 { + panic(fmt.Sprintf("objc: argument %d to a block is nil; pass a typed value", i)) + } + reflectedArgs[i+1] = reflect.ValueOf(arg) + in[i+1] = reflectedArgs[i+1].Type() + } + var out []reflect.Type + if resultType != nil { + out = []reflect.Type{resultType} + } + + fn := reflect.New(reflect.FuncOf(in, out, false)) + purego.RegisterFunc(fn.Interface(), invoke) + return fn.Elem().Call(reflectedArgs) +} + // Invoke calls the implementation of a block. +// +// Blocks created by [NewBlock] call the associated Go function directly. +// Any other block, such as a completion handler supplied by Objective-C, is called +// through the Blocks ABI using the dynamic types of args; pass typed values +// (for example int32(1) rather than a constant if the block takes an int32), +// and any result is discarded. func (b Block) Invoke(args ...any) { fn := theBlocksCache.Functions.Load(b) + if !fn.IsValid() { + b.callForeign(nil, args) + return + } reflectedArgs := make([]reflect.Value, len(args)+1) reflectedArgs[0] = reflect.ValueOf(b) @@ -250,7 +295,18 @@ func NewBlock(fn any) Block { // InvokeBlock is a convenience method for calling the implementation of a block. // The block implementation must return 1 value. +// +// Blocks that were not created by [NewBlock] are called through the Blocks ABI +// with a signature derived from T and the dynamic types of args. 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 := block.callForeign(reflect.TypeFor[T](), args) + result, _ = reflect.TypeAssert[T](out[0]) + return result, nil + } + block = block.Copy() defer block.Release() diff --git a/objc/objc_block_darwin_test.go b/objc/objc_block_darwin_test.go index 0f4fd923..856f6c66 100644 --- a/objc/objc_block_darwin_test.go +++ b/objc/objc_block_darwin_test.go @@ -7,6 +7,7 @@ import ( "fmt" "structs" "testing" + "unsafe" "github.com/ebitengine/purego" "github.com/ebitengine/purego/objc" @@ -154,3 +155,76 @@ func TestBlockCopyAndBlockRelease(t *testing.T) { t.Fatalf("refCount: %d != 1", refCount) } } + +// foreignBlock returns a block allocated by the runtime (not by NewBlock) that wraps inner. +func foreignBlock(t *testing.T, inner objc.Block) objc.Block { + t.Helper() + lib, err := purego.Dlopen("/usr/lib/libSystem.B.dylib", purego.RTLD_LAZY|purego.RTLD_GLOBAL) + if err != nil { + t.Fatal(err) + } + create, err := purego.Dlsym(lib, "dispatch_block_create") + if err != nil { + t.Fatal(err) + } + r, _, _ := purego.SyscallN(create, 0, uintptr(inner)) + if r == 0 { + t.Fatal("dispatch_block_create returned nil") + } + return objc.Block(r) +} + +func TestInvokeForeignBlock(t *testing.T) { + ran := false + ours := objc.NewBlock(func(_ objc.Block) { ran = true }) + defer ours.Release() + + foreign := foreignBlock(t, ours) + defer foreign.Release() + if foreign == ours { + t.Fatal("expected a distinct block") + } + + foreign.Invoke() + if !ran { + t.Fatal("inner block was not run") + } +} + +// literal mirrors a block literal that was not created by NewBlock. +type literal struct { + _ structs.HostLayout + isa uintptr + flags int32 + _ int32 + invoke uintptr + descriptor uintptr +} + +func TestInvokeForeignBlockArgs(t *testing.T) { + var got int32 + var gotF float64 + var gotBlock objc.Block + desc := [2]uintptr{0, unsafe.Sizeof(literal{})} + lit := &literal{ + invoke: purego.NewCallback(func(b objc.Block, i int32, f float64) int32 { + gotBlock, got, gotF = b, i, f + return i * 2 + }), + descriptor: uintptr(unsafe.Pointer(&desc)), + } + block := objc.Block(unsafe.Pointer(lit)) + + block.Invoke(int32(7), 2.5) + if got != 7 || gotF != 2.5 || gotBlock != block { + t.Fatalf("got (%d, %v, %#x), want (7, 2.5, %#x)", got, gotF, gotBlock, block) + } + + res, err := objc.InvokeBlock[int32](block, int32(21), 1.5) + if err != nil { + t.Fatal(err) + } + if res != 42 { + t.Fatalf("got %d, want 42", res) + } +} From 9fb80e2f7b30e3a138108470f7fd6c1b91375b7f Mon Sep 17 00:00:00 2001 From: TotallyGamerJet Date: Wed, 30 Sep 2026 13:54:32 -0400 Subject: [PATCH 2/8] objc: test foreign blocks with an NSItemProvider completion handler Co-Authored-By: Claude Sonnet 5.5 --- objc/objc_block_darwin_test.go | 85 ++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/objc/objc_block_darwin_test.go b/objc/objc_block_darwin_test.go index 856f6c66..57e45123 100644 --- a/objc/objc_block_darwin_test.go +++ b/objc/objc_block_darwin_test.go @@ -7,7 +7,6 @@ import ( "fmt" "structs" "testing" - "unsafe" "github.com/ebitengine/purego" "github.com/ebitengine/purego/objc" @@ -191,40 +190,66 @@ func TestInvokeForeignBlock(t *testing.T) { } } -// literal mirrors a block literal that was not created by NewBlock. -type literal struct { - _ structs.HostLayout - isa uintptr - flags int32 - _ int32 - invoke uintptr - descriptor uintptr -} - -func TestInvokeForeignBlockArgs(t *testing.T) { - var got int32 - var gotF float64 - var gotBlock objc.Block - desc := [2]uintptr{0, unsafe.Sizeof(literal{})} - lit := &literal{ - invoke: purego.NewCallback(func(b objc.Block, i int32, f float64) int32 { - gotBlock, got, gotF = b, i, f - return i * 2 - }), - descriptor: uintptr(unsafe.Pointer(&desc)), +// TestInvokeFrameworkCompletionHandler invokes a block created by Foundation. +// NSItemProvider passes a completion handler (a stack block) to the load handler +// given to registerItemForTypeIdentifier:loadHandler:. +func TestInvokeFrameworkCompletionHandler(t *testing.T) { + if _, err := purego.Dlopen("/System/Library/Frameworks/Foundation.framework/Foundation", purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil { + t.Fatal(err) } - block := objc.Block(unsafe.Pointer(lit)) + str := func(s string) objc.ID { + return objc.Send[objc.ID](objc.ID(objc.GetClass("NSString")), objc.RegisterName("stringWithUTF8String:"), s) + } + typeID := str("public.plain-text") + provider := objc.Send[objc.ID](objc.ID(objc.GetClass("NSItemProvider")), objc.RegisterName("new")) + defer objc.Send[objc.ID](provider, objc.RegisterName("release")) - block.Invoke(int32(7), 2.5) - if got != 7 || gotF != 2.5 || gotBlock != block { - t.Fatalf("got (%d, %v, %#x), want (7, 2.5, %#x)", got, gotF, gotBlock, block) + want := str("hello") + load := objc.NewBlock(func(_ objc.Block, completion objc.Block, _ objc.Class, _ objc.ID) { + completion.Invoke(want, objc.ID(0)) + }) + defer load.Release() + objc.Send[objc.ID](provider, objc.RegisterName("registerItemForTypeIdentifier:loadHandler:"), typeID, load) + + done := make(chan objc.ID, 1) + handler := objc.NewBlock(func(_ objc.Block, item, _ objc.ID) { done <- item }) + defer handler.Release() + objc.Send[objc.ID](provider, objc.RegisterName("loadItemForTypeIdentifier:options:completionHandler:"), typeID, objc.ID(0), handler) + + // the load handler is dispatched to the main queue, so the main run loop must run. + runLoop := objc.Send[objc.ID](objc.ID(objc.GetClass("NSRunLoop")), objc.RegisterName("mainRunLoop")) + date := objc.Send[objc.ID](objc.ID(objc.GetClass("NSDate")), objc.RegisterName("dateWithTimeIntervalSinceNow:"), 5.0) + for { + select { + case item := <-done: + if item != want { + t.Fatalf("got item %#x, want %#x", item, want) + } + return + default: + } + mode := str("kCFRunLoopDefaultMode") + soon := objc.Send[objc.ID](objc.ID(objc.GetClass("NSDate")), objc.RegisterName("dateWithTimeIntervalSinceNow:"), 0.05) + objc.Send[bool](runLoop, objc.RegisterName("runMode:beforeDate:"), mode, soon) + if objc.Send[float64](date, objc.RegisterName("timeIntervalSinceNow")) < 0 { + t.Fatal("timed out waiting for the completion handler") + } } +} - res, err := objc.InvokeBlock[int32](block, int32(21), 1.5) - if err != nil { +func TestInvokeBlockForeign(t *testing.T) { + ran := false + ours := objc.NewBlock(func(_ objc.Block) { ran = true }) + defer ours.Release() + + foreign := foreignBlock(t, ours) + defer foreign.Release() + + // the wrapper returns nothing meaningful; just check the call goes through. + if _, err := objc.InvokeBlock[int32](foreign); err != nil { t.Fatal(err) } - if res != 42 { - t.Fatalf("got %d, want 42", res) + if !ran { + t.Fatal("inner block was not run") } } From fc7f91ab521a55fc1fee791f6532f5229c9823ae Mon Sep 17 00:00:00 2001 From: TotallyGamerJet Date: Wed, 30 Sep 2026 13:58:38 -0400 Subject: [PATCH 3/8] objc: test foreign blocks with blocks created in Objective-C Co-Authored-By: Claude Sonnet 5.5 --- objc/objc_block_darwin_test.go | 129 ++++++++++++--------------------- objc/testdata/block.m | 24 ++++++ 2 files changed, 70 insertions(+), 83 deletions(-) create mode 100644 objc/testdata/block.m diff --git a/objc/objc_block_darwin_test.go b/objc/objc_block_darwin_test.go index 57e45123..7fc3d956 100644 --- a/objc/objc_block_darwin_test.go +++ b/objc/objc_block_darwin_test.go @@ -5,6 +5,9 @@ package objc_test import ( "fmt" + "os/exec" + "path/filepath" + "runtime" "structs" "testing" @@ -155,101 +158,61 @@ func TestBlockCopyAndBlockRelease(t *testing.T) { } } -// foreignBlock returns a block allocated by the runtime (not by NewBlock) that wraps inner. -func foreignBlock(t *testing.T, inner objc.Block) objc.Block { +// loadBlockFixture compiles testdata/block.m, which creates blocks in Objective-C. +func loadBlockFixture(t *testing.T) uintptr { t.Helper() - lib, err := purego.Dlopen("/usr/lib/libSystem.B.dylib", purego.RTLD_LAZY|purego.RTLD_GLOBAL) - if err != nil { - t.Fatal(err) + arch := "arm64" + if runtime.GOARCH == "amd64" { + arch = "x86_64" } - create, err := purego.Dlsym(lib, "dispatch_block_create") + library := filepath.Join(t.TempDir(), "block.dylib") + cmd := exec.Command("clang", "-dynamiclib", "-arch", arch, "-framework", "Foundation", "-o", library, "testdata/block.m") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("compile block fixture: %v\n%s", err, out) + } + lib, err := purego.Dlopen(library, purego.RTLD_GLOBAL|purego.RTLD_NOW) if err != nil { t.Fatal(err) } - r, _, _ := purego.SyscallN(create, 0, uintptr(inner)) - if r == 0 { - t.Fatal("dispatch_block_create returned nil") - } - return objc.Block(r) + return lib } +// TestInvokeForeignBlock invokes blocks that Objective-C created rather than NewBlock. func TestInvokeForeignBlock(t *testing.T) { - ran := false - ours := objc.NewBlock(func(_ objc.Block) { ran = true }) - defer ours.Release() - - foreign := foreignBlock(t, ours) - defer foreign.Release() - if foreign == ours { - t.Fatal("expected a distinct block") - } + lib := loadBlockFixture(t) - foreign.Invoke() - if !ran { - t.Fatal("inner block was not run") - } -} - -// TestInvokeFrameworkCompletionHandler invokes a block created by Foundation. -// NSItemProvider passes a completion handler (a stack block) to the load handler -// given to registerItemForTypeIdentifier:loadHandler:. -func TestInvokeFrameworkCompletionHandler(t *testing.T) { - if _, err := purego.Dlopen("/System/Library/Frameworks/Foundation.framework/Foundation", purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil { - t.Fatal(err) - } - str := func(s string) objc.ID { - return objc.Send[objc.ID](objc.ID(objc.GetClass("NSString")), objc.RegisterName("stringWithUTF8String:"), s) - } - typeID := str("public.plain-text") - provider := objc.Send[objc.ID](objc.ID(objc.GetClass("NSItemProvider")), objc.RegisterName("new")) - defer objc.Send[objc.ID](provider, objc.RegisterName("release")) - - want := str("hello") - load := objc.NewBlock(func(_ objc.Block, completion objc.Block, _ objc.Class, _ objc.ID) { - completion.Invoke(want, objc.ID(0)) - }) - defer load.Release() - objc.Send[objc.ID](provider, objc.RegisterName("registerItemForTypeIdentifier:loadHandler:"), typeID, load) - - done := make(chan objc.ID, 1) - handler := objc.NewBlock(func(_ objc.Block, item, _ objc.ID) { done <- item }) - defer handler.Release() - objc.Send[objc.ID](provider, objc.RegisterName("loadItemForTypeIdentifier:options:completionHandler:"), typeID, objc.ID(0), handler) - - // the load handler is dispatched to the main queue, so the main run loop must run. - runLoop := objc.Send[objc.ID](objc.ID(objc.GetClass("NSRunLoop")), objc.RegisterName("mainRunLoop")) - date := objc.Send[objc.ID](objc.ID(objc.GetClass("NSDate")), objc.RegisterName("dateWithTimeIntervalSinceNow:"), 5.0) - for { - select { - case item := <-done: - if item != want { - t.Fatalf("got item %#x, want %#x", item, want) - } - return - default: + 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) } - mode := str("kCFRunLoopDefaultMode") - soon := objc.Send[objc.ID](objc.ID(objc.GetClass("NSDate")), objc.RegisterName("dateWithTimeIntervalSinceNow:"), 0.05) - objc.Send[bool](runLoop, objc.RegisterName("runMode:beforeDate:"), mode, soon) - if objc.Send[float64](date, objc.RegisterName("timeIntervalSinceNow")) < 0 { - t.Fatal("timed out waiting for the completion handler") + if got != 123 { + t.Errorf("%s: InvokeBlock = %d, want 123", name, got) } } -} -func TestInvokeBlockForeign(t *testing.T) { - ran := false - ours := objc.NewBlock(func(_ objc.Block) { ran = true }) - defer ours.Release() - - foreign := foreignBlock(t, ours) - defer foreign.Release() + 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) + }) - // the wrapper returns nothing meaningful; just check the call goes through. - if _, err := objc.InvokeBlock[int32](foreign); err != nil { - t.Fatal(err) - } - if !ran { - t.Fatal("inner block was not run") - } + 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") + } + }) } diff --git a/objc/testdata/block.m b/objc/testdata/block.m new file mode 100644 index 00000000..5839adad --- /dev/null +++ b/objc/testdata/block.m @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors + +#import +#include + +typedef int64_t (^PuregoBlock)(int64_t, double); + +// purego_heap_block returns a heap block that adds base, i, and f. +void *purego_heap_block(int64_t base) { + PuregoBlock b = ^int64_t(int64_t i, double f) { + return base + i + (int64_t)f; + }; + return Block_copy(b); +} + +// purego_with_stack_block calls cb with a block that is still on the stack. +// The block is only valid until cb returns. +void purego_with_stack_block(int64_t base, void (*cb)(void *block)) { + PuregoBlock b = ^int64_t(int64_t i, double f) { + return base + i + (int64_t)f; + }; + cb((void *)b); +} From 04074b8070cd99784166c69f36e53670de3f0fc4 Mon Sep 17 00:00:00 2001 From: TotallyGamerJet Date: Wed, 30 Sep 2026 14:02:48 -0400 Subject: [PATCH 4/8] objc: share an Objective-C shared library builder between tests Co-Authored-By: Claude Sonnet 5.5 --- objc/build_darwin_test.go | 46 ++++++++++++++++++++++++++++++++++ objc/objc_block_darwin_test.go | 11 ++------ objc/super_darwin_test.go | 11 ++------ 3 files changed, 50 insertions(+), 18 deletions(-) create mode 100644 objc/build_darwin_test.go diff --git a/objc/build_darwin_test.go b/objc/build_darwin_test.go new file mode 100644 index 00000000..662634ff --- /dev/null +++ b/objc/build_darwin_test.go @@ -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 +} diff --git a/objc/objc_block_darwin_test.go b/objc/objc_block_darwin_test.go index 7fc3d956..3d4e405d 100644 --- a/objc/objc_block_darwin_test.go +++ b/objc/objc_block_darwin_test.go @@ -5,9 +5,7 @@ package objc_test import ( "fmt" - "os/exec" "path/filepath" - "runtime" "structs" "testing" @@ -161,14 +159,9 @@ func TestBlockCopyAndBlockRelease(t *testing.T) { // loadBlockFixture compiles testdata/block.m, which creates blocks in Objective-C. func loadBlockFixture(t *testing.T) uintptr { t.Helper() - arch := "arm64" - if runtime.GOARCH == "amd64" { - arch = "x86_64" - } library := filepath.Join(t.TempDir(), "block.dylib") - cmd := exec.Command("clang", "-dynamiclib", "-arch", arch, "-framework", "Foundation", "-o", library, "testdata/block.m") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("compile block fixture: %v\n%s", err, out) + 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 { diff --git a/objc/super_darwin_test.go b/objc/super_darwin_test.go index 10d8b308..82b76a68 100644 --- a/objc/super_darwin_test.go +++ b/objc/super_darwin_test.go @@ -5,9 +5,7 @@ package objc_test import ( "fmt" - "os/exec" "path/filepath" - "runtime" "structs" "sync/atomic" "testing" @@ -97,14 +95,9 @@ func TestSendSuperDispatch(t *testing.T) { } func TestSendSuperStruct(t *testing.T) { - arch := "arm64" - if runtime.GOARCH == "amd64" { - arch = "x86_64" - } library := filepath.Join(t.TempDir(), "super.dylib") - cmd := exec.Command("clang", "-dynamiclib", "-arch", arch, "-framework", "Foundation", "-o", library, "testdata/super.m") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("compile super fixture: %v\n%s", err, out) + if err := buildSharedLib(t, library, filepath.Join("testdata", "super.m")); err != nil { + t.Fatal(err) } // Objective-C retains the registered classes and their implementations. if _, err := purego.Dlopen(library, purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil { From 9be702e90ed60968225d78bb2e674258ab488a86 Mon Sep 17 00:00:00 2001 From: TotallyGamerJet Date: Wed, 30 Sep 2026 14:15:58 -0400 Subject: [PATCH 5/8] remove some comments --- objc/objc_block_darwin.go | 9 --------- objc/objc_block_darwin_test.go | 1 - 2 files changed, 10 deletions(-) diff --git a/objc/objc_block_darwin.go b/objc/objc_block_darwin.go index 6f68615f..634686f6 100644 --- a/objc/objc_block_darwin.go +++ b/objc/objc_block_darwin.go @@ -253,12 +253,6 @@ func (b Block) callForeign(resultType reflect.Type, args []any) []reflect.Value } // Invoke calls the implementation of a block. -// -// Blocks created by [NewBlock] call the associated Go function directly. -// Any other block, such as a completion handler supplied by Objective-C, is called -// through the Blocks ABI using the dynamic types of args; pass typed values -// (for example int32(1) rather than a constant if the block takes an int32), -// and any result is discarded. func (b Block) Invoke(args ...any) { fn := theBlocksCache.Functions.Load(b) if !fn.IsValid() { @@ -295,9 +289,6 @@ func NewBlock(fn any) Block { // InvokeBlock is a convenience method for calling the implementation of a block. // The block implementation must return 1 value. -// -// Blocks that were not created by [NewBlock] are called through the Blocks ABI -// with a signature derived from T and the dynamic types of args. 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 diff --git a/objc/objc_block_darwin_test.go b/objc/objc_block_darwin_test.go index 3d4e405d..aac5fdea 100644 --- a/objc/objc_block_darwin_test.go +++ b/objc/objc_block_darwin_test.go @@ -170,7 +170,6 @@ func loadBlockFixture(t *testing.T) uintptr { return lib } -// TestInvokeForeignBlock invokes blocks that Objective-C created rather than NewBlock. func TestInvokeForeignBlock(t *testing.T) { lib := loadBlockFixture(t) From d92dd6e7a94c95d25c6d5e9df04647c590cc2b54 Mon Sep 17 00:00:00 2001 From: TotallyGamerJet Date: Wed, 30 Sep 2026 14:40:38 -0400 Subject: [PATCH 6/8] objc: check foreign block calls against the block's signature Use the block's type encoding to validate the argument count and the kinds of the arguments and result before calling, refuse Invoke on blocks that return a struct (a hidden result pointer is needed), and reject func arguments, which would consume a callback on every call. Co-Authored-By: Claude Sonnet 5.5 --- objc/objc_block_darwin.go | 179 +++++++++++++++++++++++++++++++-- objc/objc_block_darwin_test.go | 77 ++++++++++++++ objc/testdata/block.m | 21 ++++ 3 files changed, 269 insertions(+), 8 deletions(-) diff --git a/objc/objc_block_darwin.go b/objc/objc_block_darwin.go index 634686f6..5bf6ac0e 100644 --- a/objc/objc_block_darwin.go +++ b/objc/objc_block_darwin.go @@ -4,8 +4,10 @@ package objc import ( + "errors" "fmt" "reflect" + "strings" "structs" "sync" "unsafe" @@ -217,46 +219,204 @@ func (b Block) Copy() Block { return _Block_copy(b) } +// signature returns the type encoding of a block, as given to @encode, +// or false if the block does not export one. +func (b Block) signature() (string, bool) { + layout := *(**blockLayout)(unsafe.Pointer(&b)) + if layout.flags&blockHasSignature == 0 { + return "", false + } + // The descriptor is { reserved, size, [copy, dispose,] [signature] } where the + // helpers are only present with blockHasCopyDispose. + offset := 2 * unsafe.Sizeof(uintptr(0)) + if layout.flags&blockHasCopyDispose != 0 { + offset += 2 * unsafe.Sizeof(uintptr(0)) + } + sig := *(**byte)(unsafe.Add(unsafe.Pointer(layout.descriptor), offset)) + if sig == nil { + return "", false + } + n := 0 + for *(*byte)(unsafe.Add(unsafe.Pointer(sig), n)) != 0 { + n++ + } + return unsafe.String(sig, n), true +} + +// skipEncoding returns the index just past the first type encoding in s, +// including any leading qualifiers and any trailing frame offset. +func skipEncoding(s string, i int) int { + for i < len(s) && strings.IndexByte("rnNoORV", s[i]) >= 0 { + i++ + } + if i >= len(s) { + return i + } + switch s[i] { + case '{', '(', '[': + open, end := s[i], map[byte]byte{'{': '}', '(': ')', '[': ']'}[s[i]] + for depth := 0; i < len(s); i++ { + if s[i] == open { + depth++ + } else if s[i] == end { + if depth--; depth == 0 { + i++ + break + } + } + } + case '^': + i = skipEncoding(s, i+1) + return i // the offset was consumed by the pointee + case 'b': + i++ + case '@': + i++ + if i < len(s) && s[i] == '?' { + i++ + } else if i < len(s) && s[i] == '"' { + i++ + for i < len(s) && s[i] != '"' { + i++ + } + i++ + } + default: + i++ + } + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + return i +} + +// splitSignature splits a method or block type encoding into the return type followed by the argument types. +func splitSignature(sig string) (types []string) { + for i := 0; i < len(sig); { + j := skipEncoding(sig, i) + types = append(types, strings.TrimRight(strings.TrimLeft(sig[i:j], "rnNoORV"), "0123456789")) + i = j + } + return types +} + +// encodingClass reduces a type encoding to what matters to the calling convention. +func encodingClass(enc string) string { + enc = strings.TrimLeft(enc, "rnNoORV") + switch { + case enc == "": + return "" + case enc[0] == 'v': + return "void" + case enc[0] == 'f': + return "float" + case enc[0] == 'd': + return "double" + case enc[0] == '{' || enc[0] == '(': + return "struct" + } + return "integer" +} + +// goClass is encodingClass for a Go type. +func goClass(typ reflect.Type) (string, error) { + switch typ.Kind() { + case reflect.Float32: + return "float", nil + case reflect.Float64: + return "double", nil + case reflect.Struct: + return "struct", nil + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, + reflect.Pointer, reflect.UnsafePointer, reflect.String: + return "integer", nil + case reflect.Func: + // RegisterFunc would create a callback for every call, and callbacks are never freed. + return "", fmt.Errorf("objc: a %s argument to a block is not supported; create the callback once with purego.NewCallback and pass the uintptr", typ) + } + return "", fmt.Errorf("objc: unsupported block argument or result type %s", typ) +} + // 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). +// 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 { +func (b Block) callForeign(resultType reflect.Type, args []any) ([]reflect.Value, error) { if b == 0 { - panic("objc: cannot invoke a nil block") + return nil, errors.New("objc: cannot invoke a nil block") } invoke := (*(**blockLayout)(unsafe.Pointer(&b))).invoke if invoke == 0 { - panic("objc: block has no invoke function") + return nil, errors.New("objc: block has no invoke function") } in := make([]reflect.Type, len(args)+1) + classes := make([]string, 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 { - panic(fmt.Sprintf("objc: argument %d to a block is nil; pass a typed value", i)) + 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 classes[i], err = goClass(in[i+1]); err != nil { + return nil, err + } } + resultClass := "void" var out []reflect.Type if resultType != nil { + var err error + if resultClass, err = goClass(resultType); err != nil { + return nil, err + } out = []reflect.Type{resultType} } + if sig, ok := b.signature(); ok { + types := splitSignature(sig) + // types is the result, then the block itself, then the parameters. + if 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 := encodingClass(types[0]) + switch { + case resultType == nil && want == "struct": + // The caller has to provide the result buffer for a struct returned in memory. + return nil, fmt.Errorf("objc: block returns a struct (%s); use InvokeBlock to receive it", types[0]) + case resultType == nil && want != "void": + // the result is discarded. + case want != resultClass: + return nil, fmt.Errorf("objc: block returns %s (%s), not %s", want, types[0], resultClass) + } + for i, class := range classes { + if want := encodingClass(types[i+2]); want != class { + return nil, fmt.Errorf("objc: argument %d to the block is %s (%s), not %s", i, want, types[i+2], class) + } + } + } + fn := reflect.New(reflect.FuncOf(in, out, false)) purego.RegisterFunc(fn.Interface(), invoke) - return fn.Elem().Call(reflectedArgs) + 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() { - b.callForeign(nil, args) + if _, err := b.callForeign(nil, args); err != nil { + panic(err) + } return } @@ -293,7 +453,10 @@ 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 := block.callForeign(reflect.TypeFor[T](), args) + out, err := block.callForeign(reflect.TypeFor[T](), args) + if err != nil { + return result, err + } result, _ = reflect.TypeAssert[T](out[0]) return result, nil } diff --git a/objc/objc_block_darwin_test.go b/objc/objc_block_darwin_test.go index aac5fdea..fc295fae 100644 --- a/objc/objc_block_darwin_test.go +++ b/objc/objc_block_darwin_test.go @@ -208,3 +208,80 @@ func TestInvokeForeignBlock(t *testing.T) { } }) } + +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[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) + } + + // 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 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") + } + + called := 0 + cb := purego.NewCallback(func() { called++ }) + for range 5000 { + block.Invoke(cb) + } + if called != 5000 { + t.Errorf("called = %d, want 5000", called) + } +} diff --git a/objc/testdata/block.m b/objc/testdata/block.m index 5839adad..31394ef2 100644 --- a/objc/testdata/block.m +++ b/objc/testdata/block.m @@ -22,3 +22,24 @@ void purego_with_stack_block(int64_t base, void (*cb)(void *block)) { }; cb((void *)b); } + +typedef struct { + int64_t a, b, c, d; +} Big; + +// purego_big_block returns a block that returns a struct in memory. +void *purego_big_block(void) { + Big (^b)(int64_t) = ^Big(int64_t x) { + Big r = {x, x + 1, x + 2, x + 3}; + return r; + }; + return Block_copy(b); +} + +// purego_fnptr_block returns a block that calls a function pointer. +void *purego_fnptr_block(void) { + void (^b)(void (*)(void)) = ^(void (*f)(void)) { + f(); + }; + return Block_copy(b); +} From 87dc0245c250b69022c099d4336130f5bcb65ecd Mon Sep 17 00:00:00 2001 From: TotallyGamerJet Date: Wed, 30 Sep 2026 14:48:12 -0400 Subject: [PATCH 7/8] objc: compare foreign block calls against the block's ABI Parse block signatures with extended block encodings (@?<...>) and quoted names, and compare arguments and results by size and field layout instead of only by kind, so a struct of the wrong size or an integer of the wrong width is refused rather than called. Move the signature parsing into its own file with unit tests, and reuse internal/strings.GoString. Co-Authored-By: Claude Opus 5.5 --- objc/objc_block_darwin.go | 158 +++------------- objc/objc_block_darwin_test.go | 51 +++++ objc/objc_block_signature_darwin.go | 229 +++++++++++++++++++++++ objc/objc_block_signature_darwin_test.go | 135 +++++++++++++ objc/testdata/block.m | 8 + 5 files changed, 445 insertions(+), 136 deletions(-) create mode 100644 objc/objc_block_signature_darwin.go create mode 100644 objc/objc_block_signature_darwin_test.go diff --git a/objc/objc_block_darwin.go b/objc/objc_block_darwin.go index 5bf6ac0e..57893c5b 100644 --- a/objc/objc_block_darwin.go +++ b/objc/objc_block_darwin.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "reflect" - "strings" "structs" "sync" "unsafe" @@ -219,125 +218,6 @@ func (b Block) Copy() Block { return _Block_copy(b) } -// signature returns the type encoding of a block, as given to @encode, -// or false if the block does not export one. -func (b Block) signature() (string, bool) { - layout := *(**blockLayout)(unsafe.Pointer(&b)) - if layout.flags&blockHasSignature == 0 { - return "", false - } - // The descriptor is { reserved, size, [copy, dispose,] [signature] } where the - // helpers are only present with blockHasCopyDispose. - offset := 2 * unsafe.Sizeof(uintptr(0)) - if layout.flags&blockHasCopyDispose != 0 { - offset += 2 * unsafe.Sizeof(uintptr(0)) - } - sig := *(**byte)(unsafe.Add(unsafe.Pointer(layout.descriptor), offset)) - if sig == nil { - return "", false - } - n := 0 - for *(*byte)(unsafe.Add(unsafe.Pointer(sig), n)) != 0 { - n++ - } - return unsafe.String(sig, n), true -} - -// skipEncoding returns the index just past the first type encoding in s, -// including any leading qualifiers and any trailing frame offset. -func skipEncoding(s string, i int) int { - for i < len(s) && strings.IndexByte("rnNoORV", s[i]) >= 0 { - i++ - } - if i >= len(s) { - return i - } - switch s[i] { - case '{', '(', '[': - open, end := s[i], map[byte]byte{'{': '}', '(': ')', '[': ']'}[s[i]] - for depth := 0; i < len(s); i++ { - if s[i] == open { - depth++ - } else if s[i] == end { - if depth--; depth == 0 { - i++ - break - } - } - } - case '^': - i = skipEncoding(s, i+1) - return i // the offset was consumed by the pointee - case 'b': - i++ - case '@': - i++ - if i < len(s) && s[i] == '?' { - i++ - } else if i < len(s) && s[i] == '"' { - i++ - for i < len(s) && s[i] != '"' { - i++ - } - i++ - } - default: - i++ - } - for i < len(s) && s[i] >= '0' && s[i] <= '9' { - i++ - } - return i -} - -// splitSignature splits a method or block type encoding into the return type followed by the argument types. -func splitSignature(sig string) (types []string) { - for i := 0; i < len(sig); { - j := skipEncoding(sig, i) - types = append(types, strings.TrimRight(strings.TrimLeft(sig[i:j], "rnNoORV"), "0123456789")) - i = j - } - return types -} - -// encodingClass reduces a type encoding to what matters to the calling convention. -func encodingClass(enc string) string { - enc = strings.TrimLeft(enc, "rnNoORV") - switch { - case enc == "": - return "" - case enc[0] == 'v': - return "void" - case enc[0] == 'f': - return "float" - case enc[0] == 'd': - return "double" - case enc[0] == '{' || enc[0] == '(': - return "struct" - } - return "integer" -} - -// goClass is encodingClass for a Go type. -func goClass(typ reflect.Type) (string, error) { - switch typ.Kind() { - case reflect.Float32: - return "float", nil - case reflect.Float64: - return "double", nil - case reflect.Struct: - return "struct", nil - case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, - reflect.Pointer, reflect.UnsafePointer, reflect.String: - return "integer", nil - case reflect.Func: - // RegisterFunc would create a callback for every call, and callbacks are never freed. - return "", fmt.Errorf("objc: a %s argument to a block is not supported; create the callback once with purego.NewCallback and pass the uintptr", typ) - } - return "", fmt.Errorf("objc: unsupported block argument or result type %s", typ) -} - // 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 @@ -354,7 +234,7 @@ func (b Block) callForeign(resultType reflect.Type, args []any) ([]reflect.Value } in := make([]reflect.Type, len(args)+1) - classes := make([]string, len(args)) + abis := make([]string, len(args)) reflectedArgs := make([]reflect.Value, len(args)+1) in[0] = reflect.TypeFor[Block]() reflectedArgs[0] = reflect.ValueOf(b) @@ -365,42 +245,48 @@ func (b Block) callForeign(resultType reflect.Type, args []any) ([]reflect.Value reflectedArgs[i+1] = reflect.ValueOf(arg) in[i+1] = reflectedArgs[i+1].Type() var err error - if classes[i], err = goClass(in[i+1]); err != nil { + if abis[i], err = goABI(in[i+1]); err != nil { return nil, err } } - resultClass := "void" + resultABI := abiVoid var out []reflect.Type if resultType != nil { var err error - if resultClass, err = goClass(resultType); err != nil { + if resultABI, err = goABI(resultType); err != nil { return nil, err } out = []reflect.Type{resultType} } if sig, ok := b.signature(); ok { - types := splitSignature(sig) + types, err := splitSignature(sig) // types is the result, then the block itself, then the parameters. - if len(types) < 2 { + 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 := encodingClass(types[0]) + want, err := encodingABI(types[0]) switch { - case resultType == nil && want == "struct": + case resultType == nil && (err != nil || want[0] == '{'): // The caller has to provide the result buffer for a struct returned in memory. - return nil, fmt.Errorf("objc: block returns a struct (%s); use InvokeBlock to receive it", types[0]) - case resultType == nil && want != "void": - // the result is discarded. - case want != resultClass: - return nil, fmt.Errorf("objc: block returns %s (%s), not %s", want, types[0], resultClass) + 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 != resultABI: + return nil, fmt.Errorf("objc: block returns %s, which does not match %s", types[0], resultType) } - for i, class := range classes { - if want := encodingClass(types[i+2]); want != class { - return nil, fmt.Errorf("objc: argument %d to the block is %s (%s), not %s", i, want, types[i+2], class) + for i, got := range abis { + want, err := encodingABI(types[i+2]) + if err != nil { + return nil, fmt.Errorf("objc: block argument %d %s: %w", i, types[i+2], err) + } + if want != got { + return nil, fmt.Errorf("objc: block argument %d is %s, which does not match %s", i, types[i+2], in[i+1]) } } } diff --git a/objc/objc_block_darwin_test.go b/objc/objc_block_darwin_test.go index fc295fae..8dd29681 100644 --- a/objc/objc_block_darwin_test.go +++ b/objc/objc_block_darwin_test.go @@ -228,6 +228,15 @@ func TestInvokeForeignBlockMismatch(t *testing.T) { 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") } @@ -255,6 +264,22 @@ func TestInvokeForeignBlockStruct(t *testing.T) { 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 { @@ -276,6 +301,15 @@ func TestInvokeForeignBlockFuncArgument(t *testing.T) { 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 { @@ -285,3 +319,20 @@ func TestInvokeForeignBlockFuncArgument(t *testing.T) { 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) + } +} diff --git a/objc/objc_block_signature_darwin.go b/objc/objc_block_signature_darwin.go new file mode 100644 index 00000000..105295e7 --- /dev/null +++ b/objc/objc_block_signature_darwin.go @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors + +package objc + +import ( + "errors" + "fmt" + "reflect" + stdstrings "strings" + "unsafe" + + "github.com/ebitengine/purego/internal/strings" +) + +// encQualifiers are the method type qualifiers (const, in, inout, out, bycopy, byref, oneway) +// that may prefix a type encoding. They do not affect the calling convention. +const encQualifiers = "rnNoORV" + +// abiVoid is the ABI form of no value. See encodingABI. +const abiVoid = "v" + +// signature returns the type encoding of a block, or false if the block does not export one. +func (b Block) signature() (string, bool) { + layout := *(**blockLayout)(unsafe.Pointer(&b)) + if layout.flags&blockHasSignature == 0 { + return "", false + } + // The descriptor is { reserved, size, [copy, dispose,] [signature] } where the + // helpers are only present with blockHasCopyDispose. + offset := 2 * unsafe.Sizeof(uintptr(0)) + if layout.flags&blockHasCopyDispose != 0 { + offset += 2 * unsafe.Sizeof(uintptr(0)) + } + sig := strings.GoString(*(*uintptr)(unsafe.Add(unsafe.Pointer(layout.descriptor), offset))) + return sig, sig != "" +} + +// splitSignature splits a method or block type encoding, such as "v24@?0q8d16", +// into the result type followed by the argument types, without qualifiers or frame offsets. +func splitSignature(sig string) ([]string, error) { + var types []string + for sig != "" { + sig = stdstrings.TrimLeft(sig, encQualifiers) + n, err := encodingLen(sig) + if err != nil { + return nil, err + } + types = append(types, sig[:n]) + sig = stdstrings.TrimLeft(sig[n:], "0123456789") + } + return types, nil +} + +// encodingLen returns the length of the single type encoding at the start of s. +func encodingLen(s string) (int, error) { + if s == "" { + return 0, errors.New("missing type encoding") + } + switch s[0] { + case '{', '(', '[': + return bracketLen(s) + case '^': + rest := stdstrings.TrimLeft(s[1:], encQualifiers) + n, err := encodingLen(rest) + return len(s) - len(rest) + n, err + case 'b': + return 1 + len(s[1:]) - len(stdstrings.TrimLeft(s[1:], "0123456789")), nil + case '@': + switch { + case len(s) > 1 && s[1] == '"': + // an object with its class name: @"NSString" + n, err := quotedLen(s[1:]) + return 1 + n, err + case len(s) > 2 && s[1] == '?' && s[2] == '<': + // a block with its signature: @? + n, err := bracketLen(s[2:]) + return 2 + n, err + case len(s) > 1 && s[1] == '?': + return 2, nil + } + } + return 1, nil +} + +// bracketLen returns the length of the bracketed encoding at the start of s, +// such as {name=type...}, (name=type...), [count type] or . +func bracketLen(s string) (int, error) { + depth := 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '{', '(', '[', '<': + depth++ + case '}', ')', ']', '>': + if depth--; depth == 0 { + return i + 1, nil + } + case '"': + // field and class names may contain brackets, as in @"". + n, err := quotedLen(s[i:]) + if err != nil { + return 0, err + } + i += n - 1 + } + } + return 0, fmt.Errorf("unterminated type encoding %q", s) +} + +// quotedLen returns the length of the quoted name at the start of s, including the quotes. +func quotedLen(s string) (int, error) { + if end := stdstrings.IndexByte(s[1:], '"'); end >= 0 { + return end + 2, nil + } + return 0, fmt.Errorf("unterminated name in type encoding %q", s) +} + +// encodingABI reduces a type encoding to what the calling convention sees, so that it +// can be compared with goABI: each scalar becomes its size in bytes, or "f" or "d" for +// floating point, and a struct becomes its scalars in order between braces, with nested +// structs and arrays flattened. For example, {CGRect={CGPoint=dd}{CGSize=dd}} is "{dddd}". +func encodingABI(enc string) (string, error) { + enc = stdstrings.TrimLeft(enc, encQualifiers) + if enc == "" { + return "", errors.New("missing type encoding") + } + switch enc[0] { + case 'v': + return abiVoid, nil + case 'c', 'C', 'B': + return "1", nil + case 's', 'S': + return "2", nil + case 'i', 'I', 'l', 'L': // long is encoded as a 32-bit quantity + return "4", nil + case 'q', 'Q', '^', '*', '@', '#', ':': + return "8", nil + case 'f', 'd': + return enc[:1], nil + case '[': + count := len(enc) - 1 - len(stdstrings.TrimLeft(enc[1:], "0123456789")) + n := 0 + for _, c := range enc[1 : 1+count] { + n = n*10 + int(c-'0') + } + elem, err := encodingABI(enc[1+count : len(enc)-1]) + if err != nil { + return "", err + } + return stdstrings.Repeat(stdstrings.Trim(elem, "{}"), n), nil + case '{': + _, fields, ok := stdstrings.Cut(enc[1:len(enc)-1], "=") + if !ok { + return "", fmt.Errorf("struct %s has no fields", enc) + } + var abi stdstrings.Builder + abi.WriteByte('{') + for fields != "" { + if fields[0] == '"' { + // a field name + n, err := quotedLen(fields) + if err != nil { + return "", err + } + fields = fields[n:] + continue + } + n, err := encodingLen(fields) + if err != nil { + return "", err + } + field, err := encodingABI(fields[:n]) + if err != nil { + return "", err + } + abi.WriteString(stdstrings.Trim(field, "{}")) + fields = fields[n:] + } + abi.WriteByte('}') + return abi.String(), nil + } + return "", fmt.Errorf("unsupported type encoding %s", enc) +} + +// goABI is encodingABI for a Go type. +func goABI(typ reflect.Type) (string, error) { + switch typ.Kind() { + case reflect.Bool, reflect.Int8, reflect.Uint8: + return "1", nil + case reflect.Int16, reflect.Uint16: + return "2", nil + case reflect.Int32, reflect.Uint32: + return "4", nil + case reflect.Int, reflect.Int64, reflect.Uint, reflect.Uint64, reflect.Uintptr, + reflect.Pointer, reflect.UnsafePointer, reflect.String: + return "8", nil + case reflect.Float32: + return "f", nil + case reflect.Float64: + return "d", nil + case reflect.Array: + elem, err := goABI(typ.Elem()) + if err != nil { + return "", err + } + return stdstrings.Repeat(stdstrings.Trim(elem, "{}"), typ.Len()), nil + case reflect.Struct: + var abi stdstrings.Builder + abi.WriteByte('{') + for i := range typ.NumField() { + f := typ.Field(i) + if f.Type.Size() == 0 { + // structs.HostLayout and other zero-sized fields have no counterpart in C. + continue + } + field, err := goABI(f.Type) + if err != nil { + return "", err + } + abi.WriteString(stdstrings.Trim(field, "{}")) + } + abi.WriteByte('}') + return abi.String(), nil + case reflect.Func: + // RegisterFunc would create a callback for every call, and callbacks are never freed. + return "", fmt.Errorf("objc: a %s argument to a block is not supported; create the callback once with purego.NewCallback and pass the uintptr", typ) + } + return "", fmt.Errorf("objc: unsupported block argument or result type %s", typ) +} diff --git a/objc/objc_block_signature_darwin_test.go b/objc/objc_block_signature_darwin_test.go new file mode 100644 index 00000000..d2c69865 --- /dev/null +++ b/objc/objc_block_signature_darwin_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors + +package objc + +import ( + "reflect" + "slices" + "structs" + "testing" +) + +func TestSplitSignature(t *testing.T) { + tests := []struct { + sig string + want []string + }{ + {"v24@?0q8d16", []string{"v", "@?", "q", "d"}}, + {"q28@?0i8c12s16q20", []string{"q", "@?", "i", "c", "s", "q"}}, + {`v32@?0@"NSString"8@"NSError"16q24`, []string{"v", "@?", `@"NSString"`, `@"NSError"`, "q"}}, + {"v24@?0@?8q16", []string{"v", "@?", "@?", "q"}}, + {"v16@?0@?>8", []string{"v", "@?", "@?>"}}, + {`v16@?0@""8`, []string{"v", "@?", `@""`}}, + {"{Big=qqqq}16@?0q8", []string{"{Big=qqqq}", "@?", "q"}}, + {"v16@?0^{S=[2{T=i}]}8", []string{"v", "@?", "^{S=[2{T=i}]}"}}, + {"v24@?0r*8Vv16", []string{"v", "@?", "*", "v"}}, + {"v16@?0^r^v8", []string{"v", "@?", "^r^v"}}, + {`{P="x"d"y"d}8@?0`, []string{`{P="x"d"y"d}`, "@?"}}, + } + for _, tt := range tests { + got, err := splitSignature(tt.sig) + if err != nil { + t.Errorf("splitSignature(%q): %v", tt.sig, err) + continue + } + if !slices.Equal(got, tt.want) { + t.Errorf("splitSignature(%q) = %q, want %q", tt.sig, got, tt.want) + } + } + + for _, sig := range []string{"v8@?0{S=i", `v8@?0@"NSString`, "v8@?0@?", "8"}, + {`@"NSString"`, "8"}, + {"r*", "8"}, + {"f", "f"}, + {"d", "d"}, + {"{Big=qqqq}", "{8888}"}, + {"{CGRect={CGPoint=dd}{CGSize=dd}}", "{dddd}"}, + {`{P="x"d"y"i}`, "{d4}"}, + {"{S=[3f]c}", "{fff1}"}, + {"{S=[2{T=id}]}", "{4d4d}"}, + } + for _, tt := range tests { + got, err := encodingABI(tt.enc) + if err != nil { + t.Errorf("encodingABI(%q): %v", tt.enc, err) + continue + } + if got != tt.want { + t.Errorf("encodingABI(%q) = %q, want %q", tt.enc, got, tt.want) + } + } + + for _, enc := range []string{"(U=id)", "{S=b3}", "D", "{Opaque}"} { + if got, err := encodingABI(enc); err == nil { + t.Errorf("encodingABI(%q) = %q, want an error", enc, got) + } + } +} + +func TestGoABI(t *testing.T) { + type point struct { + _ structs.HostLayout + X, Y float64 + } + type rect struct { + _ structs.HostLayout + Origin point + Size point + } + type mixed struct { + _ structs.HostLayout + A [3]float32 + B bool + C *int + } + tests := []struct { + typ reflect.Type + want string + }{ + {reflect.TypeFor[bool](), "1"}, + {reflect.TypeFor[int32](), "4"}, + {reflect.TypeFor[int](), "8"}, + {reflect.TypeFor[ID](), "8"}, + {reflect.TypeFor[Block](), "8"}, + {reflect.TypeFor[float32](), "f"}, + {reflect.TypeFor[rect](), "{dddd}"}, + {reflect.TypeFor[mixed](), "{fff18}"}, + } + for _, tt := range tests { + got, err := goABI(tt.typ) + if err != nil { + t.Errorf("goABI(%v): %v", tt.typ, err) + continue + } + if got != tt.want { + t.Errorf("goABI(%v) = %q, want %q", tt.typ, got, tt.want) + } + } + + for _, typ := range []reflect.Type{reflect.TypeFor[func()](), reflect.TypeFor[any](), reflect.TypeFor[[]int](), reflect.TypeFor[map[int]int]()} { + if got, err := goABI(typ); err == nil { + t.Errorf("goABI(%v) = %q, want an error", typ, got) + } + } +} diff --git a/objc/testdata/block.m b/objc/testdata/block.m index 31394ef2..80e7f4bf 100644 --- a/objc/testdata/block.m +++ b/objc/testdata/block.m @@ -43,3 +43,11 @@ void purego_with_stack_block(int64_t base, void (*cb)(void *block)) { }; return Block_copy(b); } + +// purego_blockarg_block returns a block that calls the block it is given with x + 1. +void *purego_blockarg_block(void) { + void (^b)(void (^)(int64_t), int64_t) = ^(void (^h)(int64_t), int64_t x) { + h(x + 1); + }; + return Block_copy(b); +} From 999cfc71f32005555826b0ecd7f16eed0be7c9e3 Mon Sep 17 00:00:00 2001 From: TotallyGamerJet Date: Fri, 2 Oct 2026 09:34:25 -0400 Subject: [PATCH 8/8] objc: treat blank struct fields as padding when matching block signatures A Go struct with its padding written out as blank fields, as RegisterFunc asks for, was refused because a type encoding has no entry for padding. Compare the two sides by offset instead: the sizes must be equal and every named member must be at the same offset with the same kind, while a blank field may cover any integer members or none. Floating point members decide which registers a struct uses, so they must match even when blank. Co-Authored-By: Claude Opus 5.5 --- objc/objc_block_darwin.go | 20 +-- objc/objc_block_darwin_test.go | 23 +++ objc/objc_block_signature_darwin.go | 217 +++++++++++++++++------ objc/objc_block_signature_darwin_test.go | 177 +++++++++++++----- objc/testdata/block.m | 14 ++ 5 files changed, 342 insertions(+), 109 deletions(-) diff --git a/objc/objc_block_darwin.go b/objc/objc_block_darwin.go index 57893c5b..e409f890 100644 --- a/objc/objc_block_darwin.go +++ b/objc/objc_block_darwin.go @@ -234,7 +234,7 @@ func (b Block) callForeign(resultType reflect.Type, args []any) ([]reflect.Value } in := make([]reflect.Type, len(args)+1) - abis := make([]string, len(args)) + layouts := make([]abiLayout, len(args)) reflectedArgs := make([]reflect.Value, len(args)+1) in[0] = reflect.TypeFor[Block]() reflectedArgs[0] = reflect.ValueOf(b) @@ -245,15 +245,15 @@ func (b Block) callForeign(resultType reflect.Type, args []any) ([]reflect.Value reflectedArgs[i+1] = reflect.ValueOf(arg) in[i+1] = reflectedArgs[i+1].Type() var err error - if abis[i], err = goABI(in[i+1]); err != nil { + if layouts[i], err = goLayout(in[i+1]); err != nil { return nil, err } } - resultABI := abiVoid + var resultLayout abiLayout var out []reflect.Type if resultType != nil { var err error - if resultABI, err = goABI(resultType); err != nil { + if resultLayout, err = goLayout(resultType); err != nil { return nil, err } out = []reflect.Type{resultType} @@ -268,24 +268,24 @@ func (b Block) callForeign(resultType reflect.Type, args []any) ([]reflect.Value 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 := encodingABI(types[0]) + want, err := encodingLayout(types[0]) switch { - case resultType == nil && (err != nil || want[0] == '{'): + 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 != resultABI: + case !want.matches(resultLayout): return nil, fmt.Errorf("objc: block returns %s, which does not match %s", types[0], resultType) } - for i, got := range abis { - want, err := encodingABI(types[i+2]) + 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 != got { + 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]) } } diff --git a/objc/objc_block_darwin_test.go b/objc/objc_block_darwin_test.go index 8dd29681..d7039603 100644 --- a/objc/objc_block_darwin_test.go +++ b/objc/objc_block_darwin_test.go @@ -289,6 +289,29 @@ func TestInvokeForeignBlockStruct(t *testing.T) { 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 diff --git a/objc/objc_block_signature_darwin.go b/objc/objc_block_signature_darwin.go index 105295e7..925ad331 100644 --- a/objc/objc_block_signature_darwin.go +++ b/objc/objc_block_signature_darwin.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "reflect" + "slices" stdstrings "strings" "unsafe" @@ -17,9 +18,6 @@ import ( // that may prefix a type encoding. They do not affect the calling convention. const encQualifiers = "rnNoORV" -// abiVoid is the ABI form of no value. See encodingABI. -const abiVoid = "v" - // signature returns the type encoding of a block, or false if the block does not export one. func (b Block) signature() (string, bool) { layout := *(**blockLayout)(unsafe.Pointer(&b)) @@ -115,115 +113,228 @@ func quotedLen(s string) (int, error) { return 0, fmt.Errorf("unterminated name in type encoding %q", s) } -// encodingABI reduces a type encoding to what the calling convention sees, so that it -// can be compared with goABI: each scalar becomes its size in bytes, or "f" or "d" for -// floating point, and a struct becomes its scalars in order between braces, with nested -// structs and arrays flattened. For example, {CGRect={CGPoint=dd}{CGSize=dd}} is "{dddd}". -func encodingABI(enc string) (string, error) { +// abiScalar is one scalar member of a type, as the calling convention sees it. +type abiScalar struct { + offset uintptr + // kind is the size in bytes of an integer or pointer ('1', '2', '4' or '8'), + // or 'f' or 'd' for floating point. + kind byte +} + +// size returns the size of the scalar in bytes. +func (s abiScalar) size() uintptr { + switch s.kind { + case 'f': + return 4 + case 'd': + return 8 + } + return uintptr(s.kind - '0') +} + +// isFloat reports whether the scalar is passed in a floating point register. +func (s abiScalar) isFloat() bool { + return s.kind == 'f' || s.kind == 'd' +} + +// abiLayout is the layout of a type with nested structs and arrays flattened into scalars, +// so that a type encoding can be compared with a Go type. A void result has no size. +type abiLayout struct { + size, align uintptr + scalars []abiScalar + + // blank and blankScalars describe the blank (_) fields of a Go struct. + // They are padding as far as the Go type is concerned, so the type encoding + // may have nothing there, or a member that the Go type does not name. + blank []abiScalar + blankRegions [][2]uintptr // offset and size +} + +// append adds the members of l at offset. +func (a *abiLayout) append(l abiLayout, offset uintptr) { + for _, s := range l.scalars { + a.scalars = append(a.scalars, abiScalar{offset + s.offset, s.kind}) + } + for _, s := range l.blank { + a.blank = append(a.blank, abiScalar{offset + s.offset, s.kind}) + } + for _, r := range l.blankRegions { + a.blankRegions = append(a.blankRegions, [2]uintptr{offset + r[0], r[1]}) + } +} + +func scalarLayout(kind byte) abiLayout { + s := abiScalar{kind: kind} + return abiLayout{size: s.size(), align: s.size(), scalars: []abiScalar{s}} +} + +func alignUp(n, align uintptr) uintptr { + return (n + align - 1) / align * align +} + +// encodingLayout returns the layout of a type encoding, with members at their natural alignment. +func encodingLayout(enc string) (abiLayout, error) { enc = stdstrings.TrimLeft(enc, encQualifiers) if enc == "" { - return "", errors.New("missing type encoding") + return abiLayout{}, errors.New("missing type encoding") } switch enc[0] { case 'v': - return abiVoid, nil + return abiLayout{}, nil case 'c', 'C', 'B': - return "1", nil + return scalarLayout('1'), nil case 's', 'S': - return "2", nil + return scalarLayout('2'), nil case 'i', 'I', 'l', 'L': // long is encoded as a 32-bit quantity - return "4", nil + return scalarLayout('4'), nil case 'q', 'Q', '^', '*', '@', '#', ':': - return "8", nil + return scalarLayout('8'), nil case 'f', 'd': - return enc[:1], nil + return scalarLayout(enc[0]), nil case '[': - count := len(enc) - 1 - len(stdstrings.TrimLeft(enc[1:], "0123456789")) - n := 0 - for _, c := range enc[1 : 1+count] { - n = n*10 + int(c-'0') + digits := len(enc) - 1 - len(stdstrings.TrimLeft(enc[1:], "0123456789")) + var count uintptr + for _, c := range enc[1 : 1+digits] { + count = count*10 + uintptr(c-'0') } - elem, err := encodingABI(enc[1+count : len(enc)-1]) + elem, err := encodingLayout(enc[1+digits : len(enc)-1]) if err != nil { - return "", err + return abiLayout{}, err + } + layout := abiLayout{size: count * elem.size, align: elem.align} + for i := range count { + layout.append(elem, i*elem.size) } - return stdstrings.Repeat(stdstrings.Trim(elem, "{}"), n), nil + return layout, nil case '{': _, fields, ok := stdstrings.Cut(enc[1:len(enc)-1], "=") if !ok { - return "", fmt.Errorf("struct %s has no fields", enc) + return abiLayout{}, fmt.Errorf("struct %s has no fields", enc) } - var abi stdstrings.Builder - abi.WriteByte('{') + layout := abiLayout{align: 1} for fields != "" { if fields[0] == '"' { // a field name n, err := quotedLen(fields) if err != nil { - return "", err + return abiLayout{}, err } fields = fields[n:] continue } n, err := encodingLen(fields) if err != nil { - return "", err + return abiLayout{}, err } - field, err := encodingABI(fields[:n]) + field, err := encodingLayout(fields[:n]) if err != nil { - return "", err + return abiLayout{}, err } - abi.WriteString(stdstrings.Trim(field, "{}")) fields = fields[n:] + if field.size == 0 { + continue + } + layout.size = alignUp(layout.size, field.align) + layout.append(field, layout.size) + layout.size += field.size + layout.align = max(layout.align, field.align) } - abi.WriteByte('}') - return abi.String(), nil + layout.size = alignUp(layout.size, layout.align) + return layout, nil } - return "", fmt.Errorf("unsupported type encoding %s", enc) + return abiLayout{}, fmt.Errorf("unsupported type encoding %s", enc) } -// goABI is encodingABI for a Go type. -func goABI(typ reflect.Type) (string, error) { +// goLayout is encodingLayout for a Go type. +func goLayout(typ reflect.Type) (abiLayout, error) { switch typ.Kind() { case reflect.Bool, reflect.Int8, reflect.Uint8: - return "1", nil + return scalarLayout('1'), nil case reflect.Int16, reflect.Uint16: - return "2", nil + return scalarLayout('2'), nil case reflect.Int32, reflect.Uint32: - return "4", nil + return scalarLayout('4'), nil case reflect.Int, reflect.Int64, reflect.Uint, reflect.Uint64, reflect.Uintptr, reflect.Pointer, reflect.UnsafePointer, reflect.String: - return "8", nil + return scalarLayout('8'), nil case reflect.Float32: - return "f", nil + return scalarLayout('f'), nil case reflect.Float64: - return "d", nil + return scalarLayout('d'), nil case reflect.Array: - elem, err := goABI(typ.Elem()) + elem, err := goLayout(typ.Elem()) if err != nil { - return "", err + return abiLayout{}, err + } + layout := abiLayout{size: typ.Size(), align: uintptr(typ.Align())} + for i := range uintptr(typ.Len()) { + layout.append(elem, i*elem.size) } - return stdstrings.Repeat(stdstrings.Trim(elem, "{}"), typ.Len()), nil + return layout, nil case reflect.Struct: - var abi stdstrings.Builder - abi.WriteByte('{') + layout := abiLayout{size: typ.Size(), align: uintptr(typ.Align())} for i := range typ.NumField() { f := typ.Field(i) if f.Type.Size() == 0 { // structs.HostLayout and other zero-sized fields have no counterpart in C. continue } - field, err := goABI(f.Type) + field, err := goLayout(f.Type) if err != nil { - return "", err + return abiLayout{}, err } - abi.WriteString(stdstrings.Trim(field, "{}")) + if f.Name == "_" { + // everything in a blank field is blank. + field.blank = append(field.blank, field.scalars...) + field.scalars = nil + field.blankRegions = [][2]uintptr{{0, field.size}} + } + layout.append(field, f.Offset) } - abi.WriteByte('}') - return abi.String(), nil + return layout, nil case reflect.Func: // RegisterFunc would create a callback for every call, and callbacks are never freed. - return "", fmt.Errorf("objc: a %s argument to a block is not supported; create the callback once with purego.NewCallback and pass the uintptr", typ) + return abiLayout{}, fmt.Errorf("objc: a %s argument to a block is not supported; create the callback once with purego.NewCallback and pass the uintptr", typ) + } + return abiLayout{}, fmt.Errorf("objc: unsupported block argument or result type %s", typ) +} + +// matches reports whether a value of the Go type laid out as g can be passed as the C type laid out as c. +// +// Every member must be at the same offset with the same kind on both sides, except for +// the blank (_) fields of the Go type. Callers use those for padding, which has no +// counterpart in a type encoding, and for members they have no use for. A blank field +// may therefore cover any integer or pointer members, or none. Floating point members +// decide which registers a struct is passed in, so they must match even when blank. +func (c abiLayout) matches(g abiLayout) bool { + if c.size != g.size { + return false + } + for _, s := range c.scalars { + if slices.Contains(g.scalars, s) { + continue + } + if s.isFloat() { + if !slices.Contains(g.blank, s) { + return false + } + continue + } + if !slices.ContainsFunc(g.blankRegions, func(r [2]uintptr) bool { + return s.offset >= r[0] && s.offset+s.size() <= r[0]+r[1] + }) { + return false + } + } + for _, s := range g.scalars { + if !slices.Contains(c.scalars, s) { + return false + } + } + for _, s := range g.blank { + if s.isFloat() && !slices.Contains(c.scalars, s) { + return false + } } - return "", fmt.Errorf("objc: unsupported block argument or result type %s", typ) + return true } diff --git a/objc/objc_block_signature_darwin_test.go b/objc/objc_block_signature_darwin_test.go index d2c69865..393d7ba5 100644 --- a/objc/objc_block_signature_darwin_test.go +++ b/objc/objc_block_signature_darwin_test.go @@ -45,49 +45,56 @@ func TestSplitSignature(t *testing.T) { } } -func TestEncodingABI(t *testing.T) { +func TestEncodingLayout(t *testing.T) { tests := []struct { - enc string - want string + enc string + size, align uintptr + scalars []abiScalar }{ - {"v", "v"}, - {"B", "1"}, - {"c", "1"}, - {"S", "2"}, - {"i", "4"}, - {"L", "4"}, - {"q", "8"}, - {"^v", "8"}, - {"@?", "8"}, - {`@"NSString"`, "8"}, - {"r*", "8"}, - {"f", "f"}, - {"d", "d"}, - {"{Big=qqqq}", "{8888}"}, - {"{CGRect={CGPoint=dd}{CGSize=dd}}", "{dddd}"}, - {`{P="x"d"y"i}`, "{d4}"}, - {"{S=[3f]c}", "{fff1}"}, - {"{S=[2{T=id}]}", "{4d4d}"}, + {"v", 0, 0, nil}, + {"B", 1, 1, []abiScalar{{0, '1'}}}, + {"S", 2, 2, []abiScalar{{0, '2'}}}, + {"L", 4, 4, []abiScalar{{0, '4'}}}, + {"^v", 8, 8, []abiScalar{{0, '8'}}}, + {"@?", 8, 8, []abiScalar{{0, '8'}}}, + {`@"NSString"`, 8, 8, []abiScalar{{0, '8'}}}, + {"r*", 8, 8, []abiScalar{{0, '8'}}}, + {"f", 4, 4, []abiScalar{{0, 'f'}}}, + {"{Big=qqqq}", 32, 8, []abiScalar{{0, '8'}, {8, '8'}, {16, '8'}, {24, '8'}}}, + {"{CGRect={CGPoint=dd}{CGSize=dd}}", 32, 8, []abiScalar{{0, 'd'}, {8, 'd'}, {16, 'd'}, {24, 'd'}}}, + {"{BoolFloat=Bf}", 8, 4, []abiScalar{{0, '1'}, {4, 'f'}}}, + {`{P="x"d"y"i}`, 16, 8, []abiScalar{{0, 'd'}, {8, '4'}}}, + {"{S=c[3s]q}", 16, 8, []abiScalar{{0, '1'}, {2, '2'}, {4, '2'}, {6, '2'}, {8, '8'}}}, + {"{S=[2{T=cd}]c}", 40, 8, []abiScalar{{0, '1'}, {8, 'd'}, {16, '1'}, {24, 'd'}, {32, '1'}}}, } for _, tt := range tests { - got, err := encodingABI(tt.enc) + got, err := encodingLayout(tt.enc) if err != nil { - t.Errorf("encodingABI(%q): %v", tt.enc, err) + t.Errorf("encodingLayout(%q): %v", tt.enc, err) continue } - if got != tt.want { - t.Errorf("encodingABI(%q) = %q, want %q", tt.enc, got, tt.want) + if got.size != tt.size || got.align != tt.align || !slices.Equal(got.scalars, tt.scalars) { + t.Errorf("encodingLayout(%q) = size %d, align %d, %v; want size %d, align %d, %v", + tt.enc, got.size, got.align, got.scalars, tt.size, tt.align, tt.scalars) } } for _, enc := range []string{"(U=id)", "{S=b3}", "D", "{Opaque}"} { - if got, err := encodingABI(enc); err == nil { - t.Errorf("encodingABI(%q) = %q, want an error", enc, got) + if got, err := encodingLayout(enc); err == nil { + t.Errorf("encodingLayout(%q) = %v, want an error", enc, got) + } + } +} + +func TestGoLayoutUnsupported(t *testing.T) { + for _, typ := range []reflect.Type{reflect.TypeFor[func()](), reflect.TypeFor[any](), reflect.TypeFor[[]int](), reflect.TypeFor[map[int]int]()} { + if got, err := goLayout(typ); err == nil { + t.Errorf("goLayout(%v) = %v, want an error", typ, got) } } } -func TestGoABI(t *testing.T) { +func TestLayoutMatches(t *testing.T) { type point struct { _ structs.HostLayout X, Y float64 @@ -103,33 +110,111 @@ func TestGoABI(t *testing.T) { B bool C *int } + // padding written out, as the RegisterFunc documentation asks for. + type boolFloat struct { + _ structs.HostLayout + B bool + _ [3]byte + F float32 + } + type boolFloatImplicit struct { + _ structs.HostLayout + B bool + F float32 + } + // a blank field standing in for a member the caller does not need. + type skipInt struct { + _ structs.HostLayout + A int32 + _ int32 + C int64 + } + type skipInts struct { + _ structs.HostLayout + _ [2]int32 + C int64 + } + type skipFloat struct { + _ structs.HostLayout + A float32 + _ float32 + } + type intOverFloat struct { + _ structs.HostLayout + A float32 + _ int32 + } + type floatOverInt struct { + _ structs.HostLayout + A int32 + _ float32 + } + type tailPadding struct { + _ structs.HostLayout + A int64 + B int8 + _ [7]byte + } + type namedPadding struct { + _ structs.HostLayout + B bool + Pad [3]byte + F float32 + } + tests := []struct { + enc string typ reflect.Type - want string + want bool }{ - {reflect.TypeFor[bool](), "1"}, - {reflect.TypeFor[int32](), "4"}, - {reflect.TypeFor[int](), "8"}, - {reflect.TypeFor[ID](), "8"}, - {reflect.TypeFor[Block](), "8"}, - {reflect.TypeFor[float32](), "f"}, - {reflect.TypeFor[rect](), "{dddd}"}, - {reflect.TypeFor[mixed](), "{fff18}"}, + {"B", reflect.TypeFor[bool](), true}, + {"c", reflect.TypeFor[bool](), true}, + {"i", reflect.TypeFor[int32](), true}, + {"i", reflect.TypeFor[int](), false}, + {"q", reflect.TypeFor[int](), true}, + {"q", reflect.TypeFor[int32](), false}, + {"q", reflect.TypeFor[float64](), false}, + {"d", reflect.TypeFor[float64](), true}, + {"d", reflect.TypeFor[float32](), false}, + {"@", reflect.TypeFor[ID](), true}, + {"@?", reflect.TypeFor[Block](), true}, + {"v", reflect.TypeFor[int](), false}, + {"{CGRect={CGPoint=dd}{CGSize=dd}}", reflect.TypeFor[rect](), true}, + {"{CGPoint=dd}", reflect.TypeFor[rect](), false}, + {"{CGRect={CGPoint=dd}{CGSize=dd}}", reflect.TypeFor[point](), false}, + {"{S=[3f]B^i}", reflect.TypeFor[mixed](), true}, + {"{S=[3f]i^i}", reflect.TypeFor[mixed](), false}, + {"{BoolFloat=Bf}", reflect.TypeFor[boolFloat](), true}, + {"{BoolFloat=Bf}", reflect.TypeFor[boolFloatImplicit](), true}, + {"{S=Bi}", reflect.TypeFor[boolFloat](), false}, + {"{S=iiq}", reflect.TypeFor[skipInt](), true}, + {"{S=issq}", reflect.TypeFor[skipInt](), true}, + {"{S=ifq}", reflect.TypeFor[skipInt](), false}, + {"{S=qq}", reflect.TypeFor[skipInt](), false}, + {"{S=iiq}", reflect.TypeFor[skipInts](), true}, + {"{S=qq}", reflect.TypeFor[skipInts](), true}, + {"{S=ff}", reflect.TypeFor[skipFloat](), true}, + {"{S=fi}", reflect.TypeFor[skipFloat](), false}, + {"{S=f}", reflect.TypeFor[skipFloat](), false}, + {"{S=ff}", reflect.TypeFor[intOverFloat](), false}, + {"{S=fi}", reflect.TypeFor[intOverFloat](), true}, + {"{S=ii}", reflect.TypeFor[floatOverInt](), false}, + {"{S=qc}", reflect.TypeFor[tailPadding](), true}, + {"{BoolFloat=Bf}", reflect.TypeFor[namedPadding](), false}, } for _, tt := range tests { - got, err := goABI(tt.typ) + c, err := encodingLayout(tt.enc) if err != nil { - t.Errorf("goABI(%v): %v", tt.typ, err) + t.Errorf("encodingLayout(%q): %v", tt.enc, err) continue } - if got != tt.want { - t.Errorf("goABI(%v) = %q, want %q", tt.typ, got, tt.want) + g, err := goLayout(tt.typ) + if err != nil { + t.Errorf("goLayout(%v): %v", tt.typ, err) + continue } - } - - for _, typ := range []reflect.Type{reflect.TypeFor[func()](), reflect.TypeFor[any](), reflect.TypeFor[[]int](), reflect.TypeFor[map[int]int]()} { - if got, err := goABI(typ); err == nil { - t.Errorf("goABI(%v) = %q, want an error", typ, got) + if got := c.matches(g); got != tt.want { + t.Errorf("%q matches %v = %v, want %v", tt.enc, tt.typ, got, tt.want) } } } diff --git a/objc/testdata/block.m b/objc/testdata/block.m index 80e7f4bf..faf8813b 100644 --- a/objc/testdata/block.m +++ b/objc/testdata/block.m @@ -51,3 +51,17 @@ void purego_with_stack_block(int64_t base, void (*cb)(void *block)) { }; return Block_copy(b); } + +typedef struct { + bool b; + float f; +} BoolFloat; + +// purego_boolfloat_block returns a block that returns a struct with padding between its members. +void *purego_boolfloat_block(void) { + BoolFloat (^b)(float) = ^BoolFloat(float f) { + BoolFloat r = {true, f * 2}; + return r; + }; + return Block_copy(b); +}