detached 3b58ec4

fix(colorscheme): resolve callback unregister race condition and CI test env

commit
3b58ec414f4f2e5bfd079fd34c7eac48a41c04d4
author
bnema · · 2025-12-30 04:03 UTC
parent
77255a2
signature
GPG Unverified2B82207D370127A0 The key is unknown
2 files · +20 -8
M .github/workflows/ci.yml +3 -0
@@ -103,4 +103,7 @@ jobs:
103103 - name: Build frontend assets
104104 run: make build-frontend
105105 - name: Run tests
106+ env:
107+ GDK_BACKEND: wayland
108+ WAYLAND_DISPLAY: wayland-0
106109 run: go test -v ./...
@@ -103,4 +103,7 @@ jobs:
103 - name: Build frontend assets103 - name: Build frontend assets
104 run: make build-frontend104 run: make build-frontend
105 - name: Run tests105 - name: Run tests
106+ env:
107+ GDK_BACKEND: wayland
108+ WAYLAND_DISPLAY: wayland-0
106 run: go test -v ./...109 run: go test -v ./...
M internal/infrastructure/colorscheme/resolver.go +17 -8
@@ -22,6 +22,11 @@ type ConfigProvider interface {
2222 GetColorScheme() string
2323 }
2424
25+// callbackWrapper wraps a callback function to enable pointer comparison for removal.
26+type callbackWrapper struct {
27+ fn func(port.ColorSchemePreference)
28+}
29+
2530 // Resolver implements port.ColorSchemeResolver.
2631 // It manages multiple detectors and respects config overrides.
2732 type Resolver struct {
@@ -29,7 +34,7 @@ type Resolver struct {
2934 config ConfigProvider
3035 detectors []port.ColorSchemeDetector
3136 current port.ColorSchemePreference
32- callbacks []func(port.ColorSchemePreference)
37+ callbacks []*callbackWrapper
3338 }
3439
3540 // NewResolver creates a new color scheme resolver.
@@ -118,13 +123,13 @@ func (r *Resolver) Refresh() port.ColorSchemePreference {
118123 if newPref.PrefersDark != r.current.PrefersDark {
119124 r.current = newPref
120125 // Copy callbacks to avoid holding lock during callback invocation
121- callbacks := make([]func(port.ColorSchemePreference), len(r.callbacks))
126+ callbacks := make([]*callbackWrapper, len(r.callbacks))
122127 copy(callbacks, r.callbacks)
123128
124129 // Invoke callbacks outside of lock
125130 r.mu.Unlock()
126131 for _, cb := range callbacks {
127- cb(newPref)
132+ cb.fn(newPref)
128133 }
129134 r.mu.Lock()
130135 } else {
@@ -139,17 +144,21 @@ func (r *Resolver) OnChange(callback func(port.ColorSchemePreference)) func() {
139144 r.mu.Lock()
140145 defer r.mu.Unlock()
141146
142- r.callbacks = append(r.callbacks, callback)
143- idx := len(r.callbacks) - 1
147+ // Wrap callback to enable pointer comparison for removal
148+ wrapper := &callbackWrapper{fn: callback}
149+ r.callbacks = append(r.callbacks, wrapper)
144150
145151 // Return unregister function
146152 return func() {
147153 r.mu.Lock()
148154 defer r.mu.Unlock()
149155
150- // Remove callback by setting to nil and compacting later
151- if idx < len(r.callbacks) {
152- r.callbacks = append(r.callbacks[:idx], r.callbacks[idx+1:]...)
156+ // Find and remove callback by pointer equality
157+ for i, cb := range r.callbacks {
158+ if cb == wrapper {
159+ r.callbacks = append(r.callbacks[:i], r.callbacks[i+1:]...)
160+ return
161+ }
153162 }
154163 }
155164 }
@@ -22,6 +22,11 @@ type ConfigProvider interface {
22 GetColorScheme() string22 GetColorScheme() string
23 }23 }
24 24
25+// callbackWrapper wraps a callback function to enable pointer comparison for removal.
26+type callbackWrapper struct {
27+ fn func(port.ColorSchemePreference)
28+}
29+
25 // Resolver implements port.ColorSchemeResolver.30 // Resolver implements port.ColorSchemeResolver.
26 // It manages multiple detectors and respects config overrides.31 // It manages multiple detectors and respects config overrides.
27 type Resolver struct {32 type Resolver struct {
@@ -29,7 +34,7 @@ type Resolver struct {
29 config ConfigProvider34 config ConfigProvider
30 detectors []port.ColorSchemeDetector35 detectors []port.ColorSchemeDetector
31 current port.ColorSchemePreference36 current port.ColorSchemePreference
32- callbacks []func(port.ColorSchemePreference)37+ callbacks []*callbackWrapper
33 }38 }
34 39
35 // NewResolver creates a new color scheme resolver.40 // NewResolver creates a new color scheme resolver.
@@ -118,13 +123,13 @@ func (r *Resolver) Refresh() port.ColorSchemePreference {
118 if newPref.PrefersDark != r.current.PrefersDark {123 if newPref.PrefersDark != r.current.PrefersDark {
119 r.current = newPref124 r.current = newPref
120 // Copy callbacks to avoid holding lock during callback invocation125 // Copy callbacks to avoid holding lock during callback invocation
121- callbacks := make([]func(port.ColorSchemePreference), len(r.callbacks))126+ callbacks := make([]*callbackWrapper, len(r.callbacks))
122 copy(callbacks, r.callbacks)127 copy(callbacks, r.callbacks)
123 128
124 // Invoke callbacks outside of lock129 // Invoke callbacks outside of lock
125 r.mu.Unlock()130 r.mu.Unlock()
126 for _, cb := range callbacks {131 for _, cb := range callbacks {
127- cb(newPref)132+ cb.fn(newPref)
128 }133 }
129 r.mu.Lock()134 r.mu.Lock()
130 } else {135 } else {
@@ -139,17 +144,21 @@ func (r *Resolver) OnChange(callback func(port.ColorSchemePreference)) func() {
139 r.mu.Lock()144 r.mu.Lock()
140 defer r.mu.Unlock()145 defer r.mu.Unlock()
141 146
142- r.callbacks = append(r.callbacks, callback)147+ // Wrap callback to enable pointer comparison for removal
143- idx := len(r.callbacks) - 1148+ wrapper := &callbackWrapper{fn: callback}
149+ r.callbacks = append(r.callbacks, wrapper)
144 150
145 // Return unregister function151 // Return unregister function
146 return func() {152 return func() {
147 r.mu.Lock()153 r.mu.Lock()
148 defer r.mu.Unlock()154 defer r.mu.Unlock()
149 155
150- // Remove callback by setting to nil and compacting later156+ // Find and remove callback by pointer equality
151- if idx < len(r.callbacks) {157+ for i, cb := range r.callbacks {
152- r.callbacks = append(r.callbacks[:idx], r.callbacks[idx+1:]...)158+ if cb == wrapper {
159+ r.callbacks = append(r.callbacks[:i], r.callbacks[i+1:]...)
160+ return
161+ }
153 }162 }
154 }163 }
155 }164 }

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