detached 3a10209

#1 fix(syscall): keep fixed-arity syscall args off the goroutine stack

state
merged · 3a10209 · fast-forward
branch
fix/syscall-stack-args → main
opened
bnema ·
updated
CommitSubjectAuthorDateSignatureStatus
3a10209style(callback): gofmt noescape directive commentbriceGPG Verified
915ecc7docs(syscall): describe Syscall15 allocations as pooledbriceGPG Verified
ba8f819fix(syscall): keep fixed-arity syscall args off the goroutine stackbriceGPG Verified

Checks 0

no checks

4 files · +101 -10
no checks
M .gitignore +1 -0
@@ -1,1 +1,2 @@
11 *~
2+.worktrees/
@@ -1,1 +1,2 @@
1 *~1 *~
2+.worktrees/
M syscall_fixed.go +10 -10
@@ -10,12 +10,8 @@ import (
1010 "unsafe"
1111 )
1212
13-//go:noescape
14-//go:linkname noescapeFixed runtime.noescape
15-func noescapeFixed(p unsafe.Pointer) unsafe.Pointer
16-
17-// Syscall15 calls fn with up to 15 integer-class arguments without
18-// allocating. Unused arguments must be 0. Unlike SyscallN it does not use a
13+// Syscall15 calls fn with up to 15 integer-class arguments. It does not
14+// allocate per call once its argument pool is warm. Unused arguments must be 0. Unlike SyscallN it does not use a
1915 // variadic argument slice. Like SyscallN, a uintptr converted from an
2016 // unsafe.Pointer in the call expression keeps its object alive and un-moved
2117 // for the duration of the call (go:uintptrescapes); the object escapes to
@@ -30,14 +26,18 @@ func Syscall15(fn, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14,
3026 if runtime.GOOS == "windows" {
3127 return syscall_syscallN(fn, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15)
3228 }
33- s := syscallArgs{
29+ // Must not live on the goroutine stack: a Go callback made by fn can move
30+ // that stack, and syscallX would store results through the stale address.
31+ s := thePool.Get().(*syscallArgs)
32+ *s = syscallArgs{
3433 fn: fn, a1: a1, a2: a2, a3: a3, a4: a4, a5: a5,
3534 a6: a6, a7: a7, a8: a8, a9: a9, a10: a10,
3635 a11: a11, a12: a12, a13: a13, a14: a14, a15: a15,
3736 }
38- // cgocall is synchronous: s remains on this goroutine's stack until it returns.
39- runtime_cgocall(syscallXABI0, noescapeFixed(unsafe.Pointer(&s)))
40- return s.a1, s.a2, s.a3
37+ runtime_cgocall(syscallXABI0, unsafe.Pointer(s))
38+ r1, r2, err = s.a1, s.a2, s.a3
39+ thePool.Put(s)
40+ return r1, r2, err
4141 }
4242
4343 // Syscall6 is Syscall15 with six arguments. Like Syscall15, pointer-derived
@@ -10,12 +10,8 @@ import (
10 "unsafe"10 "unsafe"
11 )11 )
12 12
13-//go:noescape13+// Syscall15 calls fn with up to 15 integer-class arguments. It does not
14-//go:linkname noescapeFixed runtime.noescape14+// allocate per call once its argument pool is warm. Unused arguments must be 0. Unlike SyscallN it does not use a
15-func noescapeFixed(p unsafe.Pointer) unsafe.Pointer
16-
17-// Syscall15 calls fn with up to 15 integer-class arguments without
18-// allocating. Unused arguments must be 0. Unlike SyscallN it does not use a
19 // variadic argument slice. Like SyscallN, a uintptr converted from an15 // variadic argument slice. Like SyscallN, a uintptr converted from an
20 // unsafe.Pointer in the call expression keeps its object alive and un-moved16 // unsafe.Pointer in the call expression keeps its object alive and un-moved
21 // for the duration of the call (go:uintptrescapes); the object escapes to17 // for the duration of the call (go:uintptrescapes); the object escapes to
@@ -30,14 +26,18 @@ func Syscall15(fn, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14,
30 if runtime.GOOS == "windows" {26 if runtime.GOOS == "windows" {
31 return syscall_syscallN(fn, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15)27 return syscall_syscallN(fn, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15)
32 }28 }
33- s := syscallArgs{29+ // Must not live on the goroutine stack: a Go callback made by fn can move
30+ // that stack, and syscallX would store results through the stale address.
31+ s := thePool.Get().(*syscallArgs)
32+ *s = syscallArgs{
34 fn: fn, a1: a1, a2: a2, a3: a3, a4: a4, a5: a5,33 fn: fn, a1: a1, a2: a2, a3: a3, a4: a4, a5: a5,
35 a6: a6, a7: a7, a8: a8, a9: a9, a10: a10,34 a6: a6, a7: a7, a8: a8, a9: a9, a10: a10,
36 a11: a11, a12: a12, a13: a13, a14: a14, a15: a15,35 a11: a11, a12: a12, a13: a13, a14: a14, a15: a15,
37 }36 }
38- // cgocall is synchronous: s remains on this goroutine's stack until it returns.37+ runtime_cgocall(syscallXABI0, unsafe.Pointer(s))
39- runtime_cgocall(syscallXABI0, noescapeFixed(unsafe.Pointer(&s)))38+ r1, r2, err = s.a1, s.a2, s.a3
40- return s.a1, s.a2, s.a339+ thePool.Put(s)
40+ return r1, r2, err
41 }41 }
42 42
43 // Syscall6 is Syscall15 with six arguments. Like Syscall15, pointer-derived43 // Syscall6 is Syscall15 with six arguments. Like Syscall15, pointer-derived
A syscall_stack_test.go +89 -0
@@ -0,0 +1,89 @@
1+// SPDX-License-Identifier: Apache-2.0
2+// SPDX-FileCopyrightText: 2026 The Ebitengine Authors
3+
4+//go:build !windows && !plan9
5+
6+package purego_test
7+
8+import (
9+ "runtime"
10+ "testing"
11+ "unsafe"
12+
13+ "github.com/bnema/purego"
14+ "github.com/bnema/purego/internal/load"
15+)
16+
17+//go:noinline
18+func growCallbackStack(depth int) {
19+ var pad [4096]byte
20+ pad[0] = byte(depth)
21+ if depth > 0 {
22+ growCallbackStack(depth - 1)
23+ }
24+ runtime.KeepAlive(pad)
25+}
26+
27+func TestSyscall6CallbackStackRelocation(t *testing.T) {
28+ library, err := getSystemLibrary()
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ lib, err := load.OpenLibrary(library)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ defer load.CloseLibrary(lib)
37+ search, err := load.OpenSymbol(lib, "bsearch")
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ for _, fast := range []bool{false, true} {
42+ name := "NewCallback"
43+ if fast {
44+ name = "NewCallbackInts"
45+ }
46+ t.Run(name, func(t *testing.T) {
47+ calls := 0
48+ compare := func() uintptr { calls++; growCallbackStack(128); return 1 }
49+ var cb uintptr
50+ if fast {
51+ cb = purego.NewCallbackInts(func(*purego.CallbackArgs) uintptr { return compare() })
52+ } else {
53+ cb = purego.NewCallback(func(_ purego.CDecl, _, _ uintptr) uintptr { return compare() })
54+ }
55+ // A fresh goroutine starts with a small stack for each callback kind.
56+ done := make(chan uintptr, 1)
57+ go func() {
58+ key, data := new(uintptr), new(uintptr)
59+ r, _, _ := purego.Syscall6(search, uintptr(unsafe.Pointer(key)), uintptr(unsafe.Pointer(data)), 1, unsafe.Sizeof(*data), cb, 0)
60+ done <- r
61+ }()
62+ if got := <-done; got != 0 {
63+ t.Errorf("bsearch returned %#x, want NULL (key absent)", got)
64+ }
65+ if calls != 1 {
66+ t.Errorf("comparator called %d times, want 1", calls)
67+ }
68+ })
69+ }
70+}
71+
72+func TestSyscall6Allocs(t *testing.T) {
73+ library, err := getSystemLibrary()
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ lib, err := load.OpenLibrary(library)
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ defer load.CloseLibrary(lib)
82+ fn, err := load.OpenSymbol(lib, "abs")
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ if got := testing.AllocsPerRun(100, func() { purego.Syscall6(fn, 1, 0, 0, 0, 0, 0) }); got != 0 {
87+ t.Fatalf("Syscall6 allocations = %v, want 0", got)
88+ }
89+}
@@ -0,0 +1,89 @@
1+// SPDX-License-Identifier: Apache-2.0
2+// SPDX-FileCopyrightText: 2026 The Ebitengine Authors
3+
4+//go:build !windows && !plan9
5+
6+package purego_test
7+
8+import (
9+ "runtime"
10+ "testing"
11+ "unsafe"
12+
13+ "github.com/bnema/purego"
14+ "github.com/bnema/purego/internal/load"
15+)
16+
17+//go:noinline
18+func growCallbackStack(depth int) {
19+ var pad [4096]byte
20+ pad[0] = byte(depth)
21+ if depth > 0 {
22+ growCallbackStack(depth - 1)
23+ }
24+ runtime.KeepAlive(pad)
25+}
26+
27+func TestSyscall6CallbackStackRelocation(t *testing.T) {
28+ library, err := getSystemLibrary()
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ lib, err := load.OpenLibrary(library)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ defer load.CloseLibrary(lib)
37+ search, err := load.OpenSymbol(lib, "bsearch")
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ for _, fast := range []bool{false, true} {
42+ name := "NewCallback"
43+ if fast {
44+ name = "NewCallbackInts"
45+ }
46+ t.Run(name, func(t *testing.T) {
47+ calls := 0
48+ compare := func() uintptr { calls++; growCallbackStack(128); return 1 }
49+ var cb uintptr
50+ if fast {
51+ cb = purego.NewCallbackInts(func(*purego.CallbackArgs) uintptr { return compare() })
52+ } else {
53+ cb = purego.NewCallback(func(_ purego.CDecl, _, _ uintptr) uintptr { return compare() })
54+ }
55+ // A fresh goroutine starts with a small stack for each callback kind.
56+ done := make(chan uintptr, 1)
57+ go func() {
58+ key, data := new(uintptr), new(uintptr)
59+ r, _, _ := purego.Syscall6(search, uintptr(unsafe.Pointer(key)), uintptr(unsafe.Pointer(data)), 1, unsafe.Sizeof(*data), cb, 0)
60+ done <- r
61+ }()
62+ if got := <-done; got != 0 {
63+ t.Errorf("bsearch returned %#x, want NULL (key absent)", got)
64+ }
65+ if calls != 1 {
66+ t.Errorf("comparator called %d times, want 1", calls)
67+ }
68+ })
69+ }
70+}
71+
72+func TestSyscall6Allocs(t *testing.T) {
73+ library, err := getSystemLibrary()
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ lib, err := load.OpenLibrary(library)
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ defer load.CloseLibrary(lib)
82+ fn, err := load.OpenSymbol(lib, "abs")
83+ if err != nil {
84+ t.Fatal(err)
85+ }
86+ if got := testing.AllocsPerRun(100, func() { purego.Syscall6(fn, 1, 0, 0, 0, 0, 0) }); got != 0 {
87+ t.Fatalf("Syscall6 allocations = %v, want 0", got)
88+ }
89+}
M syscall_unix.go +1 -0
@@ -491,6 +491,7 @@ var callbackasmABI0 = uintptr(unsafe.Pointer(&__callbackasm))
491491 var callbackWrap_call = callbackWrap
492492
493493 // noescape is runtime.noescape; the synchronous callee must not retain p.
494+//
494495 //go:noescape
495496 //go:linkname noescape runtime.noescape
496497 func noescape(p unsafe.Pointer) unsafe.Pointer
@@ -491,6 +491,7 @@ var callbackasmABI0 = uintptr(unsafe.Pointer(&__callbackasm))
491 var callbackWrap_call = callbackWrap491 var callbackWrap_call = callbackWrap
492 492
493 // noescape is runtime.noescape; the synchronous callee must not retain p.493 // noescape is runtime.noescape; the synchronous callee must not retain p.
494+//
494 //go:noescape495 //go:noescape
495 //go:linkname noescape runtime.noescape496 //go:linkname noescape runtime.noescape
496 func noescape(p unsafe.Pointer) unsafe.Pointer497 func noescape(p unsafe.Pointer) unsafe.Pointer

Comments0 open / 0

no comments yet.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 1

Tags 102

↑↓ moveenter openesc close