Merge pull request #2 from bnema/feature/callback-diagnostics-ledger
chore(callbacks): add opt-in callback diagnostics
5 files · +301 -21
M .github/workflows/test.yml +6 -13
| @@ -3,14 +3,15 @@ name: Test | |||
| 3 | 3 | on: [push, pull_request] | |
| 4 | 4 | ||
| 5 | 5 | env: | |
| 6 | - | QEMU_VERSION: "10.2.1+ds-1" | |
| 6 | + | QEMU_VERSION: "11.0.1+ds-1" | |
| 7 | 7 | ||
| 8 | 8 | jobs: | |
| 9 | 9 | test: | |
| 10 | 10 | strategy: | |
| 11 | + | fail-fast: false | |
| 11 | 12 | matrix: | |
| 12 | 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 | 15 | name: Test with Go ${{ matrix.go }} on ${{ matrix.os }} | |
| 15 | 16 | runs-on: ${{ matrix.os }} | |
| 16 | 17 | defaults: | |
| @@ -121,15 +122,6 @@ jobs: | |||
| 121 | 122 | env CGO_ENABLED=0 GOARCH=386 go test -shuffle=on -v -count=10 ./... | |
| 122 | 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 | 125 | - name: go test (Linux arm) | |
| 134 | 126 | if: ${{ runner.os == 'Linux' && runner.arch == 'ARM64' }} | |
| 135 | 127 | run: | | |
| @@ -156,18 +148,18 @@ jobs: | |||
| 156 | 148 | go env -u CXX | |
| 157 | 149 | ||
| 158 | 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 | 152 | run: | | |
| 161 | 153 | # -race usually requires Cgo, but macOS is an exception. See https://go.dev/doc/articles/race_detector#Requirements | |
| 162 | 154 | env CGO_ENABLED=0 go test -race -shuffle=on -v -count=10 ./... | |
| 163 | 155 | ||
| 164 | 156 | - name: go test race (Cgo) | |
| 165 | - | if: ${{ !startsWith(matrix.go, '1.18.') && !startsWith(matrix.go, '1.19.') }} | |
| 166 | 157 | run: | | |
| 167 | 158 | env CGO_ENABLED=1 go test -race -shuffle=on -v -count=10 ./... | |
| 168 | 159 | ||
| 169 | 160 | minor-arches: | |
| 170 | 161 | strategy: | |
| 162 | + | fail-fast: false | |
| 171 | 163 | matrix: | |
| 172 | 164 | # Test only the latest two Go versions to save CI time. | |
| 173 | 165 | # See https://go.dev/doc/devel/release#policy | |
| @@ -255,6 +247,7 @@ jobs: | |||
| 255 | 247 | ||
| 256 | 248 | bsd: | |
| 257 | 249 | strategy: | |
| 250 | + | fail-fast: false | |
| 258 | 251 | matrix: | |
| 259 | 252 | os: ['FreeBSD', 'NetBSD'] | |
| 260 | 253 | # 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 CXX | 148 | 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#Requirements | 153 | # -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#policy | 165 | # 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() { | |||
| 94 | 94 | ||
| 95 | 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 | 120 | ## Questions | |
| 98 | 121 | ||
| 99 | 122 | 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 | ## Questions | 120 | ## Questions | ||
| 98 | 121 | ||||
| 99 | If you have questions about how to incorporate purego in your project or want to discuss | 122 | 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 @@ | |||
| 1 | 1 | module github.com/bnema/purego | |
| 2 | 2 | ||
| 3 | - | go 1.18 | |
| 3 | + | go 1.20 | |
| @@ -1,3 +1,3 @@ | |||||
| 1 | module github.com/bnema/purego | 1 | module github.com/bnema/purego | ||
| 2 | 2 | ||||
| 3 | - | go 1.18 | 3 | + | go 1.20 |
M syscall_unix.go +225 -7
| @@ -7,13 +7,19 @@ | |||
| 7 | 7 | package purego | |
| 8 | 8 | ||
| 9 | 9 | import ( | |
| 10 | + | "encoding/json" | |
| 10 | 11 | "errors" | |
| 12 | + | "fmt" | |
| 13 | + | "os" | |
| 11 | 14 | "reflect" | |
| 12 | 15 | "runtime" | |
| 16 | + | "runtime/debug" | |
| 17 | + | "strings" | |
| 13 | 18 | "sync" | |
| 19 | + | "time" | |
| 14 | 20 | "unsafe" | |
| 15 | 21 | ||
| 16 | - | "github.com/bnema/purego/internal/strings" | |
| 22 | + | internalstrings "github.com/bnema/purego/internal/strings" | |
| 17 | 23 | ) | |
| 18 | 24 | ||
| 19 | 25 | var syscall15XABI0 uintptr | |
| @@ -60,7 +66,10 @@ func NewCallbackFnPtr(fnPtr any) uintptr { | |||
| 60 | 66 | if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Func { | |
| 61 | 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 | 73 | return addr | |
| 65 | 74 | } | |
| 66 | 75 | addr := compileCallback(val.Elem().Interface()) | |
| @@ -68,6 +77,7 @@ func NewCallbackFnPtr(fnPtr any) uintptr { | |||
| 68 | 77 | cbs.knownFnPtr[val.Pointer()] = addr | |
| 69 | 78 | if idx, ok := cbs.knownIdx[addr]; ok { | |
| 70 | 79 | cbs.fnPtrKeys[idx] = val.Pointer() | |
| 80 | + | recordCallbackLedger("fnptr-bind", idx, addr, val.Pointer(), val.Elem(), false, "") | |
| 71 | 81 | } | |
| 72 | 82 | cbs.lock.Unlock() | |
| 73 | 83 | return addr | |
| @@ -79,9 +89,12 @@ func UnrefCallback(cb uintptr) error { | |||
| 79 | 89 | defer cbs.lock.Unlock() | |
| 80 | 90 | idx, ok := cbs.knownIdx[cb] | |
| 81 | 91 | if !ok { | |
| 92 | + | recordCallbackLedger("release-miss", -1, cb, 0, reflect.Value{}, false, "callback not found") | |
| 82 | 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 | 98 | delete(cbs.knownFnPtr, key) | |
| 86 | 99 | cbs.fnPtrKeys[idx] = 0 | |
| 87 | 100 | } | |
| @@ -89,6 +102,7 @@ func UnrefCallback(cb uintptr) error { | |||
| 89 | 102 | cbs.holes[idx] = struct{}{} | |
| 90 | 103 | cbs.funcs[idx] = reflect.Value{} | |
| 91 | 104 | cbs.argPools[idx] = nil | |
| 105 | + | recordCallbackLedger("release", idx, cb, key, val, false, "") | |
| 92 | 106 | return nil | |
| 93 | 107 | } | |
| 94 | 108 | ||
| @@ -105,19 +119,23 @@ func UnrefCallbackFnPtr(fnPtr any) error { | |||
| 105 | 119 | defer cbs.lock.Unlock() | |
| 106 | 120 | addr, ok := cbs.knownFnPtr[val.Pointer()] | |
| 107 | 121 | if !ok { | |
| 122 | + | recordCallbackLedger("release-fnptr-miss", -1, 0, val.Pointer(), val.Elem(), false, "callback not found") | |
| 108 | 123 | return errors.New("callback not found") | |
| 109 | 124 | } | |
| 110 | 125 | idx, ok := cbs.knownIdx[addr] | |
| 111 | 126 | if !ok { | |
| 112 | 127 | delete(cbs.knownFnPtr, val.Pointer()) | |
| 128 | + | recordCallbackLedger("release-fnptr-miss", -1, addr, val.Pointer(), val.Elem(), false, "callback not found") | |
| 113 | 129 | return errors.New("callback not found") | |
| 114 | 130 | } | |
| 131 | + | callbackVal := cbs.funcs[idx] | |
| 115 | 132 | delete(cbs.knownFnPtr, val.Pointer()) | |
| 116 | 133 | delete(cbs.knownIdx, addr) | |
| 117 | 134 | cbs.fnPtrKeys[idx] = 0 | |
| 118 | 135 | cbs.holes[idx] = struct{}{} | |
| 119 | 136 | cbs.funcs[idx] = reflect.Value{} | |
| 120 | 137 | cbs.argPools[idx] = nil | |
| 138 | + | recordCallbackLedger("release-fnptr", idx, addr, val.Pointer(), callbackVal, false, "") | |
| 121 | 139 | return nil | |
| 122 | 140 | } | |
| 123 | 141 | ||
| @@ -134,17 +152,204 @@ var cbs = struct { | |||
| 134 | 152 | knownIdx map[uintptr]int // callback address -> slot index | |
| 135 | 153 | knownFnPtr map[uintptr]uintptr // function pointer variable address -> callback address | |
| 136 | 154 | fnPtrKeys [maxCB]uintptr // slot index -> function pointer variable address | |
| 155 | + | ledgerSeq uint64 // monotonic callback ledger event sequence | |
| 137 | 156 | }{ | |
| 138 | 157 | holes: make(map[int]struct{}), | |
| 139 | 158 | knownIdx: make(map[uintptr]int, maxCB), | |
| 140 | 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 | 163 | cbs.lock.RLock() | |
| 145 | 164 | defer cbs.lock.RUnlock() | |
| 146 | 165 | 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 | + | } | |
| 148 | 353 | } | |
| 149 | 354 | ||
| 150 | 355 | func compileCallback(fn any) uintptr { | |
| @@ -188,15 +393,23 @@ output: | |||
| 188 | 393 | cbs.lock.Lock() | |
| 189 | 394 | defer cbs.lock.Unlock() | |
| 190 | 395 | index := -1 | |
| 396 | + | reused := false | |
| 191 | 397 | for i := range cbs.holes { | |
| 192 | 398 | index = i | |
| 193 | 399 | delete(cbs.holes, i) | |
| 400 | + | reused = true | |
| 194 | 401 | break | |
| 195 | 402 | } | |
| 196 | 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 | 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 | 413 | index = cbs.numFn | |
| 201 | 414 | cbs.numFn++ | |
| 202 | 415 | } | |
| @@ -209,6 +422,11 @@ output: | |||
| 209 | 422 | } | |
| 210 | 423 | addr := callbackasmAddr(index) | |
| 211 | 424 | 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 | 430 | return addr | |
| 213 | 431 | } | |
| 214 | 432 | ||
| @@ -329,7 +547,7 @@ func callbackWrap(a *callbackArgs) { | |||
| 329 | 547 | ptr = frame[pos] | |
| 330 | 548 | } | |
| 331 | 549 | intsN += slots | |
| 332 | - | args[i] = reflect.ValueOf(strings.GoString(ptr)) | |
| 550 | + | args[i] = reflect.ValueOf(internalstrings.GoString(ptr)) | |
| 333 | 551 | continue | |
| 334 | 552 | case reflect.Struct: | |
| 335 | 553 | if i == 0 && inType.AssignableTo(reflect.TypeOf(CDecl{})) { | |
| @@ -7,13 +7,19 @@ | |||||
| 7 | package purego | 7 | 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 uintptr | 25 | 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 addr | 73 | 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()] = addr | 77 | 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 addr | 83 | 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] = 0 | 99 | 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] = nil | 104 | cbs.argPools[idx] = nil | ||
| 105 | + | recordCallbackLedger("release", idx, cb, key, val, false, "") | |||
| 92 | return nil | 106 | 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] = 0 | 134 | 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] = nil | 137 | cbs.argPools[idx] = nil | ||
| 138 | + | recordCallbackLedger("release-fnptr", idx, addr, val.Pointer(), callbackVal, false, "") | |||
| 121 | return nil | 139 | return nil | ||
| 122 | } | 140 | } | ||
| 123 | 141 | ||||
| @@ -134,17 +152,204 @@ var cbs = struct { | |||||
| 134 | knownIdx map[uintptr]int // callback address -> slot index | 152 | knownIdx map[uintptr]int // callback address -> slot index | ||
| 135 | knownFnPtr map[uintptr]uintptr // function pointer variable address -> callback address | 153 | knownFnPtr map[uintptr]uintptr // function pointer variable address -> callback address | ||
| 136 | fnPtrKeys [maxCB]uintptr // slot index -> function pointer variable address | 154 | 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, 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 | + | } | |||
| 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 := -1 | 395 | index := -1 | ||
| 396 | + | reused := false | |||
| 191 | for i := range cbs.holes { | 397 | for i := range cbs.holes { | ||
| 192 | index = i | 398 | index = i | ||
| 193 | delete(cbs.holes, i) | 399 | delete(cbs.holes, i) | ||
| 400 | + | reused = true | |||
| 194 | break | 401 | 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.numFn | 413 | 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] = index | 424 | 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 addr | 430 | 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 += slots | 549 | intsN += slots | ||
| 332 | - | args[i] = reflect.ValueOf(strings.GoString(ptr)) | 550 | + | args[i] = reflect.ValueOf(internalstrings.GoString(ptr)) |
| 333 | continue | 551 | 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{})) { | ||