detached 3df818c

fix(ticks): unref raw callback slots exactly once

commit
3df818c98e7cb92e9a0b64f6914535a73b078fb6
author
brice · · 2026-07-14 07:10 UTC
committer
brice · · 2026-07-14 09:32 UTC
parent
7b6d57c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +26 -6
M internal/infrastructure/cef/webview.go +16 -5
@@ -39,6 +39,10 @@ var (
3939 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
4040 var errDestroyed = errors.New("cef: webview is destroyed")
4141
42+// unrefTickCallback is the canonical puregotk/purego callback-slot release.
43+// It remains injectable so lifecycle tests can prove exactly-once ownership.
44+var unrefTickCallback = glib.UnrefCallback
45+
4246 // errNoBrowser is returned when the browser has not been created yet.
4347 var errNoBrowser = errors.New("cef: browser not yet created")
4448
@@ -2103,26 +2107,33 @@ func (wv *WebView) startBeginFrameLoop() {
21032107 func (wv *WebView) stopBeginFrameLoop() {
21042108 wv.mu.Lock()
21052109 tickID := wv.beginFrameTickID
2110+ callback := wv.beginFrameTick
21062111 wv.beginFrameTickID = 0
21072112 wv.beginFrameTick = nil
21082113 bridge := wv.viewBridge
21092114 wv.mu.Unlock()
21102115
2111- if tickID == 0 || bridge == nil {
2112- return
2116+ if tickID != 0 && bridge != nil {
2117+ if widget := bridge.Widget(); widget != nil {
2118+ widget.RemoveTickCallback(tickID)
2119+ }
21132120 }
2114- if widget := bridge.Widget(); widget != nil {
2115- widget.RemoveTickCallback(tickID)
2121+ if callback != nil {
2122+ _ = unrefTickCallback(callback)
21162123 }
21172124 }
21182125
2119-// releaseBeginFrameTickCallback releases the Go callback reference after GTK
2126+// releaseBeginFrameTickCallback releases the purego callback slot after GTK
21202127 // removes a source because its callback returned false.
21212128 func (wv *WebView) releaseBeginFrameTickCallback() {
21222129 wv.mu.Lock()
2130+ callback := wv.beginFrameTick
21232131 wv.beginFrameTickID = 0
21242132 wv.beginFrameTick = nil
21252133 wv.mu.Unlock()
2134+ if callback != nil {
2135+ _ = unrefTickCallback(callback)
2136+ }
21262137 }
21272138
21282139 func (wv *WebView) runOnGTK(fn func()) {
@@ -39,6 +39,10 @@ var (
39 // errDestroyed is returned when an operation is attempted on a destroyed WebView.39 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
40 var errDestroyed = errors.New("cef: webview is destroyed")40 var errDestroyed = errors.New("cef: webview is destroyed")
41 41
42+// unrefTickCallback is the canonical puregotk/purego callback-slot release.
43+// It remains injectable so lifecycle tests can prove exactly-once ownership.
44+var unrefTickCallback = glib.UnrefCallback
45+
42 // errNoBrowser is returned when the browser has not been created yet.46 // errNoBrowser is returned when the browser has not been created yet.
43 var errNoBrowser = errors.New("cef: browser not yet created")47 var errNoBrowser = errors.New("cef: browser not yet created")
44 48
@@ -2103,26 +2107,33 @@ func (wv *WebView) startBeginFrameLoop() {
2103 func (wv *WebView) stopBeginFrameLoop() {2107 func (wv *WebView) stopBeginFrameLoop() {
2104 wv.mu.Lock()2108 wv.mu.Lock()
2105 tickID := wv.beginFrameTickID2109 tickID := wv.beginFrameTickID
2110+ callback := wv.beginFrameTick
2106 wv.beginFrameTickID = 02111 wv.beginFrameTickID = 0
2107 wv.beginFrameTick = nil2112 wv.beginFrameTick = nil
2108 bridge := wv.viewBridge2113 bridge := wv.viewBridge
2109 wv.mu.Unlock()2114 wv.mu.Unlock()
2110 2115
2111- if tickID == 0 || bridge == nil {2116+ if tickID != 0 && bridge != nil {
2112- return2117+ if widget := bridge.Widget(); widget != nil {
2118+ widget.RemoveTickCallback(tickID)
2119+ }
2113 }2120 }
2114- if widget := bridge.Widget(); widget != nil {2121+ if callback != nil {
2115- widget.RemoveTickCallback(tickID)2122+ _ = unrefTickCallback(callback)
2116 }2123 }
2117 }2124 }
2118 2125
2119-// releaseBeginFrameTickCallback releases the Go callback reference after GTK2126+// releaseBeginFrameTickCallback releases the purego callback slot after GTK
2120 // removes a source because its callback returned false.2127 // removes a source because its callback returned false.
2121 func (wv *WebView) releaseBeginFrameTickCallback() {2128 func (wv *WebView) releaseBeginFrameTickCallback() {
2122 wv.mu.Lock()2129 wv.mu.Lock()
2130+ callback := wv.beginFrameTick
2123 wv.beginFrameTickID = 02131 wv.beginFrameTickID = 0
2124 wv.beginFrameTick = nil2132 wv.beginFrameTick = nil
2125 wv.mu.Unlock()2133 wv.mu.Unlock()
2134+ if callback != nil {
2135+ _ = unrefTickCallback(callback)
2136+ }
2126 }2137 }
2127 2138
2128 func (wv *WebView) runOnGTK(fn func()) {2139 func (wv *WebView) runOnGTK(fn func()) {
M internal/ui/app.go +10 -1
@@ -57,7 +57,10 @@ func gtkApplicationFlags() gio.ApplicationFlags {
5757 return gio.GApplicationNonUniqueValue
5858 }
5959
60-var adwaitaInitOnce sync.Once
60+var (
61+ adwaitaInitOnce sync.Once
62+ unrefTickCallback = glib.UnrefCallback
63+)
6164
6265 // EnsureAdwaitaInitialized initializes libadwaita and GTK exactly once.
6366 func EnsureAdwaitaInitialized() {
@@ -4635,9 +4638,15 @@ func (session *floatingWorkspaceSession) releaseResizeTickCallback() {
46354638 if session == nil {
46364639 return
46374640 }
4641+ callback := session.resizeTickCallback
46384642 session.resizeTickID = 0
46394643 session.resizeTickCallback = nil
46404644 session.resizeWatcherActive = false
4645+ if callback != nil {
4646+ // AddTickCallback creates a purego function-pointer slot. Clearing the Go
4647+ // pointer alone cannot return that finite runtime slot to the ledger.
4648+ _ = unrefTickCallback(callback)
4649+ }
46414650 }
46424651
46434652 func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {
@@ -57,7 +57,10 @@ func gtkApplicationFlags() gio.ApplicationFlags {
57 return gio.GApplicationNonUniqueValue57 return gio.GApplicationNonUniqueValue
58 }58 }
59 59
60-var adwaitaInitOnce sync.Once60+var (
61+ adwaitaInitOnce sync.Once
62+ unrefTickCallback = glib.UnrefCallback
63+)
61 64
62 // EnsureAdwaitaInitialized initializes libadwaita and GTK exactly once.65 // EnsureAdwaitaInitialized initializes libadwaita and GTK exactly once.
63 func EnsureAdwaitaInitialized() {66 func EnsureAdwaitaInitialized() {
@@ -4635,9 +4638,15 @@ func (session *floatingWorkspaceSession) releaseResizeTickCallback() {
4635 if session == nil {4638 if session == nil {
4636 return4639 return
4637 }4640 }
4641+ callback := session.resizeTickCallback
4638 session.resizeTickID = 04642 session.resizeTickID = 0
4639 session.resizeTickCallback = nil4643 session.resizeTickCallback = nil
4640 session.resizeWatcherActive = false4644 session.resizeWatcherActive = false
4645+ if callback != nil {
4646+ // AddTickCallback creates a purego function-pointer slot. Clearing the Go
4647+ // pointer alone cannot return that finite runtime slot to the ledger.
4648+ _ = unrefTickCallback(callback)
4649+ }
4641 }4650 }
4642 4651
4643 func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {4652 func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspaceSession) {

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