detached 8953d42

fix(daemon): enforce overflow handoff invariants

commit
8953d4264e07c1bb42e9b616fe0fe64748f5c090
author
brice · · 2026-07-25 05:29 UTC
parent
777c969
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +78 -1
M internal/usecase/daemon/navigation_overflow.go +27 -0
@@ -50,6 +50,23 @@ func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count
5050 return step
5151 }
5252
53+// overflowSourceEligible validates that directional overflow still originates
54+// from the active tiled tab. The session-to-tab lock order matches tab overflow
55+// commit and leaves no lock held across a session handoff.
56+func overflowSourceEligible(sess *session, expectedSource *tab) bool {
57+ if sess == nil || expectedSource == nil {
58+ return false
59+ }
60+ sess.mu.Lock()
61+ defer sess.mu.Unlock()
62+ if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {
63+ return false
64+ }
65+ expectedSource.mu.Lock()
66+ defer expectedSource.mu.Unlock()
67+ return expectedSource.floating.state != floatingVisible
68+}
69+
5370 type sessionOverflowSnapshot struct {
5471 session *session
5572 id domain.SessionID
@@ -98,6 +115,16 @@ func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction,
98115 return picker.Target{Session: target.id, TabIndex: -1}, true
99116 }
100117
118+// commitSessionOverflow revalidates the floating-source exclusion immediately
119+// before switchToTarget. It deliberately releases session and tab locks first:
120+// switchToTarget owns the daemon -> routing -> session lock order.
121+func (d *Daemon) commitSessionOverflow(from *session, ac *attachedClient, expectedSource *tab, target picker.Target) error {
122+ if !overflowSourceEligible(from, expectedSource) {
123+ return errNoNeighbor
124+ }
125+ return d.switchToTarget(from, ac, target)
126+}
127+
101128 type tabOverflowCandidate struct {
102129 source *tab
103130 target *tab
@@ -50,6 +50,23 @@ func resolveOverflow(dir layout.Direction, cfg domain.NavConfig, position, count
50 return step50 return step
51 }51 }
52 52
53+// overflowSourceEligible validates that directional overflow still originates
54+// from the active tiled tab. The session-to-tab lock order matches tab overflow
55+// commit and leaves no lock held across a session handoff.
56+func overflowSourceEligible(sess *session, expectedSource *tab) bool {
57+ if sess == nil || expectedSource == nil {
58+ return false
59+ }
60+ sess.mu.Lock()
61+ defer sess.mu.Unlock()
62+ if sess.active < 0 || sess.active >= len(sess.tabs) || sess.tabs[sess.active] != expectedSource {
63+ return false
64+ }
65+ expectedSource.mu.Lock()
66+ defer expectedSource.mu.Unlock()
67+ return expectedSource.floating.state != floatingVisible
68+}
69+
53 type sessionOverflowSnapshot struct {70 type sessionOverflowSnapshot struct {
54 session *session71 session *session
55 id domain.SessionID72 id domain.SessionID
@@ -98,6 +115,16 @@ func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction,
98 return picker.Target{Session: target.id, TabIndex: -1}, true115 return picker.Target{Session: target.id, TabIndex: -1}, true
99 }116 }
100 117
118+// commitSessionOverflow revalidates the floating-source exclusion immediately
119+// before switchToTarget. It deliberately releases session and tab locks first:
120+// switchToTarget owns the daemon -> routing -> session lock order.
121+func (d *Daemon) commitSessionOverflow(from *session, ac *attachedClient, expectedSource *tab, target picker.Target) error {
122+ if !overflowSourceEligible(from, expectedSource) {
123+ return errNoNeighbor
124+ }
125+ return d.switchToTarget(from, ac, target)
126+}
127+
101 type tabOverflowCandidate struct {128 type tabOverflowCandidate struct {
102 source *tab129 source *tab
103 target *tab130 target *tab
M internal/usecase/daemon/navigation_overflow_test.go +47 -0
@@ -149,6 +149,53 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
149149 require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning")
150150 }
151151
152+func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
153+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
154+ alpha.mu.Lock()
155+ alpha.name = "alpha"
156+ alpha.mu.Unlock()
157+ installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
158+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
159+ d.mu.Lock()
160+ d.sessions[charlie.id] = charlie
161+ d.mu.Unlock()
162+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
163+
164+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown)
165+
166+ require.Same(t, alpha, ac.currentSession())
167+ alpha.mu.Lock()
168+ require.Same(t, ac, alpha.client)
169+ alpha.mu.Unlock()
170+ charlie.mu.Lock()
171+ require.Nil(t, charlie.client)
172+ charlie.mu.Unlock()
173+}
174+
175+func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
176+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
177+ alpha.mu.Lock()
178+ alpha.name = "alpha"
179+ alpha.mu.Unlock()
180+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
181+ d.mu.Lock()
182+ d.sessions[charlie.id] = charlie
183+ d.mu.Unlock()
184+
185+ target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true})
186+ require.True(t, ok)
187+ installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
188+
189+ require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
190+ require.Same(t, alpha, ac.currentSession())
191+ alpha.mu.Lock()
192+ require.Same(t, ac, alpha.client)
193+ alpha.mu.Unlock()
194+ charlie.mu.Lock()
195+ require.Nil(t, charlie.client)
196+ charlie.mu.Unlock()
197+}
198+
152199 func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
153200 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
154201 alpha.mu.Lock()
@@ -149,6 +149,53 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
149 require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning")149 require.Same(t, ac, attached, "the source session owns the genuinely attached client after returning")
150 }150 }
151 151
152+func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
153+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
154+ alpha.mu.Lock()
155+ alpha.name = "alpha"
156+ alpha.mu.Unlock()
157+ installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
158+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
159+ d.mu.Lock()
160+ d.sessions[charlie.id] = charlie
161+ d.mu.Unlock()
162+ d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
163+
164+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneDown)
165+
166+ require.Same(t, alpha, ac.currentSession())
167+ alpha.mu.Lock()
168+ require.Same(t, ac, alpha.client)
169+ alpha.mu.Unlock()
170+ charlie.mu.Lock()
171+ require.Nil(t, charlie.client)
172+ charlie.mu.Unlock()
173+}
174+
175+func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
176+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
177+ alpha.mu.Lock()
178+ alpha.name = "alpha"
179+ alpha.mu.Unlock()
180+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
181+ d.mu.Lock()
182+ d.sessions[charlie.id] = charlie
183+ d.mu.Unlock()
184+
185+ target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true})
186+ require.True(t, ok)
187+ installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
188+
189+ require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
190+ require.Same(t, alpha, ac.currentSession())
191+ alpha.mu.Lock()
192+ require.Same(t, ac, alpha.client)
193+ alpha.mu.Unlock()
194+ charlie.mu.Lock()
195+ require.Nil(t, charlie.client)
196+ charlie.mu.Unlock()
197+}
198+
152 func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {199 func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
153 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)200 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
154 alpha.mu.Lock()201 alpha.mu.Lock()
M internal/usecase/daemon/pane_actions.go +4 -1
@@ -319,13 +319,16 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
319319 if !errors.Is(err, errNoNeighbor) || target.tab == nil {
320320 return err
321321 }
322+ if !overflowSourceEligible(sess, target.tab) {
323+ return errNoNeighbor
324+ }
322325
323326 cfg := d.currentNavConfig()
324327 if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {
325328 if ac == nil {
326329 return errNoNeighbor
327330 }
328- return d.switchToTarget(sess, ac, sessionTarget)
331+ return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
329332 }
330333
331334 sess.mu.Lock()
@@ -319,13 +319,16 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
319 if !errors.Is(err, errNoNeighbor) || target.tab == nil {319 if !errors.Is(err, errNoNeighbor) || target.tab == nil {
320 return err320 return err
321 }321 }
322+ if !overflowSourceEligible(sess, target.tab) {
323+ return errNoNeighbor
324+ }
322 325
323 cfg := d.currentNavConfig()326 cfg := d.currentNavConfig()
324 if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {327 if sessionTarget, ok := d.prepareSessionOverflow(sess, dir, cfg); ok {
325 if ac == nil {328 if ac == nil {
326 return errNoNeighbor329 return errNoNeighbor
327 }330 }
328- return d.switchToTarget(sess, ac, sessionTarget)331+ return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
329 }332 }
330 333
331 sess.mu.Lock()334 sess.mu.Lock()

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