detached cf1b83e

Merge pull request #2 from bnema/feature/callback-diagnostics-ledger

chore(callbacks): add opt-in callback diagnostics
commit
cf1b83e362983220c8b6501845e9c29f0d1da3b4
author
bnema · · 2026-06-09 17:39 UTC
committer
GitHub · · 2026-06-09 17:39 UTC
merge of
2dfd053 + 12556a4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.11.0-bnema.4
5 files · +301 -21
M .github/workflows/test.yml +6 -13
@@ -3,14 +3,15 @@ name: Test
33 on: [push, pull_request]
44
55 env:
6- QEMU_VERSION: "10.2.1+ds-1"
6+ QEMU_VERSION: "11.0.1+ds-1"
77
88 jobs:
99 test:
1010 strategy:
11+ fail-fast: false
1112 matrix:
1213 os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
13- go: ['1.18.x', '1.19.x', '1.20.x', '1.21.x', '1.22.x', '1.23.x', '1.24.x', '1.25.x', '1.26.x']
14+ go: ['1.20.x', '1.21.x', '1.22.x', '1.23.x', '1.24.x', '1.25.x', '1.26.x']
1415 name: Test with Go ${{ matrix.go }} on ${{ matrix.os }}
1516 runs-on: ${{ matrix.os }}
1617 defaults:
@@ -121,15 +122,6 @@ jobs:
121122 env CGO_ENABLED=0 GOARCH=386 go test -shuffle=on -v -count=10 ./...
122123 env CGO_ENABLED=1 GOARCH=386 go test -shuffle=on -v -count=10 ./...
123124
124- - name: go test (Linux 386)
125- if: ${{ runner.os == 'Linux' && runner.arch != 'ARM64' }}
126- run: |
127- sudo apt-get update
128- sudo apt-get install gcc-multilib
129- sudo apt-get install g++-multilib
130- env CGO_ENABLED=0 GOARCH=386 go test -shuffle=on -v -count=10 ./...
131- env CGO_ENABLED=1 GOARCH=386 go test -shuffle=on -v -count=10 ./...
132-
133125 - name: go test (Linux arm)
134126 if: ${{ runner.os == 'Linux' && runner.arch == 'ARM64' }}
135127 run: |
@@ -156,18 +148,18 @@ jobs:
156148 go env -u CXX
157149
158150 - name: go test race (no Cgo)
159- if: ${{ runner.os == 'macOS' && !startsWith(matrix.go, '1.18.') && !startsWith(matrix.go, '1.19.') }}
151+ if: runner.os == 'macOS'
160152 run: |
161153 # -race usually requires Cgo, but macOS is an exception. See https://go.dev/doc/articles/race_detector#Requirements
162154 env CGO_ENABLED=0 go test -race -shuffle=on -v -count=10 ./...
163155
164156 - name: go test race (Cgo)
165- if: ${{ !startsWith(matrix.go, '1.18.') && !startsWith(matrix.go, '1.19.') }}
166157 run: |
167158 env CGO_ENABLED=1 go test -race -shuffle=on -v -count=10 ./...
168159
169160 minor-arches:
170161 strategy:
162+ fail-fast: false
171163 matrix:
172164 # Test only the latest two Go versions to save CI time.
173165 # See https://go.dev/doc/devel/release#policy
@@ -255,6 +247,7 @@ jobs:
255247
256248 bsd:
257249 strategy:
250+ fail-fast: false
258251 matrix:
259252 os: ['FreeBSD', 'NetBSD']
260253 # Test only the latest two Go versions to save CI time.
@@ -3,14 +3,15 @@ name: Test
3 on: [push, pull_request]3 on: [push, pull_request]
4 4
5 env:5 env:
6- QEMU_VERSION: "10.2.1+ds-1"6+ QEMU_VERSION: "11.0.1+ds-1"
7 7
8 jobs:8 jobs:
9 test:9 test:
10 strategy:10 strategy:
11+ fail-fast: false
11 matrix:12 matrix:
12 os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]13 os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
13- go: ['1.18.x', '1.19.x', '1.20.x', '1.21.x', '1.22.x', '1.23.x', '1.24.x', '1.25.x', '1.26.x']14+ go: ['1.20.x', '1.21.x', '1.22.x', '1.23.x', '1.24.x', '1.25.x', '1.26.x']
14 name: Test with Go ${{ matrix.go }} on ${{ matrix.os }}15 name: Test with Go ${{ matrix.go }} on ${{ matrix.os }}
15 runs-on: ${{ matrix.os }}16 runs-on: ${{ matrix.os }}
16 defaults:17 defaults:
@@ -121,15 +122,6 @@ jobs:
121 env CGO_ENABLED=0 GOARCH=386 go test -shuffle=on -v -count=10 ./...122 env CGO_ENABLED=0 GOARCH=386 go test -shuffle=on -v -count=10 ./...
122 env CGO_ENABLED=1 GOARCH=386 go test -shuffle=on -v -count=10 ./...123 env CGO_ENABLED=1 GOARCH=386 go test -shuffle=on -v -count=10 ./...
123 124
124- - name: go test (Linux 386)
125- if: ${{ runner.os == 'Linux' && runner.arch != 'ARM64' }}
126- run: |
127- sudo apt-get update
128- sudo apt-get install gcc-multilib
129- sudo apt-get install g++-multilib
130- env CGO_ENABLED=0 GOARCH=386 go test -shuffle=on -v -count=10 ./...
131- env CGO_ENABLED=1 GOARCH=386 go test -shuffle=on -v -count=10 ./...
132-
133 - name: go test (Linux arm)125 - name: go test (Linux arm)
134 if: ${{ runner.os == 'Linux' && runner.arch == 'ARM64' }}126 if: ${{ runner.os == 'Linux' && runner.arch == 'ARM64' }}
135 run: |127 run: |
@@ -156,18 +148,18 @@ jobs:
156 go env -u CXX148 go env -u CXX
157 149
158 - name: go test race (no Cgo)150 - name: go test race (no Cgo)
159- if: ${{ runner.os == 'macOS' && !startsWith(matrix.go, '1.18.') && !startsWith(matrix.go, '1.19.') }}151+ if: runner.os == 'macOS'
160 run: |152 run: |
161 # -race usually requires Cgo, but macOS is an exception. See https://go.dev/doc/articles/race_detector#Requirements153 # -race usually requires Cgo, but macOS is an exception. See https://go.dev/doc/articles/race_detector#Requirements
162 env CGO_ENABLED=0 go test -race -shuffle=on -v -count=10 ./...154 env CGO_ENABLED=0 go test -race -shuffle=on -v -count=10 ./...
163 155
164 - name: go test race (Cgo)156 - name: go test race (Cgo)
165- if: ${{ !startsWith(matrix.go, '1.18.') && !startsWith(matrix.go, '1.19.') }}
166 run: |157 run: |
167 env CGO_ENABLED=1 go test -race -shuffle=on -v -count=10 ./...158 env CGO_ENABLED=1 go test -race -shuffle=on -v -count=10 ./...
168 159
169 minor-arches:160 minor-arches:
170 strategy:161 strategy:
162+ fail-fast: false
171 matrix:163 matrix:
172 # Test only the latest two Go versions to save CI time.164 # Test only the latest two Go versions to save CI time.
173 # See https://go.dev/doc/devel/release#policy165 # See https://go.dev/doc/devel/release#policy
@@ -255,6 +247,7 @@ jobs:
255 247
256 bsd:248 bsd:
257 strategy:249 strategy:
250+ fail-fast: false
258 matrix:251 matrix:
259 os: ['FreeBSD', 'NetBSD']252 os: ['FreeBSD', 'NetBSD']
260 # Test only the latest two Go versions to save CI time.253 # Test only the latest two Go versions to save CI time.
M README.md +23 -0
@@ -94,6 +94,29 @@ func main() {
9494
9595 Then to run: `CGO_ENABLED=0 go run main.go`
9696
97+## Callback diagnostics
98+
99+Callback allocation diagnostics are disabled by default. When debugging callback slot
100+exhaustion on Unix-like platforms, set `PUREGO_CALLBACK_LEDGER=1` to append JSONL
101+callback lifecycle events to `/tmp/purego-callback-ledger-<pid>.jsonl`, or set
102+`PUREGO_CALLBACK_LEDGER_FILE=/path/to/file.jsonl` to choose the output file. Stack
103+capture is also disabled by default; set `PUREGO_CALLBACK_LEDGER_STACK=1` when call
104+site attribution is needed. Stack capture adds per-callback overhead and should be
105+reserved for active debugging sessions. For ad-hoc stderr/file traces near callback
106+exhaustion, set `PUREGO_CALLBACK_TRACE=1`; `PUREGO_CALLBACK_TRACE_FILE` overrides
107+the default `/tmp/purego-callback-trace-<pid>.log` path.
108+
109+Diagnostic files are created with `0o600` permissions. They are append-only and
110+unbounded, so long-running processes can exhaust disk space if diagnostics stay
111+enabled. A ledger line is a JSON object such as:
112+
113+```json
114+{"time":"2026-06-09T14:03:00Z","pid":1234,"seq":42,"event":"alloc","index":7,"addr":"0x1234","stack":"..."}
115+```
116+
117+Use `time` as the timestamp, `index` as the callback ID, and the `pid`, `seq`,
118+`event`, `addr`, and optional `stack` fields when parsing the ledger output.
119+
97120 ## Questions
98121
99122 If you have questions about how to incorporate purego in your project or want to discuss
@@ -94,6 +94,29 @@ func main() {
94 94
95 Then to run: `CGO_ENABLED=0 go run main.go`95 Then to run: `CGO_ENABLED=0 go run main.go`
96 96
97+## Callback diagnostics
98+
99+Callback allocation diagnostics are disabled by default. When debugging callback slot
100+exhaustion on Unix-like platforms, set `PUREGO_CALLBACK_LEDGER=1` to append JSONL
101+callback lifecycle events to `/tmp/purego-callback-ledger-<pid>.jsonl`, or set
102+`PUREGO_CALLBACK_LEDGER_FILE=/path/to/file.jsonl` to choose the output file. Stack
103+capture is also disabled by default; set `PUREGO_CALLBACK_LEDGER_STACK=1` when call
104+site attribution is needed. Stack capture adds per-callback overhead and should be
105+reserved for active debugging sessions. For ad-hoc stderr/file traces near callback
106+exhaustion, set `PUREGO_CALLBACK_TRACE=1`; `PUREGO_CALLBACK_TRACE_FILE` overrides
107+the default `/tmp/purego-callback-trace-<pid>.log` path.
108+
109+Diagnostic files are created with `0o600` permissions. They are append-only and
110+unbounded, so long-running processes can exhaust disk space if diagnostics stay
111+enabled. A ledger line is a JSON object such as:
112+
113+```json
114+{"time":"2026-06-09T14:03:00Z","pid":1234,"seq":42,"event":"alloc","index":7,"addr":"0x1234","stack":"..."}
115+```
116+
117+Use `time` as the timestamp, `index` as the callback ID, and the `pid`, `seq`,
118+`event`, `addr`, and optional `stack` fields when parsing the ledger output.
119+
97 ## Questions120 ## Questions
98 121
99 If you have questions about how to incorporate purego in your project or want to discuss122 If you have questions about how to incorporate purego in your project or want to discuss
A callback_diagnostics_test.go +46 -0
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: Apache-2.0
2+// SPDX-FileCopyrightText: 2022 The Ebitengine Authors
3+
4+//go:build darwin || freebsd || (linux && (386 || amd64 || arm || arm64 || loong64 || ppc64le || riscv64 || (cgo && s390x))) || netbsd
5+
6+package purego
7+
8+import "testing"
9+
10+func TestCallbackDiagnosticsDisabledByDefault(t *testing.T) {
11+ t.Setenv("PUREGO_CALLBACK_LEDGER", "")
12+ t.Setenv("PUREGO_CALLBACK_LEDGER_STACK", "")
13+ t.Setenv("PUREGO_CALLBACK_TRACE", "")
14+
15+ if callbackLedgerEnabled() {
16+ t.Fatal("callback ledger must be disabled by default")
17+ }
18+ if callbackLedgerStackEnabled() {
19+ t.Fatal("callback ledger stack capture must be disabled by default")
20+ }
21+ if callbackTraceEnabled() {
22+ t.Fatal("callback trace must be disabled by default")
23+ }
24+}
25+
26+func TestCallbackDiagnosticsExplicitOptIn(t *testing.T) {
27+ for _, value := range []string{"1", "true", "yes", "on", "TRUE", "On"} {
28+ t.Run(value, func(t *testing.T) {
29+ t.Setenv("PUREGO_CALLBACK_LEDGER", value)
30+ if !callbackLedgerEnabled() {
31+ t.Fatalf("callback ledger was not enabled by %q", value)
32+ }
33+ })
34+ }
35+}
36+
37+func TestCallbackDiagnosticsRejectsImplicitValues(t *testing.T) {
38+ for _, value := range []string{"0", "false", "no", "off", "anything-else"} {
39+ t.Run(value, func(t *testing.T) {
40+ t.Setenv("PUREGO_CALLBACK_LEDGER", value)
41+ if callbackLedgerEnabled() {
42+ t.Fatalf("callback ledger unexpectedly enabled by %q", value)
43+ }
44+ })
45+ }
46+}
@@ -0,0 +1,46 @@
1+// SPDX-License-Identifier: Apache-2.0
2+// SPDX-FileCopyrightText: 2022 The Ebitengine Authors
3+
4+//go:build darwin || freebsd || (linux && (386 || amd64 || arm || arm64 || loong64 || ppc64le || riscv64 || (cgo && s390x))) || netbsd
5+
6+package purego
7+
8+import "testing"
9+
10+func TestCallbackDiagnosticsDisabledByDefault(t *testing.T) {
11+ t.Setenv("PUREGO_CALLBACK_LEDGER", "")
12+ t.Setenv("PUREGO_CALLBACK_LEDGER_STACK", "")
13+ t.Setenv("PUREGO_CALLBACK_TRACE", "")
14+
15+ if callbackLedgerEnabled() {
16+ t.Fatal("callback ledger must be disabled by default")
17+ }
18+ if callbackLedgerStackEnabled() {
19+ t.Fatal("callback ledger stack capture must be disabled by default")
20+ }
21+ if callbackTraceEnabled() {
22+ t.Fatal("callback trace must be disabled by default")
23+ }
24+}
25+
26+func TestCallbackDiagnosticsExplicitOptIn(t *testing.T) {
27+ for _, value := range []string{"1", "true", "yes", "on", "TRUE", "On"} {
28+ t.Run(value, func(t *testing.T) {
29+ t.Setenv("PUREGO_CALLBACK_LEDGER", value)
30+ if !callbackLedgerEnabled() {
31+ t.Fatalf("callback ledger was not enabled by %q", value)
32+ }
33+ })
34+ }
35+}
36+
37+func TestCallbackDiagnosticsRejectsImplicitValues(t *testing.T) {
38+ for _, value := range []string{"0", "false", "no", "off", "anything-else"} {
39+ t.Run(value, func(t *testing.T) {
40+ t.Setenv("PUREGO_CALLBACK_LEDGER", value)
41+ if callbackLedgerEnabled() {
42+ t.Fatalf("callback ledger unexpectedly enabled by %q", value)
43+ }
44+ })
45+ }
46+}
M go.mod +1 -1
@@ -1,3 +1,3 @@
11 module github.com/bnema/purego
22
3-go 1.18
3+go 1.20
@@ -1,3 +1,3 @@
1 module github.com/bnema/purego1 module github.com/bnema/purego
2 2
3-go 1.183+go 1.20
M syscall_unix.go +225 -7
@@ -7,13 +7,19 @@
77 package purego
88
99 import (
10+ "encoding/json"
1011 "errors"
12+ "fmt"
13+ "os"
1114 "reflect"
1215 "runtime"
16+ "runtime/debug"
17+ "strings"
1318 "sync"
19+ "time"
1420 "unsafe"
1521
16- "github.com/bnema/purego/internal/strings"
22+ internalstrings "github.com/bnema/purego/internal/strings"
1723 )
1824
1925 var syscall15XABI0 uintptr
@@ -60,7 +66,10 @@ func NewCallbackFnPtr(fnPtr any) uintptr {
6066 if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Func {
6167 panic("purego: the type must be a function pointer but was not")
6268 }
63- if addr, ok := getCallbackByFnPtr(val); ok {
69+ if addr, idx, ok := getCallbackByFnPtr(val); ok {
70+ cbs.lock.Lock()
71+ recordCallbackLedger("dedupe", idx, addr, val.Pointer(), val.Elem(), false, "")
72+ cbs.lock.Unlock()
6473 return addr
6574 }
6675 addr := compileCallback(val.Elem().Interface())
@@ -68,6 +77,7 @@ func NewCallbackFnPtr(fnPtr any) uintptr {
6877 cbs.knownFnPtr[val.Pointer()] = addr
6978 if idx, ok := cbs.knownIdx[addr]; ok {
7079 cbs.fnPtrKeys[idx] = val.Pointer()
80+ recordCallbackLedger("fnptr-bind", idx, addr, val.Pointer(), val.Elem(), false, "")
7181 }
7282 cbs.lock.Unlock()
7383 return addr
@@ -79,9 +89,12 @@ func UnrefCallback(cb uintptr) error {
7989 defer cbs.lock.Unlock()
8090 idx, ok := cbs.knownIdx[cb]
8191 if !ok {
92+ recordCallbackLedger("release-miss", -1, cb, 0, reflect.Value{}, false, "callback not found")
8293 return errors.New("callback not found")
8394 }
84- if key := cbs.fnPtrKeys[idx]; key != 0 {
95+ val := cbs.funcs[idx]
96+ key := cbs.fnPtrKeys[idx]
97+ if key != 0 {
8598 delete(cbs.knownFnPtr, key)
8699 cbs.fnPtrKeys[idx] = 0
87100 }
@@ -89,6 +102,7 @@ func UnrefCallback(cb uintptr) error {
89102 cbs.holes[idx] = struct{}{}
90103 cbs.funcs[idx] = reflect.Value{}
91104 cbs.argPools[idx] = nil
105+ recordCallbackLedger("release", idx, cb, key, val, false, "")
92106 return nil
93107 }
94108
@@ -105,19 +119,23 @@ func UnrefCallbackFnPtr(fnPtr any) error {
105119 defer cbs.lock.Unlock()
106120 addr, ok := cbs.knownFnPtr[val.Pointer()]
107121 if !ok {
122+ recordCallbackLedger("release-fnptr-miss", -1, 0, val.Pointer(), val.Elem(), false, "callback not found")
108123 return errors.New("callback not found")
109124 }
110125 idx, ok := cbs.knownIdx[addr]
111126 if !ok {
112127 delete(cbs.knownFnPtr, val.Pointer())
128+ recordCallbackLedger("release-fnptr-miss", -1, addr, val.Pointer(), val.Elem(), false, "callback not found")
113129 return errors.New("callback not found")
114130 }
131+ callbackVal := cbs.funcs[idx]
115132 delete(cbs.knownFnPtr, val.Pointer())
116133 delete(cbs.knownIdx, addr)
117134 cbs.fnPtrKeys[idx] = 0
118135 cbs.holes[idx] = struct{}{}
119136 cbs.funcs[idx] = reflect.Value{}
120137 cbs.argPools[idx] = nil
138+ recordCallbackLedger("release-fnptr", idx, addr, val.Pointer(), callbackVal, false, "")
121139 return nil
122140 }
123141
@@ -134,17 +152,204 @@ var cbs = struct {
134152 knownIdx map[uintptr]int // callback address -> slot index
135153 knownFnPtr map[uintptr]uintptr // function pointer variable address -> callback address
136154 fnPtrKeys [maxCB]uintptr // slot index -> function pointer variable address
155+ ledgerSeq uint64 // monotonic callback ledger event sequence
137156 }{
138157 holes: make(map[int]struct{}),
139158 knownIdx: make(map[uintptr]int, maxCB),
140159 knownFnPtr: make(map[uintptr]uintptr, maxCB),
141160 }
142161
143-func getCallbackByFnPtr(val reflect.Value) (uintptr, bool) {
162+func getCallbackByFnPtr(val reflect.Value) (uintptr, int, bool) {
144163 cbs.lock.RLock()
145164 defer cbs.lock.RUnlock()
146165 addr, ok := cbs.knownFnPtr[val.Pointer()]
147- return addr, ok
166+ if !ok {
167+ return 0, -1, false
168+ }
169+ idx, ok := cbs.knownIdx[addr]
170+ if !ok {
171+ return addr, -1, true
172+ }
173+ return addr, idx, true
174+}
175+
176+type callbackLedgerEvent struct {
177+ Marker string `json:"marker"`
178+ PID int `json:"pid"`
179+ Seq uint64 `json:"seq"`
180+ Time string `json:"time"`
181+ Event string `json:"event"`
182+ Index int `json:"index"`
183+ Addr string `json:"addr,omitempty"`
184+ FnPtrKey string `json:"fn_ptr_key,omitempty"`
185+ Type string `json:"type,omitempty"`
186+ Family string `json:"family"`
187+ StackKey string `json:"stack_key"`
188+ NumFn int `json:"num_fn"`
189+ Occupied int `json:"occupied"`
190+ Reusable int `json:"reusable"`
191+ KnownFnPtr int `json:"known_fn_ptr"`
192+ Remaining int `json:"remaining"`
193+ Max int `json:"max"`
194+ ReusedSlot bool `json:"reused_slot,omitempty"`
195+ Stack string `json:"stack,omitempty"`
196+ Error string `json:"error,omitempty"`
197+}
198+
199+func callbackEnvEnabled(name string) bool {
200+ switch strings.ToLower(os.Getenv(name)) {
201+ case "1", "true", "yes", "on":
202+ return true
203+ default:
204+ return false
205+ }
206+}
207+
208+func callbackLedgerEnabled() bool {
209+ return callbackEnvEnabled("PUREGO_CALLBACK_LEDGER")
210+}
211+
212+func callbackLedgerStackEnabled() bool {
213+ return callbackEnvEnabled("PUREGO_CALLBACK_LEDGER_STACK")
214+}
215+
216+func callbackTraceEnabled() bool {
217+ return callbackEnvEnabled("PUREGO_CALLBACK_TRACE")
218+}
219+
220+func callbackLedgerPath() string {
221+ if path := os.Getenv("PUREGO_CALLBACK_LEDGER_FILE"); path != "" {
222+ return path
223+ }
224+ return fmt.Sprintf("/tmp/purego-callback-ledger-%d.jsonl", os.Getpid())
225+}
226+
227+func typeName(val reflect.Value) string {
228+ if !val.IsValid() {
229+ return ""
230+ }
231+ return val.Type().String()
232+}
233+
234+func hexUintptr(v uintptr) string {
235+ if v == 0 {
236+ return ""
237+ }
238+ return fmt.Sprintf("0x%x", v)
239+}
240+
241+func classifyCallbackStack(stack string) (family, stackKey string) {
242+ family = "unknown"
243+ stackKey = "unknown"
244+ lines := strings.Split(stack, "\n")
245+ for i, line := range lines {
246+ if line == "" || strings.HasPrefix(line, "goroutine ") || strings.Contains(line, "runtime/debug.Stack") || strings.Contains(line, "recordCallbackLedger") || strings.Contains(line, "traceCallbackAllocation") {
247+ continue
248+ }
249+ if strings.Contains(line, "github.com/bnema/purego.") || strings.Contains(line, "github.com/ebitengine/purego.") {
250+ continue
251+ }
252+ if strings.HasPrefix(strings.TrimSpace(line), "/") || strings.HasPrefix(strings.TrimSpace(line), "../") {
253+ continue
254+ }
255+ stackKey = strings.TrimSpace(line)
256+ for j := i + 1; j < len(lines); j++ {
257+ fileLine := strings.TrimSpace(lines[j])
258+ if fileLine == "" {
259+ continue
260+ }
261+ family = classifyCallbackFamily(fileLine + " " + stackKey)
262+ return family, stackKey
263+ }
264+ family = classifyCallbackFamily(stackKey)
265+ return family, stackKey
266+ }
267+ return family, stackKey
268+}
269+
270+func classifyCallbackFamily(s string) string {
271+ switch {
272+ case strings.Contains(s, "purego-cef2gtk"):
273+ return "purego-cef2gtk"
274+ case strings.Contains(s, "purego-cef"):
275+ return "purego-cef"
276+ case strings.Contains(s, "puregotk"):
277+ return "puregotk"
278+ case strings.Contains(s, "dumber"):
279+ return "dumber"
280+ case strings.Contains(s, "github.com/bnema/purego") || strings.Contains(s, "/purego/"):
281+ return "purego"
282+ default:
283+ return "unknown"
284+ }
285+}
286+
287+func recordCallbackLedger(event string, idx int, addr uintptr, fnPtrKey uintptr, val reflect.Value, reused bool, errText string) {
288+ if !callbackLedgerEnabled() {
289+ return
290+ }
291+ stack := ""
292+ family := "unknown"
293+ stackKey := "unknown"
294+ if callbackLedgerStackEnabled() {
295+ stack = string(debug.Stack())
296+ family, stackKey = classifyCallbackStack(stack)
297+ }
298+ cbs.ledgerSeq++
299+ occupied := cbs.numFn - len(cbs.holes)
300+ entry := callbackLedgerEvent{
301+ Marker: "PUREGO-CALLBACK-LEDGER",
302+ PID: os.Getpid(),
303+ Seq: cbs.ledgerSeq,
304+ Time: time.Now().Format(time.RFC3339Nano),
305+ Event: event,
306+ Index: idx,
307+ Addr: hexUintptr(addr),
308+ FnPtrKey: hexUintptr(fnPtrKey),
309+ Type: typeName(val),
310+ Family: family,
311+ StackKey: stackKey,
312+ NumFn: cbs.numFn,
313+ Occupied: occupied,
314+ Reusable: len(cbs.holes),
315+ KnownFnPtr: len(cbs.knownFnPtr),
316+ Remaining: maxCB - occupied,
317+ Max: maxCB,
318+ ReusedSlot: reused,
319+ Error: errText,
320+ }
321+ if callbackLedgerStackEnabled() {
322+ entry.Stack = stack
323+ }
324+ payload, err := json.Marshal(entry)
325+ if err != nil {
326+ return
327+ }
328+ payload = append(payload, '\n')
329+ if f, err := os.OpenFile(callbackLedgerPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
330+ _, _ = f.Write(payload)
331+ _ = f.Close()
332+ }
333+}
334+
335+func traceCallbackAllocation(event string, val reflect.Value, remaining int) {
336+ if !callbackTraceEnabled() {
337+ return
338+ }
339+ message := fmt.Sprintf("PUREGO-CALLBACK-TRACE event=%s remaining=%d live=%d type=%s\n%s\n", event, remaining, maxCB-remaining, val.Type(), debug.Stack())
340+ fmt.Fprint(os.Stderr, message)
341+ traceFile := os.Getenv("PUREGO_CALLBACK_TRACE_FILE")
342+ if traceFile == "" {
343+ traceFile = fmt.Sprintf("/tmp/purego-callback-trace-%d.log", os.Getpid())
344+ }
345+ // traceCallbackAllocation intentionally accepts the opt-in
346+ // PUREGO_CALLBACK_TRACE_FILE path. Users must set PUREGO_CALLBACK_TRACE=1 to
347+ // enable tracing, and callers who override the path control their debugging
348+ // environment. This documents the deliberate gosec G304 tradeoff for audits.
349+ if f, err := os.OpenFile(traceFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
350+ _, _ = f.WriteString(message)
351+ _ = f.Close()
352+ }
148353 }
149354
150355 func compileCallback(fn any) uintptr {
@@ -188,15 +393,23 @@ output:
188393 cbs.lock.Lock()
189394 defer cbs.lock.Unlock()
190395 index := -1
396+ reused := false
191397 for i := range cbs.holes {
192398 index = i
193399 delete(cbs.holes, i)
400+ reused = true
194401 break
195402 }
196403 if index < 0 {
197- if cbs.numFn >= maxCB {
404+ remaining := maxCB - cbs.numFn
405+ if remaining <= 0 {
406+ recordCallbackLedger("exhausted", -1, 0, 0, val, false, "maximum callbacks reached")
407+ traceCallbackAllocation("exhausted", val, 0)
198408 panic("purego: the maximum number of callbacks has been reached")
199409 }
410+ if remaining <= 100 || remaining%250 == 0 {
411+ traceCallbackAllocation("allocated", val, remaining)
412+ }
200413 index = cbs.numFn
201414 cbs.numFn++
202415 }
@@ -209,6 +422,11 @@ output:
209422 }
210423 addr := callbackasmAddr(index)
211424 cbs.knownIdx[addr] = index
425+ if reused {
426+ recordCallbackLedger("reuse", index, addr, 0, val, true, "")
427+ } else {
428+ recordCallbackLedger("alloc", index, addr, 0, val, false, "")
429+ }
212430 return addr
213431 }
214432
@@ -329,7 +547,7 @@ func callbackWrap(a *callbackArgs) {
329547 ptr = frame[pos]
330548 }
331549 intsN += slots
332- args[i] = reflect.ValueOf(strings.GoString(ptr))
550+ args[i] = reflect.ValueOf(internalstrings.GoString(ptr))
333551 continue
334552 case reflect.Struct:
335553 if i == 0 && inType.AssignableTo(reflect.TypeOf(CDecl{})) {
@@ -7,13 +7,19 @@
7 package purego7 package purego
8 8
9 import (9 import (
10+ "encoding/json"
10 "errors"11 "errors"
12+ "fmt"
13+ "os"
11 "reflect"14 "reflect"
12 "runtime"15 "runtime"
16+ "runtime/debug"
17+ "strings"
13 "sync"18 "sync"
19+ "time"
14 "unsafe"20 "unsafe"
15 21
16- "github.com/bnema/purego/internal/strings"22+ internalstrings "github.com/bnema/purego/internal/strings"
17 )23 )
18 24
19 var syscall15XABI0 uintptr25 var syscall15XABI0 uintptr
@@ -60,7 +66,10 @@ func NewCallbackFnPtr(fnPtr any) uintptr {
60 if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Func {66 if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Func {
61 panic("purego: the type must be a function pointer but was not")67 panic("purego: the type must be a function pointer but was not")
62 }68 }
63- if addr, ok := getCallbackByFnPtr(val); ok {69+ if addr, idx, ok := getCallbackByFnPtr(val); ok {
70+ cbs.lock.Lock()
71+ recordCallbackLedger("dedupe", idx, addr, val.Pointer(), val.Elem(), false, "")
72+ cbs.lock.Unlock()
64 return addr73 return addr
65 }74 }
66 addr := compileCallback(val.Elem().Interface())75 addr := compileCallback(val.Elem().Interface())
@@ -68,6 +77,7 @@ func NewCallbackFnPtr(fnPtr any) uintptr {
68 cbs.knownFnPtr[val.Pointer()] = addr77 cbs.knownFnPtr[val.Pointer()] = addr
69 if idx, ok := cbs.knownIdx[addr]; ok {78 if idx, ok := cbs.knownIdx[addr]; ok {
70 cbs.fnPtrKeys[idx] = val.Pointer()79 cbs.fnPtrKeys[idx] = val.Pointer()
80+ recordCallbackLedger("fnptr-bind", idx, addr, val.Pointer(), val.Elem(), false, "")
71 }81 }
72 cbs.lock.Unlock()82 cbs.lock.Unlock()
73 return addr83 return addr
@@ -79,9 +89,12 @@ func UnrefCallback(cb uintptr) error {
79 defer cbs.lock.Unlock()89 defer cbs.lock.Unlock()
80 idx, ok := cbs.knownIdx[cb]90 idx, ok := cbs.knownIdx[cb]
81 if !ok {91 if !ok {
92+ recordCallbackLedger("release-miss", -1, cb, 0, reflect.Value{}, false, "callback not found")
82 return errors.New("callback not found")93 return errors.New("callback not found")
83 }94 }
84- if key := cbs.fnPtrKeys[idx]; key != 0 {95+ val := cbs.funcs[idx]
96+ key := cbs.fnPtrKeys[idx]
97+ if key != 0 {
85 delete(cbs.knownFnPtr, key)98 delete(cbs.knownFnPtr, key)
86 cbs.fnPtrKeys[idx] = 099 cbs.fnPtrKeys[idx] = 0
87 }100 }
@@ -89,6 +102,7 @@ func UnrefCallback(cb uintptr) error {
89 cbs.holes[idx] = struct{}{}102 cbs.holes[idx] = struct{}{}
90 cbs.funcs[idx] = reflect.Value{}103 cbs.funcs[idx] = reflect.Value{}
91 cbs.argPools[idx] = nil104 cbs.argPools[idx] = nil
105+ recordCallbackLedger("release", idx, cb, key, val, false, "")
92 return nil106 return nil
93 }107 }
94 108
@@ -105,19 +119,23 @@ func UnrefCallbackFnPtr(fnPtr any) error {
105 defer cbs.lock.Unlock()119 defer cbs.lock.Unlock()
106 addr, ok := cbs.knownFnPtr[val.Pointer()]120 addr, ok := cbs.knownFnPtr[val.Pointer()]
107 if !ok {121 if !ok {
122+ recordCallbackLedger("release-fnptr-miss", -1, 0, val.Pointer(), val.Elem(), false, "callback not found")
108 return errors.New("callback not found")123 return errors.New("callback not found")
109 }124 }
110 idx, ok := cbs.knownIdx[addr]125 idx, ok := cbs.knownIdx[addr]
111 if !ok {126 if !ok {
112 delete(cbs.knownFnPtr, val.Pointer())127 delete(cbs.knownFnPtr, val.Pointer())
128+ recordCallbackLedger("release-fnptr-miss", -1, addr, val.Pointer(), val.Elem(), false, "callback not found")
113 return errors.New("callback not found")129 return errors.New("callback not found")
114 }130 }
131+ callbackVal := cbs.funcs[idx]
115 delete(cbs.knownFnPtr, val.Pointer())132 delete(cbs.knownFnPtr, val.Pointer())
116 delete(cbs.knownIdx, addr)133 delete(cbs.knownIdx, addr)
117 cbs.fnPtrKeys[idx] = 0134 cbs.fnPtrKeys[idx] = 0
118 cbs.holes[idx] = struct{}{}135 cbs.holes[idx] = struct{}{}
119 cbs.funcs[idx] = reflect.Value{}136 cbs.funcs[idx] = reflect.Value{}
120 cbs.argPools[idx] = nil137 cbs.argPools[idx] = nil
138+ recordCallbackLedger("release-fnptr", idx, addr, val.Pointer(), callbackVal, false, "")
121 return nil139 return nil
122 }140 }
123 141
@@ -134,17 +152,204 @@ var cbs = struct {
134 knownIdx map[uintptr]int // callback address -> slot index152 knownIdx map[uintptr]int // callback address -> slot index
135 knownFnPtr map[uintptr]uintptr // function pointer variable address -> callback address153 knownFnPtr map[uintptr]uintptr // function pointer variable address -> callback address
136 fnPtrKeys [maxCB]uintptr // slot index -> function pointer variable address154 fnPtrKeys [maxCB]uintptr // slot index -> function pointer variable address
155+ ledgerSeq uint64 // monotonic callback ledger event sequence
137 }{156 }{
138 holes: make(map[int]struct{}),157 holes: make(map[int]struct{}),
139 knownIdx: make(map[uintptr]int, maxCB),158 knownIdx: make(map[uintptr]int, maxCB),
140 knownFnPtr: make(map[uintptr]uintptr, maxCB),159 knownFnPtr: make(map[uintptr]uintptr, maxCB),
141 }160 }
142 161
143-func getCallbackByFnPtr(val reflect.Value) (uintptr, bool) {162+func getCallbackByFnPtr(val reflect.Value) (uintptr, int, bool) {
144 cbs.lock.RLock()163 cbs.lock.RLock()
145 defer cbs.lock.RUnlock()164 defer cbs.lock.RUnlock()
146 addr, ok := cbs.knownFnPtr[val.Pointer()]165 addr, ok := cbs.knownFnPtr[val.Pointer()]
147- return addr, ok166+ if !ok {
167+ return 0, -1, false
168+ }
169+ idx, ok := cbs.knownIdx[addr]
170+ if !ok {
171+ return addr, -1, true
172+ }
173+ return addr, idx, true
174+}
175+
176+type callbackLedgerEvent struct {
177+ Marker string `json:"marker"`
178+ PID int `json:"pid"`
179+ Seq uint64 `json:"seq"`
180+ Time string `json:"time"`
181+ Event string `json:"event"`
182+ Index int `json:"index"`
183+ Addr string `json:"addr,omitempty"`
184+ FnPtrKey string `json:"fn_ptr_key,omitempty"`
185+ Type string `json:"type,omitempty"`
186+ Family string `json:"family"`
187+ StackKey string `json:"stack_key"`
188+ NumFn int `json:"num_fn"`
189+ Occupied int `json:"occupied"`
190+ Reusable int `json:"reusable"`
191+ KnownFnPtr int `json:"known_fn_ptr"`
192+ Remaining int `json:"remaining"`
193+ Max int `json:"max"`
194+ ReusedSlot bool `json:"reused_slot,omitempty"`
195+ Stack string `json:"stack,omitempty"`
196+ Error string `json:"error,omitempty"`
197+}
198+
199+func callbackEnvEnabled(name string) bool {
200+ switch strings.ToLower(os.Getenv(name)) {
201+ case "1", "true", "yes", "on":
202+ return true
203+ default:
204+ return false
205+ }
206+}
207+
208+func callbackLedgerEnabled() bool {
209+ return callbackEnvEnabled("PUREGO_CALLBACK_LEDGER")
210+}
211+
212+func callbackLedgerStackEnabled() bool {
213+ return callbackEnvEnabled("PUREGO_CALLBACK_LEDGER_STACK")
214+}
215+
216+func callbackTraceEnabled() bool {
217+ return callbackEnvEnabled("PUREGO_CALLBACK_TRACE")
218+}
219+
220+func callbackLedgerPath() string {
221+ if path := os.Getenv("PUREGO_CALLBACK_LEDGER_FILE"); path != "" {
222+ return path
223+ }
224+ return fmt.Sprintf("/tmp/purego-callback-ledger-%d.jsonl", os.Getpid())
225+}
226+
227+func typeName(val reflect.Value) string {
228+ if !val.IsValid() {
229+ return ""
230+ }
231+ return val.Type().String()
232+}
233+
234+func hexUintptr(v uintptr) string {
235+ if v == 0 {
236+ return ""
237+ }
238+ return fmt.Sprintf("0x%x", v)
239+}
240+
241+func classifyCallbackStack(stack string) (family, stackKey string) {
242+ family = "unknown"
243+ stackKey = "unknown"
244+ lines := strings.Split(stack, "\n")
245+ for i, line := range lines {
246+ if line == "" || strings.HasPrefix(line, "goroutine ") || strings.Contains(line, "runtime/debug.Stack") || strings.Contains(line, "recordCallbackLedger") || strings.Contains(line, "traceCallbackAllocation") {
247+ continue
248+ }
249+ if strings.Contains(line, "github.com/bnema/purego.") || strings.Contains(line, "github.com/ebitengine/purego.") {
250+ continue
251+ }
252+ if strings.HasPrefix(strings.TrimSpace(line), "/") || strings.HasPrefix(strings.TrimSpace(line), "../") {
253+ continue
254+ }
255+ stackKey = strings.TrimSpace(line)
256+ for j := i + 1; j < len(lines); j++ {
257+ fileLine := strings.TrimSpace(lines[j])
258+ if fileLine == "" {
259+ continue
260+ }
261+ family = classifyCallbackFamily(fileLine + " " + stackKey)
262+ return family, stackKey
263+ }
264+ family = classifyCallbackFamily(stackKey)
265+ return family, stackKey
266+ }
267+ return family, stackKey
268+}
269+
270+func classifyCallbackFamily(s string) string {
271+ switch {
272+ case strings.Contains(s, "purego-cef2gtk"):
273+ return "purego-cef2gtk"
274+ case strings.Contains(s, "purego-cef"):
275+ return "purego-cef"
276+ case strings.Contains(s, "puregotk"):
277+ return "puregotk"
278+ case strings.Contains(s, "dumber"):
279+ return "dumber"
280+ case strings.Contains(s, "github.com/bnema/purego") || strings.Contains(s, "/purego/"):
281+ return "purego"
282+ default:
283+ return "unknown"
284+ }
285+}
286+
287+func recordCallbackLedger(event string, idx int, addr uintptr, fnPtrKey uintptr, val reflect.Value, reused bool, errText string) {
288+ if !callbackLedgerEnabled() {
289+ return
290+ }
291+ stack := ""
292+ family := "unknown"
293+ stackKey := "unknown"
294+ if callbackLedgerStackEnabled() {
295+ stack = string(debug.Stack())
296+ family, stackKey = classifyCallbackStack(stack)
297+ }
298+ cbs.ledgerSeq++
299+ occupied := cbs.numFn - len(cbs.holes)
300+ entry := callbackLedgerEvent{
301+ Marker: "PUREGO-CALLBACK-LEDGER",
302+ PID: os.Getpid(),
303+ Seq: cbs.ledgerSeq,
304+ Time: time.Now().Format(time.RFC3339Nano),
305+ Event: event,
306+ Index: idx,
307+ Addr: hexUintptr(addr),
308+ FnPtrKey: hexUintptr(fnPtrKey),
309+ Type: typeName(val),
310+ Family: family,
311+ StackKey: stackKey,
312+ NumFn: cbs.numFn,
313+ Occupied: occupied,
314+ Reusable: len(cbs.holes),
315+ KnownFnPtr: len(cbs.knownFnPtr),
316+ Remaining: maxCB - occupied,
317+ Max: maxCB,
318+ ReusedSlot: reused,
319+ Error: errText,
320+ }
321+ if callbackLedgerStackEnabled() {
322+ entry.Stack = stack
323+ }
324+ payload, err := json.Marshal(entry)
325+ if err != nil {
326+ return
327+ }
328+ payload = append(payload, '\n')
329+ if f, err := os.OpenFile(callbackLedgerPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
330+ _, _ = f.Write(payload)
331+ _ = f.Close()
332+ }
333+}
334+
335+func traceCallbackAllocation(event string, val reflect.Value, remaining int) {
336+ if !callbackTraceEnabled() {
337+ return
338+ }
339+ message := fmt.Sprintf("PUREGO-CALLBACK-TRACE event=%s remaining=%d live=%d type=%s\n%s\n", event, remaining, maxCB-remaining, val.Type(), debug.Stack())
340+ fmt.Fprint(os.Stderr, message)
341+ traceFile := os.Getenv("PUREGO_CALLBACK_TRACE_FILE")
342+ if traceFile == "" {
343+ traceFile = fmt.Sprintf("/tmp/purego-callback-trace-%d.log", os.Getpid())
344+ }
345+ // traceCallbackAllocation intentionally accepts the opt-in
346+ // PUREGO_CALLBACK_TRACE_FILE path. Users must set PUREGO_CALLBACK_TRACE=1 to
347+ // enable tracing, and callers who override the path control their debugging
348+ // environment. This documents the deliberate gosec G304 tradeoff for audits.
349+ if f, err := os.OpenFile(traceFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
350+ _, _ = f.WriteString(message)
351+ _ = f.Close()
352+ }
148 }353 }
149 354
150 func compileCallback(fn any) uintptr {355 func compileCallback(fn any) uintptr {
@@ -188,15 +393,23 @@ output:
188 cbs.lock.Lock()393 cbs.lock.Lock()
189 defer cbs.lock.Unlock()394 defer cbs.lock.Unlock()
190 index := -1395 index := -1
396+ reused := false
191 for i := range cbs.holes {397 for i := range cbs.holes {
192 index = i398 index = i
193 delete(cbs.holes, i)399 delete(cbs.holes, i)
400+ reused = true
194 break401 break
195 }402 }
196 if index < 0 {403 if index < 0 {
197- if cbs.numFn >= maxCB {404+ remaining := maxCB - cbs.numFn
405+ if remaining <= 0 {
406+ recordCallbackLedger("exhausted", -1, 0, 0, val, false, "maximum callbacks reached")
407+ traceCallbackAllocation("exhausted", val, 0)
198 panic("purego: the maximum number of callbacks has been reached")408 panic("purego: the maximum number of callbacks has been reached")
199 }409 }
410+ if remaining <= 100 || remaining%250 == 0 {
411+ traceCallbackAllocation("allocated", val, remaining)
412+ }
200 index = cbs.numFn413 index = cbs.numFn
201 cbs.numFn++414 cbs.numFn++
202 }415 }
@@ -209,6 +422,11 @@ output:
209 }422 }
210 addr := callbackasmAddr(index)423 addr := callbackasmAddr(index)
211 cbs.knownIdx[addr] = index424 cbs.knownIdx[addr] = index
425+ if reused {
426+ recordCallbackLedger("reuse", index, addr, 0, val, true, "")
427+ } else {
428+ recordCallbackLedger("alloc", index, addr, 0, val, false, "")
429+ }
212 return addr430 return addr
213 }431 }
214 432
@@ -329,7 +547,7 @@ func callbackWrap(a *callbackArgs) {
329 ptr = frame[pos]547 ptr = frame[pos]
330 }548 }
331 intsN += slots549 intsN += slots
332- args[i] = reflect.ValueOf(strings.GoString(ptr))550+ args[i] = reflect.ValueOf(internalstrings.GoString(ptr))
333 continue551 continue
334 case reflect.Struct:552 case reflect.Struct:
335 if i == 0 && inType.AssignableTo(reflect.TypeOf(CDecl{})) {553 if i == 0 && inType.AssignableTo(reflect.TypeOf(CDecl{})) {

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
esc
close a dialog, leave a field

Branches 1

Tags 102

↑↓ moveenter openesc close