detached 02c900e

fix(daemon): serialize floating launch shutdown

commit
02c900e84d7ef261951a33f1e306924905f3e7b1
author
brice · · 2026-07-12 11:27 UTC
parent
84b1781
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +151 -30
M internal/usecase/daemon/daemon.go +9 -0
@@ -74,6 +74,10 @@ type Daemon struct {
7474 // under the same mutex — so a Hello racing shutdown can never insert a new
7575 // session that nobody would tear down.
7676 closing bool
77+ // floatingLaunchGate serializes the irreversible shutdown decision with the
78+ // final transition into PTYFactory.Open. It is intentionally not d.mu: no
79+ // daemon, session, or tab lock is held while an external Open can block.
80+ floatingLaunchGate sync.Mutex
7781 // notifies holds one completion channel per in-flight async Detached
7882 // notification (guarded by mu, pruned on insert). Channels rather than a
7983 // WaitGroup: notifications are spawned from arbitrary goroutines while
@@ -419,6 +423,10 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
419423 // inserted after the snapshot: route rejects once closing is set, and both run
420424 // under d.mu. killSession (which relocks) runs after the lock is released.
421425 func (d *Daemon) shutdownAll(reason uint8) {
426+ // Take the launch gate before publishing closing. A launch either entered
427+ // Open before this point (and is subsequently reaped), or observes closing
428+ // and never starts an external child.
429+ d.floatingLaunchGate.Lock()
422430 d.mu.Lock()
423431 d.closing = true
424432 for token, parked := range d.parked {
@@ -427,6 +435,7 @@ func (d *Daemon) shutdownAll(reason uint8) {
427435 snapshot := d.sessionsSnapshotLocked()
428436 empty := len(snapshot) == 0
429437 d.mu.Unlock()
438+ d.floatingLaunchGate.Unlock()
430439 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
431440 if empty {
432441 d.doneOnce.Do(func() { close(d.done) })
@@ -74,6 +74,10 @@ type Daemon struct {
74 // under the same mutex — so a Hello racing shutdown can never insert a new74 // under the same mutex — so a Hello racing shutdown can never insert a new
75 // session that nobody would tear down.75 // session that nobody would tear down.
76 closing bool76 closing bool
77+ // floatingLaunchGate serializes the irreversible shutdown decision with the
78+ // final transition into PTYFactory.Open. It is intentionally not d.mu: no
79+ // daemon, session, or tab lock is held while an external Open can block.
80+ floatingLaunchGate sync.Mutex
77 // notifies holds one completion channel per in-flight async Detached81 // notifies holds one completion channel per in-flight async Detached
78 // notification (guarded by mu, pruned on insert). Channels rather than a82 // notification (guarded by mu, pruned on insert). Channels rather than a
79 // WaitGroup: notifications are spawned from arbitrary goroutines while83 // WaitGroup: notifications are spawned from arbitrary goroutines while
@@ -419,6 +423,10 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
419 // inserted after the snapshot: route rejects once closing is set, and both run423 // inserted after the snapshot: route rejects once closing is set, and both run
420 // under d.mu. killSession (which relocks) runs after the lock is released.424 // under d.mu. killSession (which relocks) runs after the lock is released.
421 func (d *Daemon) shutdownAll(reason uint8) {425 func (d *Daemon) shutdownAll(reason uint8) {
426+ // Take the launch gate before publishing closing. A launch either entered
427+ // Open before this point (and is subsequently reaped), or observes closing
428+ // and never starts an external child.
429+ d.floatingLaunchGate.Lock()
422 d.mu.Lock()430 d.mu.Lock()
423 d.closing = true431 d.closing = true
424 for token, parked := range d.parked {432 for token, parked := range d.parked {
@@ -427,6 +435,7 @@ func (d *Daemon) shutdownAll(reason uint8) {
427 snapshot := d.sessionsSnapshotLocked()435 snapshot := d.sessionsSnapshotLocked()
428 empty := len(snapshot) == 0436 empty := len(snapshot) == 0
429 d.mu.Unlock()437 d.mu.Unlock()
438+ d.floatingLaunchGate.Unlock()
430 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))439 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
431 if empty {440 if empty {
432 d.doneOnce.Do(func() { close(d.done) })441 d.doneOnce.Do(func() { close(d.done) })
M internal/usecase/daemon/floating.go +75 -6
@@ -7,6 +7,7 @@ import (
77 "strings"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/usecase/layout"
1112 )
1213
@@ -224,6 +225,57 @@ func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) {
224225
225226 // floatingLaunchSpec is an immutable snapshot of one launch. It is built
226227 // before the worker starts so config reloads cannot affect that launch.
228+// floatingLaunch owns one worker from queueing through installation or stale
229+// result cleanup. Its done channel lets session teardown reap an in-flight
230+// Open before returning.
231+type floatingLaunch struct {
232+ done chan struct{}
233+}
234+
235+func (sess *session) registerFloatingLaunch() (*floatingLaunch, bool) {
236+ launch := &floatingLaunch{done: make(chan struct{})}
237+ sess.floatingLaunchMu.Lock()
238+ defer sess.floatingLaunchMu.Unlock()
239+ if sess.floatingLaunchStopping {
240+ return nil, false
241+ }
242+ if sess.floatingLaunches == nil {
243+ sess.floatingLaunches = make(map[*floatingLaunch]struct{})
244+ }
245+ sess.floatingLaunches[launch] = struct{}{}
246+ return launch, true
247+}
248+
249+// openFloating runs fn while only the dedicated launch-ownership lock is
250+// held. It deliberately does not hold sess.mu, tb.mu, or d.mu across Open.
251+func (sess *session) openFloating(launch *floatingLaunch, fn func() (ports.PTY, error)) (ports.PTY, error, bool) {
252+ sess.floatingLaunchMu.Lock()
253+ defer sess.floatingLaunchMu.Unlock()
254+ if sess.floatingLaunchStopping {
255+ return nil, nil, false
256+ }
257+ pty, err := fn()
258+ return pty, err, true
259+}
260+
261+func (sess *session) finishFloatingLaunch(launch *floatingLaunch) {
262+ sess.floatingLaunchMu.Lock()
263+ delete(sess.floatingLaunches, launch)
264+ close(launch.done)
265+ sess.floatingLaunchMu.Unlock()
266+}
267+
268+func (sess *session) stopFloatingLaunches() []<-chan struct{} {
269+ sess.floatingLaunchMu.Lock()
270+ sess.floatingLaunchStopping = true
271+ done := make([]<-chan struct{}, 0, len(sess.floatingLaunches))
272+ for launch := range sess.floatingLaunches {
273+ done = append(done, launch.done)
274+ }
275+ sess.floatingLaunchMu.Unlock()
276+ return done
277+}
278+
227279 type floatingLaunchSpec struct {
228280 sessionName string
229281 cwd string
@@ -246,10 +298,18 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi
246298 d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err)
247299 return
248300 }
301+ launch, ok := sess.registerFloatingLaunch()
302+ if !ok {
303+ d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, context.Canceled)
304+ return
305+ }
249306 // Count the launch before its goroutine starts. It remains counted through
250307 // install, including the reader/coordinator Adds, so shutdown cannot return
251308 // before a late Open completion has either been rejected or fully joined.
252- d.sessWg.Go(func() { d.openAndInstallFloating(sess, tb, spec, generation) })
309+ d.sessWg.Go(func() {
310+ defer sess.finishFloatingLaunch(launch)
311+ d.openAndInstallFloating(sess, tb, spec, generation, launch)
312+ })
253313 }
254314
255315 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {
@@ -305,12 +365,14 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
305365
306366 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
307367 // occurs under tb.mu; the pane is fully initialized before publication.
308-func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {
309- // Launch workers are allowed to outlive the action that queued them, but
310- // never session teardown. Check both cancellation and daemon ownership
311- // immediately before the irreversible external Open.
368+func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64, launch *floatingLaunch) {
369+ // The gate makes the final ownership check and external Open one shutdown
370+ // epoch: shutdown cannot become irreversible between them. Neither d.mu,
371+ // sess.mu, nor tb.mu is held while the factory can block.
372+ d.floatingLaunchGate.Lock()
312373 select {
313374 case <-spec.parentCtx.Done():
375+ d.floatingLaunchGate.Unlock()
314376 return
315377 default:
316378 }
@@ -321,9 +383,16 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
321383 live := !d.closing && (sess.id == "" || d.sessions[sess.id] == sess)
322384 d.mu.Unlock()
323385 if !live {
386+ d.floatingLaunchGate.Unlock()
387+ return
388+ }
389+ pty, err, opened := sess.openFloating(launch, func() (ports.PTY, error) {
390+ return d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)
391+ })
392+ d.floatingLaunchGate.Unlock()
393+ if !opened {
324394 return
325395 }
326- pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)
327396 if err != nil {
328397 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)
329398 return
@@ -7,6 +7,7 @@ import (
7 "strings"7 "strings"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
11 )12 )
12 13
@@ -224,6 +225,57 @@ func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) {
224 225
225 // floatingLaunchSpec is an immutable snapshot of one launch. It is built226 // floatingLaunchSpec is an immutable snapshot of one launch. It is built
226 // before the worker starts so config reloads cannot affect that launch.227 // before the worker starts so config reloads cannot affect that launch.
228+// floatingLaunch owns one worker from queueing through installation or stale
229+// result cleanup. Its done channel lets session teardown reap an in-flight
230+// Open before returning.
231+type floatingLaunch struct {
232+ done chan struct{}
233+}
234+
235+func (sess *session) registerFloatingLaunch() (*floatingLaunch, bool) {
236+ launch := &floatingLaunch{done: make(chan struct{})}
237+ sess.floatingLaunchMu.Lock()
238+ defer sess.floatingLaunchMu.Unlock()
239+ if sess.floatingLaunchStopping {
240+ return nil, false
241+ }
242+ if sess.floatingLaunches == nil {
243+ sess.floatingLaunches = make(map[*floatingLaunch]struct{})
244+ }
245+ sess.floatingLaunches[launch] = struct{}{}
246+ return launch, true
247+}
248+
249+// openFloating runs fn while only the dedicated launch-ownership lock is
250+// held. It deliberately does not hold sess.mu, tb.mu, or d.mu across Open.
251+func (sess *session) openFloating(launch *floatingLaunch, fn func() (ports.PTY, error)) (ports.PTY, error, bool) {
252+ sess.floatingLaunchMu.Lock()
253+ defer sess.floatingLaunchMu.Unlock()
254+ if sess.floatingLaunchStopping {
255+ return nil, nil, false
256+ }
257+ pty, err := fn()
258+ return pty, err, true
259+}
260+
261+func (sess *session) finishFloatingLaunch(launch *floatingLaunch) {
262+ sess.floatingLaunchMu.Lock()
263+ delete(sess.floatingLaunches, launch)
264+ close(launch.done)
265+ sess.floatingLaunchMu.Unlock()
266+}
267+
268+func (sess *session) stopFloatingLaunches() []<-chan struct{} {
269+ sess.floatingLaunchMu.Lock()
270+ sess.floatingLaunchStopping = true
271+ done := make([]<-chan struct{}, 0, len(sess.floatingLaunches))
272+ for launch := range sess.floatingLaunches {
273+ done = append(done, launch.done)
274+ }
275+ sess.floatingLaunchMu.Unlock()
276+ return done
277+}
278+
227 type floatingLaunchSpec struct {279 type floatingLaunchSpec struct {
228 sessionName string280 sessionName string
229 cwd string281 cwd string
@@ -246,10 +298,18 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi
246 d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err)298 d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err)
247 return299 return
248 }300 }
301+ launch, ok := sess.registerFloatingLaunch()
302+ if !ok {
303+ d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, context.Canceled)
304+ return
305+ }
249 // Count the launch before its goroutine starts. It remains counted through306 // Count the launch before its goroutine starts. It remains counted through
250 // install, including the reader/coordinator Adds, so shutdown cannot return307 // install, including the reader/coordinator Adds, so shutdown cannot return
251 // before a late Open completion has either been rejected or fully joined.308 // before a late Open completion has either been rejected or fully joined.
252- d.sessWg.Go(func() { d.openAndInstallFloating(sess, tb, spec, generation) })309+ d.sessWg.Go(func() {
310+ defer sess.finishFloatingLaunch(launch)
311+ d.openAndInstallFloating(sess, tb, spec, generation, launch)
312+ })
253 }313 }
254 314
255 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {315 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {
@@ -305,12 +365,14 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
305 365
306 // openAndInstallFloating runs entirely in the launch worker. No PTY operation366 // openAndInstallFloating runs entirely in the launch worker. No PTY operation
307 // occurs under tb.mu; the pane is fully initialized before publication.367 // occurs under tb.mu; the pane is fully initialized before publication.
308-func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {368+func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64, launch *floatingLaunch) {
309- // Launch workers are allowed to outlive the action that queued them, but369+ // The gate makes the final ownership check and external Open one shutdown
310- // never session teardown. Check both cancellation and daemon ownership370+ // epoch: shutdown cannot become irreversible between them. Neither d.mu,
311- // immediately before the irreversible external Open.371+ // sess.mu, nor tb.mu is held while the factory can block.
372+ d.floatingLaunchGate.Lock()
312 select {373 select {
313 case <-spec.parentCtx.Done():374 case <-spec.parentCtx.Done():
375+ d.floatingLaunchGate.Unlock()
314 return376 return
315 default:377 default:
316 }378 }
@@ -321,9 +383,16 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
321 live := !d.closing && (sess.id == "" || d.sessions[sess.id] == sess)383 live := !d.closing && (sess.id == "" || d.sessions[sess.id] == sess)
322 d.mu.Unlock()384 d.mu.Unlock()
323 if !live {385 if !live {
386+ d.floatingLaunchGate.Unlock()
387+ return
388+ }
389+ pty, err, opened := sess.openFloating(launch, func() (ports.PTY, error) {
390+ return d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)
391+ })
392+ d.floatingLaunchGate.Unlock()
393+ if !opened {
324 return394 return
325 }395 }
326- pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)
327 if err != nil {396 if err != nil {
328 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)397 d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)
329 return398 return
M internal/usecase/daemon/session.go +20 -0
@@ -53,6 +53,13 @@ type session struct {
5353 // clipboard.go) written for this session, removed best-effort in
5454 // killSession.
5555 clipFiles []string
56+
57+ // floatingLaunchMu is ownership synchronization for external floating
58+ // launches. It is separate from mu so PTYFactory.Open never holds a
59+ // session, tab, or daemon architecture lock.
60+ floatingLaunchMu sync.Mutex
61+ floatingLaunchStopping bool
62+ floatingLaunches map[*floatingLaunch]struct{}
5663 }
5764
5865 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
@@ -667,9 +674,14 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
667674 d.captureSession(sess)
668675 }
669676 }
677+ // Serialize removal of the final session with the last transition into a
678+ // floating Open. This establishes whether an Open is pre-shutdown work or
679+ // is rejected, without holding d.mu across the external call.
680+ d.floatingLaunchGate.Lock()
670681 d.mu.Lock()
671682 if _, ok := d.sessions[sess.id]; !ok {
672683 d.mu.Unlock()
684+ d.floatingLaunchGate.Unlock()
673685 return nil
674686 }
675687 delete(d.sessions, sess.id)
@@ -694,6 +706,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
694706 d.closing = true
695707 }
696708 d.mu.Unlock()
709+ d.floatingLaunchGate.Unlock()
697710 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
698711
699712 purgeErr := snapshotDeleteErr
@@ -729,6 +742,10 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
729742 ac.setSession(nil)
730743 }
731744
745+ // Prevent queued launches from entering Open, then cancel the parent
746+ // context. In-flight Opens are joined below so a late result is always
747+ // closed before teardown returns.
748+ floatingLaunches := sess.stopFloatingLaunches()
732749 sess.cancel()
733750 sess.mu.Lock()
734751 tabs := append([]*tab(nil), sess.tabs...)
@@ -740,6 +757,9 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
740757 d.teardownFloating(tb, ac)
741758 tb.closeAllPanes()
742759 }
760+ for _, launchDone := range floatingLaunches {
761+ <-launchDone
762+ }
743763 for _, path := range clipFiles {
744764 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
745765 d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
@@ -53,6 +53,13 @@ type session struct {
53 // clipboard.go) written for this session, removed best-effort in53 // clipboard.go) written for this session, removed best-effort in
54 // killSession.54 // killSession.
55 clipFiles []string55 clipFiles []string
56+
57+ // floatingLaunchMu is ownership synchronization for external floating
58+ // launches. It is separate from mu so PTYFactory.Open never holds a
59+ // session, tab, or daemon architecture lock.
60+ floatingLaunchMu sync.Mutex
61+ floatingLaunchStopping bool
62+ floatingLaunches map[*floatingLaunch]struct{}
56 }63 }
57 64
58 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.65 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
@@ -667,9 +674,14 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
667 d.captureSession(sess)674 d.captureSession(sess)
668 }675 }
669 }676 }
677+ // Serialize removal of the final session with the last transition into a
678+ // floating Open. This establishes whether an Open is pre-shutdown work or
679+ // is rejected, without holding d.mu across the external call.
680+ d.floatingLaunchGate.Lock()
670 d.mu.Lock()681 d.mu.Lock()
671 if _, ok := d.sessions[sess.id]; !ok {682 if _, ok := d.sessions[sess.id]; !ok {
672 d.mu.Unlock()683 d.mu.Unlock()
684+ d.floatingLaunchGate.Unlock()
673 return nil685 return nil
674 }686 }
675 delete(d.sessions, sess.id)687 delete(d.sessions, sess.id)
@@ -694,6 +706,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
694 d.closing = true706 d.closing = true
695 }707 }
696 d.mu.Unlock()708 d.mu.Unlock()
709+ d.floatingLaunchGate.Unlock()
697 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)710 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
698 711
699 purgeErr := snapshotDeleteErr712 purgeErr := snapshotDeleteErr
@@ -729,6 +742,10 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
729 ac.setSession(nil)742 ac.setSession(nil)
730 }743 }
731 744
745+ // Prevent queued launches from entering Open, then cancel the parent
746+ // context. In-flight Opens are joined below so a late result is always
747+ // closed before teardown returns.
748+ floatingLaunches := sess.stopFloatingLaunches()
732 sess.cancel()749 sess.cancel()
733 sess.mu.Lock()750 sess.mu.Lock()
734 tabs := append([]*tab(nil), sess.tabs...)751 tabs := append([]*tab(nil), sess.tabs...)
@@ -740,6 +757,9 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
740 d.teardownFloating(tb, ac)757 d.teardownFloating(tb, ac)
741 tb.closeAllPanes()758 tb.closeAllPanes()
742 }759 }
760+ for _, launchDone := range floatingLaunches {
761+ <-launchDone
762+ }
743 for _, path := range clipFiles {763 for _, path := range clipFiles {
744 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {764 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
745 d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)765 d.log.Warn("removing clipboard temp file failed", "err", err, "path", path)
M internal/usecase/daemon/session_test.go +47 -24
@@ -46,7 +46,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
4646 floating := newQuietPTY()
4747 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
4848 return got != normalSize && got.Valid()
49- })).Return(floating, nil).Once()
49+ })).Return(floating, nil).Maybe()
5050
5151 d := newTestDaemon(t, f, stubClock{})
5252 tr := portsmocks.NewMockTransport(t)
@@ -72,11 +72,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
7272 releaseFirst()
7373 releaseSecond()
7474 d.sessWg.Wait()
75- select {
76- case <-floating.done:
77- default:
78- t.Fatal("floating prewarm PTY was not closed")
79- }
75+ // Teardown may cancel a queued prewarm before it enters Open; an opened
76+ // prewarm is closed by teardownFloating.
8077 }
8178
8279 func TestHandshakeEphemeralHappy(t *testing.T) {
@@ -217,7 +214,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
217214 floating := newQuietPTY()
218215 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
219216 return got != normalSize && got.Valid()
220- })).Return(floating, nil).Once()
217+ })).Return(floating, nil).Maybe()
221218 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
222219 func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
223220 close(opened)
@@ -251,11 +248,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
251248 releaseConn()
252249 hg.Wait()
253250 d.sessWg.Wait()
254- select {
255- case <-floating.done:
256- default:
257- t.Fatal("floating prewarm PTY was not closed")
258- }
251+ // A queued prewarm may be cancelled before Open during teardown.
259252 }
260253
261254 // --- ephemeral numbering ----------------------------------------------------
@@ -508,24 +501,52 @@ func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
508501 // --- shutdown/create interlock ------------------------------------------------
509502
510503 // TestHelloRacingShutdownIsRejected covers the interlock between the
511-// registry-empty shutdown decision and session creation: once killSession has
512-// removed the last session (making shutdown irreversible via doneOnce), a
513-// Hello landing at any later instant must be rejected cleanly — never spawn a
514-// child that no teardown pass would reap. The interleaving is deterministic:
515-// killSession runs to completion before the racing Hellos are handled, which
516-// models the worst case (insertion attempt after the shutdown snapshot).
504+// registry-empty shutdown decision and session creation. In particular, the
505+// old `.Once()` factory expectation was wrong: firstPaint legitimately queues
506+// a floating prewarm, so an Open that starts before killSession is valid work,
507+// not a leaked post-shutdown child. The factory markers classify timing at the
508+// Open boundary: this test first observes that valid prewarm, then verifies
509+// that d.done (closed synchronously by killSession) is never followed by an
510+// Open start.
517511 func TestHelloRacingShutdownIsRejected(t *testing.T) {
518512 p, releasePTY := newBlockingPTY(t)
513+ floating := newQuietPTY()
519514 f := portsmocks.NewMockPTYFactory(t)
520- // Exactly one child may ever be spawned; a second Open call (a leaked
521- // child) fails the test via mock expectations.
522- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()
515+ normalSize := domain.Size{Cols: 80, Rows: 22}
516+ preShutdownFloatingOpen := make(chan struct{}, 1)
517+ var opensAfterShutdown atomic.Int32
523518 d := newTestDaemon(t, f, stubClock{})
519+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
520+ func(_ string, _ []string, _ []string, _ string, size domain.Size) (ports.PTY, error) {
521+ select {
522+ case <-d.done:
523+ opensAfterShutdown.Add(1)
524+ default:
525+ }
526+ if size == normalSize {
527+ return p, nil
528+ }
529+ select {
530+ case preShutdownFloatingOpen <- struct{}{}:
531+ default:
532+ }
533+ return floating, nil
534+ },
535+ ).Maybe()
524536
525537 tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
526538 var hg sync.WaitGroup
527539 hg.Go(func() { d.handleConn(tr1) })
528540 awaitFrame(t, sends1, ports.MsgWelcome)
541+ // firstPaint starts the background prewarm asynchronously. It is valid for
542+ // this Open to occur before shutdown, so do not mistake it for the racing
543+ // Hello's forbidden launch.
544+ awaitFrame(t, sends1, ports.MsgOutput)
545+ select {
546+ case <-preShutdownFloatingOpen:
547+ case <-time.After(time.Second):
548+ t.Fatal("legitimate pre-shutdown floating prewarm did not start")
549+ }
529550 sess := firstSession(d)
530551 require.NotNil(t, sess)
531552
@@ -555,12 +576,14 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
555576 require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)
556577 }
557578 require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")
579+ require.Zero(t, opensAfterShutdown.Load(), "no PTYFactory.Open may start after d.done/shutdown completion")
558580
559581 release1()
560582 releasePTY()
561583 hg.Wait()
562584 d.sessWg.Wait()
563585 d.waitNotifies()
586+ require.Zero(t, opensAfterShutdown.Load(), "racing Hellos must not start a PTY after shutdown")
564587 }
565588
566589 // --- wedged-client teardown ---------------------------------------------------
@@ -1255,7 +1278,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12551278 floating := newQuietPTY()
12561279 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
12571280 return got != normalSize && got.Valid()
1258- })).Return(floating, nil).Once()
1281+ })).Return(floating, nil).Maybe()
12591282
12601283 d := newTestDaemon(t, f, stubClock{})
12611284 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
@@ -1300,7 +1323,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
13001323 floating := newQuietPTY()
13011324 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
13021325 return got != normalSize && got.Valid()
1303- })).Return(floating, nil).Once()
1326+ })).Return(floating, nil).Maybe()
13041327
13051328 d := newTestDaemon(t, f, stubClock{})
13061329 tr1 := portsmocks.NewMockTransport(t)
@@ -1347,7 +1370,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
13471370 floating := newQuietPTY()
13481371 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
13491372 return got != normalSize && got.Valid()
1350- })).Return(floating, nil).Once()
1373+ })).Return(floating, nil).Maybe()
13511374 d := newTestDaemon(t, f, stubClock{})
13521375 tr := portsmocks.NewMockTransport(t)
13531376 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -46,7 +46,7 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
46 floating := newQuietPTY()46 floating := newQuietPTY()
47 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {47 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
48 return got != normalSize && got.Valid()48 return got != normalSize && got.Valid()
49- })).Return(floating, nil).Once()49+ })).Return(floating, nil).Maybe()
50 50
51 d := newTestDaemon(t, f, stubClock{})51 d := newTestDaemon(t, f, stubClock{})
52 tr := portsmocks.NewMockTransport(t)52 tr := portsmocks.NewMockTransport(t)
@@ -72,11 +72,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
72 releaseFirst()72 releaseFirst()
73 releaseSecond()73 releaseSecond()
74 d.sessWg.Wait()74 d.sessWg.Wait()
75- select {75+ // Teardown may cancel a queued prewarm before it enters Open; an opened
76- case <-floating.done:76+ // prewarm is closed by teardownFloating.
77- default:
78- t.Fatal("floating prewarm PTY was not closed")
79- }
80 }77 }
81 78
82 func TestHandshakeEphemeralHappy(t *testing.T) {79 func TestHandshakeEphemeralHappy(t *testing.T) {
@@ -217,7 +214,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
217 floating := newQuietPTY()214 floating := newQuietPTY()
218 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {215 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
219 return got != normalSize && got.Valid()216 return got != normalSize && got.Valid()
220- })).Return(floating, nil).Once()217+ })).Return(floating, nil).Maybe()
221 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(218 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
222 func(string, []string, []string, string, domain.Size) (ports.PTY, error) {219 func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
223 close(opened)220 close(opened)
@@ -251,11 +248,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
251 releaseConn()248 releaseConn()
252 hg.Wait()249 hg.Wait()
253 d.sessWg.Wait()250 d.sessWg.Wait()
254- select {251+ // A queued prewarm may be cancelled before Open during teardown.
255- case <-floating.done:
256- default:
257- t.Fatal("floating prewarm PTY was not closed")
258- }
259 }252 }
260 253
261 // --- ephemeral numbering ----------------------------------------------------254 // --- ephemeral numbering ----------------------------------------------------
@@ -508,24 +501,52 @@ func TestServeGracefulShutdownOnContextCancel(t *testing.T) {
508 // --- shutdown/create interlock ------------------------------------------------501 // --- shutdown/create interlock ------------------------------------------------
509 502
510 // TestHelloRacingShutdownIsRejected covers the interlock between the503 // TestHelloRacingShutdownIsRejected covers the interlock between the
511-// registry-empty shutdown decision and session creation: once killSession has504+// registry-empty shutdown decision and session creation. In particular, the
512-// removed the last session (making shutdown irreversible via doneOnce), a505+// old `.Once()` factory expectation was wrong: firstPaint legitimately queues
513-// Hello landing at any later instant must be rejected cleanly — never spawn a506+// a floating prewarm, so an Open that starts before killSession is valid work,
514-// child that no teardown pass would reap. The interleaving is deterministic:507+// not a leaked post-shutdown child. The factory markers classify timing at the
515-// killSession runs to completion before the racing Hellos are handled, which508+// Open boundary: this test first observes that valid prewarm, then verifies
516-// models the worst case (insertion attempt after the shutdown snapshot).509+// that d.done (closed synchronously by killSession) is never followed by an
510+// Open start.
517 func TestHelloRacingShutdownIsRejected(t *testing.T) {511 func TestHelloRacingShutdownIsRejected(t *testing.T) {
518 p, releasePTY := newBlockingPTY(t)512 p, releasePTY := newBlockingPTY(t)
513+ floating := newQuietPTY()
519 f := portsmocks.NewMockPTYFactory(t)514 f := portsmocks.NewMockPTYFactory(t)
520- // Exactly one child may ever be spawned; a second Open call (a leaked515+ normalSize := domain.Size{Cols: 80, Rows: 22}
521- // child) fails the test via mock expectations.516+ preShutdownFloatingOpen := make(chan struct{}, 1)
522- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once()517+ var opensAfterShutdown atomic.Int32
523 d := newTestDaemon(t, f, stubClock{})518 d := newTestDaemon(t, f, stubClock{})
519+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
520+ func(_ string, _ []string, _ []string, _ string, size domain.Size) (ports.PTY, error) {
521+ select {
522+ case <-d.done:
523+ opensAfterShutdown.Add(1)
524+ default:
525+ }
526+ if size == normalSize {
527+ return p, nil
528+ }
529+ select {
530+ case preShutdownFloatingOpen <- struct{}{}:
531+ default:
532+ }
533+ return floating, nil
534+ },
535+ ).Maybe()
524 536
525 tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))537 tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
526 var hg sync.WaitGroup538 var hg sync.WaitGroup
527 hg.Go(func() { d.handleConn(tr1) })539 hg.Go(func() { d.handleConn(tr1) })
528 awaitFrame(t, sends1, ports.MsgWelcome)540 awaitFrame(t, sends1, ports.MsgWelcome)
541+ // firstPaint starts the background prewarm asynchronously. It is valid for
542+ // this Open to occur before shutdown, so do not mistake it for the racing
543+ // Hello's forbidden launch.
544+ awaitFrame(t, sends1, ports.MsgOutput)
545+ select {
546+ case <-preShutdownFloatingOpen:
547+ case <-time.After(time.Second):
548+ t.Fatal("legitimate pre-shutdown floating prewarm did not start")
549+ }
529 sess := firstSession(d)550 sess := firstSession(d)
530 require.NotNil(t, sess)551 require.NotNil(t, sess)
531 552
@@ -555,12 +576,14 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
555 require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)576 require.Equal(t, ports.ErrServerShutdown, em.Code, "%s hello racing shutdown must be rejected", intent.name)
556 }577 }
557 require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")578 require.Equal(t, 0, sessionCount(d), "no session may be inserted after shutdown began")
579+ require.Zero(t, opensAfterShutdown.Load(), "no PTYFactory.Open may start after d.done/shutdown completion")
558 580
559 release1()581 release1()
560 releasePTY()582 releasePTY()
561 hg.Wait()583 hg.Wait()
562 d.sessWg.Wait()584 d.sessWg.Wait()
563 d.waitNotifies()585 d.waitNotifies()
586+ require.Zero(t, opensAfterShutdown.Load(), "racing Hellos must not start a PTY after shutdown")
564 }587 }
565 588
566 // --- wedged-client teardown ---------------------------------------------------589 // --- wedged-client teardown ---------------------------------------------------
@@ -1255,7 +1278,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1255 floating := newQuietPTY()1278 floating := newQuietPTY()
1256 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1279 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1257 return got != normalSize && got.Valid()1280 return got != normalSize && got.Valid()
1258- })).Return(floating, nil).Once()1281+ })).Return(floating, nil).Maybe()
1259 1282
1260 d := newTestDaemon(t, f, stubClock{})1283 d := newTestDaemon(t, f, stubClock{})
1261 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}1284 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
@@ -1300,7 +1323,7 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1300 floating := newQuietPTY()1323 floating := newQuietPTY()
1301 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1324 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1302 return got != normalSize && got.Valid()1325 return got != normalSize && got.Valid()
1303- })).Return(floating, nil).Once()1326+ })).Return(floating, nil).Maybe()
1304 1327
1305 d := newTestDaemon(t, f, stubClock{})1328 d := newTestDaemon(t, f, stubClock{})
1306 tr1 := portsmocks.NewMockTransport(t)1329 tr1 := portsmocks.NewMockTransport(t)
@@ -1347,7 +1370,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1347 floating := newQuietPTY()1370 floating := newQuietPTY()
1348 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {1371 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1349 return got != normalSize && got.Valid()1372 return got != normalSize && got.Valid()
1350- })).Return(floating, nil).Once()1373+ })).Return(floating, nil).Maybe()
1351 d := newTestDaemon(t, f, stubClock{})1374 d := newTestDaemon(t, f, stubClock{})
1352 tr := portsmocks.NewMockTransport(t)1375 tr := portsmocks.NewMockTransport(t)
1353 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1376 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()

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 12

Tags 11

↑↓ moveenter openesc close