detached 92bee5e

fix(systemviews): harden action lifecycle

commit
92bee5e8373f3170518929d12c946db9cf791709
author
brice · · 2026-04-24 08:30 UTC
parent
279f5af
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +107 -17
M assets/systemviews/systemviews.wasm.br +0 -0 binary file: no text diff

binary file: no text diff

M internal/infrastructure/systemviewsbridge/browser_transport_js.go +44 -8
@@ -249,24 +249,52 @@ func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
249249 }
250250 resultCh := make(chan result, 1)
251251
252- thenFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
252+ var (
253+ callbackMu sync.Mutex
254+ active = true
255+ released bool
256+ thenFn js.Func
257+ catchFn js.Func
258+ )
259+ releaseCallbacksLocked := func() {
260+ if released {
261+ return
262+ }
263+ released = true
264+ thenFn.Release()
265+ catchFn.Release()
266+ }
267+ publish := func(res result) {
268+ callbackMu.Lock()
269+ defer callbackMu.Unlock()
270+ if !active {
271+ releaseCallbacksLocked()
272+ return
273+ }
274+ active = false
275+ select {
276+ case resultCh <- res:
277+ default:
278+ }
279+ releaseCallbacksLocked()
280+ }
281+
282+ thenFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
253283 if len(args) == 0 {
254- resultCh <- result{value: js.Undefined()}
284+ publish(result{value: js.Undefined()})
255285 return nil
256286 }
257- resultCh <- result{value: args[0]}
287+ publish(result{value: args[0]})
258288 return nil
259289 })
260- catchFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
290+ catchFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
261291 if len(args) == 0 {
262- resultCh <- result{err: errors.New("promise rejected")}
292+ publish(result{err: errors.New("promise rejected")})
263293 return nil
264294 }
265- resultCh <- result{err: errors.New(args[0].String())}
295+ publish(result{err: errors.New(args[0].String())})
266296 return nil
267297 })
268- defer thenFn.Release()
269- defer catchFn.Release()
270298
271299 promise.Call("then", thenFn).Call("catch", catchFn)
272300
@@ -274,6 +302,14 @@ func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
274302 case res := <-resultCh:
275303 return res.value, res.err
276304 case <-ctx.Done():
305+ select {
306+ case res := <-resultCh:
307+ return res.value, res.err
308+ default:
309+ }
310+ callbackMu.Lock()
311+ active = false
312+ callbackMu.Unlock()
277313 return js.Undefined(), ctx.Err()
278314 }
279315 }
@@ -249,24 +249,52 @@ func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
249 }249 }
250 resultCh := make(chan result, 1)250 resultCh := make(chan result, 1)
251 251
252- thenFn := js.FuncOf(func(_ js.Value, args []js.Value) any {252+ var (
253+ callbackMu sync.Mutex
254+ active = true
255+ released bool
256+ thenFn js.Func
257+ catchFn js.Func
258+ )
259+ releaseCallbacksLocked := func() {
260+ if released {
261+ return
262+ }
263+ released = true
264+ thenFn.Release()
265+ catchFn.Release()
266+ }
267+ publish := func(res result) {
268+ callbackMu.Lock()
269+ defer callbackMu.Unlock()
270+ if !active {
271+ releaseCallbacksLocked()
272+ return
273+ }
274+ active = false
275+ select {
276+ case resultCh <- res:
277+ default:
278+ }
279+ releaseCallbacksLocked()
280+ }
281+
282+ thenFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
253 if len(args) == 0 {283 if len(args) == 0 {
254- resultCh <- result{value: js.Undefined()}284+ publish(result{value: js.Undefined()})
255 return nil285 return nil
256 }286 }
257- resultCh <- result{value: args[0]}287+ publish(result{value: args[0]})
258 return nil288 return nil
259 })289 })
260- catchFn := js.FuncOf(func(_ js.Value, args []js.Value) any {290+ catchFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
261 if len(args) == 0 {291 if len(args) == 0 {
262- resultCh <- result{err: errors.New("promise rejected")}292+ publish(result{err: errors.New("promise rejected")})
263 return nil293 return nil
264 }294 }
265- resultCh <- result{err: errors.New(args[0].String())}295+ publish(result{err: errors.New(args[0].String())})
266 return nil296 return nil
267 })297 })
268- defer thenFn.Release()
269- defer catchFn.Release()
270 298
271 promise.Call("then", thenFn).Call("catch", catchFn)299 promise.Call("then", thenFn).Call("catch", catchFn)
272 300
@@ -274,6 +302,14 @@ func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
274 case res := <-resultCh:302 case res := <-resultCh:
275 return res.value, res.err303 return res.value, res.err
276 case <-ctx.Done():304 case <-ctx.Done():
305+ select {
306+ case res := <-resultCh:
307+ return res.value, res.err
308+ default:
309+ }
310+ callbackMu.Lock()
311+ active = false
312+ callbackMu.Unlock()
277 return js.Undefined(), ctx.Err()313 return js.Undefined(), ctx.Err()
278 }314 }
279 }315 }
M internal/ui/systemviews/app.go +30 -9
@@ -21,6 +21,7 @@ type Dependencies struct {
2121
2222 type App struct {
2323 mu sync.Mutex
24+ actionMu sync.Mutex
2425 deps Dependencies
2526 currentRoute Route
2627 shellTheme shellTheme
@@ -85,6 +86,7 @@ func (a *App) RunWithContext(ctx context.Context) error {
8586 }
8687
8788 func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
89+ a.actionMu.Lock()
8890 if a.actionQueue == nil {
8991 workerCtx, cancel := context.WithCancel(ctx)
9092 a.actionQueue = make(chan DOMAction, 64)
@@ -92,24 +94,40 @@ func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error
9294 a.actionCancel = cancel
9395 go a.runActionWorker(workerCtx)
9496 }
97+ a.actionMu.Unlock()
9598 return binder.BindActions(func(action DOMAction) {
96- if !a.enqueueDOMAction(action) {
99+ if !a.enqueueDOMAction(action) && a.actionWorkerActive() {
97100 go a.surfaceActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action))
98101 }
99102 })
100103 }
101104
105+func (a *App) actionWorkerActive() bool {
106+ if a == nil {
107+ return false
108+ }
109+ a.actionMu.Lock()
110+ queue := a.actionQueue
111+ ctx := a.actionCtx
112+ a.actionMu.Unlock()
113+ return queue != nil && ctx != nil && ctx.Err() == nil
114+}
115+
102116 func (a *App) enqueueDOMAction(action DOMAction) bool {
103- if a == nil || a.actionQueue == nil {
117+ if a == nil {
104118 return false
105119 }
106- if a.actionCtx == nil {
120+ a.actionMu.Lock()
121+ queue := a.actionQueue
122+ ctx := a.actionCtx
123+ a.actionMu.Unlock()
124+ if queue == nil || ctx == nil {
107125 return false
108126 }
109127 select {
110- case <-a.actionCtx.Done():
128+ case <-ctx.Done():
111129 return false
112- case a.actionQueue <- action:
130+ case queue <- action:
113131 return true
114132 default:
115133 return false
@@ -146,12 +164,15 @@ func (a *App) Close() {
146164 if a == nil {
147165 return
148166 }
149- if a.actionCancel != nil {
150- a.actionCancel()
151- a.actionCancel = nil
152- }
167+ a.actionMu.Lock()
168+ cancel := a.actionCancel
169+ a.actionCancel = nil
153170 a.actionCtx = nil
154171 a.actionQueue = nil
172+ a.actionMu.Unlock()
173+ if cancel != nil {
174+ cancel()
175+ }
155176 if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {
156177 releaser.Release()
157178 }
@@ -21,6 +21,7 @@ type Dependencies struct {
21 21
22 type App struct {22 type App struct {
23 mu sync.Mutex23 mu sync.Mutex
24+ actionMu sync.Mutex
24 deps Dependencies25 deps Dependencies
25 currentRoute Route26 currentRoute Route
26 shellTheme shellTheme27 shellTheme shellTheme
@@ -85,6 +86,7 @@ func (a *App) RunWithContext(ctx context.Context) error {
85 }86 }
86 87
87 func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {88 func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
89+ a.actionMu.Lock()
88 if a.actionQueue == nil {90 if a.actionQueue == nil {
89 workerCtx, cancel := context.WithCancel(ctx)91 workerCtx, cancel := context.WithCancel(ctx)
90 a.actionQueue = make(chan DOMAction, 64)92 a.actionQueue = make(chan DOMAction, 64)
@@ -92,24 +94,40 @@ func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error
92 a.actionCancel = cancel94 a.actionCancel = cancel
93 go a.runActionWorker(workerCtx)95 go a.runActionWorker(workerCtx)
94 }96 }
97+ a.actionMu.Unlock()
95 return binder.BindActions(func(action DOMAction) {98 return binder.BindActions(func(action DOMAction) {
96- if !a.enqueueDOMAction(action) {99+ if !a.enqueueDOMAction(action) && a.actionWorkerActive() {
97 go a.surfaceActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action))100 go a.surfaceActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action))
98 }101 }
99 })102 })
100 }103 }
101 104
105+func (a *App) actionWorkerActive() bool {
106+ if a == nil {
107+ return false
108+ }
109+ a.actionMu.Lock()
110+ queue := a.actionQueue
111+ ctx := a.actionCtx
112+ a.actionMu.Unlock()
113+ return queue != nil && ctx != nil && ctx.Err() == nil
114+}
115+
102 func (a *App) enqueueDOMAction(action DOMAction) bool {116 func (a *App) enqueueDOMAction(action DOMAction) bool {
103- if a == nil || a.actionQueue == nil {117+ if a == nil {
104 return false118 return false
105 }119 }
106- if a.actionCtx == nil {120+ a.actionMu.Lock()
121+ queue := a.actionQueue
122+ ctx := a.actionCtx
123+ a.actionMu.Unlock()
124+ if queue == nil || ctx == nil {
107 return false125 return false
108 }126 }
109 select {127 select {
110- case <-a.actionCtx.Done():128+ case <-ctx.Done():
111 return false129 return false
112- case a.actionQueue <- action:130+ case queue <- action:
113 return true131 return true
114 default:132 default:
115 return false133 return false
@@ -146,12 +164,15 @@ func (a *App) Close() {
146 if a == nil {164 if a == nil {
147 return165 return
148 }166 }
149- if a.actionCancel != nil {167+ a.actionMu.Lock()
150- a.actionCancel()168+ cancel := a.actionCancel
151- a.actionCancel = nil169+ a.actionCancel = nil
152- }
153 a.actionCtx = nil170 a.actionCtx = nil
154 a.actionQueue = nil171 a.actionQueue = nil
172+ a.actionMu.Unlock()
173+ if cancel != nil {
174+ cancel()
175+ }
155 if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {176 if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {
156 releaser.Release()177 releaser.Release()
157 }178 }
M internal/ui/systemviews/app_test.go +33 -0
@@ -163,6 +163,24 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
163163 assert.Contains(t, dom.html, "Deleted history for example.com")
164164 }
165165
166+func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) {
167+ dom := &fakeActionDOM{}
168+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
169+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
170+
171+ ctx, cancel := context.WithCancel(context.Background())
172+ defer cancel()
173+ require.NoError(t, app.RunWithContext(ctx))
174+ require.NotNil(t, dom.handler)
175+
176+ app.Close()
177+ assert.True(t, dom.released)
178+ assert.False(t, app.enqueueDOMAction(DOMAction{Action: historyActionClear}))
179+ require.NotPanics(t, func() {
180+ dom.handler(DOMAction{Action: historyActionClear})
181+ })
182+}
183+
166184 func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
167185 t.Parallel()
168186
@@ -627,6 +645,21 @@ func (d *fakeDOM) Mount(html string) error {
627645 return nil
628646 }
629647
648+type fakeActionDOM struct {
649+ fakeDOM
650+ handler DOMActionHandler
651+ released bool
652+}
653+
654+func (d *fakeActionDOM) BindActions(handler DOMActionHandler) error {
655+ d.handler = handler
656+ return nil
657+}
658+
659+func (d *fakeActionDOM) Release() {
660+ d.released = true
661+}
662+
630663 // Handwritten fake to capture history state for stateful render assertions.
631664 type fakeHistoryService struct {
632665 called bool
@@ -163,6 +163,24 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
163 assert.Contains(t, dom.html, "Deleted history for example.com")163 assert.Contains(t, dom.html, "Deleted history for example.com")
164 }164 }
165 165
166+func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) {
167+ dom := &fakeActionDOM{}
168+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
169+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
170+
171+ ctx, cancel := context.WithCancel(context.Background())
172+ defer cancel()
173+ require.NoError(t, app.RunWithContext(ctx))
174+ require.NotNil(t, dom.handler)
175+
176+ app.Close()
177+ assert.True(t, dom.released)
178+ assert.False(t, app.enqueueDOMAction(DOMAction{Action: historyActionClear}))
179+ require.NotPanics(t, func() {
180+ dom.handler(DOMAction{Action: historyActionClear})
181+ })
182+}
183+
166 func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {184 func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
167 t.Parallel()185 t.Parallel()
168 186
@@ -627,6 +645,21 @@ func (d *fakeDOM) Mount(html string) error {
627 return nil645 return nil
628 }646 }
629 647
648+type fakeActionDOM struct {
649+ fakeDOM
650+ handler DOMActionHandler
651+ released bool
652+}
653+
654+func (d *fakeActionDOM) BindActions(handler DOMActionHandler) error {
655+ d.handler = handler
656+ return nil
657+}
658+
659+func (d *fakeActionDOM) Release() {
660+ d.released = true
661+}
662+
630 // Handwritten fake to capture history state for stateful render assertions.663 // Handwritten fake to capture history state for stateful render assertions.
631 type fakeHistoryService struct {664 type fakeHistoryService struct {
632 called bool665 called bool

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