detached 5f064ea

fix(daemon): retain latest resize timer ownership

commit
5f064eab5f05db6d7b9a3ea385e63ab1e73bcfdc
author
brice · · 2026-07-12 13:51 UTC
parent
037ef43
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +54 -8
M internal/usecase/daemon/pane.go +1 -1
@@ -19,7 +19,7 @@ type pane struct {
1919 id layout.PaneID
2020 stableID string
2121 pty ports.PTY
22- mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
22+ mu sync.Mutex // guards screen, scrollback, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
2323 resizeMu sync.Mutex // serializes PTY resizes without holding mu
2424 screen *vt.Screen
2525 scrollback *scopy.Scrollback
@@ -19,7 +19,7 @@ type pane struct {
19 id layout.PaneID19 id layout.PaneID
20 stableID string20 stableID string
21 pty ports.PTY21 pty ports.PTY
22- mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title22+ mu sync.Mutex // guards screen, scrollback, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title
23 resizeMu sync.Mutex // serializes PTY resizes without holding mu23 resizeMu sync.Mutex // serializes PTY resizes without holding mu
24 screen *vt.Screen24 screen *vt.Screen
25 scrollback *scopy.Scrollback25 scrollback *scopy.Scrollback
M internal/usecase/daemon/render_coordinator.go +13 -4
@@ -825,11 +825,8 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli
825825 }
826826 c.mu.Lock()
827827 valid := !c.torndown && c.resizeGen == gen && c.resize.epoch == epoch && c.resize.source == source
828- c.resizeTimer = nil
829- if c.resizeGen == gen {
830- c.resizeCancel = nil
831- }
832828 c.mu.Unlock()
829+ c.clearResizeTimer(gen)
833830 if valid {
834831 run(epoch)
835832 }
@@ -837,6 +834,18 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli
837834 return epoch
838835 }
839836
837+// clearResizeTimer releases timer ownership only for the matching resize generation.
838+// A stale callback must not clear a newer request's timer or cancellation channel.
839+func (c *renderCoordinator) clearResizeTimer(gen uint64) {
840+ c.mu.Lock()
841+ defer c.mu.Unlock()
842+ if c.resizeGen != gen {
843+ return
844+ }
845+ c.resizeTimer = nil
846+ c.resizeCancel = nil
847+}
848+
840849 // resizeCurrent verifies that an apply/commit attempt still owns the newest
841850 // request. commit also advances the separately observable committed epoch.
842851 func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient, commit bool) bool {
@@ -825,11 +825,8 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli
825 }825 }
826 c.mu.Lock()826 c.mu.Lock()
827 valid := !c.torndown && c.resizeGen == gen && c.resize.epoch == epoch && c.resize.source == source827 valid := !c.torndown && c.resizeGen == gen && c.resize.epoch == epoch && c.resize.source == source
828- c.resizeTimer = nil
829- if c.resizeGen == gen {
830- c.resizeCancel = nil
831- }
832 c.mu.Unlock()828 c.mu.Unlock()
829+ c.clearResizeTimer(gen)
833 if valid {830 if valid {
834 run(epoch)831 run(epoch)
835 }832 }
@@ -837,6 +834,18 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli
837 return epoch834 return epoch
838 }835 }
839 836
837+// clearResizeTimer releases timer ownership only for the matching resize generation.
838+// A stale callback must not clear a newer request's timer or cancellation channel.
839+func (c *renderCoordinator) clearResizeTimer(gen uint64) {
840+ c.mu.Lock()
841+ defer c.mu.Unlock()
842+ if c.resizeGen != gen {
843+ return
844+ }
845+ c.resizeTimer = nil
846+ c.resizeCancel = nil
847+}
848+
840 // resizeCurrent verifies that an apply/commit attempt still owns the newest849 // resizeCurrent verifies that an apply/commit attempt still owns the newest
841 // request. commit also advances the separately observable committed epoch.850 // request. commit also advances the separately observable committed epoch.
842 func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient, commit bool) bool {851 func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient, commit bool) bool {
M internal/usecase/daemon/render_coordinator_test.go +23 -0
@@ -1332,6 +1332,29 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal
13321332 requireNoCoordinatorOutputFrame(t, ownerSends)
13331333 }
13341334
1335+func TestRenderCoordinatorClearResizeTimerKeepsNewerTimer(t *testing.T) {
1336+ rc := newRenderCoordinator(renderCoordinatorOptions{})
1337+ newer := &portsmocks.MockTimer{}
1338+
1339+ rc.mu.Lock()
1340+ rc.resizeGen = 2
1341+ rc.resizeTimer = newer
1342+ rc.resizeCancel = make(chan struct{})
1343+ rc.mu.Unlock()
1344+
1345+ rc.clearResizeTimer(1)
1346+ rc.mu.Lock()
1347+ require.Same(t, newer, rc.resizeTimer)
1348+ require.NotNil(t, rc.resizeCancel)
1349+ rc.mu.Unlock()
1350+
1351+ rc.clearResizeTimer(2)
1352+ rc.mu.Lock()
1353+ require.Nil(t, rc.resizeTimer)
1354+ require.Nil(t, rc.resizeCancel)
1355+ rc.mu.Unlock()
1356+}
1357+
13351358 func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {
13361359 clock := portsmocks.NewMockClock(t)
13371360 timer := portsmocks.NewMockTimer(t)
@@ -1332,6 +1332,29 @@ func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstal
1332 requireNoCoordinatorOutputFrame(t, ownerSends)1332 requireNoCoordinatorOutputFrame(t, ownerSends)
1333 }1333 }
1334 1334
1335+func TestRenderCoordinatorClearResizeTimerKeepsNewerTimer(t *testing.T) {
1336+ rc := newRenderCoordinator(renderCoordinatorOptions{})
1337+ newer := &portsmocks.MockTimer{}
1338+
1339+ rc.mu.Lock()
1340+ rc.resizeGen = 2
1341+ rc.resizeTimer = newer
1342+ rc.resizeCancel = make(chan struct{})
1343+ rc.mu.Unlock()
1344+
1345+ rc.clearResizeTimer(1)
1346+ rc.mu.Lock()
1347+ require.Same(t, newer, rc.resizeTimer)
1348+ require.NotNil(t, rc.resizeCancel)
1349+ rc.mu.Unlock()
1350+
1351+ rc.clearResizeTimer(2)
1352+ rc.mu.Lock()
1353+ require.Nil(t, rc.resizeTimer)
1354+ require.Nil(t, rc.resizeCancel)
1355+ rc.mu.Unlock()
1356+}
1357+
1335 func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {1358 func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {
1336 clock := portsmocks.NewMockClock(t)1359 clock := portsmocks.NewMockClock(t)
1337 timer := portsmocks.NewMockTimer(t)1360 timer := portsmocks.NewMockTimer(t)
M internal/usecase/daemon/snapshot.go +1 -1
@@ -141,7 +141,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
141141 return fmt.Errorf("snapshot: generating pane identity: %w", err)
142142 }
143143 }
144- pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
144+ pty, err := d.ptys.Open(ctx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
145145 if err != nil {
146146 closeOpened()
147147 return err
@@ -141,7 +141,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
141 return fmt.Errorf("snapshot: generating pane identity: %w", err)141 return fmt.Errorf("snapshot: generating pane identity: %w", err)
142 }142 }
143 }143 }
144- pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)144+ pty, err := d.ptys.Open(ctx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
145 if err != nil {145 if err != nil {
146 closeOpened()146 closeOpened()
147 return err147 return err
M internal/usecase/daemon/snapshot_restore_test.go +16 -2
@@ -251,6 +251,19 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
251251 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
252252 }
253253
254+func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) {
255+ factory := &restorePTYFactory{}
256+ d := newTestDaemon(t, factory, stubClock{})
257+ ctx, cancel := context.WithCancel(context.Background())
258+ defer cancel()
259+
260+ require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{
261+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp"}},
262+ }}}))
263+ require.Len(t, factory.opens, 1)
264+ require.Same(t, ctx, factory.opens[0].ctx)
265+}
266+
254267 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
255268 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
256269 Name: "stacked",
@@ -438,18 +451,19 @@ type restorePTYFactory struct {
438451 }
439452
440453 type restorePTYOpen struct {
454+ ctx context.Context
441455 dir string
442456 size domain.Size
443457 env []string
444458 pty *restorePTY
445459 }
446460
447-func (f *restorePTYFactory) Open(_ context.Context, _ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
461+func (f *restorePTYFactory) Open(ctx context.Context, _ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
448462 f.mu.Lock()
449463 defer f.mu.Unlock()
450464 pty := newRestorePTY()
451465 pty.writeErr = f.writeErr
452- f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
466+ f.opens = append(f.opens, restorePTYOpen{ctx: ctx, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
453467 return pty, nil
454468 }
455469
@@ -251,6 +251,19 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
251 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)251 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
252 }252 }
253 253
254+func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) {
255+ factory := &restorePTYFactory{}
256+ d := newTestDaemon(t, factory, stubClock{})
257+ ctx, cancel := context.WithCancel(context.Background())
258+ defer cancel()
259+
260+ require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{
261+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp"}},
262+ }}}))
263+ require.Len(t, factory.opens, 1)
264+ require.Same(t, ctx, factory.opens[0].ctx)
265+}
266+
254 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {267 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
255 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{268 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
256 Name: "stacked",269 Name: "stacked",
@@ -438,18 +451,19 @@ type restorePTYFactory struct {
438 }451 }
439 452
440 type restorePTYOpen struct {453 type restorePTYOpen struct {
454+ ctx context.Context
441 dir string455 dir string
442 size domain.Size456 size domain.Size
443 env []string457 env []string
444 pty *restorePTY458 pty *restorePTY
445 }459 }
446 460
447-func (f *restorePTYFactory) Open(_ context.Context, _ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {461+func (f *restorePTYFactory) Open(ctx context.Context, _ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
448 f.mu.Lock()462 f.mu.Lock()
449 defer f.mu.Unlock()463 defer f.mu.Unlock()
450 pty := newRestorePTY()464 pty := newRestorePTY()
451 pty.writeErr = f.writeErr465 pty.writeErr = f.writeErr
452- f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})466+ f.opens = append(f.opens, restorePTYOpen{ctx: ctx, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
453 return pty, nil467 return pty, nil
454 }468 }
455 469

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