omnibox_callback_pressure_stress_test.go RawHistoryFolder
1package component
2
3import (
4 "bufio"
5 "encoding/json"
6 "os"
7 "testing"
8 "time"
9
10 "github.com/bnema/purego"
11 "github.com/bnema/puregotk/v4/gdk"
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
18const gtkCallbackStressIterations = 3_200
19
20type 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.
28func 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 display := gdk.DisplayGetDefault()
37 if display == nil {
38 t.Skip("GTK native display prerequisite unavailable (no default GDK display)")
39 }
40 mainContext := glib.MainContextDefault()
41 require.NotNil(t, mainContext)
42 done := make(chan struct{})
43 stress := glib.SourceFunc(func(uintptr) bool {
44 defer close(done)
45 runGTKCallbackLifecycleStressOnMain(t, mainContext, ledgerPath)
46 return false
47 })
48 // Invoke guarantees that the entire GTK mutation sequence is owned by the
49 // default context, whether the test thread owns it or a native GTK loop does.
50 mainContext.Invoke(&stress, 0)
51 select {
52 case <-done:
53 case <-time.After(30 * time.Second):
54 t.Fatal("GTK main context did not execute callback lifecycle stress")
55 }
56}
57
58func runGTKCallbackLifecycleStressOnMain(t *testing.T, mainContext *glib.MainContext, ledgerPath string) {
59 t.Helper()
60 assertMainContextOwner := func() {
61 t.Helper()
62 require.True(t, mainContext.IsOwner(), "GTK mutation must own the default GLib main context")
63 }
64
65 assertMainContextOwner()
66 root := gtk.NewBox(gtk.OrientationVerticalValue, 0)
67 assertMainContextOwner()
68 p0 := gtk.NewBox(gtk.OrientationVerticalValue, 0)
69 assertMainContextOwner()
70 p3 := gtk.NewBox(gtk.OrientationVerticalValue, 0)
71 require.NotNil(t, root)
72 require.NotNil(t, p0)
73 require.NotNil(t, p3)
74 assertMainContextOwner()
75 root.Append(&p0.Widget)
76 assertMainContextOwner()
77 root.Append(&p3.Widget)
78 ledgerStart := len(readCallbackLedgerEvents(t, ledgerPath))
79
80 enters, leaves := 0, 0
81 for i := 0; i < gtkCallbackStressIterations; i++ {
82 pane := p0
83 if i%2 != 0 {
84 pane = p3
85 }
86 assertMainContextOwner()
87 controller := gtk.NewEventControllerMotion()
88 require.NotNil(t, controller)
89 enter := func(_ gtk.EventControllerMotion, _ float64, _ float64) { enters++ }
90 leave := func(_ gtk.EventControllerMotion) { leaves++ }
91 enterID := controller.ConnectEnter(&enter)
92 leaveID := controller.ConnectLeave(&leave)
93 assertMainContextOwner()
94 pane.AddController(&controller.EventController)
95
96 // Invoke the real connected handler functions in GTK enter/leave order
97 // while the alternate pane is closed and the workspace rebuilt.
98 enter(*controller, 0, 0)
99 leave(*controller)
100
101 tick := gtk.TickCallback(func(uintptr, uintptr, uintptr) bool { return false })
102 tickCallback := glib.NewCallback(&tick)
103 assertMainContextOwner()
104 tickID := pane.AddTickCallback(&tick, 0, nil)
105 require.NotZero(t, tickID)
106 assertMainContextOwner()
107 pane.RemoveTickCallback(tickID)
108 require.NoError(t, purego.UnrefCallback(tickCallback), "raw tick slot must be released after GTK removal")
109
110 assertMainContextOwner()
111 gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), enterID)
112 assertMainContextOwner()
113 gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(controller.GoPointer()), leaveID)
114 assertMainContextOwner()
115 pane.RemoveController(&controller.EventController)
116
117 // Alternate p3 <-> p0 close/rebuild mutations on the same main context.
118 assertMainContextOwner()
119 root.Remove(&pane.Widget)
120 assertMainContextOwner()
121 root.Append(&pane.Widget)
122 }
123 require.Equal(t, gtkCallbackStressIterations, enters)
124 require.Equal(t, gtkCallbackStressIterations, leaves)
125
126 events := readCallbackLedgerEvents(t, ledgerPath)[ledgerStart:]
127 var tickReleases, tickReuses int
128 for _, event := range events {
129 switch event.Event {
130 case "release-fnptr", "release":
131 tickReleases++
132 case "reuse":
133 tickReuses++
134 case "release-miss", "release-fnptr-miss", "exhausted":
135 t.Fatalf("callback ledger recorded %s for tick callback", event.Event)
136 }
137 }
138 require.Equal(t, gtkCallbackStressIterations, tickReleases, "every raw GTK tick slot must be released exactly once")
139 // The first motion-controller connection may initialize one shared GTK
140 // trampoline after the baseline; every subsequent raw tick slot is reused.
141 require.GreaterOrEqual(t, tickReuses, gtkCallbackStressIterations-2, "released raw tick slots must be reused")
142}
143
144func readCallbackLedgerEvents(t *testing.T, path string) []callbackLedgerEvent {
145 t.Helper()
146 file, err := os.Open(path)
147 require.NoError(t, err)
148 defer file.Close()
149
150 var events []callbackLedgerEvent
151 scanner := bufio.NewScanner(file)
152 for scanner.Scan() {
153 var event callbackLedgerEvent
154 require.NoError(t, json.Unmarshal(scanner.Bytes(), &event))
155 events = append(events, event)
156 }
157 require.NoError(t, scanner.Err())
158 return events
159}