detached 8d7a354

fix(daemon): join only started remote link workers

commit
8d7a35460c108f4c7afc9914de44875317c7955d
author
brice · · 2026-08-06 20:05 UTC
parent
b902d96
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +21 -6
M internal/usecase/daemon/daemon.go +1 -1
@@ -849,7 +849,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
849849 warm.stop()
850850 }
851851 for _, link := range remoteLinks {
852- <-link.done
852+ joinRemoteLink(link)
853853 }
854854 d.finishParkedAttachmentRetirements(parkedRetirements)
855855 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
@@ -849,7 +849,7 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
849 warm.stop()849 warm.stop()
850 }850 }
851 for _, link := range remoteLinks {851 for _, link := range remoteLinks {
852- <-link.done852+ joinRemoteLink(link)
853 }853 }
854 d.finishParkedAttachmentRetirements(parkedRetirements)854 d.finishParkedAttachmentRetirements(parkedRetirements)
855 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))855 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
M internal/usecase/daemon/remote_link.go +16 -4
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "sync"
9+ "sync/atomic"
910
1011 "github.com/bnema/vev/internal/domain"
1112 "github.com/bnema/vev/internal/ports"
@@ -37,10 +38,11 @@ type remoteLink struct {
3738 transport ports.Transport
3839 cancel context.CancelFunc
3940
40- sendMu sync.Mutex
41- startOnce sync.Once
42- done chan struct{}
43- active bool // guarded by view.mu
41+ sendMu sync.Mutex
42+ startOnce sync.Once
43+ workerStarted atomic.Bool
44+ done chan struct{}
45+ active bool // guarded by view.mu
4446 }
4547
4648 // remoteOutputState is the remote-link equivalent of the thin client's
@@ -458,10 +460,20 @@ func (d *Daemon) startRemoteLink(link *remoteLink) {
458460 return
459461 }
460462 link.startOnce.Do(func() {
463+ link.workerStarted.Store(true)
461464 go d.runRemoteLink(link)
462465 })
463466 }
464467
468+// joinRemoteLink waits only for a receiver that was actually started. Candidate
469+// and test-only links may have no transport worker; treating their open done
470+// channel as joinable would deadlock terminal cleanup.
471+func joinRemoteLink(link *remoteLink) {
472+ if link != nil && link.workerStarted.Load() {
473+ <-link.done
474+ }
475+}
476+
465477 func (d *Daemon) runRemoteLink(link *remoteLink) {
466478 defer close(link.done)
467479 for {
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "sync"8 "sync"
9+ "sync/atomic"
9 10
10 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
@@ -37,10 +38,11 @@ type remoteLink struct {
37 transport ports.Transport38 transport ports.Transport
38 cancel context.CancelFunc39 cancel context.CancelFunc
39 40
40- sendMu sync.Mutex41+ sendMu sync.Mutex
41- startOnce sync.Once42+ startOnce sync.Once
42- done chan struct{}43+ workerStarted atomic.Bool
43- active bool // guarded by view.mu44+ done chan struct{}
45+ active bool // guarded by view.mu
44 }46 }
45 47
46 // remoteOutputState is the remote-link equivalent of the thin client's48 // remoteOutputState is the remote-link equivalent of the thin client's
@@ -458,10 +460,20 @@ func (d *Daemon) startRemoteLink(link *remoteLink) {
458 return460 return
459 }461 }
460 link.startOnce.Do(func() {462 link.startOnce.Do(func() {
463+ link.workerStarted.Store(true)
461 go d.runRemoteLink(link)464 go d.runRemoteLink(link)
462 })465 })
463 }466 }
464 467
468+// joinRemoteLink waits only for a receiver that was actually started. Candidate
469+// and test-only links may have no transport worker; treating their open done
470+// channel as joinable would deadlock terminal cleanup.
471+func joinRemoteLink(link *remoteLink) {
472+ if link != nil && link.workerStarted.Load() {
473+ <-link.done
474+ }
475+}
476+
465 func (d *Daemon) runRemoteLink(link *remoteLink) {477 func (d *Daemon) runRemoteLink(link *remoteLink) {
466 defer close(link.done)478 defer close(link.done)
467 for {479 for {
M internal/usecase/daemon/remote_warm.go +1 -1
@@ -145,7 +145,7 @@ func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) {
145145 if link.transport != nil {
146146 _ = link.transport.Close()
147147 }
148- <-link.done
148+ joinRemoteLink(link)
149149 }
150150
151151 // stopRemoteViewWarm clears and returns only the current timer. Its caller is
@@ -145,7 +145,7 @@ func (d *Daemon) expireRemoteViewWarm(view *remoteView, warm *remoteViewWarm) {
145 if link.transport != nil {145 if link.transport != nil {
146 _ = link.transport.Close()146 _ = link.transport.Close()
147 }147 }
148- <-link.done148+ joinRemoteLink(link)
149 }149 }
150 150
151 // stopRemoteViewWarm clears and returns only the current timer. Its caller is151 // stopRemoteViewWarm clears and returns only the current timer. Its caller is
M internal/usecase/daemon/remote_warm_test.go +3 -0
@@ -13,6 +13,9 @@ func TestRemoteViewWarmExpiryRemovesOnlyDetachedExactView(t *testing.T) {
1313 clock := &signalClock{timers: make(chan *signalTimer, 1)}
1414 d := newTestDaemon(t, nil, clock)
1515 view := registerRemoteWarmTestView(t, d)
16+ view.mu.Lock()
17+ view.link = &remoteLink{view: view, generation: view.linkGeneration, cancel: func() {}, done: make(chan struct{}), active: true}
18+ view.mu.Unlock()
1619
1720 d.parkRemoteViewWarm(view)
1821 warm, timer := remoteWarmTimer(t, clock, view)
@@ -13,6 +13,9 @@ func TestRemoteViewWarmExpiryRemovesOnlyDetachedExactView(t *testing.T) {
13 clock := &signalClock{timers: make(chan *signalTimer, 1)}13 clock := &signalClock{timers: make(chan *signalTimer, 1)}
14 d := newTestDaemon(t, nil, clock)14 d := newTestDaemon(t, nil, clock)
15 view := registerRemoteWarmTestView(t, d)15 view := registerRemoteWarmTestView(t, d)
16+ view.mu.Lock()
17+ view.link = &remoteLink{view: view, generation: view.linkGeneration, cancel: func() {}, done: make(chan struct{}), active: true}
18+ view.mu.Unlock()
16 19
17 d.parkRemoteViewWarm(view)20 d.parkRemoteViewWarm(view)
18 warm, timer := remoteWarmTimer(t, clock, view)21 warm, timer := remoteWarmTimer(t, clock, view)

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 2

Tags 13

↑↓ moveenter openesc close