detached 80df2be

fix(daemon): trace tab and pane move outcomes

commit
80df2be5e08ea8cf5a7ec03099a87b263a12e7fa
author
brice · · 2026-08-20 08:30 UTC
parent
83b15cd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +135 -4
M internal/usecase/daemon/move.go +25 -2
@@ -32,8 +32,30 @@ type moveTabRequest struct {
3232 // section starts, the caller holds the ordered architecture locks and only
3333 // non-failing in-memory writes are permitted.
3434 func (d *Daemon) movePane(req movePaneRequest) (result error) {
35- defer func() { result = normalizeMoveRejection(result) }()
36- if d == nil || req.Source.ID == "" || req.Destination.ID == "" ||
35+ if d == nil {
36+ return errMovePaneInvalid
37+ }
38+ d.log.Info("pane move requested",
39+ "source_session_id", req.Source.ID,
40+ "source_tab_id", req.SourceTabID,
41+ "source_pane_id", req.SourcePaneID,
42+ "destination_session_id", req.Destination.ID,
43+ "destination_tab_id", req.DestinationTabID,
44+ )
45+ defer func() {
46+ result = normalizeMoveRejection(result)
47+ if result != nil {
48+ d.log.Warn("pane move rejected",
49+ "err", result,
50+ "source_session_id", req.Source.ID,
51+ "source_tab_id", req.SourceTabID,
52+ "source_pane_id", req.SourcePaneID,
53+ "destination_session_id", req.Destination.ID,
54+ "destination_tab_id", req.DestinationTabID,
55+ )
56+ }
57+ }()
58+ if req.Source.ID == "" || req.Destination.ID == "" ||
3759 req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" {
3860 return errMovePaneInvalid
3961 }
@@ -150,6 +172,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
150172 sourceTab: sourceTab,
151173 destinationTab: destinationTab,
152174 movedPane: movedPane,
175+ operation: "pane",
153176 sourceAttachments: admission.sourceAttachments,
154177 syncCleanup: commit.syncCleanup,
155178 frozenEffects: frozen,
@@ -32,8 +32,30 @@ type moveTabRequest struct {
32 // section starts, the caller holds the ordered architecture locks and only32 // section starts, the caller holds the ordered architecture locks and only
33 // non-failing in-memory writes are permitted.33 // non-failing in-memory writes are permitted.
34 func (d *Daemon) movePane(req movePaneRequest) (result error) {34 func (d *Daemon) movePane(req movePaneRequest) (result error) {
35- defer func() { result = normalizeMoveRejection(result) }()35+ if d == nil {
36- if d == nil || req.Source.ID == "" || req.Destination.ID == "" ||36+ return errMovePaneInvalid
37+ }
38+ d.log.Info("pane move requested",
39+ "source_session_id", req.Source.ID,
40+ "source_tab_id", req.SourceTabID,
41+ "source_pane_id", req.SourcePaneID,
42+ "destination_session_id", req.Destination.ID,
43+ "destination_tab_id", req.DestinationTabID,
44+ )
45+ defer func() {
46+ result = normalizeMoveRejection(result)
47+ if result != nil {
48+ d.log.Warn("pane move rejected",
49+ "err", result,
50+ "source_session_id", req.Source.ID,
51+ "source_tab_id", req.SourceTabID,
52+ "source_pane_id", req.SourcePaneID,
53+ "destination_session_id", req.Destination.ID,
54+ "destination_tab_id", req.DestinationTabID,
55+ )
56+ }
57+ }()
58+ if req.Source.ID == "" || req.Destination.ID == "" ||
37 req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" {59 req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" {
38 return errMovePaneInvalid60 return errMovePaneInvalid
39 }61 }
@@ -150,6 +172,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
150 sourceTab: sourceTab,172 sourceTab: sourceTab,
151 destinationTab: destinationTab,173 destinationTab: destinationTab,
152 movedPane: movedPane,174 movedPane: movedPane,
175+ operation: "pane",
153 sourceAttachments: admission.sourceAttachments,176 sourceAttachments: admission.sourceAttachments,
154 syncCleanup: commit.syncCleanup,177 syncCleanup: commit.syncCleanup,
155 frozenEffects: frozen,178 frozenEffects: frozen,
A internal/usecase/daemon/move_observability_test.go +70 -0
@@ -0,0 +1,70 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+)
13+
14+func TestMoveTabLogsCommitAndSourceAttachmentTermination(t *testing.T) {
15+ var logs bytes.Buffer
16+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY())
17+ d.log = slog.New(slog.NewJSONHandler(&logs, nil))
18+
19+ destinationCtx, destinationCancel := context.WithCancel(d.serveCtx)
20+ t.Cleanup(destinationCancel)
21+ destinationTab := newTabWithStableID("destination-tab", "destination-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
22+ destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)
23+ destination := &session{
24+ sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}},
25+ ctx: destinationCtx,
26+ cancel: destinationCancel,
27+ tabs: []*tab{destinationTab},
28+ }
29+ publishTiledPaneOwners(destination, destinationTab)
30+ d.mu.Lock()
31+ d.sessions[destination.id] = destination
32+ d.mu.Unlock()
33+
34+ sourceTab := source.tabs[0]
35+ sourceTab.stableID = "source-tab"
36+ require.NoError(t, d.moveTab(moveTabRequest{
37+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
38+ SourceTabID: domain.TabStableID(sourceTab.stableID),
39+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
40+ }))
41+ d.waitNotifies()
42+
43+ output := logs.String()
44+ require.Contains(t, output, `"msg":"tab move requested"`)
45+ require.Contains(t, output, `"msg":"move committed"`)
46+ require.Contains(t, output, `"operation":"tab"`)
47+ require.Contains(t, output, `"source_retired":true`)
48+ require.Contains(t, output, `"msg":"move detaching source attachments"`)
49+ require.Contains(t, output, `"reason":"session-killed"`)
50+ require.Contains(t, output, `"msg":"client detached"`)
51+ require.Contains(t, output, `"reason":1`)
52+}
53+
54+func TestMoveTabLogsNormalizedRejection(t *testing.T) {
55+ var logs bytes.Buffer
56+ d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&logs, nil)))
57+
58+ err := d.moveTab(moveTabRequest{
59+ Source: moveSessionLocator{ID: "missing", Incarnation: domain.IncarnationID{1}},
60+ SourceTabID: "tab",
61+ Destination: moveSessionLocator{ID: "also-missing", Incarnation: domain.IncarnationID{2}},
62+ })
63+
64+ require.ErrorIs(t, err, errMovePaneInvalid)
65+ output := logs.String()
66+ require.Contains(t, output, `"msg":"tab move requested"`)
67+ require.Contains(t, output, `"msg":"tab move rejected"`)
68+ require.Contains(t, output, `"source_session_id":"missing"`)
69+ require.Contains(t, output, `"destination_session_id":"also-missing"`)
70+}
@@ -0,0 +1,70 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+)
13+
14+func TestMoveTabLogsCommitAndSourceAttachmentTermination(t *testing.T) {
15+ var logs bytes.Buffer
16+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY())
17+ d.log = slog.New(slog.NewJSONHandler(&logs, nil))
18+
19+ destinationCtx, destinationCancel := context.WithCancel(d.serveCtx)
20+ t.Cleanup(destinationCancel)
21+ destinationTab := newTabWithStableID("destination-tab", "destination-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
22+ destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)
23+ destination := &session{
24+ sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}},
25+ ctx: destinationCtx,
26+ cancel: destinationCancel,
27+ tabs: []*tab{destinationTab},
28+ }
29+ publishTiledPaneOwners(destination, destinationTab)
30+ d.mu.Lock()
31+ d.sessions[destination.id] = destination
32+ d.mu.Unlock()
33+
34+ sourceTab := source.tabs[0]
35+ sourceTab.stableID = "source-tab"
36+ require.NoError(t, d.moveTab(moveTabRequest{
37+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
38+ SourceTabID: domain.TabStableID(sourceTab.stableID),
39+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
40+ }))
41+ d.waitNotifies()
42+
43+ output := logs.String()
44+ require.Contains(t, output, `"msg":"tab move requested"`)
45+ require.Contains(t, output, `"msg":"move committed"`)
46+ require.Contains(t, output, `"operation":"tab"`)
47+ require.Contains(t, output, `"source_retired":true`)
48+ require.Contains(t, output, `"msg":"move detaching source attachments"`)
49+ require.Contains(t, output, `"reason":"session-killed"`)
50+ require.Contains(t, output, `"msg":"client detached"`)
51+ require.Contains(t, output, `"reason":1`)
52+}
53+
54+func TestMoveTabLogsNormalizedRejection(t *testing.T) {
55+ var logs bytes.Buffer
56+ d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&logs, nil)))
57+
58+ err := d.moveTab(moveTabRequest{
59+ Source: moveSessionLocator{ID: "missing", Incarnation: domain.IncarnationID{1}},
60+ SourceTabID: "tab",
61+ Destination: moveSessionLocator{ID: "also-missing", Incarnation: domain.IncarnationID{2}},
62+ })
63+
64+ require.ErrorIs(t, err, errMovePaneInvalid)
65+ output := logs.String()
66+ require.Contains(t, output, `"msg":"tab move requested"`)
67+ require.Contains(t, output, `"msg":"tab move rejected"`)
68+ require.Contains(t, output, `"source_session_id":"missing"`)
69+ require.Contains(t, output, `"destination_session_id":"also-missing"`)
70+}
M internal/usecase/daemon/move_pane_postcommit.go +19 -0
@@ -26,6 +26,7 @@ type movePanePostcommitPlan struct {
2626 destinationTab *tab
2727 movedPane *pane
2828 movedPanes []*pane
29+ operation string
2930 sourceAttachments []*attachedClient
3031 syncCleanup syncTimerCleanup
3132 frozenEffects frozenAttachmentEffectGates
@@ -79,6 +80,15 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
7980 }
8081 p.unlockDispatch()
8182 p.reservation.Release()
83+ d.log.Info("move committed",
84+ "operation", p.operation,
85+ "source_session", p.source.name,
86+ "source_session_id", p.source.id,
87+ "source_tab_id", p.sourceTab.stableID,
88+ "destination_session", p.destination.name,
89+ "destination_session_id", p.destination.id,
90+ "source_retired", p.sourceEmpty,
91+ )
8292
8393 // Repair attachment-local stable targets after shared topology publication
8494 // and after the move dispatch locks have been released.
@@ -114,6 +124,15 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
114124 if p.sourceEmpty {
115125 retireEmptySessionAfterMove(d, p.source)
116126 }
127+ if len(p.retiredAttachments) != 0 {
128+ d.log.Info("move detaching source attachments",
129+ "operation", p.operation,
130+ "source_session", p.source.name,
131+ "source_session_id", p.source.id,
132+ "reason", "session-killed",
133+ "attachments", len(p.retiredAttachments),
134+ )
135+ }
117136 for _, attachment := range p.retiredAttachments {
118137 d.unregisterPreview(attachment.ac)
119138 attachment.ac.clearCaptureFrames()
@@ -26,6 +26,7 @@ type movePanePostcommitPlan struct {
26 destinationTab *tab26 destinationTab *tab
27 movedPane *pane27 movedPane *pane
28 movedPanes []*pane28 movedPanes []*pane
29+ operation string
29 sourceAttachments []*attachedClient30 sourceAttachments []*attachedClient
30 syncCleanup syncTimerCleanup31 syncCleanup syncTimerCleanup
31 frozenEffects frozenAttachmentEffectGates32 frozenEffects frozenAttachmentEffectGates
@@ -79,6 +80,15 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
79 }80 }
80 p.unlockDispatch()81 p.unlockDispatch()
81 p.reservation.Release()82 p.reservation.Release()
83+ d.log.Info("move committed",
84+ "operation", p.operation,
85+ "source_session", p.source.name,
86+ "source_session_id", p.source.id,
87+ "source_tab_id", p.sourceTab.stableID,
88+ "destination_session", p.destination.name,
89+ "destination_session_id", p.destination.id,
90+ "source_retired", p.sourceEmpty,
91+ )
82 92
83 // Repair attachment-local stable targets after shared topology publication93 // Repair attachment-local stable targets after shared topology publication
84 // and after the move dispatch locks have been released.94 // and after the move dispatch locks have been released.
@@ -114,6 +124,15 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
114 if p.sourceEmpty {124 if p.sourceEmpty {
115 retireEmptySessionAfterMove(d, p.source)125 retireEmptySessionAfterMove(d, p.source)
116 }126 }
127+ if len(p.retiredAttachments) != 0 {
128+ d.log.Info("move detaching source attachments",
129+ "operation", p.operation,
130+ "source_session", p.source.name,
131+ "source_session_id", p.source.id,
132+ "reason", "session-killed",
133+ "attachments", len(p.retiredAttachments),
134+ )
135+ }
117 for _, attachment := range p.retiredAttachments {136 for _, attachment := range p.retiredAttachments {
118 d.unregisterPreview(attachment.ac)137 d.unregisterPreview(attachment.ac)
119 attachment.ac.clearCaptureFrames()138 attachment.ac.clearCaptureFrames()
M internal/usecase/daemon/move_tab.go +21 -2
@@ -42,8 +42,26 @@ type moveTabCommit struct {
4242 }
4343
4444 func (d *Daemon) moveTab(req moveTabRequest) (result error) {
45- defer func() { result = normalizeMoveRejection(result) }()
46- if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
45+ if d == nil {
46+ return errMovePaneInvalid
47+ }
48+ d.log.Info("tab move requested",
49+ "source_session_id", req.Source.ID,
50+ "source_tab_id", req.SourceTabID,
51+ "destination_session_id", req.Destination.ID,
52+ )
53+ defer func() {
54+ result = normalizeMoveRejection(result)
55+ if result != nil {
56+ d.log.Warn("tab move rejected",
57+ "err", result,
58+ "source_session_id", req.Source.ID,
59+ "source_tab_id", req.SourceTabID,
60+ "destination_session_id", req.Destination.ID,
61+ )
62+ }
63+ }()
64+ if req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
4765 return errMovePaneInvalid
4866 }
4967 d.mu.Lock()
@@ -134,6 +152,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
134152 destinationTab: admission.tab,
135153 movedPane: firstMovePane(admission.panes),
136154 movedPanes: admission.panes,
155+ operation: "tab",
137156 sourceAttachments: admission.sourceAttachments,
138157 syncCleanup: commit.syncCleanup,
139158 frozenEffects: frozen,
@@ -42,8 +42,26 @@ type moveTabCommit struct {
42 }42 }
43 43
44 func (d *Daemon) moveTab(req moveTabRequest) (result error) {44 func (d *Daemon) moveTab(req moveTabRequest) (result error) {
45- defer func() { result = normalizeMoveRejection(result) }()45+ if d == nil {
46- if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {46+ return errMovePaneInvalid
47+ }
48+ d.log.Info("tab move requested",
49+ "source_session_id", req.Source.ID,
50+ "source_tab_id", req.SourceTabID,
51+ "destination_session_id", req.Destination.ID,
52+ )
53+ defer func() {
54+ result = normalizeMoveRejection(result)
55+ if result != nil {
56+ d.log.Warn("tab move rejected",
57+ "err", result,
58+ "source_session_id", req.Source.ID,
59+ "source_tab_id", req.SourceTabID,
60+ "destination_session_id", req.Destination.ID,
61+ )
62+ }
63+ }()
64+ if req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
47 return errMovePaneInvalid65 return errMovePaneInvalid
48 }66 }
49 d.mu.Lock()67 d.mu.Lock()
@@ -134,6 +152,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
134 destinationTab: admission.tab,152 destinationTab: admission.tab,
135 movedPane: firstMovePane(admission.panes),153 movedPane: firstMovePane(admission.panes),
136 movedPanes: admission.panes,154 movedPanes: admission.panes,
155+ operation: "tab",
137 sourceAttachments: admission.sourceAttachments,156 sourceAttachments: admission.sourceAttachments,
138 syncCleanup: commit.syncCleanup,157 syncCleanup: commit.syncCleanup,
139 frozenEffects: frozen,158 frozenEffects: frozen,

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