detached 05d4687

fix(daemon): guard session overflow handoff atomically

commit
05d468760e2b22624f6507d0a50e3bb88b8767f1
author
brice · · 2026-07-25 05:39 UTC
parent
8953d42
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +106 -9
M internal/usecase/daemon/navigation_overflow.go +5 -5
@@ -115,14 +115,14 @@ func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction,
115115 return picker.Target{Session: target.id, TabIndex: -1}, true
116116 }
117117
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.
118+// commitSessionOverflow carries its source constraint into the handoff so tab
119+// identity and floating visibility are revalidated while source ownership is
120+// transferred under the daemon -> routing -> session -> tab lock order.
121121 func (d *Daemon) commitSessionOverflow(from *session, ac *attachedClient, expectedSource *tab, target picker.Target) error {
122- if !overflowSourceEligible(from, expectedSource) {
122+ if from == nil || ac == nil || expectedSource == nil {
123123 return errNoNeighbor
124124 }
125- return d.switchToTarget(from, ac, target)
125+ return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{expectedSource: expectedSource})
126126 }
127127
128128 type tabOverflowCandidate struct {
@@ -115,14 +115,14 @@ func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction,
115 return picker.Target{Session: target.id, TabIndex: -1}, true115 return picker.Target{Session: target.id, TabIndex: -1}, true
116 }116 }
117 117
118-// commitSessionOverflow revalidates the floating-source exclusion immediately118+// commitSessionOverflow carries its source constraint into the handoff so tab
119-// before switchToTarget. It deliberately releases session and tab locks first:119+// identity and floating visibility are revalidated while source ownership is
120-// switchToTarget owns the daemon -> routing -> session lock order.120+// transferred under the daemon -> routing -> session -> tab lock order.
121 func (d *Daemon) commitSessionOverflow(from *session, ac *attachedClient, expectedSource *tab, target picker.Target) error {121 func (d *Daemon) commitSessionOverflow(from *session, ac *attachedClient, expectedSource *tab, target picker.Target) error {
122- if !overflowSourceEligible(from, expectedSource) {122+ if from == nil || ac == nil || expectedSource == nil {
123 return errNoNeighbor123 return errNoNeighbor
124 }124 }
125- return d.switchToTarget(from, ac, target)125+ return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{expectedSource: expectedSource})
126 }126 }
127 127
128 type tabOverflowCandidate struct {128 type tabOverflowCandidate struct {
M internal/usecase/daemon/navigation_overflow_test.go +62 -0
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/vev/internal/domain"
1010 "github.com/bnema/vev/internal/usecase/keys"
1111 "github.com/bnema/vev/internal/usecase/layout"
12+ "github.com/bnema/vev/internal/usecase/picker"
1213 )
1314
1415 func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {
@@ -196,6 +197,67 @@ func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
196197 charlie.mu.Unlock()
197198 }
198199
200+func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
201+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil)
202+ alpha.mu.Lock()
203+ alpha.name = "alpha"
204+ expectedSource := alpha.tabs[0]
205+ alpha.active = 1
206+ alpha.mu.Unlock()
207+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
208+ d.mu.Lock()
209+ d.sessions[charlie.id] = charlie
210+ d.mu.Unlock()
211+
212+ require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor)
213+ require.Same(t, alpha, ac.currentSession())
214+ alpha.mu.Lock()
215+ require.Same(t, ac, alpha.client)
216+ alpha.mu.Unlock()
217+ charlie.mu.Lock()
218+ require.Nil(t, charlie.client)
219+ charlie.mu.Unlock()
220+}
221+
222+func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) {
223+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
224+ installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
225+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
226+ d.mu.Lock()
227+ d.sessions[charlie.id] = charlie
228+ d.mu.Unlock()
229+
230+ require.NoError(t, d.switchToTarget(alpha, ac, picker.Target{Session: charlie.id, TabIndex: -1}))
231+ require.Same(t, charlie, ac.currentSession())
232+}
233+
234+func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
235+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
236+ alpha.mu.Lock()
237+ alpha.name = "alpha"
238+ alpha.mu.Unlock()
239+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
240+ d.mu.Lock()
241+ d.sessions[charlie.id] = charlie
242+ d.mu.Unlock()
243+
244+ target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true})
245+ require.True(t, ok)
246+ replacement := &attachedClient{}
247+ alpha.mu.Lock()
248+ alpha.client = replacement
249+ alpha.mu.Unlock()
250+
251+ require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
252+ require.Same(t, alpha, ac.currentSession())
253+ alpha.mu.Lock()
254+ require.Same(t, replacement, alpha.client)
255+ alpha.mu.Unlock()
256+ charlie.mu.Lock()
257+ require.Nil(t, charlie.client)
258+ charlie.mu.Unlock()
259+}
260+
199261 func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
200262 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
201263 alpha.mu.Lock()
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/usecase/keys"10 "github.com/bnema/vev/internal/usecase/keys"
11 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
12+ "github.com/bnema/vev/internal/usecase/picker"
12 )13 )
13 14
14 func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {15 func TestResolveOverflowObeysAxisConfigurationAndWalls(t *testing.T) {
@@ -196,6 +197,67 @@ func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
196 charlie.mu.Unlock()197 charlie.mu.Unlock()
197 }198 }
198 199
200+func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
201+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil, nil)
202+ alpha.mu.Lock()
203+ alpha.name = "alpha"
204+ expectedSource := alpha.tabs[0]
205+ alpha.active = 1
206+ alpha.mu.Unlock()
207+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
208+ d.mu.Lock()
209+ d.sessions[charlie.id] = charlie
210+ d.mu.Unlock()
211+
212+ require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, expectedSource, picker.Target{Session: charlie.id, TabIndex: -1}), errNoNeighbor)
213+ require.Same(t, alpha, ac.currentSession())
214+ alpha.mu.Lock()
215+ require.Same(t, ac, alpha.client)
216+ alpha.mu.Unlock()
217+ charlie.mu.Lock()
218+ require.Nil(t, charlie.client)
219+ charlie.mu.Unlock()
220+}
221+
222+func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) {
223+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
224+ installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
225+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
226+ d.mu.Lock()
227+ d.sessions[charlie.id] = charlie
228+ d.mu.Unlock()
229+
230+ require.NoError(t, d.switchToTarget(alpha, ac, picker.Target{Session: charlie.id, TabIndex: -1}))
231+ require.Same(t, charlie, ac.currentSession())
232+}
233+
234+func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
235+ d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
236+ alpha.mu.Lock()
237+ alpha.name = "alpha"
238+ alpha.mu.Unlock()
239+ charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
240+ d.mu.Lock()
241+ d.sessions[charlie.id] = charlie
242+ d.mu.Unlock()
243+
244+ target, ok := d.prepareSessionOverflow(alpha, layout.Down, domain.NavConfig{OverflowSessions: true})
245+ require.True(t, ok)
246+ replacement := &attachedClient{}
247+ alpha.mu.Lock()
248+ alpha.client = replacement
249+ alpha.mu.Unlock()
250+
251+ require.ErrorIs(t, d.commitSessionOverflow(alpha, ac, alpha.tabs[0], target), errNoNeighbor)
252+ require.Same(t, alpha, ac.currentSession())
253+ alpha.mu.Lock()
254+ require.Same(t, replacement, alpha.client)
255+ alpha.mu.Unlock()
256+ charlie.mu.Lock()
257+ require.Nil(t, charlie.client)
258+ charlie.mu.Unlock()
259+}
260+
199 func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {261 func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
200 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)262 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
201 alpha.mu.Lock()263 alpha.mu.Lock()
M internal/usecase/daemon/picker.go +39 -4
@@ -341,10 +341,23 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
341341 return d.sessions[id]
342342 }
343343
344+// sessionHandoffGuard optionally constrains an active-session handoff to the
345+// tab that initiated navigation. Its zero value preserves ordinary picker and
346+// command switching behavior.
347+type sessionHandoffGuard struct {
348+ expectedSource *tab
349+}
350+
344351 // switchToTarget resolves named lifecycle targets and commits their transition
345352 // while d.mu is held. A named palette result is allowed to cross an
346353 // active/stopped transition, but it never follows a same-name replacement.
347354 func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {
355+ return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
356+}
357+
358+// switchToTargetGuarded is the navigation-only variant whose guard is checked
359+// as part of the source ownership transfer.
360+func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
348361 d.mu.Lock()
349362 var (
350363 targetSess *session
@@ -363,15 +376,18 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
363376 // locked name/lifecycle resolver chose this active incarnation.
364377 resolvedTarget := target
365378 resolvedTarget.Session = active.id
366- targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget)
379+ targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard)
367380 }
368381 }
369382 } else if active := d.sessions[target.Session]; active != nil {
370- targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target)
383+ targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard)
371384 }
372385 d.mu.Unlock()
373386
374387 if !switched {
388+ if guard.expectedSource != nil {
389+ return errNoNeighbor
390+ }
375391 d.invalidateRender(from, ac, true, "picker.go")
376392 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
377393 }
@@ -413,7 +429,7 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
413429
414430 // switchToActiveTargetLocked commits an active target handoff. Caller holds
415431 // d.mu and releases it before completing render cleanup or first paint.
416-func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) {
432+func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard) (*session, *attachedClient, []renderLifecycleCleanup, bool) {
417433 // The caller already holds d.mu. Keep handoff ownership atomic with global
418434 // routing so it cannot select an attachment midway between sessions.
419435 d.notices.routingMu.Lock()
@@ -443,10 +459,29 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
443459 targetSess.mu.Unlock()
444460 return nil, nil, nil, false
445461 }
462+ var guardedSource *tab
463+ if guard.expectedSource != nil {
464+ if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource {
465+ from.mu.Unlock()
466+ targetSess.mu.Unlock()
467+ return nil, nil, nil, false
468+ }
469+ guardedSource = guard.expectedSource
470+ guardedSource.mu.Lock()
471+ if guardedSource.floating.state == floatingVisible {
472+ guardedSource.mu.Unlock()
473+ from.mu.Unlock()
474+ targetSess.mu.Unlock()
475+ return nil, nil, nil, false
476+ }
477+ }
446478 term := from.terminal
447479 env := copyEnvironment(from.env)
448480 from.client = nil
449481 ac.setSession(nil)
482+ if guardedSource != nil {
483+ guardedSource.mu.Unlock()
484+ }
450485 from.mu.Unlock()
451486
452487 old := targetSess.client
@@ -513,7 +548,7 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
513548 // targets must use switchToTarget so resolution and commit share d.mu.
514549 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
515550 d.mu.Lock()
516- _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target)
551+ _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{})
517552 d.mu.Unlock()
518553 if !switched {
519554 return nil
@@ -341,10 +341,23 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
341 return d.sessions[id]341 return d.sessions[id]
342 }342 }
343 343
344+// sessionHandoffGuard optionally constrains an active-session handoff to the
345+// tab that initiated navigation. Its zero value preserves ordinary picker and
346+// command switching behavior.
347+type sessionHandoffGuard struct {
348+ expectedSource *tab
349+}
350+
344 // switchToTarget resolves named lifecycle targets and commits their transition351 // switchToTarget resolves named lifecycle targets and commits their transition
345 // while d.mu is held. A named palette result is allowed to cross an352 // while d.mu is held. A named palette result is allowed to cross an
346 // active/stopped transition, but it never follows a same-name replacement.353 // active/stopped transition, but it never follows a same-name replacement.
347 func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {354 func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {
355+ return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
356+}
357+
358+// switchToTargetGuarded is the navigation-only variant whose guard is checked
359+// as part of the source ownership transfer.
360+func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
348 d.mu.Lock()361 d.mu.Lock()
349 var (362 var (
350 targetSess *session363 targetSess *session
@@ -363,15 +376,18 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
363 // locked name/lifecycle resolver chose this active incarnation.376 // locked name/lifecycle resolver chose this active incarnation.
364 resolvedTarget := target377 resolvedTarget := target
365 resolvedTarget.Session = active.id378 resolvedTarget.Session = active.id
366- targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget)379+ targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard)
367 }380 }
368 }381 }
369 } else if active := d.sessions[target.Session]; active != nil {382 } else if active := d.sessions[target.Session]; active != nil {
370- targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target)383+ targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard)
371 }384 }
372 d.mu.Unlock()385 d.mu.Unlock()
373 386
374 if !switched {387 if !switched {
388+ if guard.expectedSource != nil {
389+ return errNoNeighbor
390+ }
375 d.invalidateRender(from, ac, true, "picker.go")391 d.invalidateRender(from, ac, true, "picker.go")
376 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)392 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
377 }393 }
@@ -413,7 +429,7 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
413 429
414 // switchToActiveTargetLocked commits an active target handoff. Caller holds430 // switchToActiveTargetLocked commits an active target handoff. Caller holds
415 // d.mu and releases it before completing render cleanup or first paint.431 // d.mu and releases it before completing render cleanup or first paint.
416-func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) {432+func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard) (*session, *attachedClient, []renderLifecycleCleanup, bool) {
417 // The caller already holds d.mu. Keep handoff ownership atomic with global433 // The caller already holds d.mu. Keep handoff ownership atomic with global
418 // routing so it cannot select an attachment midway between sessions.434 // routing so it cannot select an attachment midway between sessions.
419 d.notices.routingMu.Lock()435 d.notices.routingMu.Lock()
@@ -443,10 +459,29 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
443 targetSess.mu.Unlock()459 targetSess.mu.Unlock()
444 return nil, nil, nil, false460 return nil, nil, nil, false
445 }461 }
462+ var guardedSource *tab
463+ if guard.expectedSource != nil {
464+ if from.active < 0 || from.active >= len(from.tabs) || from.tabs[from.active] != guard.expectedSource {
465+ from.mu.Unlock()
466+ targetSess.mu.Unlock()
467+ return nil, nil, nil, false
468+ }
469+ guardedSource = guard.expectedSource
470+ guardedSource.mu.Lock()
471+ if guardedSource.floating.state == floatingVisible {
472+ guardedSource.mu.Unlock()
473+ from.mu.Unlock()
474+ targetSess.mu.Unlock()
475+ return nil, nil, nil, false
476+ }
477+ }
446 term := from.terminal478 term := from.terminal
447 env := copyEnvironment(from.env)479 env := copyEnvironment(from.env)
448 from.client = nil480 from.client = nil
449 ac.setSession(nil)481 ac.setSession(nil)
482+ if guardedSource != nil {
483+ guardedSource.mu.Unlock()
484+ }
450 from.mu.Unlock()485 from.mu.Unlock()
451 486
452 old := targetSess.client487 old := targetSess.client
@@ -513,7 +548,7 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
513 // targets must use switchToTarget so resolution and commit share d.mu.548 // targets must use switchToTarget so resolution and commit share d.mu.
514 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {549 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
515 d.mu.Lock()550 d.mu.Lock()
516- _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target)551+ _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{})
517 d.mu.Unlock()552 d.mu.Unlock()
518 if !switched {553 if !switched {
519 return nil554 return nil

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