detached d319b30

fix(runtime): address timer lifecycle review

commit
d319b309f17f177c3c3bb1bb6fb64b704a9c9728
author
brice · · 2026-07-13 18:42 UTC
parent
c577b15
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +30 -34
M internal/usecase/daemon/daemon.go +22 -32
@@ -659,6 +659,24 @@ type protoErr struct {
659659
660660 func (e *protoErr) Error() string { return e.text }
661661
662+// finishAttach completes an attachment prepared while d.mu is held. It
663+// publishes the terminal state before releasing d.mu, then performs worker
664+// joins and replacement teardown without the daemon lock.
665+func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient {
666+ ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{
667+ clientID: h.ClientID,
668+ resumeCapable: true,
669+ maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
670+ })
671+ sess.mu.Lock()
672+ sess.terminal = term
673+ sess.mu.Unlock()
674+ d.mu.Unlock()
675+ cleanup.finish()
676+ d.detachReplacedClient(old)
677+ return ac
678+}
679+
662680 // route resolves a Hello to a session and a freshly attached client, creating
663681 // the session for ephemeral/new intents.
664682 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
@@ -709,14 +727,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
709727 }
710728 }
711729 d.purgeParkedForSessionLocked(sess)
712- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})
713- sess.mu.Lock()
714- sess.terminal = term
715- sess.mu.Unlock()
716- d.mu.Unlock()
717- cleanup.finish()
718- d.detachReplacedClient(old)
719- return sess, ac, nil
730+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
720731
721732 case ports.IntentEphemeral:
722733 name := d.allocEphemeralNameLocked()
@@ -726,14 +737,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
726737 return nil, nil, err
727738 }
728739 d.purgeParkedForSessionLocked(sess)
729- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})
730- sess.mu.Lock()
731- sess.terminal = term
732- sess.mu.Unlock()
733- d.mu.Unlock()
734- cleanup.finish()
735- d.detachReplacedClient(old)
736- return sess, ac, nil
740+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
737741
738742 case ports.IntentNew:
739743 if h.Name == "" {
@@ -754,14 +758,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
754758 return nil, nil, err
755759 }
756760 d.purgeParkedForSessionLocked(sess)
757- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})
758- sess.mu.Lock()
759- sess.terminal = term
760- sess.mu.Unlock()
761- d.mu.Unlock()
762- cleanup.finish()
763- d.detachReplacedClient(old)
764- return sess, ac, nil
761+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
765762
766763 case ports.IntentAttach:
767764 sess := d.findByNameLocked(h.Name)
@@ -780,14 +777,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
780777 }
781778 }
782779 d.purgeParkedForSessionLocked(sess)
783- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})
784- sess.mu.Lock()
785- sess.terminal = term
786- sess.mu.Unlock()
787- d.mu.Unlock()
788- cleanup.finish()
789- d.detachReplacedClient(old)
790- return sess, ac, nil
780+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
791781
792782 default:
793783 d.mu.Unlock()
@@ -659,6 +659,24 @@ type protoErr struct {
659 659
660 func (e *protoErr) Error() string { return e.text }660 func (e *protoErr) Error() string { return e.text }
661 661
662+// finishAttach completes an attachment prepared while d.mu is held. It
663+// publishes the terminal state before releasing d.mu, then performs worker
664+// joins and replacement teardown without the daemon lock.
665+func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient {
666+ ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{
667+ clientID: h.ClientID,
668+ resumeCapable: true,
669+ maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
670+ })
671+ sess.mu.Lock()
672+ sess.terminal = term
673+ sess.mu.Unlock()
674+ d.mu.Unlock()
675+ cleanup.finish()
676+ d.detachReplacedClient(old)
677+ return ac
678+}
679+
662 // route resolves a Hello to a session and a freshly attached client, creating680 // route resolves a Hello to a session and a freshly attached client, creating
663 // the session for ephemeral/new intents.681 // the session for ephemeral/new intents.
664 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {682 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
@@ -709,14 +727,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
709 }727 }
710 }728 }
711 d.purgeParkedForSessionLocked(sess)729 d.purgeParkedForSessionLocked(sess)
712- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})730+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
713- sess.mu.Lock()
714- sess.terminal = term
715- sess.mu.Unlock()
716- d.mu.Unlock()
717- cleanup.finish()
718- d.detachReplacedClient(old)
719- return sess, ac, nil
720 731
721 case ports.IntentEphemeral:732 case ports.IntentEphemeral:
722 name := d.allocEphemeralNameLocked()733 name := d.allocEphemeralNameLocked()
@@ -726,14 +737,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
726 return nil, nil, err737 return nil, nil, err
727 }738 }
728 d.purgeParkedForSessionLocked(sess)739 d.purgeParkedForSessionLocked(sess)
729- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})740+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
730- sess.mu.Lock()
731- sess.terminal = term
732- sess.mu.Unlock()
733- d.mu.Unlock()
734- cleanup.finish()
735- d.detachReplacedClient(old)
736- return sess, ac, nil
737 741
738 case ports.IntentNew:742 case ports.IntentNew:
739 if h.Name == "" {743 if h.Name == "" {
@@ -754,14 +758,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
754 return nil, nil, err758 return nil, nil, err
755 }759 }
756 d.purgeParkedForSessionLocked(sess)760 d.purgeParkedForSessionLocked(sess)
757- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})761+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
758- sess.mu.Lock()
759- sess.terminal = term
760- sess.mu.Unlock()
761- d.mu.Unlock()
762- cleanup.finish()
763- d.detachReplacedClient(old)
764- return sess, ac, nil
765 762
766 case ports.IntentAttach:763 case ports.IntentAttach:
767 sess := d.findByNameLocked(h.Name)764 sess := d.findByNameLocked(h.Name)
@@ -780,14 +777,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
780 }777 }
781 }778 }
782 d.purgeParkedForSessionLocked(sess)779 d.purgeParkedForSessionLocked(sess)
783- ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight)})780+ return sess, d.finishAttach(sess, tr, sz, term, h), nil
784- sess.mu.Lock()
785- sess.terminal = term
786- sess.mu.Unlock()
787- d.mu.Unlock()
788- cleanup.finish()
789- d.detachReplacedClient(old)
790- return sess, ac, nil
791 781
792 default:782 default:
793 d.mu.Unlock()783 d.mu.Unlock()
M internal/usecase/daemon/render_resize_deadline.go +6 -0
@@ -128,6 +128,9 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
128128 c.mu.Unlock()
129129 stopAndJoinTimerWorker(old, nil)
130130 if clock == nil {
131+ c.mu.Lock()
132+ c.retryTimerOwner().completeLocked(gen)
133+ c.mu.Unlock()
131134 if c.retryCurrentForLease(epoch, source, lease) {
132135 run()
133136 }
@@ -139,6 +142,9 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
139142 // A nil timer channel is the deterministic disabled-clock contract used
140143 // by headless tests; do not spin retries synchronously.
141144 stopTimer(timer)
145+ c.mu.Lock()
146+ c.retryTimerOwner().completeLocked(gen)
147+ c.mu.Unlock()
142148 return
143149 }
144150 c.mu.Lock()
@@ -128,6 +128,9 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
128 c.mu.Unlock()128 c.mu.Unlock()
129 stopAndJoinTimerWorker(old, nil)129 stopAndJoinTimerWorker(old, nil)
130 if clock == nil {130 if clock == nil {
131+ c.mu.Lock()
132+ c.retryTimerOwner().completeLocked(gen)
133+ c.mu.Unlock()
131 if c.retryCurrentForLease(epoch, source, lease) {134 if c.retryCurrentForLease(epoch, source, lease) {
132 run()135 run()
133 }136 }
@@ -139,6 +142,9 @@ func (c *renderCoordinator) scheduleResizeRetryForLease(epoch uint64, source *at
139 // A nil timer channel is the deterministic disabled-clock contract used142 // A nil timer channel is the deterministic disabled-clock contract used
140 // by headless tests; do not spin retries synchronously.143 // by headless tests; do not spin retries synchronously.
141 stopTimer(timer)144 stopTimer(timer)
145+ c.mu.Lock()
146+ c.retryTimerOwner().completeLocked(gen)
147+ c.mu.Unlock()
142 return148 return
143 }149 }
144 c.mu.Lock()150 c.mu.Lock()
M internal/usecase/daemon/render_timer_test.go +2 -2
@@ -19,7 +19,7 @@ func TestTimerOwnershipRejectsStaleTicketsAndCancelsWorkers(t *testing.T) {
1919
2020 ticket, stopped := owner.replaceLocked()
2121 require.Nil(t, stopped.timer)
22- first := &portsmocks.MockTimer{}
22+ first := portsmocks.NewMockTimer(t)
2323 first.EXPECT().Stop().Return(true).Once()
2424 firstCancel, firstDone, ok := owner.publishLocked(ticket, first)
2525 require.True(t, ok)
@@ -32,7 +32,7 @@ func TestTimerOwnershipRejectsStaleTicketsAndCancelsWorkers(t *testing.T) {
3232 require.Same(t, first, stopped.timer)
3333 stopAndJoinTimerWorker(stopped, nil)
3434
35- _, _, ok = owner.publishLocked(ticket, &portsmocks.MockTimer{})
35+ _, _, ok = owner.publishLocked(ticket, portsmocks.NewMockTimer(t))
3636 require.False(t, ok, "a stale generation must not reclaim a replacement lane")
3737
3838 require.True(t, owner.completeLocked(generation))
@@ -19,7 +19,7 @@ func TestTimerOwnershipRejectsStaleTicketsAndCancelsWorkers(t *testing.T) {
19 19
20 ticket, stopped := owner.replaceLocked()20 ticket, stopped := owner.replaceLocked()
21 require.Nil(t, stopped.timer)21 require.Nil(t, stopped.timer)
22- first := &portsmocks.MockTimer{}22+ first := portsmocks.NewMockTimer(t)
23 first.EXPECT().Stop().Return(true).Once()23 first.EXPECT().Stop().Return(true).Once()
24 firstCancel, firstDone, ok := owner.publishLocked(ticket, first)24 firstCancel, firstDone, ok := owner.publishLocked(ticket, first)
25 require.True(t, ok)25 require.True(t, ok)
@@ -32,7 +32,7 @@ func TestTimerOwnershipRejectsStaleTicketsAndCancelsWorkers(t *testing.T) {
32 require.Same(t, first, stopped.timer)32 require.Same(t, first, stopped.timer)
33 stopAndJoinTimerWorker(stopped, nil)33 stopAndJoinTimerWorker(stopped, nil)
34 34
35- _, _, ok = owner.publishLocked(ticket, &portsmocks.MockTimer{})35+ _, _, ok = owner.publishLocked(ticket, portsmocks.NewMockTimer(t))
36 require.False(t, ok, "a stale generation must not reclaim a replacement lane")36 require.False(t, ok, "a stale generation must not reclaim a replacement lane")
37 37
38 require.True(t, owner.completeLocked(generation))38 require.True(t, owner.completeLocked(generation))

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