detached 703eaba

fix(stress): exercise GTK callback lifecycle under load

commit
703eabaf315a10db257673e4f76e33619782d103
author
brice · · 2026-07-14 07:22 UTC
committer
brice · · 2026-07-14 09:32 UTC
parent
fba7744
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +156 -0
A internal/ui/component/omnibox_callback_pressure_stress_test.go +156 -0
@@ -0,0 +1,156 @@
1+package component
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/purego"
12+ "github.com/bnema/puregotk/v4/glib"
13+ "github.com/bnema/puregotk/v4/gobject"
14+ "github.com/bnema/puregotk/v4/gtk"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+const gtkCallbackStressIterations = 3_200
19+
20+type callbackLedgerEvent struct {
21+ Event string `json:"event"`
22+ Type string `json:"type"`
23+}
24+
25+// runGTKCallbackLifecycleStress exercises real GTK motion controllers and raw
26+// tick callbacks while p3 and p0 are repeatedly removed/rebuilt. It is never
27+// opt-in: GTK init is skipped only when the host has no usable native display.
28+func runGTKCallbackLifecycleStress(t *testing.T) {
29+ t.Helper()
30+ ledgerPath := t.TempDir() + "/purego-callback-ledger.jsonl"
31+ t.Setenv("PUREGO_CALLBACK_LEDGER", "1")
32+ t.Setenv("PUREGO_CALLBACK_LEDGER_FILE", ledgerPath)
33+ if !gtk.InitCheck() {
34+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
35+ }
36+
37+ mainContext := glib.MainContextDefault()
38+ require.NotNil(t, mainContext)
39+ done := make(chan struct{})
40+ stress := glib.SourceFunc(func(uintptr) bool {
41+ defer close(done)
42+ runGTKCallbackLifecycleStressOnMain(t, mainContext, ledgerPath)
43+ return false
44+ })
45+ // Invoke guarantees that the entire GTK mutation sequence is owned by the
46+ // default context, whether the test thread owns it or a native GTK loop does.
47+ mainContext.Invoke(&stress, 0)
48+ select {
49+ case <-done:
50+ case <-time.After(30 * time.Second):
51+ t.Fatal("GTK main context did not execute callback lifecycle stress")
52+ }
53+}
54+
55+func runGTKCallbackLifecycleStressOnMain(t *testing.T, mainContext *glib.MainContext, ledgerPath string) {
56+ t.Helper()
57+ assertMainContextOwner := func() {
58+ t.Helper()
59+ require.True(t, mainContext.IsOwner(), "GTK mutation must own the default GLib main context")
60+ }
61+
62+ assertMainContextOwner()
63+ root := gtk.NewBox(gtk.OrientationVerticalValue, 0)
64+ assertMainContextOwner()
65+ p0 := gtk.NewBox(gtk.OrientationVerticalValue, 0)
66+ assertMainContextOwner()
67+ p3 := gtk.NewBox(gtk.OrientationVerticalValue, 0)
68+ require.NotNil(t, root)
69+ require.NotNil(t, p0)
70+ require.NotNil(t, p3)
71+ assertMainContextOwner()
72+ root.Append(&p0.Widget)
73+ assertMainContextOwner()
74+ root.Append(&p3.Widget)
75+
76+ enters, leaves := 0, 0
77+ for i := 0; i < gtkCallbackStressIterations; i++ {
78+ pane := p0
79+ if i%2 != 0 {
80+ pane = p3
81+ }
82+ assertMainContextOwner()
83+ controller := gtk.NewEventControllerMotion()
84+ require.NotNil(t, controller)
85+ enter := func(_ gtk.EventControllerMotion, _ float64, _ float64) { enters++ }
86+ leave := func(_ gtk.EventControllerMotion) { leaves++ }
87+ enterID := controller.ConnectEnter(&enter)
88+ leaveID := controller.ConnectLeave(&leave)
89+ assertMainContextOwner()
90+ pane.Widget.AddController(&controller.EventController)
91+
92+ // Invoke the real connected handler functions in GTK enter/leave order
93+ // while the alternate pane is closed and the workspace rebuilt.
94+ enter(*controller, 0, 0)
95+ leave(*controller)
96+
97+ tick := gtk.TickCallback(func(uintptr, uintptr, uintptr) bool { return false })
98+ tickCallback := glib.NewCallback(&tick)
99+ assertMainContextOwner()
100+ tickID := pane.Widget.AddTickCallback(&tick, 0, nil)
101+ require.NotZero(t, tickID)
102+ assertMainContextOwner()
103+ pane.Widget.RemoveTickCallback(tickID)
104+ require.NoError(t, purego.UnrefCallback(tickCallback), "raw tick slot must be released after GTK removal")
105+
106+ assertMainContextOwner()
107+ gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), enterID)
108+ assertMainContextOwner()
109+ gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), leaveID)
110+ assertMainContextOwner()
111+ pane.Widget.RemoveController(&controller.EventController)
112+
113+ // Alternate p3 <-> p0 close/rebuild mutations on the same main context.
114+ assertMainContextOwner()
115+ root.Remove(&pane.Widget)
116+ assertMainContextOwner()
117+ root.Append(&pane.Widget)
118+ }
119+ require.Equal(t, gtkCallbackStressIterations, enters)
120+ require.Equal(t, gtkCallbackStressIterations, leaves)
121+
122+ events := readCallbackLedgerEvents(t, ledgerPath)
123+ var tickReleases, tickReuses int
124+ for _, event := range events {
125+ if !strings.Contains(event.Type, "TickCallback") {
126+ continue
127+ }
128+ switch event.Event {
129+ case "release-fnptr", "release":
130+ tickReleases++
131+ case "reuse":
132+ tickReuses++
133+ case "release-miss", "release-fnptr-miss", "exhausted":
134+ t.Fatalf("callback ledger recorded %s for tick callback", event.Event)
135+ }
136+ }
137+ require.Equal(t, gtkCallbackStressIterations, tickReleases, "every raw GTK tick slot must be released exactly once")
138+ require.GreaterOrEqual(t, tickReuses, gtkCallbackStressIterations-1, "released raw tick slots must be reused")
139+}
140+
141+func readCallbackLedgerEvents(t *testing.T, path string) []callbackLedgerEvent {
142+ t.Helper()
143+ file, err := os.Open(path)
144+ require.NoError(t, err)
145+ defer file.Close()
146+
147+ var events []callbackLedgerEvent
148+ scanner := bufio.NewScanner(file)
149+ for scanner.Scan() {
150+ var event callbackLedgerEvent
151+ require.NoError(t, json.Unmarshal(scanner.Bytes(), &event))
152+ events = append(events, event)
153+ }
154+ require.NoError(t, scanner.Err())
155+ return events
156+}
@@ -0,0 +1,156 @@
1+package component
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/purego"
12+ "github.com/bnema/puregotk/v4/glib"
13+ "github.com/bnema/puregotk/v4/gobject"
14+ "github.com/bnema/puregotk/v4/gtk"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+const gtkCallbackStressIterations = 3_200
19+
20+type callbackLedgerEvent struct {
21+ Event string `json:"event"`
22+ Type string `json:"type"`
23+}
24+
25+// runGTKCallbackLifecycleStress exercises real GTK motion controllers and raw
26+// tick callbacks while p3 and p0 are repeatedly removed/rebuilt. It is never
27+// opt-in: GTK init is skipped only when the host has no usable native display.
28+func runGTKCallbackLifecycleStress(t *testing.T) {
29+ t.Helper()
30+ ledgerPath := t.TempDir() + "/purego-callback-ledger.jsonl"
31+ t.Setenv("PUREGO_CALLBACK_LEDGER", "1")
32+ t.Setenv("PUREGO_CALLBACK_LEDGER_FILE", ledgerPath)
33+ if !gtk.InitCheck() {
34+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
35+ }
36+
37+ mainContext := glib.MainContextDefault()
38+ require.NotNil(t, mainContext)
39+ done := make(chan struct{})
40+ stress := glib.SourceFunc(func(uintptr) bool {
41+ defer close(done)
42+ runGTKCallbackLifecycleStressOnMain(t, mainContext, ledgerPath)
43+ return false
44+ })
45+ // Invoke guarantees that the entire GTK mutation sequence is owned by the
46+ // default context, whether the test thread owns it or a native GTK loop does.
47+ mainContext.Invoke(&stress, 0)
48+ select {
49+ case <-done:
50+ case <-time.After(30 * time.Second):
51+ t.Fatal("GTK main context did not execute callback lifecycle stress")
52+ }
53+}
54+
55+func runGTKCallbackLifecycleStressOnMain(t *testing.T, mainContext *glib.MainContext, ledgerPath string) {
56+ t.Helper()
57+ assertMainContextOwner := func() {
58+ t.Helper()
59+ require.True(t, mainContext.IsOwner(), "GTK mutation must own the default GLib main context")
60+ }
61+
62+ assertMainContextOwner()
63+ root := gtk.NewBox(gtk.OrientationVerticalValue, 0)
64+ assertMainContextOwner()
65+ p0 := gtk.NewBox(gtk.OrientationVerticalValue, 0)
66+ assertMainContextOwner()
67+ p3 := gtk.NewBox(gtk.OrientationVerticalValue, 0)
68+ require.NotNil(t, root)
69+ require.NotNil(t, p0)
70+ require.NotNil(t, p3)
71+ assertMainContextOwner()
72+ root.Append(&p0.Widget)
73+ assertMainContextOwner()
74+ root.Append(&p3.Widget)
75+
76+ enters, leaves := 0, 0
77+ for i := 0; i < gtkCallbackStressIterations; i++ {
78+ pane := p0
79+ if i%2 != 0 {
80+ pane = p3
81+ }
82+ assertMainContextOwner()
83+ controller := gtk.NewEventControllerMotion()
84+ require.NotNil(t, controller)
85+ enter := func(_ gtk.EventControllerMotion, _ float64, _ float64) { enters++ }
86+ leave := func(_ gtk.EventControllerMotion) { leaves++ }
87+ enterID := controller.ConnectEnter(&enter)
88+ leaveID := controller.ConnectLeave(&leave)
89+ assertMainContextOwner()
90+ pane.Widget.AddController(&controller.EventController)
91+
92+ // Invoke the real connected handler functions in GTK enter/leave order
93+ // while the alternate pane is closed and the workspace rebuilt.
94+ enter(*controller, 0, 0)
95+ leave(*controller)
96+
97+ tick := gtk.TickCallback(func(uintptr, uintptr, uintptr) bool { return false })
98+ tickCallback := glib.NewCallback(&tick)
99+ assertMainContextOwner()
100+ tickID := pane.Widget.AddTickCallback(&tick, 0, nil)
101+ require.NotZero(t, tickID)
102+ assertMainContextOwner()
103+ pane.Widget.RemoveTickCallback(tickID)
104+ require.NoError(t, purego.UnrefCallback(tickCallback), "raw tick slot must be released after GTK removal")
105+
106+ assertMainContextOwner()
107+ gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), enterID)
108+ assertMainContextOwner()
109+ gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), leaveID)
110+ assertMainContextOwner()
111+ pane.Widget.RemoveController(&controller.EventController)
112+
113+ // Alternate p3 <-> p0 close/rebuild mutations on the same main context.
114+ assertMainContextOwner()
115+ root.Remove(&pane.Widget)
116+ assertMainContextOwner()
117+ root.Append(&pane.Widget)
118+ }
119+ require.Equal(t, gtkCallbackStressIterations, enters)
120+ require.Equal(t, gtkCallbackStressIterations, leaves)
121+
122+ events := readCallbackLedgerEvents(t, ledgerPath)
123+ var tickReleases, tickReuses int
124+ for _, event := range events {
125+ if !strings.Contains(event.Type, "TickCallback") {
126+ continue
127+ }
128+ switch event.Event {
129+ case "release-fnptr", "release":
130+ tickReleases++
131+ case "reuse":
132+ tickReuses++
133+ case "release-miss", "release-fnptr-miss", "exhausted":
134+ t.Fatalf("callback ledger recorded %s for tick callback", event.Event)
135+ }
136+ }
137+ require.Equal(t, gtkCallbackStressIterations, tickReleases, "every raw GTK tick slot must be released exactly once")
138+ require.GreaterOrEqual(t, tickReuses, gtkCallbackStressIterations-1, "released raw tick slots must be reused")
139+}
140+
141+func readCallbackLedgerEvents(t *testing.T, path string) []callbackLedgerEvent {
142+ t.Helper()
143+ file, err := os.Open(path)
144+ require.NoError(t, err)
145+ defer file.Close()
146+
147+ var events []callbackLedgerEvent
148+ scanner := bufio.NewScanner(file)
149+ for scanner.Scan() {
150+ var event callbackLedgerEvent
151+ require.NoError(t, json.Unmarshal(scanner.Bytes(), &event))
152+ events = append(events, event)
153+ }
154+ require.NoError(t, scanner.Err())
155+ return events
156+}

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 8

Tags 42

↑↓ moveenter openesc close