From c33ccc5e71da281a3d60d58fb326083fd639a37e Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 19 Jul 2026 14:44:54 +0300 Subject: [PATCH 1/3] fix(wgpu): normalize callback string view ABI --- wgpu/adapter.go | 25 ++------ wgpu/buffer.go | 21 ++----- wgpu/callback_flat.go | 24 ++++++++ wgpu/callback_flat_test.go | 109 +++++++++++++++++++++++++++++++++ wgpu/callback_windows_amd64.go | 30 +++++++++ wgpu/device.go | 21 ++----- wgpu/errors.go | 23 ++----- 7 files changed, 187 insertions(+), 66 deletions(-) create mode 100644 wgpu/callback_flat.go create mode 100644 wgpu/callback_flat_test.go create mode 100644 wgpu/callback_windows_amd64.go diff --git a/wgpu/adapter.go b/wgpu/adapter.go index cba6165..12ac290 100644 --- a/wgpu/adapter.go +++ b/wgpu/adapter.go @@ -79,21 +79,9 @@ var ( adapterCallbackOnce sync.Once ) -// adapterCallbackHandler is the Go function called by C code via ffi.NewCallback. -// Windows x64 ABI: args in RCX, RDX, R8, R9, then stack. -// Signature: void(status uint32, adapter uintptr, message *StringView, userdata1 uintptr, userdata2 uintptr) -// Note: On Windows x64 ABI, structs > 8 bytes are passed by pointer. -// goffi v0.2.1+ requires all args to be uintptr and exactly one uintptr return. -func adapterCallbackHandler(status uintptr, adapter uintptr, message uintptr, userdata1, userdata2 uintptr) uintptr { - // Extract message string (message is pointer to StringView on Windows) - var msg string - if message != 0 { - sv := (*StringView)(ptrFromUintptr(message)) - if sv.Data != 0 && sv.Length > 0 && sv.Length < 1<<20 { - msg = unsafe.String((*byte)(ptrFromUintptr(sv.Data)), int(sv.Length)) - } - } - +// handleAdapterCallback completes a request after the platform callback entry +// normalizes the ABI-specific WGPUStringView representation. +func handleAdapterCallback(status uintptr, adapter uintptr, message StringView, userdata1 uintptr) uintptr { // Find and complete the request adapterRequestsMu.Lock() req, ok := adapterRequests[userdata1] @@ -108,16 +96,15 @@ func adapterCallbackHandler(status uintptr, adapter uintptr, message uintptr, us trackResource(adapter, "Adapter") req.adapter = &Adapter{handle: adapter} } - req.message = msg + req.message = stringViewToString(message) close(req.done) } return 0 } -// initAdapterCallback creates the C callback function pointer using goffi. -// goffi v0.2.1+ properly handles Windows x64 calling convention. +// initAdapterCallback creates the platform-correct C callback function pointer. func initAdapterCallback() { - adapterCallbackPtr = ffi.NewCallback(adapterCallbackHandler) + adapterCallbackPtr = ffi.NewCallback(adapterCallbackEntry) } // RequestAdapter requests a GPU adapter from the instance. diff --git a/wgpu/buffer.go b/wgpu/buffer.go index e862e56..7d16018 100644 --- a/wgpu/buffer.go +++ b/wgpu/buffer.go @@ -67,18 +67,9 @@ var ( mapCallbackOnce sync.Once ) -// mapCallbackHandler is the Go function called by C code via ffi.NewCallback. -// Signature: void(status uint32, message *StringView, userdata1 uintptr, userdata2 uintptr) -func mapCallbackHandler(status uintptr, message uintptr, userdata1, userdata2 uintptr) uintptr { - // Extract message string - var msg string - if message != 0 { - sv := (*StringView)(ptrFromUintptr(message)) - if sv.Data != 0 && sv.Length > 0 && sv.Length < 1<<20 { - msg = unsafe.String((*byte)(ptrFromUintptr(sv.Data)), int(sv.Length)) - } - } - +// handleMapCallback completes a request after the platform callback entry +// normalizes the ABI-specific WGPUStringView representation. +func handleMapCallback(status uintptr, message StringView, userdata1 uintptr) uintptr { // Find and complete the request mapRequestsMu.Lock() req, ok := mapRequests[userdata1] @@ -89,15 +80,15 @@ func mapCallbackHandler(status uintptr, message uintptr, userdata1, userdata2 ui if ok && req != nil { req.status = MapAsyncStatus(status) - req.message = msg + req.message = stringViewToString(message) close(req.done) } return 0 } -// initMapCallback creates the C callback function pointer using goffi. +// initMapCallback creates the platform-correct C callback function pointer. func initMapCallback() { - mapCallbackPtr = ffi.NewCallback(mapCallbackHandler) + mapCallbackPtr = ffi.NewCallback(mapCallbackEntry) } // BufferDescriptor describes a GPU buffer to create. diff --git a/wgpu/callback_flat.go b/wgpu/callback_flat.go new file mode 100644 index 0000000..322a9d7 --- /dev/null +++ b/wgpu/callback_flat.go @@ -0,0 +1,24 @@ +//go:build ((linux || darwin || freebsd) && (amd64 || arm64)) || (windows && arm64) + +package wgpu + +// Unix amd64/arm64 and Windows ARM64 ABIs pass the two-word WGPUStringView +// callback argument by value in integer registers. goffi callbacks expose +// those words as separate uintptr arguments, so each entry reconstructs the +// view before invoking shared logic. + +func adapterCallbackEntry(status, adapter, messageData, messageLength, userdata1, _ uintptr) uintptr { + return handleAdapterCallback(status, adapter, StringView{Data: messageData, Length: messageLength}, userdata1) +} + +func deviceCallbackEntry(status, device, messageData, messageLength, userdata1, _ uintptr) uintptr { + return handleDeviceCallback(status, device, StringView{Data: messageData, Length: messageLength}, userdata1) +} + +func mapCallbackEntry(status, messageData, messageLength, userdata1, _ uintptr) uintptr { + return handleMapCallback(status, StringView{Data: messageData, Length: messageLength}, userdata1) +} + +func errorScopeCallbackEntry(status, errType, messageData, messageLength, userdata1, _ uintptr) uintptr { + return handleErrorScopeCallback(status, errType, StringView{Data: messageData, Length: messageLength}, userdata1) +} diff --git a/wgpu/callback_flat_test.go b/wgpu/callback_flat_test.go new file mode 100644 index 0000000..1a7791e --- /dev/null +++ b/wgpu/callback_flat_test.go @@ -0,0 +1,109 @@ +//go:build ((linux || darwin || freebsd) && (amd64 || arm64)) || (windows && arm64) + +package wgpu + +import ( + "testing" + "unsafe" +) + +func TestABICallbackEntriesPreserveStringViewAndUserdata(t *testing.T) { + message := []byte("callback message") + messageData := uintptr(unsafe.Pointer(&message[0])) + messageLength := uintptr(len(message)) + + t.Run("adapter", func(t *testing.T) { + const requestID = uintptr(101) + req := &adapterRequest{done: make(chan struct{})} + adapterRequestsMu.Lock() + adapterRequests[requestID] = req + adapterRequestsMu.Unlock() + t.Cleanup(func() { + adapterRequestsMu.Lock() + delete(adapterRequests, requestID) + adapterRequestsMu.Unlock() + }) + + adapterCallbackEntry(7, 0, messageData, messageLength, requestID, 0) + + assertCallbackCompleted(t, req.done, req.message) + if req.status != RequestAdapterStatus(7) { + t.Fatalf("status = %d, want 7", req.status) + } + }) + + t.Run("device", func(t *testing.T) { + const requestID = uintptr(102) + req := &deviceRequest{done: make(chan struct{})} + deviceRequestsMu.Lock() + deviceRequests[requestID] = req + deviceRequestsMu.Unlock() + t.Cleanup(func() { + deviceRequestsMu.Lock() + delete(deviceRequests, requestID) + deviceRequestsMu.Unlock() + }) + + deviceCallbackEntry(8, 0, messageData, messageLength, requestID, 0) + + assertCallbackCompleted(t, req.done, req.message) + if req.status != RequestDeviceStatus(8) { + t.Fatalf("status = %d, want 8", req.status) + } + }) + + t.Run("buffer map", func(t *testing.T) { + const requestID = uintptr(103) + req := &mapRequest{done: make(chan struct{})} + mapRequestsMu.Lock() + mapRequests[requestID] = req + mapRequestsMu.Unlock() + t.Cleanup(func() { + mapRequestsMu.Lock() + delete(mapRequests, requestID) + mapRequestsMu.Unlock() + }) + + mapCallbackEntry(9, messageData, messageLength, requestID, 0) + + assertCallbackCompleted(t, req.done, req.message) + if req.status != MapAsyncStatus(9) { + t.Fatalf("status = %d, want 9", req.status) + } + }) + + t.Run("error scope", func(t *testing.T) { + const requestID = uintptr(104) + result := &errorScopeResult{done: make(chan struct{})} + errorScopeResultsMu.Lock() + errorScopeResults[requestID] = result + errorScopeResultsMu.Unlock() + t.Cleanup(func() { + errorScopeResultsMu.Lock() + delete(errorScopeResults, requestID) + errorScopeResultsMu.Unlock() + }) + + errorScopeCallbackEntry(10, 11, messageData, messageLength, requestID, 0) + + assertCallbackCompleted(t, result.done, result.message) + if result.status != PopErrorScopeStatus(10) { + t.Fatalf("status = %d, want 10", result.status) + } + if result.errType != ErrorType(11) { + t.Fatalf("error type = %d, want 11", result.errType) + } + }) +} + +func assertCallbackCompleted(t *testing.T, done <-chan struct{}, message string) { + t.Helper() + select { + case <-done: + default: + t.Fatal("callback did not complete the registered request") + } + if message != "callback message" { + t.Fatalf("message = %q, want %q", message, "callback message") + } +} diff --git a/wgpu/callback_windows_amd64.go b/wgpu/callback_windows_amd64.go new file mode 100644 index 0000000..4a2f243 --- /dev/null +++ b/wgpu/callback_windows_amd64.go @@ -0,0 +1,30 @@ +//go:build windows && amd64 + +package wgpu + +// Windows x64 passes a WGPUStringView callback argument indirectly because +// the aggregate is larger than one register. Normalize that pointer into the +// same value form used by the shared callback logic. + +func adapterCallbackEntry(status, adapter, message, userdata1, _ uintptr) uintptr { + return handleAdapterCallback(status, adapter, callbackStringView(message), userdata1) +} + +func deviceCallbackEntry(status, device, message, userdata1, _ uintptr) uintptr { + return handleDeviceCallback(status, device, callbackStringView(message), userdata1) +} + +func mapCallbackEntry(status, message, userdata1, _ uintptr) uintptr { + return handleMapCallback(status, callbackStringView(message), userdata1) +} + +func errorScopeCallbackEntry(status, errType, message, userdata1, _ uintptr) uintptr { + return handleErrorScopeCallback(status, errType, callbackStringView(message), userdata1) +} + +func callbackStringView(message uintptr) StringView { + if message == 0 { + return StringView{} + } + return *(*StringView)(ptrFromUintptr(message)) +} diff --git a/wgpu/device.go b/wgpu/device.go index ff4da1d..87b3c4c 100644 --- a/wgpu/device.go +++ b/wgpu/device.go @@ -38,18 +38,9 @@ var ( deviceCallbackOnce sync.Once ) -// deviceCallbackHandler is the Go function called by C code via ffi.NewCallback. -// Signature: void(status uint32, device uintptr, message *StringView, userdata1 uintptr, userdata2 uintptr) -func deviceCallbackHandler(status uintptr, device uintptr, message uintptr, userdata1, userdata2 uintptr) uintptr { - // Extract message string (message is pointer to StringView on Windows) - var msg string - if message != 0 { - sv := (*StringView)(ptrFromUintptr(message)) - if sv.Data != 0 && sv.Length > 0 && sv.Length < 1<<20 { - msg = unsafe.String((*byte)(ptrFromUintptr(sv.Data)), int(sv.Length)) - } - } - +// handleDeviceCallback completes a request after the platform callback entry +// normalizes the ABI-specific WGPUStringView representation. +func handleDeviceCallback(status uintptr, device uintptr, message StringView, userdata1 uintptr) uintptr { // Find and complete the request deviceRequestsMu.Lock() req, ok := deviceRequests[userdata1] @@ -64,15 +55,15 @@ func deviceCallbackHandler(status uintptr, device uintptr, message uintptr, user trackResource(device, "Device") req.device = &Device{handle: device} } - req.message = msg + req.message = stringViewToString(message) close(req.done) } return 0 } -// initDeviceCallback creates the C callback function pointer using goffi. +// initDeviceCallback creates the platform-correct C callback function pointer. func initDeviceCallback() { - deviceCallbackPtr = ffi.NewCallback(deviceCallbackHandler) + deviceCallbackPtr = ffi.NewCallback(deviceCallbackEntry) } // RequestDevice requests a GPU device from the adapter. diff --git a/wgpu/errors.go b/wgpu/errors.go index f1fb23a..2d4cc83 100644 --- a/wgpu/errors.go +++ b/wgpu/errors.go @@ -63,20 +63,9 @@ var ( errorScopeCallbackOnce sync.Once ) -// errorScopeCallbackHandler is the Go function called by C code via ffi.NewCallback. -// Signature matches: void callback(WGPUPopErrorScopeStatus status, WGPUErrorType type, -// -// WGPUStringView message, void* userdata1, void* userdata2) -func errorScopeCallbackHandler(status uintptr, errType uintptr, message uintptr, userdata1, _ uintptr) uintptr { - // Extract message string (message is pointer to StringView) - var msg string - if message != 0 { - sv := (*StringView)(ptrFromUintptr(message)) - if sv.Data != 0 && sv.Length > 0 && sv.Length < 1<<20 { - msg = unsafe.String((*byte)(ptrFromUintptr(sv.Data)), int(sv.Length)) - } - } - +// handleErrorScopeCallback completes a request after the platform callback +// entry normalizes the ABI-specific WGPUStringView representation. +func handleErrorScopeCallback(status uintptr, errType uintptr, message StringView, userdata1 uintptr) uintptr { // Find and complete the operation errorScopeResultsMu.Lock() result, ok := errorScopeResults[userdata1] @@ -88,16 +77,16 @@ func errorScopeCallbackHandler(status uintptr, errType uintptr, message uintptr, if ok && result != nil { result.status = PopErrorScopeStatus(status) result.errType = ErrorType(errType) - result.message = msg + result.message = stringViewToString(message) close(result.done) } return 0 // void return } -// initErrorScopeCallback creates the C callback function pointer using goffi. +// initErrorScopeCallback creates the platform-correct C callback function pointer. func initErrorScopeCallback() { - errorScopeCallbackPtr = ffi.NewCallback(errorScopeCallbackHandler) + errorScopeCallbackPtr = ffi.NewCallback(errorScopeCallbackEntry) } // Deprecated: PopErrorScope panics on failure. Use PopErrorScopeAsync instead. From b77b0025f985be8c4e2c2d299bea884c793cda6b Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 22 Jul 2026 19:16:59 +0300 Subject: [PATCH 2/3] test(wgpu): cover callback ABI edge cases --- wgpu/adapter.go | 1 + wgpu/callback_flat.go | 4 +++ wgpu/callback_flat_test.go | 33 ++++++++++++++++-------- wgpu/callback_test_helpers_test.go | 33 ++++++++++++++++++++++++ wgpu/callback_windows_amd64_test.go | 40 +++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 wgpu/callback_test_helpers_test.go create mode 100644 wgpu/callback_windows_amd64_test.go diff --git a/wgpu/adapter.go b/wgpu/adapter.go index 12ac290..b2f69d5 100644 --- a/wgpu/adapter.go +++ b/wgpu/adapter.go @@ -81,6 +81,7 @@ var ( // handleAdapterCallback completes a request after the platform callback entry // normalizes the ABI-specific WGPUStringView representation. +// userdata2 is reserved by WebGPU and discarded by the platform entry. func handleAdapterCallback(status uintptr, adapter uintptr, message StringView, userdata1 uintptr) uintptr { // Find and complete the request adapterRequestsMu.Lock() diff --git a/wgpu/callback_flat.go b/wgpu/callback_flat.go index 322a9d7..eccbfd5 100644 --- a/wgpu/callback_flat.go +++ b/wgpu/callback_flat.go @@ -2,6 +2,10 @@ package wgpu +// Callback entry implementations support amd64 and arm64 on Linux, macOS, +// FreeBSD, and Windows. Windows amd64 uses callback_windows_amd64.go; +// wgpu-native does not support other architectures. +// // Unix amd64/arm64 and Windows ARM64 ABIs pass the two-word WGPUStringView // callback argument by value in integer registers. goffi callbacks expose // those words as separate uintptr arguments, so each entry reconstructs the diff --git a/wgpu/callback_flat_test.go b/wgpu/callback_flat_test.go index 1a7791e..402e636 100644 --- a/wgpu/callback_flat_test.go +++ b/wgpu/callback_flat_test.go @@ -96,14 +96,27 @@ func TestABICallbackEntriesPreserveStringViewAndUserdata(t *testing.T) { }) } -func assertCallbackCompleted(t *testing.T, done <-chan struct{}, message string) { - t.Helper() - select { - case <-done: - default: - t.Fatal("callback did not complete the registered request") - } - if message != "callback message" { - t.Fatalf("message = %q, want %q", message, "callback message") - } +func TestABICallbackEntriesHandleMessageEdges(t *testing.T) { + t.Run("null and empty", func(t *testing.T) { + const requestID = uintptr(105) + req := registerTestAdapterRequest(t, requestID) + + adapterCallbackEntry(0, 0, 0, 0, requestID, 0) + + assertCallbackMessage(t, req.done, req.message, "") + }) + + t.Run("zero length with non-null data", func(t *testing.T) { + const requestID = uintptr(106) + req := registerTestAdapterRequest(t, requestID) + message := []byte("ignored") + + adapterCallbackEntry(0, 0, uintptr(unsafe.Pointer(&message[0])), 0, requestID, 0) + + assertCallbackMessage(t, req.done, req.message, "") + }) + + t.Run("unknown userdata", func(t *testing.T) { + adapterCallbackEntry(0, 0, 0, 0, ^uintptr(0), 0) + }) } diff --git a/wgpu/callback_test_helpers_test.go b/wgpu/callback_test_helpers_test.go new file mode 100644 index 0000000..b74dcd8 --- /dev/null +++ b/wgpu/callback_test_helpers_test.go @@ -0,0 +1,33 @@ +package wgpu + +import "testing" + +func registerTestAdapterRequest(t *testing.T, requestID uintptr) *adapterRequest { + t.Helper() + req := &adapterRequest{done: make(chan struct{})} + adapterRequestsMu.Lock() + adapterRequests[requestID] = req + adapterRequestsMu.Unlock() + t.Cleanup(func() { + adapterRequestsMu.Lock() + delete(adapterRequests, requestID) + adapterRequestsMu.Unlock() + }) + return req +} + +func assertCallbackCompleted(t *testing.T, done <-chan struct{}, message string) { + assertCallbackMessage(t, done, message, "callback message") +} + +func assertCallbackMessage(t *testing.T, done <-chan struct{}, message, want string) { + t.Helper() + select { + case <-done: + default: + t.Fatal("callback did not complete the registered request") + } + if message != want { + t.Fatalf("message = %q, want %q", message, want) + } +} diff --git a/wgpu/callback_windows_amd64_test.go b/wgpu/callback_windows_amd64_test.go new file mode 100644 index 0000000..0bb7ff8 --- /dev/null +++ b/wgpu/callback_windows_amd64_test.go @@ -0,0 +1,40 @@ +//go:build windows && amd64 + +package wgpu + +import ( + "testing" + "unsafe" +) + +func TestCallbackStringViewWindowsAMD64(t *testing.T) { + if got := callbackStringView(0); got != (StringView{}) { + t.Fatalf("callbackStringView(0) = %#v, want empty", got) + } + + message := []byte("callback message") + want := StringView{ + Data: uintptr(unsafe.Pointer(&message[0])), + Length: uintptr(len(message)), + } + if got := callbackStringView(uintptr(unsafe.Pointer(&want))); got != want { + t.Fatalf("callbackStringView(valid) = %#v, want %#v", got, want) + } +} + +func TestAdapterCallbackEntryWindowsAMD64(t *testing.T) { + const requestID = uintptr(201) + req := registerTestAdapterRequest(t, requestID) + message := []byte("callback message") + view := StringView{ + Data: uintptr(unsafe.Pointer(&message[0])), + Length: uintptr(len(message)), + } + + adapterCallbackEntry(7, 0, uintptr(unsafe.Pointer(&view)), requestID, 0) + + assertCallbackCompleted(t, req.done, req.message) + if req.status != RequestAdapterStatus(7) { + t.Fatalf("status = %d, want 7", req.status) + } +} From 9006aca548e1fc285ddfc589bc65447f0dd904fe Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 23 Jul 2026 21:25:15 +0300 Subject: [PATCH 3/3] test(wgpu): run Windows callback ABI cases in CI --- wgpu/callback_test_helpers_test.go | 27 +++++++++++++++++++++++++++ wgpu/callback_windows_amd64_test.go | 4 ++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/wgpu/callback_test_helpers_test.go b/wgpu/callback_test_helpers_test.go index b74dcd8..8ffdce5 100644 --- a/wgpu/callback_test_helpers_test.go +++ b/wgpu/callback_test_helpers_test.go @@ -2,6 +2,33 @@ package wgpu import "testing" +func TestABICallbackInitializers(t *testing.T) { + tests := []struct { + name string + init func() + target *uintptr + }{ + {name: "adapter", init: initAdapterCallback, target: &adapterCallbackPtr}, + {name: "device", init: initDeviceCallback, target: &deviceCallbackPtr}, + {name: "buffer map", init: initMapCallback, target: &mapCallbackPtr}, + {name: "error scope", init: initErrorScopeCallback, target: &errorScopeCallbackPtr}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + original := *test.target + t.Cleanup(func() { + *test.target = original + }) + + test.init() + if *test.target == 0 { + t.Fatal("callback pointer is zero") + } + }) + } +} + func registerTestAdapterRequest(t *testing.T, requestID uintptr) *adapterRequest { t.Helper() req := &adapterRequest{done: make(chan struct{})} diff --git a/wgpu/callback_windows_amd64_test.go b/wgpu/callback_windows_amd64_test.go index 0bb7ff8..8fc3de7 100644 --- a/wgpu/callback_windows_amd64_test.go +++ b/wgpu/callback_windows_amd64_test.go @@ -7,7 +7,7 @@ import ( "unsafe" ) -func TestCallbackStringViewWindowsAMD64(t *testing.T) { +func TestABICallbackStringViewWindowsAMD64(t *testing.T) { if got := callbackStringView(0); got != (StringView{}) { t.Fatalf("callbackStringView(0) = %#v, want empty", got) } @@ -22,7 +22,7 @@ func TestCallbackStringViewWindowsAMD64(t *testing.T) { } } -func TestAdapterCallbackEntryWindowsAMD64(t *testing.T) { +func TestABIAdapterCallbackEntryWindowsAMD64(t *testing.T) { const requestID = uintptr(201) req := registerTestAdapterRequest(t, requestID) message := []byte("callback message")