detached 040f576

refactor(daemon): deepen move transaction (#226)

commit
040f5764e121c5e4775e7a96bfc5b5c1c13e7041
author
bnema · · 2026-08-27 20:41 UTC
committer
GitHub · · 2026-08-27 20:41 UTC
parent
cbfd03f
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +651 -531
M internal/usecase/daemon/move.go +2 -168
@@ -27,174 +27,8 @@ type moveTabRequest struct {
2727 Destination moveSessionLocator
2828 }
2929
30-// movePane resolves, reserves, validates, and commits one pane relocation. All
31-// fallible work happens before the membership publication section. Once the
32-// section starts, the caller holds the ordered architecture locks and only
33-// non-failing in-memory writes are permitted.
34-func (d *Daemon) movePane(req movePaneRequest) (result error) {
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 == "" ||
59- req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" {
60- return errMovePaneInvalid
61- }
62-
63- d.mu.Lock()
64- source := moveSessionForLocatorLocked(d, req.Source)
65- destination := moveSessionForLocatorLocked(d, req.Destination)
66- d.mu.Unlock()
67- if source == nil || destination == nil {
68- return errMoveStaleTarget
69- }
70-
71- // Dispatch admission must precede lifecycle reservation. A final close holds
72- // dispatchMu while it waits for teardown ownership; reserving first would let
73- // that close wait on a move which is itself blocked on dispatchMu.
74- if d.beforeMoveDispatch != nil {
75- d.beforeMoveDispatch()
76- }
77- unlockDispatch := lockMoveDispatch(source, destination)
78- dispatchHeld := true
79- defer func() {
80- if dispatchHeld {
81- unlockDispatch()
82- }
83- }()
84-
85- reservation, err := d.reserveMoveLifecycles(source, destination)
86- if err != nil {
87- return errMoveStaleTarget
88- }
89- reservationHeld := true
90- defer func() {
91- if reservationHeld {
92- reservation.Release()
93- }
94- }()
95- if d.afterMoveLifecycleReserved != nil {
96- d.afterMoveLifecycleReserved()
97- }
98-
99- // Snapshot the exact live objects and generations before waiting on resize
100- // fences. The locked commit repeats every authority check before publication.
101- admission, err := d.snapshotMovePaneAdmission(req, source, destination)
102- if err != nil {
103- return err
104- }
105- sourceTab := admission.sourceTab
106- destinationTab := admission.destinationTab
107- movedPane := admission.movedPane
108- sourceTabInitialGeneration := admission.sourceGeneration
109- destinationTabInitialGeneration := admission.destinationGeneration
110- finalSourceTab := admission.finalSourceTab
111- if d.afterMovePaneSourceSnapshot != nil {
112- d.afterMovePaneSourceSnapshot()
113- }
114-
115- // A final source tab is retired without transferring any attachment to the
116- // destination. Freeze every affected source connection before publication so
117- // teardown invalidates exact generations rather than racing input or render.
118- var frozen attachmentTransitionGuard
119- var sourceEffectsFrozen bool
120- if finalSourceTab && source != destination && len(admission.sourceAttachments) != 0 {
121- interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))
122- for _, ac := range admission.sourceAttachments {
123- if transport := admission.sourceTransports[ac]; transport.transport != nil {
124- interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
125- }
126- }
127- frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
128- if d.afterAttachmentEffectGateFrozen != nil {
129- d.afterAttachmentEffectGateFrozen("move-pane", ac)
130- }
131- }}, admission.sourceAttachments...)
132- if !frozen.acquired || !frozen.drained {
133- frozen.unfreeze()
134- return errMovePaneInvalid
135- }
136- sourceEffectsFrozen = true
137- }
138- if sourceEffectsFrozen {
139- defer func() {
140- if sourceEffectsFrozen {
141- frozen.unfreeze()
142- }
143- }()
144- }
145-
146- commit := movePaneCommit{
147- req: req,
148- source: source,
149- destination: destination,
150- sourceTab: sourceTab,
151- destinationTab: destinationTab,
152- movedPane: movedPane,
153- sourceAttachments: admission.sourceAttachments,
154- sourceTransports: admission.sourceTransports,
155- sourceGeneration: sourceTabInitialGeneration,
156- destinationGeneration: destinationTabInitialGeneration,
157- frozenEffects: frozen,
158- }
159- fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane)
160- if !fences.acquire(func() bool { return commit.publishLocked(d) }) {
161- if commit.err != nil {
162- return commit.err
163- }
164- return errMoveStaleTarget
165- }
166- // Owner/layout fences end at the atomic publication boundary. The immutable
167- // postcommit plan owns every later cleanup and effect.
168- fences.Release()
169- postcommit := movePanePostcommitPlan{
170- source: source,
171- destination: destination,
172- sourceTab: sourceTab,
173- destinationTab: destinationTab,
174- movedPane: movedPane,
175- operation: "pane",
176- sourceAttachments: admission.sourceAttachments,
177- syncCleanup: commit.syncCleanup,
178- frozenEffects: frozen,
179- effectsFrozen: sourceEffectsFrozen,
180- unlockDispatch: unlockDispatch,
181- reservation: reservation,
182- sourceTabRemoved: commit.sourceTabRemoved,
183- sourceEmpty: commit.sourceEmpty,
184- retiredParked: commit.retiredParked,
185- retiredAttachments: commit.retiredAttachments,
186- sourceMetadata: commit.sourceMetadata,
187- sourceMetadataValid: commit.sourceMetadataValid,
188- destinationMetadata: commit.destinationMetadata,
189- destinationMetadataValid: commit.destinationMetadataValid,
190- }
191- // Transfer cleanup ownership away from the admission defers only after the
192- // complete postcommit plan exists.
193- sourceEffectsFrozen = false
194- dispatchHeld = false
195- reservationHeld = false
196- postcommit.execute(d)
197- return nil
30+func (d *Daemon) movePane(req movePaneRequest) error {
31+ return d.executeMove(&movePaneTopology{req: req})
19832 }
19933
20034 // moveSessionForLocatorLocked resolves only live sessions. The name is
@@ -27,174 +27,8 @@ type moveTabRequest struct {
27 Destination moveSessionLocator27 Destination moveSessionLocator
28 }28 }
29 29
30-// movePane resolves, reserves, validates, and commits one pane relocation. All30+func (d *Daemon) movePane(req movePaneRequest) error {
31-// fallible work happens before the membership publication section. Once the31+ return d.executeMove(&movePaneTopology{req: req})
32-// section starts, the caller holds the ordered architecture locks and only
33-// non-failing in-memory writes are permitted.
34-func (d *Daemon) movePane(req movePaneRequest) (result error) {
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 == "" ||
59- req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" {
60- return errMovePaneInvalid
61- }
62-
63- d.mu.Lock()
64- source := moveSessionForLocatorLocked(d, req.Source)
65- destination := moveSessionForLocatorLocked(d, req.Destination)
66- d.mu.Unlock()
67- if source == nil || destination == nil {
68- return errMoveStaleTarget
69- }
70-
71- // Dispatch admission must precede lifecycle reservation. A final close holds
72- // dispatchMu while it waits for teardown ownership; reserving first would let
73- // that close wait on a move which is itself blocked on dispatchMu.
74- if d.beforeMoveDispatch != nil {
75- d.beforeMoveDispatch()
76- }
77- unlockDispatch := lockMoveDispatch(source, destination)
78- dispatchHeld := true
79- defer func() {
80- if dispatchHeld {
81- unlockDispatch()
82- }
83- }()
84-
85- reservation, err := d.reserveMoveLifecycles(source, destination)
86- if err != nil {
87- return errMoveStaleTarget
88- }
89- reservationHeld := true
90- defer func() {
91- if reservationHeld {
92- reservation.Release()
93- }
94- }()
95- if d.afterMoveLifecycleReserved != nil {
96- d.afterMoveLifecycleReserved()
97- }
98-
99- // Snapshot the exact live objects and generations before waiting on resize
100- // fences. The locked commit repeats every authority check before publication.
101- admission, err := d.snapshotMovePaneAdmission(req, source, destination)
102- if err != nil {
103- return err
104- }
105- sourceTab := admission.sourceTab
106- destinationTab := admission.destinationTab
107- movedPane := admission.movedPane
108- sourceTabInitialGeneration := admission.sourceGeneration
109- destinationTabInitialGeneration := admission.destinationGeneration
110- finalSourceTab := admission.finalSourceTab
111- if d.afterMovePaneSourceSnapshot != nil {
112- d.afterMovePaneSourceSnapshot()
113- }
114-
115- // A final source tab is retired without transferring any attachment to the
116- // destination. Freeze every affected source connection before publication so
117- // teardown invalidates exact generations rather than racing input or render.
118- var frozen attachmentTransitionGuard
119- var sourceEffectsFrozen bool
120- if finalSourceTab && source != destination && len(admission.sourceAttachments) != 0 {
121- interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))
122- for _, ac := range admission.sourceAttachments {
123- if transport := admission.sourceTransports[ac]; transport.transport != nil {
124- interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
125- }
126- }
127- frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
128- if d.afterAttachmentEffectGateFrozen != nil {
129- d.afterAttachmentEffectGateFrozen("move-pane", ac)
130- }
131- }}, admission.sourceAttachments...)
132- if !frozen.acquired || !frozen.drained {
133- frozen.unfreeze()
134- return errMovePaneInvalid
135- }
136- sourceEffectsFrozen = true
137- }
138- if sourceEffectsFrozen {
139- defer func() {
140- if sourceEffectsFrozen {
141- frozen.unfreeze()
142- }
143- }()
144- }
145-
146- commit := movePaneCommit{
147- req: req,
148- source: source,
149- destination: destination,
150- sourceTab: sourceTab,
151- destinationTab: destinationTab,
152- movedPane: movedPane,
153- sourceAttachments: admission.sourceAttachments,
154- sourceTransports: admission.sourceTransports,
155- sourceGeneration: sourceTabInitialGeneration,
156- destinationGeneration: destinationTabInitialGeneration,
157- frozenEffects: frozen,
158- }
159- fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane)
160- if !fences.acquire(func() bool { return commit.publishLocked(d) }) {
161- if commit.err != nil {
162- return commit.err
163- }
164- return errMoveStaleTarget
165- }
166- // Owner/layout fences end at the atomic publication boundary. The immutable
167- // postcommit plan owns every later cleanup and effect.
168- fences.Release()
169- postcommit := movePanePostcommitPlan{
170- source: source,
171- destination: destination,
172- sourceTab: sourceTab,
173- destinationTab: destinationTab,
174- movedPane: movedPane,
175- operation: "pane",
176- sourceAttachments: admission.sourceAttachments,
177- syncCleanup: commit.syncCleanup,
178- frozenEffects: frozen,
179- effectsFrozen: sourceEffectsFrozen,
180- unlockDispatch: unlockDispatch,
181- reservation: reservation,
182- sourceTabRemoved: commit.sourceTabRemoved,
183- sourceEmpty: commit.sourceEmpty,
184- retiredParked: commit.retiredParked,
185- retiredAttachments: commit.retiredAttachments,
186- sourceMetadata: commit.sourceMetadata,
187- sourceMetadataValid: commit.sourceMetadataValid,
188- destinationMetadata: commit.destinationMetadata,
189- destinationMetadataValid: commit.destinationMetadataValid,
190- }
191- // Transfer cleanup ownership away from the admission defers only after the
192- // complete postcommit plan exists.
193- sourceEffectsFrozen = false
194- dispatchHeld = false
195- reservationHeld = false
196- postcommit.execute(d)
197- return nil
198 }32 }
199 33
200 // moveSessionForLocatorLocked resolves only live sessions. The name is34 // moveSessionForLocatorLocked resolves only live sessions. The name is
M internal/usecase/daemon/move_pane_admission.go +71 -32
@@ -2,47 +2,60 @@ package daemon
22
33 import "github.com/bnema/vev/internal/usecase/layout"
44
5-// movePaneAdmission is an immutable snapshot of the exact live objects and
6-// generations a move may later commit. Resize fences are acquired only after
7-// this snapshot; the locked commit revalidates every field before publication.
5+// movePaneTopology is the private pane topology Implementation consumed by the
6+// shared moveTransaction Module.
7+type movePaneTopology struct {
8+ req movePaneRequest
9+ admission movePaneAdmission
10+}
11+
12+// movePaneAdmission is an immutable snapshot of the exact live topology and
13+// generations a move may later publish. Common Session and Attachment authority
14+// is snapshotted by moveTransaction.
815 type movePaneAdmission struct {
916 source *session
1017 destination *session
1118 sourceTab *tab
1219 destinationTab *tab
1320 movedPane *pane
14- sourceAttachments []*attachedClient
15- sourceTransports map[*attachedClient]transportSnapshot
1621 sourceGeneration uint64
1722 destinationGeneration uint64
1823 finalSourceTab bool
1924 }
2025
21-func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destination *session) (*movePaneAdmission, error) {
22- d.mu.Lock()
23- defer d.mu.Unlock()
24- if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination {
25- return nil, errMoveStaleTarget
26- }
27- unlockSessions := lockAttachmentSessions(source, destination)
28- defer unlockSessions()
29- if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
30- return nil, errMoveStaleTarget
31- }
32- if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) {
33- return nil, errMoveStaleTarget
34- }
35- if req.AttachmentCapability.ac != nil && (req.AttachmentCapability.ac != req.Attachment || !req.AttachmentCapability.currentInSessionLocked(source)) {
36- return nil, errMoveStaleTarget
26+func (p *movePaneTopology) transactionRequest() moveTransactionRequest {
27+ req := p.req
28+ return moveTransactionRequest{
29+ operation: "pane",
30+ attachment: req.Attachment,
31+ attachmentCapability: req.AttachmentCapability,
32+ source: req.Source,
33+ destination: req.Destination,
34+ logAttrs: []any{
35+ "source_session_id", req.Source.ID,
36+ "source_tab_id", req.SourceTabID,
37+ "source_pane_id", req.SourcePaneID,
38+ "destination_session_id", req.Destination.ID,
39+ "destination_tab_id", req.DestinationTabID,
40+ },
3741 }
42+}
43+
44+func (p *movePaneTopology) validRequest() bool {
45+ return p != nil && p.req.SourceTabID != "" && p.req.SourcePaneID != "" && p.req.DestinationTabID != ""
46+}
3847
48+// admitLocked runs with daemon and ordered Session locks held. It acquires only
49+// the private ordered Tab locks and performs no publication.
50+func (p *movePaneTopology) admitLocked(_ *Daemon, source, destination *session) error {
51+ req := p.req
3952 sourceTab := findMoveTabLocked(source, req.SourceTabID)
4053 destinationTab := findMoveTabLocked(destination, req.DestinationTabID)
4154 if sourceTab == nil || destinationTab == nil {
42- return nil, errMoveStaleTarget
55+ return errMoveStaleTarget
4356 }
4457 if sourceTab == destinationTab {
45- return nil, errMovePaneInvalid
58+ return errMovePaneInvalid
4659 }
4760 unlockTabs := lockMoveTabs(sourceTab, destinationTab)
4861 defer unlockTabs()
@@ -55,19 +68,45 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
5568 }
5669 }
5770 if movedPane == nil {
58- return nil, errMoveStaleTarget
71+ return errMoveStaleTarget
5972 }
6073
61- attachments := source.snapshotAttachmentsLocked()
62- transports := make(map[*attachedClient]transportSnapshot, len(attachments))
63- for _, ac := range attachments {
64- transports[ac] = ac.transportSnapshot()
65- }
66- return &movePaneAdmission{
74+ p.admission = movePaneAdmission{
6775 source: source, destination: destination, sourceTab: sourceTab, destinationTab: destinationTab,
68- movedPane: movedPane, sourceAttachments: attachments, sourceTransports: transports,
76+ movedPane: movedPane,
6977 sourceGeneration: sourceTab.layoutGeneration, destinationGeneration: destinationTab.layoutGeneration,
7078 finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&
7179 sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1,
72- }, nil
80+ }
81+ return nil
82+}
83+
84+func (p *movePaneTopology) afterAdmission(d *Daemon) {
85+ if d.afterMovePaneSourceSnapshot != nil {
86+ d.afterMovePaneSourceSnapshot()
87+ }
88+}
89+
90+func (p *movePaneTopology) willRetireSource() bool {
91+ return p != nil && p.admission.finalSourceTab && p.admission.source != p.admission.destination
92+}
93+
94+func (p *movePaneTopology) resizeFences(source, destination *session) *moveResizeFences {
95+ admission := p.admission
96+ return newMovePaneResizeFences(source, destination, admission.sourceTab, admission.destinationTab, admission.movedPane)
97+}
98+
99+func (p *movePaneTopology) publishLocked(d *Daemon, source, destination *session) (moveTopologyPublication, error) {
100+ admission := p.admission
101+ commit := movePaneCommit{
102+ req: p.req,
103+ source: source,
104+ destination: destination,
105+ sourceTab: admission.sourceTab,
106+ destinationTab: admission.destinationTab,
107+ movedPane: admission.movedPane,
108+ sourceGeneration: admission.sourceGeneration,
109+ destinationGeneration: admission.destinationGeneration,
110+ }
111+ return commit.publishTopologyLocked(d)
73112 }
@@ -2,47 +2,60 @@ package daemon
2 2
3 import "github.com/bnema/vev/internal/usecase/layout"3 import "github.com/bnema/vev/internal/usecase/layout"
4 4
5-// movePaneAdmission is an immutable snapshot of the exact live objects and5+// movePaneTopology is the private pane topology Implementation consumed by the
6-// generations a move may later commit. Resize fences are acquired only after6+// shared moveTransaction Module.
7-// this snapshot; the locked commit revalidates every field before publication.7+type movePaneTopology struct {
8+ req movePaneRequest
9+ admission movePaneAdmission
10+}
11+
12+// movePaneAdmission is an immutable snapshot of the exact live topology and
13+// generations a move may later publish. Common Session and Attachment authority
14+// is snapshotted by moveTransaction.
8 type movePaneAdmission struct {15 type movePaneAdmission struct {
9 source *session16 source *session
10 destination *session17 destination *session
11 sourceTab *tab18 sourceTab *tab
12 destinationTab *tab19 destinationTab *tab
13 movedPane *pane20 movedPane *pane
14- sourceAttachments []*attachedClient
15- sourceTransports map[*attachedClient]transportSnapshot
16 sourceGeneration uint6421 sourceGeneration uint64
17 destinationGeneration uint6422 destinationGeneration uint64
18 finalSourceTab bool23 finalSourceTab bool
19 }24 }
20 25
21-func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destination *session) (*movePaneAdmission, error) {26+func (p *movePaneTopology) transactionRequest() moveTransactionRequest {
22- d.mu.Lock()27+ req := p.req
23- defer d.mu.Unlock()28+ return moveTransactionRequest{
24- if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination {29+ operation: "pane",
25- return nil, errMoveStaleTarget30+ attachment: req.Attachment,
26- }31+ attachmentCapability: req.AttachmentCapability,
27- unlockSessions := lockAttachmentSessions(source, destination)32+ source: req.Source,
28- defer unlockSessions()33+ destination: req.Destination,
29- if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {34+ logAttrs: []any{
30- return nil, errMoveStaleTarget35+ "source_session_id", req.Source.ID,
31- }36+ "source_tab_id", req.SourceTabID,
32- if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) {37+ "source_pane_id", req.SourcePaneID,
33- return nil, errMoveStaleTarget38+ "destination_session_id", req.Destination.ID,
34- }39+ "destination_tab_id", req.DestinationTabID,
35- if req.AttachmentCapability.ac != nil && (req.AttachmentCapability.ac != req.Attachment || !req.AttachmentCapability.currentInSessionLocked(source)) {40+ },
36- return nil, errMoveStaleTarget
37 }41 }
42+}
43+
44+func (p *movePaneTopology) validRequest() bool {
45+ return p != nil && p.req.SourceTabID != "" && p.req.SourcePaneID != "" && p.req.DestinationTabID != ""
46+}
38 47
48+// admitLocked runs with daemon and ordered Session locks held. It acquires only
49+// the private ordered Tab locks and performs no publication.
50+func (p *movePaneTopology) admitLocked(_ *Daemon, source, destination *session) error {
51+ req := p.req
39 sourceTab := findMoveTabLocked(source, req.SourceTabID)52 sourceTab := findMoveTabLocked(source, req.SourceTabID)
40 destinationTab := findMoveTabLocked(destination, req.DestinationTabID)53 destinationTab := findMoveTabLocked(destination, req.DestinationTabID)
41 if sourceTab == nil || destinationTab == nil {54 if sourceTab == nil || destinationTab == nil {
42- return nil, errMoveStaleTarget55+ return errMoveStaleTarget
43 }56 }
44 if sourceTab == destinationTab {57 if sourceTab == destinationTab {
45- return nil, errMovePaneInvalid58+ return errMovePaneInvalid
46 }59 }
47 unlockTabs := lockMoveTabs(sourceTab, destinationTab)60 unlockTabs := lockMoveTabs(sourceTab, destinationTab)
48 defer unlockTabs()61 defer unlockTabs()
@@ -55,19 +68,45 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
55 }68 }
56 }69 }
57 if movedPane == nil {70 if movedPane == nil {
58- return nil, errMoveStaleTarget71+ return errMoveStaleTarget
59 }72 }
60 73
61- attachments := source.snapshotAttachmentsLocked()74+ p.admission = movePaneAdmission{
62- transports := make(map[*attachedClient]transportSnapshot, len(attachments))
63- for _, ac := range attachments {
64- transports[ac] = ac.transportSnapshot()
65- }
66- return &movePaneAdmission{
67 source: source, destination: destination, sourceTab: sourceTab, destinationTab: destinationTab,75 source: source, destination: destination, sourceTab: sourceTab, destinationTab: destinationTab,
68- movedPane: movedPane, sourceAttachments: attachments, sourceTransports: transports,76+ movedPane: movedPane,
69 sourceGeneration: sourceTab.layoutGeneration, destinationGeneration: destinationTab.layoutGeneration,77 sourceGeneration: sourceTab.layoutGeneration, destinationGeneration: destinationTab.layoutGeneration,
70 finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&78 finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&
71 sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1,79 sourceTab.tree != nil && sourceTab.tree.Root != nil && len(layout.LeafIDs(sourceTab.tree.Root)) == 1,
72- }, nil80+ }
81+ return nil
82+}
83+
84+func (p *movePaneTopology) afterAdmission(d *Daemon) {
85+ if d.afterMovePaneSourceSnapshot != nil {
86+ d.afterMovePaneSourceSnapshot()
87+ }
88+}
89+
90+func (p *movePaneTopology) willRetireSource() bool {
91+ return p != nil && p.admission.finalSourceTab && p.admission.source != p.admission.destination
92+}
93+
94+func (p *movePaneTopology) resizeFences(source, destination *session) *moveResizeFences {
95+ admission := p.admission
96+ return newMovePaneResizeFences(source, destination, admission.sourceTab, admission.destinationTab, admission.movedPane)
97+}
98+
99+func (p *movePaneTopology) publishLocked(d *Daemon, source, destination *session) (moveTopologyPublication, error) {
100+ admission := p.admission
101+ commit := movePaneCommit{
102+ req: p.req,
103+ source: source,
104+ destination: destination,
105+ sourceTab: admission.sourceTab,
106+ destinationTab: admission.destinationTab,
107+ movedPane: admission.movedPane,
108+ sourceGeneration: admission.sourceGeneration,
109+ destinationGeneration: admission.destinationGeneration,
110+ }
111+ return commit.publishTopologyLocked(d)
73 }112 }
M internal/usecase/daemon/move_pane_commit.go +28 -80
@@ -1,60 +1,20 @@
11 package daemon
22
3-import (
4- "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/internal/usecase/layout"
6-)
3+import "github.com/bnema/vev/internal/usecase/layout"
74
8-// movePaneCommit owns the single architecture-locked publication section. Its
9-// inputs are immutable admission/fence snapshots; outputs form the postcommit
10-// plan. publishLocked performs all fallible checks before its first write.
5+// movePaneCommit owns pane-private validation and publication. The shared
6+// moveTransaction already holds daemon, routing, ordered Session, and resize
7+// fences. Every error below is returned before the first topology write.
118 type movePaneCommit struct {
129 req movePaneRequest
1310 source, destination *session
1411 sourceTab, destinationTab *tab
1512 movedPane *pane
16- sourceAttachments []*attachedClient
17- sourceTransports map[*attachedClient]transportSnapshot
1813 sourceGeneration uint64
1914 destinationGeneration uint64
20- frozenEffects attachmentTransitionGuard
21- err error
22-
23- syncCleanup syncTimerCleanup
24- sourceEmpty bool
25- sourceTabRemoved bool
26- retiredParked []parkedAttachmentRetirement
27- retiredAttachments []detachedAttachmentSnapshot
28- sourceMetadata domain.CatalogueMetadataUpdate
29- sourceMetadataValid bool
30- destinationMetadata domain.CatalogueMetadataUpdate
31- destinationMetadataValid bool
3215 }
3316
34-func (c *movePaneCommit) publishLocked(d *Daemon) bool {
35- var candidate *movePaneCandidate
36- var err error
37- d.mu.Lock()
38- if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {
39- d.mu.Unlock()
40- return false
41- }
42- d.notices.routingMu.Lock()
43- unlockSessions := lockAttachmentSessions(c.source, c.destination)
44- defer unlockSessions()
45- defer d.notices.routingMu.Unlock()
46- defer d.mu.Unlock()
47-
48- if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
49- return false
50- }
51- if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {
52- return false
53- }
54- if c.req.AttachmentCapability.ac != nil && (c.req.AttachmentCapability.ac != c.req.Attachment || !c.req.AttachmentCapability.currentInSessionLocked(c.source)) {
55- return false
56- }
57-
17+func (c *movePaneCommit) publishTopologyLocked(d *Daemon) (moveTopologyPublication, error) {
5818 unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab)
5919 c.movedPane.mu.Lock()
6020 defer c.movedPane.mu.Unlock()
@@ -65,38 +25,34 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
6525 c.movedPane.stableID != string(c.req.SourcePaneID) || c.sourceTab.panes[c.movedPane.id] != c.movedPane ||
6626 c.sourceTab.tree == nil || c.sourceTab.tree.Root == nil || !layout.ContainsLeaf(c.sourceTab.tree.Root, c.movedPane.id) ||
6727 c.destinationTab.tree == nil || c.destinationTab.tree.Root == nil {
68- return false
28+ return moveTopologyPublication{}, errMoveStaleTarget
6929 }
7030 owner := c.movedPane.ownerSnapshot()
7131 if owner != nil && (owner.session != c.source || owner.tab != c.sourceTab) {
72- return false
32+ return moveTopologyPublication{}, errMoveStaleTarget
7333 }
74- candidate, err = prepareMovePaneCandidate(c.sourceTab, c.destinationTab, c.movedPane)
34+ candidate, err := prepareMovePaneCandidate(c.sourceTab, c.destinationTab, c.movedPane)
7535 if err != nil {
76- c.err = err
77- return false
36+ return moveTopologyPublication{}, err
7837 }
7938 if candidate.sourceGeneration != c.sourceGeneration || candidate.destinationGeneration != c.destinationGeneration {
80- return false
81- }
82-
83- sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1
84- if sourceWillEmpty && c.source != c.destination && !sameMoveAttachmentsLocked(c.source, c.sourceAttachments) {
85- return false
39+ return moveTopologyPublication{}, errMoveStaleTarget
8640 }
87-
88- // Everything above is fallible. Shared topology and owner publication are
89- // committed once; attachment views are repaired after these locks release.
41+ idx := -1
9042 if candidate.removeSourceTab {
91- idx := indexMoveTabLocked(c.source, c.sourceTab)
43+ idx = indexMoveTabLocked(c.source, c.sourceTab)
9244 if idx < 0 {
93- return false
45+ return moveTopologyPublication{}, errMoveStaleTarget
9446 }
47+ }
48+
49+ // Everything above is fallible. The remaining topology and owner writes form
50+ // one in-memory publication and cannot turn into a Move rejection.
51+ if candidate.removeSourceTab {
9552 if len(c.source.tabs) > 1 {
9653 c.source.prepareAttachmentViewsForRemovedTabLocked(c.sourceTab, idx)
9754 }
9855 c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
99- c.sourceTabRemoved = true
10056 } else {
10157 c.sourceTab.tree = candidate.sourceTree
10258 c.sourceTab.bumpLayoutGenerationLocked()
@@ -111,23 +67,15 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
11167 c.destinationTab.bumpLayoutGenerationLocked()
11268 c.movedPane.id = candidate.destinationID
11369 oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0)
114- c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner))
70+ var syncCleanup syncTimerCleanup
71+ syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner))
11572
116- if sourceWillEmpty {
117- d.unregisterSessionLocked(c.source)
118- c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.sourceTransports)
119- c.source.tabs = nil
120- d.purgeParkingForSessionLocked(c.source)
121- c.retiredParked = d.purgeParkedForSessionLocked(c.source)
122- }
123- c.sourceEmpty = len(c.source.tabs) == 0
124- if !c.source.ephemeral {
125- c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate()
126- c.sourceMetadataValid = true
127- }
128- if !c.destination.ephemeral && c.destination != c.source {
129- c.destinationMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate()
130- c.destinationMetadataValid = true
131- }
132- return true
73+ return moveTopologyPublication{
74+ sourceTab: c.sourceTab,
75+ destinationTab: c.destinationTab,
76+ movedPane: c.movedPane,
77+ syncCleanup: syncCleanup,
78+ sourceTabRemoved: candidate.removeSourceTab,
79+ sourceEmpty: len(c.source.tabs) == 0,
80+ }, nil
13381 }
@@ -1,60 +1,20 @@
1 package daemon1 package daemon
2 2
3-import (3+import "github.com/bnema/vev/internal/usecase/layout"
4- "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/internal/usecase/layout"
6-)
7 4
8-// movePaneCommit owns the single architecture-locked publication section. Its5+// movePaneCommit owns pane-private validation and publication. The shared
9-// inputs are immutable admission/fence snapshots; outputs form the postcommit6+// moveTransaction already holds daemon, routing, ordered Session, and resize
10-// plan. publishLocked performs all fallible checks before its first write.7+// fences. Every error below is returned before the first topology write.
11 type movePaneCommit struct {8 type movePaneCommit struct {
12 req movePaneRequest9 req movePaneRequest
13 source, destination *session10 source, destination *session
14 sourceTab, destinationTab *tab11 sourceTab, destinationTab *tab
15 movedPane *pane12 movedPane *pane
16- sourceAttachments []*attachedClient
17- sourceTransports map[*attachedClient]transportSnapshot
18 sourceGeneration uint6413 sourceGeneration uint64
19 destinationGeneration uint6414 destinationGeneration uint64
20- frozenEffects attachmentTransitionGuard
21- err error
22-
23- syncCleanup syncTimerCleanup
24- sourceEmpty bool
25- sourceTabRemoved bool
26- retiredParked []parkedAttachmentRetirement
27- retiredAttachments []detachedAttachmentSnapshot
28- sourceMetadata domain.CatalogueMetadataUpdate
29- sourceMetadataValid bool
30- destinationMetadata domain.CatalogueMetadataUpdate
31- destinationMetadataValid bool
32 }15 }
33 16
34-func (c *movePaneCommit) publishLocked(d *Daemon) bool {17+func (c *movePaneCommit) publishTopologyLocked(d *Daemon) (moveTopologyPublication, error) {
35- var candidate *movePaneCandidate
36- var err error
37- d.mu.Lock()
38- if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {
39- d.mu.Unlock()
40- return false
41- }
42- d.notices.routingMu.Lock()
43- unlockSessions := lockAttachmentSessions(c.source, c.destination)
44- defer unlockSessions()
45- defer d.notices.routingMu.Unlock()
46- defer d.mu.Unlock()
47-
48- if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
49- return false
50- }
51- if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {
52- return false
53- }
54- if c.req.AttachmentCapability.ac != nil && (c.req.AttachmentCapability.ac != c.req.Attachment || !c.req.AttachmentCapability.currentInSessionLocked(c.source)) {
55- return false
56- }
57-
58 unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab)18 unlockTabs := lockMoveTabs(c.sourceTab, c.destinationTab)
59 c.movedPane.mu.Lock()19 c.movedPane.mu.Lock()
60 defer c.movedPane.mu.Unlock()20 defer c.movedPane.mu.Unlock()
@@ -65,38 +25,34 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
65 c.movedPane.stableID != string(c.req.SourcePaneID) || c.sourceTab.panes[c.movedPane.id] != c.movedPane ||25 c.movedPane.stableID != string(c.req.SourcePaneID) || c.sourceTab.panes[c.movedPane.id] != c.movedPane ||
66 c.sourceTab.tree == nil || c.sourceTab.tree.Root == nil || !layout.ContainsLeaf(c.sourceTab.tree.Root, c.movedPane.id) ||26 c.sourceTab.tree == nil || c.sourceTab.tree.Root == nil || !layout.ContainsLeaf(c.sourceTab.tree.Root, c.movedPane.id) ||
67 c.destinationTab.tree == nil || c.destinationTab.tree.Root == nil {27 c.destinationTab.tree == nil || c.destinationTab.tree.Root == nil {
68- return false28+ return moveTopologyPublication{}, errMoveStaleTarget
69 }29 }
70 owner := c.movedPane.ownerSnapshot()30 owner := c.movedPane.ownerSnapshot()
71 if owner != nil && (owner.session != c.source || owner.tab != c.sourceTab) {31 if owner != nil && (owner.session != c.source || owner.tab != c.sourceTab) {
72- return false32+ return moveTopologyPublication{}, errMoveStaleTarget
73 }33 }
74- candidate, err = prepareMovePaneCandidate(c.sourceTab, c.destinationTab, c.movedPane)34+ candidate, err := prepareMovePaneCandidate(c.sourceTab, c.destinationTab, c.movedPane)
75 if err != nil {35 if err != nil {
76- c.err = err36+ return moveTopologyPublication{}, err
77- return false
78 }37 }
79 if candidate.sourceGeneration != c.sourceGeneration || candidate.destinationGeneration != c.destinationGeneration {38 if candidate.sourceGeneration != c.sourceGeneration || candidate.destinationGeneration != c.destinationGeneration {
80- return false39+ return moveTopologyPublication{}, errMoveStaleTarget
81- }
82-
83- sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1
84- if sourceWillEmpty && c.source != c.destination && !sameMoveAttachmentsLocked(c.source, c.sourceAttachments) {
85- return false
86 }40 }
87-41+ idx := -1
88- // Everything above is fallible. Shared topology and owner publication are
89- // committed once; attachment views are repaired after these locks release.
90 if candidate.removeSourceTab {42 if candidate.removeSourceTab {
91- idx := indexMoveTabLocked(c.source, c.sourceTab)43+ idx = indexMoveTabLocked(c.source, c.sourceTab)
92 if idx < 0 {44 if idx < 0 {
93- return false45+ return moveTopologyPublication{}, errMoveStaleTarget
94 }46 }
47+ }
48+
49+ // Everything above is fallible. The remaining topology and owner writes form
50+ // one in-memory publication and cannot turn into a Move rejection.
51+ if candidate.removeSourceTab {
95 if len(c.source.tabs) > 1 {52 if len(c.source.tabs) > 1 {
96 c.source.prepareAttachmentViewsForRemovedTabLocked(c.sourceTab, idx)53 c.source.prepareAttachmentViewsForRemovedTabLocked(c.sourceTab, idx)
97 }54 }
98 c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)55 c.source.tabs = append(c.source.tabs[:idx], c.source.tabs[idx+1:]...)
99- c.sourceTabRemoved = true
100 } else {56 } else {
101 c.sourceTab.tree = candidate.sourceTree57 c.sourceTab.tree = candidate.sourceTree
102 c.sourceTab.bumpLayoutGenerationLocked()58 c.sourceTab.bumpLayoutGenerationLocked()
@@ -111,23 +67,15 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
111 c.destinationTab.bumpLayoutGenerationLocked()67 c.destinationTab.bumpLayoutGenerationLocked()
112 c.movedPane.id = candidate.destinationID68 c.movedPane.id = candidate.destinationID
113 oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0)69 oldOwner, newOwner := c.movedPane.publishOwnerLocked(c.destination, c.destinationTab, 0)
114- c.syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner))70+ var syncCleanup syncTimerCleanup
71+ syncCleanup.append(d.migratePaneSyncOwnerLocked(c.movedPane, oldOwner, newOwner))
115 72
116- if sourceWillEmpty {73+ return moveTopologyPublication{
117- d.unregisterSessionLocked(c.source)74+ sourceTab: c.sourceTab,
118- c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.sourceTransports)75+ destinationTab: c.destinationTab,
119- c.source.tabs = nil76+ movedPane: c.movedPane,
120- d.purgeParkingForSessionLocked(c.source)77+ syncCleanup: syncCleanup,
121- c.retiredParked = d.purgeParkedForSessionLocked(c.source)78+ sourceTabRemoved: candidate.removeSourceTab,
122- }79+ sourceEmpty: len(c.source.tabs) == 0,
123- c.sourceEmpty = len(c.source.tabs) == 080+ }, nil
124- if !c.source.ephemeral {
125- c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate()
126- c.sourceMetadataValid = true
127- }
128- if !c.destination.ephemeral && c.destination != c.source {
129- c.destinationMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate()
130- c.destinationMetadataValid = true
131- }
132- return true
133 }81 }
R internal/usecase/daemon/move_pane_postcommit.go → internal/usecase/daemon/move_postcommit.go +12 -10 renamed, 90% similar
@@ -9,7 +9,7 @@ import (
99 "github.com/bnema/vev/internal/ports"
1010 )
1111
12-// movePanePostcommitPlan is captured while the move's architecture and owner
12+// movePostcommitPlan is captured while the move's architecture and owner
1313 // fences still protect publication. It contains only immutable pointers,
1414 // generations, and exact attachment tokens needed by fallible follow-up work.
1515 func firstMovePane(panes []*pane) *pane {
@@ -19,9 +19,11 @@ func firstMovePane(panes []*pane) *pane {
1919 return panes[0]
2020 }
2121
22-type movePanePostcommitPlan struct {
22+type movePostcommitPlan struct {
2323 source *session
2424 destination *session
25+ sourceName string
26+ destinationName string
2527 sourceTab *tab
2628 destinationTab *tab
2729 movedPane *pane
@@ -49,7 +51,7 @@ type movePanePostcommitPlan struct {
4951 // execute runs only after every resize-owner fence and architecture lock is
5052 // released. Generation-bound tokens make stale effects drop rather than route
5153 // back to the retired source.
52-func (p movePanePostcommitPlan) execute(d *Daemon) {
54+func (p movePostcommitPlan) execute(d *Daemon) {
5355 if p.oldTabCancel != nil {
5456 p.oldTabCancel()
5557 } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil {
@@ -82,10 +84,10 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
8284 p.reservation.Release()
8385 attrs := []any{
8486 "operation", p.operation,
85- "source_session", p.source.name,
87+ "source_session", p.sourceName,
8688 "source_session_id", p.source.id,
8789 "source_tab_id", p.sourceTab.stableID,
88- "destination_session", p.destination.name,
90+ "destination_session", p.destinationName,
8991 "destination_session_id", p.destination.id,
9092 "source_retired", p.sourceEmpty,
9193 }
@@ -134,7 +136,7 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
134136 if len(p.retiredAttachments) != 0 {
135137 d.log.Info("move detaching source attachments",
136138 "operation", p.operation,
137- "source_session", p.source.name,
139+ "source_session", p.sourceName,
138140 "source_session_id", p.source.id,
139141 "reason", "session-killed",
140142 "attachments", len(p.retiredAttachments),
@@ -144,7 +146,7 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
144146 d.unregisterPreview(attachment.ac)
145147 attachment.ac.clearCaptureFrames()
146148 if err := d.cleanupAttachmentOutput(attachment.ac); err != nil {
147- d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.source.name)
149+ d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.sourceName)
148150 }
149151 d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
150152 }
@@ -157,9 +159,9 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
157159 }
158160 d.finishParkedAttachmentRetirements(p.retiredParked)
159161 if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled {
160- if err := d.beginSnapshotPurge(p.source.name, p.source.incarnation); err == nil {
161- if err := d.finishSnapshotPurge(d.serveCtx, p.source.name, p.source.incarnation); err != nil {
162- d.log.Warn("moving final pane source purge failed", "err", err, "session", p.source.name)
162+ if err := d.beginSnapshotPurge(p.sourceName, p.source.incarnation); err == nil {
163+ if err := d.finishSnapshotPurge(d.serveCtx, p.sourceName, p.source.incarnation); err != nil {
164+ d.log.Warn("moving final pane source purge failed", "err", err, "session", p.sourceName)
163165 }
164166 }
165167 }
@@ -9,7 +9,7 @@ import (
9 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
10 )10 )
11 11
12-// movePanePostcommitPlan is captured while the move's architecture and owner12+// movePostcommitPlan is captured while the move's architecture and owner
13 // fences still protect publication. It contains only immutable pointers,13 // fences still protect publication. It contains only immutable pointers,
14 // generations, and exact attachment tokens needed by fallible follow-up work.14 // generations, and exact attachment tokens needed by fallible follow-up work.
15 func firstMovePane(panes []*pane) *pane {15 func firstMovePane(panes []*pane) *pane {
@@ -19,9 +19,11 @@ func firstMovePane(panes []*pane) *pane {
19 return panes[0]19 return panes[0]
20 }20 }
21 21
22-type movePanePostcommitPlan struct {22+type movePostcommitPlan struct {
23 source *session23 source *session
24 destination *session24 destination *session
25+ sourceName string
26+ destinationName string
25 sourceTab *tab27 sourceTab *tab
26 destinationTab *tab28 destinationTab *tab
27 movedPane *pane29 movedPane *pane
@@ -49,7 +51,7 @@ type movePanePostcommitPlan struct {
49 // execute runs only after every resize-owner fence and architecture lock is51 // execute runs only after every resize-owner fence and architecture lock is
50 // released. Generation-bound tokens make stale effects drop rather than route52 // released. Generation-bound tokens make stale effects drop rather than route
51 // back to the retired source.53 // back to the retired source.
52-func (p movePanePostcommitPlan) execute(d *Daemon) {54+func (p movePostcommitPlan) execute(d *Daemon) {
53 if p.oldTabCancel != nil {55 if p.oldTabCancel != nil {
54 p.oldTabCancel()56 p.oldTabCancel()
55 } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil {57 } else if p.sourceTabRemoved && p.sourceTab != nil && p.sourceTab.cancel != nil {
@@ -82,10 +84,10 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
82 p.reservation.Release()84 p.reservation.Release()
83 attrs := []any{85 attrs := []any{
84 "operation", p.operation,86 "operation", p.operation,
85- "source_session", p.source.name,87+ "source_session", p.sourceName,
86 "source_session_id", p.source.id,88 "source_session_id", p.source.id,
87 "source_tab_id", p.sourceTab.stableID,89 "source_tab_id", p.sourceTab.stableID,
88- "destination_session", p.destination.name,90+ "destination_session", p.destinationName,
89 "destination_session_id", p.destination.id,91 "destination_session_id", p.destination.id,
90 "source_retired", p.sourceEmpty,92 "source_retired", p.sourceEmpty,
91 }93 }
@@ -134,7 +136,7 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
134 if len(p.retiredAttachments) != 0 {136 if len(p.retiredAttachments) != 0 {
135 d.log.Info("move detaching source attachments",137 d.log.Info("move detaching source attachments",
136 "operation", p.operation,138 "operation", p.operation,
137- "source_session", p.source.name,139+ "source_session", p.sourceName,
138 "source_session_id", p.source.id,140 "source_session_id", p.source.id,
139 "reason", "session-killed",141 "reason", "session-killed",
140 "attachments", len(p.retiredAttachments),142 "attachments", len(p.retiredAttachments),
@@ -144,7 +146,7 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
144 d.unregisterPreview(attachment.ac)146 d.unregisterPreview(attachment.ac)
145 attachment.ac.clearCaptureFrames()147 attachment.ac.clearCaptureFrames()
146 if err := d.cleanupAttachmentOutput(attachment.ac); err != nil {148 if err := d.cleanupAttachmentOutput(attachment.ac); err != nil {
147- d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.source.name)149+ d.log.Warn("move attachment output cleanup failed", "err", err, "operation", p.operation, "source_session", p.sourceName)
148 }150 }
149 d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)151 d.notifyDetachedSnapshotAsync(attachment, ports.ReasonSessionKilled)
150 }152 }
@@ -157,9 +159,9 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
157 }159 }
158 d.finishParkedAttachmentRetirements(p.retiredParked)160 d.finishParkedAttachmentRetirements(p.retiredParked)
159 if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled {161 if p.sourceEmpty && p.sourceMetadataValid && d.persistEnabled {
160- if err := d.beginSnapshotPurge(p.source.name, p.source.incarnation); err == nil {162+ if err := d.beginSnapshotPurge(p.sourceName, p.source.incarnation); err == nil {
161- if err := d.finishSnapshotPurge(d.serveCtx, p.source.name, p.source.incarnation); err != nil {163+ if err := d.finishSnapshotPurge(d.serveCtx, p.sourceName, p.source.incarnation); err != nil {
162- d.log.Warn("moving final pane source purge failed", "err", err, "session", p.source.name)164+ d.log.Warn("moving final pane source purge failed", "err", err, "session", p.sourceName)
163 }165 }
164 }166 }
165 }167 }
M internal/usecase/daemon/move_tab.go +110 -241
@@ -7,11 +7,17 @@ import (
77 "github.com/bnema/vev/internal/usecase/layout"
88 )
99
10+// moveTabTopology is the private whole-Tab topology Implementation consumed by
11+// the shared moveTransaction Module.
12+type moveTabTopology struct {
13+ req moveTabRequest
14+ admission moveTabAdmission
15+ fencedPanes []*pane
16+}
17+
1018 type moveTabAdmission struct {
1119 tab *tab
1220 sourceIndex int
13- sourceAttachments []*attachedClient
14- sourceTransports map[*attachedClient]transportSnapshot
1521 layoutGeneration uint64
1622 floatingState floatingState
1723 floatingPane *pane
@@ -23,270 +29,137 @@ type moveTabAdmission struct {
2329 }
2430
2531 type moveTabCommit struct {
26- req moveTabRequest
27- source *session
28- destination *session
29- admission moveTabAdmission
30- sourceEffectsFrozen bool
31- frozenEffects attachmentTransitionGuard
32- syncCleanup syncTimerCleanup
33- oldTabCancel context.CancelFunc
34- sourceEmpty bool
35- retiredParked []parkedAttachmentRetirement
36- retiredAttachments []detachedAttachmentSnapshot
37- sourceMetadata domain.CatalogueMetadataUpdate
38- sourceMetadataValid bool
39- destMetadata domain.CatalogueMetadataUpdate
40- destMetadataValid bool
41- err error
32+ req moveTabRequest
33+ source *session
34+ destination *session
35+ admission moveTabAdmission
4236 }
4337
44-func (d *Daemon) moveTab(req moveTabRequest) (result error) {
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 {
65- return errMovePaneInvalid
66- }
67- d.mu.Lock()
68- source := moveSessionForLocatorLocked(d, req.Source)
69- destination := moveSessionForLocatorLocked(d, req.Destination)
70- d.mu.Unlock()
71- if source == nil || destination == nil {
72- return errMoveStaleTarget
73- }
74- if source == destination {
75- return errMovePaneInvalid
76- }
77- // Dispatch admission must precede lifecycle reservation. A final close holds
78- // dispatchMu while it waits for teardown ownership; reserving first would let
79- // that close wait on a move which is itself blocked on dispatchMu.
80- if d.beforeMoveDispatch != nil {
81- d.beforeMoveDispatch()
82- }
83- unlockDispatch := lockMoveDispatch(source, destination)
84- dispatchHeld := true
85- defer func() {
86- if dispatchHeld {
87- unlockDispatch()
88- }
89- }()
90-
91- reservation, err := d.reserveMoveLifecycles(source, destination)
92- if err != nil {
93- return errMoveStaleTarget
94- }
95- reservationHeld := true
96- defer func() {
97- if reservationHeld {
98- reservation.Release()
99- }
100- }()
101- if d.afterMoveLifecycleReserved != nil {
102- d.afterMoveLifecycleReserved()
103- }
38+func (d *Daemon) moveTab(req moveTabRequest) error {
39+ return d.executeMove(&moveTabTopology{req: req})
40+}
10441
105- admission, err := d.snapshotMoveTabAdmission(req, source, destination)
106- if err != nil {
107- return err
42+func (p *moveTabTopology) transactionRequest() moveTransactionRequest {
43+ req := p.req
44+ return moveTransactionRequest{
45+ operation: "tab",
46+ attachment: req.Attachment,
47+ attachmentCapability: req.AttachmentCapability,
48+ source: req.Source,
49+ destination: req.Destination,
50+ logAttrs: []any{
51+ "source_session_id", req.Source.ID,
52+ "source_tab_id", req.SourceTabID,
53+ "destination_session_id", req.Destination.ID,
54+ },
10855 }
109- if d.afterMoveTabSourceSnapshot != nil {
110- d.afterMoveTabSourceSnapshot()
111- }
112- commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}
113- var frozen attachmentTransitionGuard
114- if admission.finalSource && len(admission.sourceAttachments) != 0 {
115- interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))
116- for _, ac := range admission.sourceAttachments {
117- if transport := admission.sourceTransports[ac]; transport.transport != nil {
118- interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
119- }
120- }
121- frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
122- if d.afterAttachmentEffectGateFrozen != nil {
123- d.afterAttachmentEffectGateFrozen("move-tab", ac)
124- }
125- }}, admission.sourceAttachments...)
126- if !frozen.acquired || !frozen.drained {
127- frozen.unfreeze()
128- return errMovePaneInvalid
129- }
130- commit.sourceEffectsFrozen = true
131- }
132- commit.frozenEffects = frozen
133- effectsFrozen := commit.sourceEffectsFrozen
134- defer func() {
135- if effectsFrozen {
136- frozen.unfreeze()
137- }
138- }()
56+}
13957
140- fences := newMoveTabResizeFences(source, destination, admission.tab)
141- if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) {
142- if commit.err != nil {
143- return commit.err
144- }
145- return errMoveStaleTarget
146- }
147- fences.Release()
148- postcommit := movePanePostcommitPlan{
149- source: source,
150- destination: destination,
151- sourceTab: admission.tab,
152- destinationTab: admission.tab,
153- movedPane: firstMovePane(admission.panes),
154- movedPanes: admission.panes,
155- operation: "tab",
156- sourceAttachments: admission.sourceAttachments,
157- syncCleanup: commit.syncCleanup,
158- frozenEffects: frozen,
159- effectsFrozen: effectsFrozen,
160- unlockDispatch: unlockDispatch,
161- reservation: reservation,
162- oldTabCancel: commit.oldTabCancel,
163- sourceTabRemoved: true,
164- sourceEmpty: commit.sourceEmpty,
165- retiredParked: commit.retiredParked,
166- retiredAttachments: commit.retiredAttachments,
167- sourceMetadata: commit.sourceMetadata,
168- sourceMetadataValid: commit.sourceMetadataValid,
169- destinationMetadata: commit.destMetadata,
170- destinationMetadataValid: commit.destMetadataValid,
171- }
172- effectsFrozen = false
173- dispatchHeld = false
174- reservationHeld = false
175- postcommit.execute(d)
176- return nil
58+func (p *moveTabTopology) validRequest() bool {
59+ return p != nil && p.req.SourceTabID != "" && p.req.Source.ID != p.req.Destination.ID
17760 }
17861
179-func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destination *session) (*moveTabAdmission, error) {
180- d.mu.Lock()
181- defer d.mu.Unlock()
182- if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination {
183- return nil, errMoveStaleTarget
184- }
185- unlockSessions := lockAttachmentSessions(source, destination)
186- defer unlockSessions()
187- if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
188- return nil, errMoveStaleTarget
189- }
190- if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) {
191- return nil, errMoveStaleTarget
192- }
193- if req.AttachmentCapability.ac != nil && (req.AttachmentCapability.ac != req.Attachment || !req.AttachmentCapability.currentInSessionLocked(source)) {
194- return nil, errMoveStaleTarget
62+// admitLocked runs with daemon and ordered Session locks held. It snapshots
63+// whole-Tab policy without publishing topology or Pane ownership.
64+func (p *moveTabTopology) admitLocked(_ *Daemon, source, destination *session) error {
65+ if source == destination {
66+ return errMovePaneInvalid
19567 }
196- moved := findMoveTabLocked(source, req.SourceTabID)
68+ moved := findMoveTabLocked(source, p.req.SourceTabID)
19769 if moved == nil || len(destination.tabs) == 0 {
198- return nil, errMoveStaleTarget
70+ return errMoveStaleTarget
19971 }
20072 destinationActive := destination.tabs[0]
20173 destinationActive.mu.Lock()
20274 destinationSize := destinationActive.size
20375 destinationActive.mu.Unlock()
76+
20477 moved.mu.Lock()
20578 defer moved.mu.Unlock()
20679 if !moved.floatingTransferableLocked() {
207- return nil, errMoveFloatingWarming
80+ return errMoveFloatingWarming
20881 }
20982 if moved.tree == nil || moved.tree.Root == nil {
210- return nil, errMovePaneInvalid
83+ return errMovePaneInvalid
21184 }
21285 if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok {
213- return nil, errMoveTooSmall
86+ return errMoveTooSmall
21487 }
21588 panes := make([]*pane, 0, len(moved.panes)+1)
216- for _, p := range moved.panes {
217- panes = append(panes, p)
89+ for _, pane := range moved.panes {
90+ panes = append(panes, pane)
21891 }
21992 if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) {
22093 panes = append(panes, moved.floating.pane)
22194 }
222- idx := indexMoveTabLocked(source, moved)
223- attachments := source.snapshotAttachmentsLocked()
224- transports := make(map[*attachedClient]transportSnapshot, len(attachments))
225- for _, ac := range attachments {
226- transports[ac] = ac.transportSnapshot()
227- }
228- return &moveTabAdmission{
229- tab: moved, sourceIndex: idx, sourceAttachments: attachments, sourceTransports: transports,
95+ p.admission = moveTabAdmission{
96+ tab: moved, sourceIndex: indexMoveTabLocked(source, moved),
23097 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,
23198 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,
23299 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,
233100 finalSource: len(source.tabs) == 1,
234- }, nil
101+ }
102+ return nil
235103 }
236104
237-func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
238- d.mu.Lock()
239- if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {
240- d.mu.Unlock()
241- return false
242- }
243- d.notices.routingMu.Lock()
244- unlockSessions := lockAttachmentSessions(c.source, c.destination)
245- defer unlockSessions()
246- defer d.notices.routingMu.Unlock()
247- defer d.mu.Unlock()
248- if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
249- return false
250- }
251- if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {
252- return false
253- }
254- if c.req.AttachmentCapability.ac != nil && (c.req.AttachmentCapability.ac != c.req.Attachment || !c.req.AttachmentCapability.currentInSessionLocked(c.source)) {
255- return false
105+func (p *moveTabTopology) afterAdmission(d *Daemon) {
106+ if d.afterMoveTabSourceSnapshot != nil {
107+ d.afterMoveTabSourceSnapshot()
256108 }
109+}
110+
111+func (p *moveTabTopology) willRetireSource() bool {
112+ return p != nil && p.admission.finalSource
113+}
114+
115+func (p *moveTabTopology) resizeFences(source, destination *session) *moveResizeFences {
116+ fences := newMoveTabResizeFences(source, destination, p.admission.tab)
117+ p.fencedPanes = fences.panes
118+ return fences
119+}
120+
121+func (p *moveTabTopology) publishLocked(d *Daemon, source, destination *session) (moveTopologyPublication, error) {
122+ commit := moveTabCommit{req: p.req, source: source, destination: destination, admission: p.admission}
123+ return commit.publishTopologyLocked(d, p.fencedPanes)
124+}
125+
126+// publishTopologyLocked performs every whole-Tab validation before changing
127+// membership, Tab context, or Pane owners. The shared transaction owns common
128+// Session retirement and persistence capture after this method succeeds.
129+func (c *moveTabCommit) publishTopologyLocked(d *Daemon, fencedPanes []*pane) (moveTopologyPublication, error) {
257130 moved := c.admission.tab
258131 if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil ||
259- len(c.source.tabs) == 1 != c.admission.finalSource {
260- return false
261- }
262- if c.admission.finalSource && !sameMoveAttachmentsLocked(c.source, c.admission.sourceAttachments) {
263- return false
132+ (len(c.source.tabs) == 1) != c.admission.finalSource {
133+ return moveTopologyPublication{}, errMoveStaleTarget
264134 }
265135 if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive {
266- return false
136+ return moveTopologyPublication{}, errMoveStaleTarget
267137 }
268138 c.admission.destinationActive.mu.Lock()
269139 destinationSizeCurrent := c.admission.destinationActive.size == c.admission.destinationSize
270140 c.admission.destinationActive.mu.Unlock()
271141 if !destinationSizeCurrent {
272- return false
142+ return moveTopologyPublication{}, errMoveStaleTarget
273143 }
144+
274145 moved.mu.Lock()
275146 defer moved.mu.Unlock()
276147 if moved.layoutGeneration != c.admission.layoutGeneration || !moved.floatingTransferableLocked() ||
277148 moved.floating.state != c.admission.floatingState || moved.floating.pane != c.admission.floatingPane ||
278149 moved.floating.generation != c.admission.floatingGeneration || !sameMoveTabPanesLocked(moved, c.admission.panes, fencedPanes) {
279- return false
150+ return moveTopologyPublication{}, errMoveStaleTarget
280151 }
281- for _, p := range fencedPanes {
282- p.mu.Lock()
283- defer p.mu.Unlock()
284- owner := p.ownerSnapshot()
152+ for _, pane := range fencedPanes {
153+ pane.mu.Lock()
154+ defer pane.mu.Unlock()
155+ owner := pane.ownerSnapshot()
285156 if owner == nil || owner.session != c.source || owner.tab != moved {
286- return false
157+ return moveTopologyPublication{}, errMoveStaleTarget
287158 }
288159 }
289160
161+ // Everything above is fallible. Membership, context, and Pane owner writes
162+ // below form one in-memory publication and cannot become a Move rejection.
290163 idx := c.admission.sourceIndex
291164 if len(c.source.tabs) > 1 {
292165 c.source.prepareAttachmentViewsForRemovedTabLocked(moved, idx)
@@ -295,55 +168,51 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
295168 c.destination.tabs = append(c.destination.tabs, moved)
296169 moved.size = c.admission.destinationSize
297170 moved.bumpLayoutGenerationLocked()
298- c.oldTabCancel = moved.cancel
171+ oldTabCancel := moved.cancel
299172 parent := c.destination.ctx
300173 if parent == nil {
301174 parent = d.serveCtx
302175 }
303176 moved.ctx, moved.cancel = context.WithCancel(parent)
177+
304178 type ownerChange struct {
305179 pane *pane
306180 oldOwner paneEffectLease
307181 newOwner paneEffectLease
308182 }
309183 ownerChanges := make([]ownerChange, 0, len(fencedPanes))
310- for _, p := range fencedPanes {
184+ for _, pane := range fencedPanes {
311185 floatingGeneration := uint64(0)
312- if p == moved.floating.pane {
186+ if pane == moved.floating.pane {
313187 floatingGeneration = moved.floating.generation
314188 }
315- oldOwner, newOwner := p.publishOwnerLocked(c.destination, moved, floatingGeneration)
316- ownerChanges = append(ownerChanges, ownerChange{pane: p, oldOwner: oldOwner, newOwner: newOwner})
189+ oldOwner, newOwner := pane.publishOwnerLocked(c.destination, moved, floatingGeneration)
190+ ownerChanges = append(ownerChanges, ownerChange{pane: pane, oldOwner: oldOwner, newOwner: newOwner})
317191 }
318192 if d.beforeMoveTabCommit != nil {
319193 d.beforeMoveTabCommit()
320194 }
195+ var syncCleanup syncTimerCleanup
321196 for _, change := range ownerChanges {
322- c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner))
323- }
324- if len(c.source.tabs) == 0 {
325- d.unregisterSessionLocked(c.source)
326- c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.admission.sourceTransports)
327- c.source.tabs = nil
328- d.purgeParkingForSessionLocked(c.source)
329- c.retiredParked = d.purgeParkedForSessionLocked(c.source)
330- }
331- c.sourceEmpty = len(c.source.tabs) == 0
332- if !c.source.ephemeral {
333- c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate()
334- c.sourceMetadataValid = true
197+ syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner))
335198 }
336- if !c.destination.ephemeral {
337- c.destMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate()
338- c.destMetadataValid = true
339- }
340- return true
199+
200+ return moveTopologyPublication{
201+ sourceTab: moved,
202+ destinationTab: moved,
203+ movedPane: firstMovePane(c.admission.panes),
204+ movedPanes: c.admission.panes,
205+ syncCleanup: syncCleanup,
206+ oldTabCancel: oldTabCancel,
207+ sourceTabRemoved: true,
208+ sourceEmpty: len(c.source.tabs) == 0,
209+ }, nil
341210 }
342211
343212 func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool {
344213 current := make(map[*pane]struct{}, len(tb.panes)+1)
345- for _, p := range tb.panes {
346- current[p] = struct{}{}
214+ for _, pane := range tb.panes {
215+ current[pane] = struct{}{}
347216 }
348217 if tb.floating.pane != nil && (tb.floating.state == floatingHidden || tb.floating.state == floatingVisible) {
349218 current[tb.floating.pane] = struct{}{}
@@ -351,13 +220,13 @@ func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool {
351220 if len(current) != len(admitted) || len(current) != len(fenced) {
352221 return false
353222 }
354- for _, p := range admitted {
355- if _, ok := current[p]; !ok {
223+ for _, pane := range admitted {
224+ if _, ok := current[pane]; !ok {
356225 return false
357226 }
358227 }
359- for _, p := range fenced {
360- if _, ok := current[p]; !ok {
228+ for _, pane := range fenced {
229+ if _, ok := current[pane]; !ok {
361230 return false
362231 }
363232 }
@@ -7,11 +7,17 @@ import (
7 "github.com/bnema/vev/internal/usecase/layout"7 "github.com/bnema/vev/internal/usecase/layout"
8 )8 )
9 9
10+// moveTabTopology is the private whole-Tab topology Implementation consumed by
11+// the shared moveTransaction Module.
12+type moveTabTopology struct {
13+ req moveTabRequest
14+ admission moveTabAdmission
15+ fencedPanes []*pane
16+}
17+
10 type moveTabAdmission struct {18 type moveTabAdmission struct {
11 tab *tab19 tab *tab
12 sourceIndex int20 sourceIndex int
13- sourceAttachments []*attachedClient
14- sourceTransports map[*attachedClient]transportSnapshot
15 layoutGeneration uint6421 layoutGeneration uint64
16 floatingState floatingState22 floatingState floatingState
17 floatingPane *pane23 floatingPane *pane
@@ -23,270 +29,137 @@ type moveTabAdmission struct {
23 }29 }
24 30
25 type moveTabCommit struct {31 type moveTabCommit struct {
26- req moveTabRequest32+ req moveTabRequest
27- source *session33+ source *session
28- destination *session34+ destination *session
29- admission moveTabAdmission35+ admission moveTabAdmission
30- sourceEffectsFrozen bool
31- frozenEffects attachmentTransitionGuard
32- syncCleanup syncTimerCleanup
33- oldTabCancel context.CancelFunc
34- sourceEmpty bool
35- retiredParked []parkedAttachmentRetirement
36- retiredAttachments []detachedAttachmentSnapshot
37- sourceMetadata domain.CatalogueMetadataUpdate
38- sourceMetadataValid bool
39- destMetadata domain.CatalogueMetadataUpdate
40- destMetadataValid bool
41- err error
42 }36 }
43 37
44-func (d *Daemon) moveTab(req moveTabRequest) (result error) {38+func (d *Daemon) moveTab(req moveTabRequest) error {
45- if d == nil {39+ return d.executeMove(&moveTabTopology{req: req})
46- return errMovePaneInvalid40+}
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 {
65- return errMovePaneInvalid
66- }
67- d.mu.Lock()
68- source := moveSessionForLocatorLocked(d, req.Source)
69- destination := moveSessionForLocatorLocked(d, req.Destination)
70- d.mu.Unlock()
71- if source == nil || destination == nil {
72- return errMoveStaleTarget
73- }
74- if source == destination {
75- return errMovePaneInvalid
76- }
77- // Dispatch admission must precede lifecycle reservation. A final close holds
78- // dispatchMu while it waits for teardown ownership; reserving first would let
79- // that close wait on a move which is itself blocked on dispatchMu.
80- if d.beforeMoveDispatch != nil {
81- d.beforeMoveDispatch()
82- }
83- unlockDispatch := lockMoveDispatch(source, destination)
84- dispatchHeld := true
85- defer func() {
86- if dispatchHeld {
87- unlockDispatch()
88- }
89- }()
90-
91- reservation, err := d.reserveMoveLifecycles(source, destination)
92- if err != nil {
93- return errMoveStaleTarget
94- }
95- reservationHeld := true
96- defer func() {
97- if reservationHeld {
98- reservation.Release()
99- }
100- }()
101- if d.afterMoveLifecycleReserved != nil {
102- d.afterMoveLifecycleReserved()
103- }
104 41
105- admission, err := d.snapshotMoveTabAdmission(req, source, destination)42+func (p *moveTabTopology) transactionRequest() moveTransactionRequest {
106- if err != nil {43+ req := p.req
107- return err44+ return moveTransactionRequest{
45+ operation: "tab",
46+ attachment: req.Attachment,
47+ attachmentCapability: req.AttachmentCapability,
48+ source: req.Source,
49+ destination: req.Destination,
50+ logAttrs: []any{
51+ "source_session_id", req.Source.ID,
52+ "source_tab_id", req.SourceTabID,
53+ "destination_session_id", req.Destination.ID,
54+ },
108 }55 }
109- if d.afterMoveTabSourceSnapshot != nil {56+}
110- d.afterMoveTabSourceSnapshot()
111- }
112- commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}
113- var frozen attachmentTransitionGuard
114- if admission.finalSource && len(admission.sourceAttachments) != 0 {
115- interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))
116- for _, ac := range admission.sourceAttachments {
117- if transport := admission.sourceTransports[ac]; transport.transport != nil {
118- interrupts = append(interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
119- }
120- }
121- frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
122- if d.afterAttachmentEffectGateFrozen != nil {
123- d.afterAttachmentEffectGateFrozen("move-tab", ac)
124- }
125- }}, admission.sourceAttachments...)
126- if !frozen.acquired || !frozen.drained {
127- frozen.unfreeze()
128- return errMovePaneInvalid
129- }
130- commit.sourceEffectsFrozen = true
131- }
132- commit.frozenEffects = frozen
133- effectsFrozen := commit.sourceEffectsFrozen
134- defer func() {
135- if effectsFrozen {
136- frozen.unfreeze()
137- }
138- }()
139 57
140- fences := newMoveTabResizeFences(source, destination, admission.tab)58+func (p *moveTabTopology) validRequest() bool {
141- if !fences.acquire(func() bool { return commit.publishLocked(d, fences.panes) }) {59+ return p != nil && p.req.SourceTabID != "" && p.req.Source.ID != p.req.Destination.ID
142- if commit.err != nil {
143- return commit.err
144- }
145- return errMoveStaleTarget
146- }
147- fences.Release()
148- postcommit := movePanePostcommitPlan{
149- source: source,
150- destination: destination,
151- sourceTab: admission.tab,
152- destinationTab: admission.tab,
153- movedPane: firstMovePane(admission.panes),
154- movedPanes: admission.panes,
155- operation: "tab",
156- sourceAttachments: admission.sourceAttachments,
157- syncCleanup: commit.syncCleanup,
158- frozenEffects: frozen,
159- effectsFrozen: effectsFrozen,
160- unlockDispatch: unlockDispatch,
161- reservation: reservation,
162- oldTabCancel: commit.oldTabCancel,
163- sourceTabRemoved: true,
164- sourceEmpty: commit.sourceEmpty,
165- retiredParked: commit.retiredParked,
166- retiredAttachments: commit.retiredAttachments,
167- sourceMetadata: commit.sourceMetadata,
168- sourceMetadataValid: commit.sourceMetadataValid,
169- destinationMetadata: commit.destMetadata,
170- destinationMetadataValid: commit.destMetadataValid,
171- }
172- effectsFrozen = false
173- dispatchHeld = false
174- reservationHeld = false
175- postcommit.execute(d)
176- return nil
177 }60 }
178 61
179-func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destination *session) (*moveTabAdmission, error) {62+// admitLocked runs with daemon and ordered Session locks held. It snapshots
180- d.mu.Lock()63+// whole-Tab policy without publishing topology or Pane ownership.
181- defer d.mu.Unlock()64+func (p *moveTabTopology) admitLocked(_ *Daemon, source, destination *session) error {
182- if d.closing || d.sessions[source.id] != source || d.sessions[destination.id] != destination {65+ if source == destination {
183- return nil, errMoveStaleTarget66+ return errMovePaneInvalid
184- }
185- unlockSessions := lockAttachmentSessions(source, destination)
186- defer unlockSessions()
187- if !moveSessionLocatorCurrentLocked(source, req.Source) || !moveSessionLocatorCurrentLocked(destination, req.Destination) {
188- return nil, errMoveStaleTarget
189- }
190- if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) {
191- return nil, errMoveStaleTarget
192- }
193- if req.AttachmentCapability.ac != nil && (req.AttachmentCapability.ac != req.Attachment || !req.AttachmentCapability.currentInSessionLocked(source)) {
194- return nil, errMoveStaleTarget
195 }67 }
196- moved := findMoveTabLocked(source, req.SourceTabID)68+ moved := findMoveTabLocked(source, p.req.SourceTabID)
197 if moved == nil || len(destination.tabs) == 0 {69 if moved == nil || len(destination.tabs) == 0 {
198- return nil, errMoveStaleTarget70+ return errMoveStaleTarget
199 }71 }
200 destinationActive := destination.tabs[0]72 destinationActive := destination.tabs[0]
201 destinationActive.mu.Lock()73 destinationActive.mu.Lock()
202 destinationSize := destinationActive.size74 destinationSize := destinationActive.size
203 destinationActive.mu.Unlock()75 destinationActive.mu.Unlock()
76+
204 moved.mu.Lock()77 moved.mu.Lock()
205 defer moved.mu.Unlock()78 defer moved.mu.Unlock()
206 if !moved.floatingTransferableLocked() {79 if !moved.floatingTransferableLocked() {
207- return nil, errMoveFloatingWarming80+ return errMoveFloatingWarming
208 }81 }
209 if moved.tree == nil || moved.tree.Root == nil {82 if moved.tree == nil || moved.tree.Root == nil {
210- return nil, errMovePaneInvalid83+ return errMovePaneInvalid
211 }84 }
212 if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok {85 if _, ok := layout.Solve(moved.tree.Root, domain.Rect{Width: destinationSize.Cols, Height: destinationSize.Rows}); !ok {
213- return nil, errMoveTooSmall86+ return errMoveTooSmall
214 }87 }
215 panes := make([]*pane, 0, len(moved.panes)+1)88 panes := make([]*pane, 0, len(moved.panes)+1)
216- for _, p := range moved.panes {89+ for _, pane := range moved.panes {
217- panes = append(panes, p)90+ panes = append(panes, pane)
218 }91 }
219 if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) {92 if moved.floating.pane != nil && (moved.floating.state == floatingHidden || moved.floating.state == floatingVisible) {
220 panes = append(panes, moved.floating.pane)93 panes = append(panes, moved.floating.pane)
221 }94 }
222- idx := indexMoveTabLocked(source, moved)95+ p.admission = moveTabAdmission{
223- attachments := source.snapshotAttachmentsLocked()96+ tab: moved, sourceIndex: indexMoveTabLocked(source, moved),
224- transports := make(map[*attachedClient]transportSnapshot, len(attachments))
225- for _, ac := range attachments {
226- transports[ac] = ac.transportSnapshot()
227- }
228- return &moveTabAdmission{
229- tab: moved, sourceIndex: idx, sourceAttachments: attachments, sourceTransports: transports,
230 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,97 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,
231 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,98 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,
232 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,99 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,
233 finalSource: len(source.tabs) == 1,100 finalSource: len(source.tabs) == 1,
234- }, nil101+ }
102+ return nil
235 }103 }
236 104
237-func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {105+func (p *moveTabTopology) afterAdmission(d *Daemon) {
238- d.mu.Lock()106+ if d.afterMoveTabSourceSnapshot != nil {
239- if d.closing || d.sessions[c.source.id] != c.source || d.sessions[c.destination.id] != c.destination {107+ d.afterMoveTabSourceSnapshot()
240- d.mu.Unlock()
241- return false
242- }
243- d.notices.routingMu.Lock()
244- unlockSessions := lockAttachmentSessions(c.source, c.destination)
245- defer unlockSessions()
246- defer d.notices.routingMu.Unlock()
247- defer d.mu.Unlock()
248- if !moveSessionLocatorCurrentLocked(c.source, c.req.Source) || !moveSessionLocatorCurrentLocked(c.destination, c.req.Destination) {
249- return false
250- }
251- if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {
252- return false
253- }
254- if c.req.AttachmentCapability.ac != nil && (c.req.AttachmentCapability.ac != c.req.Attachment || !c.req.AttachmentCapability.currentInSessionLocked(c.source)) {
255- return false
256 }108 }
109+}
110+
111+func (p *moveTabTopology) willRetireSource() bool {
112+ return p != nil && p.admission.finalSource
113+}
114+
115+func (p *moveTabTopology) resizeFences(source, destination *session) *moveResizeFences {
116+ fences := newMoveTabResizeFences(source, destination, p.admission.tab)
117+ p.fencedPanes = fences.panes
118+ return fences
119+}
120+
121+func (p *moveTabTopology) publishLocked(d *Daemon, source, destination *session) (moveTopologyPublication, error) {
122+ commit := moveTabCommit{req: p.req, source: source, destination: destination, admission: p.admission}
123+ return commit.publishTopologyLocked(d, p.fencedPanes)
124+}
125+
126+// publishTopologyLocked performs every whole-Tab validation before changing
127+// membership, Tab context, or Pane owners. The shared transaction owns common
128+// Session retirement and persistence capture after this method succeeds.
129+func (c *moveTabCommit) publishTopologyLocked(d *Daemon, fencedPanes []*pane) (moveTopologyPublication, error) {
257 moved := c.admission.tab130 moved := c.admission.tab
258 if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil ||131 if indexMoveTabLocked(c.source, moved) != c.admission.sourceIndex || findMoveTabLocked(c.destination, c.req.SourceTabID) != nil ||
259- len(c.source.tabs) == 1 != c.admission.finalSource {132+ (len(c.source.tabs) == 1) != c.admission.finalSource {
260- return false133+ return moveTopologyPublication{}, errMoveStaleTarget
261- }
262- if c.admission.finalSource && !sameMoveAttachmentsLocked(c.source, c.admission.sourceAttachments) {
263- return false
264 }134 }
265 if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive {135 if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive {
266- return false136+ return moveTopologyPublication{}, errMoveStaleTarget
267 }137 }
268 c.admission.destinationActive.mu.Lock()138 c.admission.destinationActive.mu.Lock()
269 destinationSizeCurrent := c.admission.destinationActive.size == c.admission.destinationSize139 destinationSizeCurrent := c.admission.destinationActive.size == c.admission.destinationSize
270 c.admission.destinationActive.mu.Unlock()140 c.admission.destinationActive.mu.Unlock()
271 if !destinationSizeCurrent {141 if !destinationSizeCurrent {
272- return false142+ return moveTopologyPublication{}, errMoveStaleTarget
273 }143 }
144+
274 moved.mu.Lock()145 moved.mu.Lock()
275 defer moved.mu.Unlock()146 defer moved.mu.Unlock()
276 if moved.layoutGeneration != c.admission.layoutGeneration || !moved.floatingTransferableLocked() ||147 if moved.layoutGeneration != c.admission.layoutGeneration || !moved.floatingTransferableLocked() ||
277 moved.floating.state != c.admission.floatingState || moved.floating.pane != c.admission.floatingPane ||148 moved.floating.state != c.admission.floatingState || moved.floating.pane != c.admission.floatingPane ||
278 moved.floating.generation != c.admission.floatingGeneration || !sameMoveTabPanesLocked(moved, c.admission.panes, fencedPanes) {149 moved.floating.generation != c.admission.floatingGeneration || !sameMoveTabPanesLocked(moved, c.admission.panes, fencedPanes) {
279- return false150+ return moveTopologyPublication{}, errMoveStaleTarget
280 }151 }
281- for _, p := range fencedPanes {152+ for _, pane := range fencedPanes {
282- p.mu.Lock()153+ pane.mu.Lock()
283- defer p.mu.Unlock()154+ defer pane.mu.Unlock()
284- owner := p.ownerSnapshot()155+ owner := pane.ownerSnapshot()
285 if owner == nil || owner.session != c.source || owner.tab != moved {156 if owner == nil || owner.session != c.source || owner.tab != moved {
286- return false157+ return moveTopologyPublication{}, errMoveStaleTarget
287 }158 }
288 }159 }
289 160
161+ // Everything above is fallible. Membership, context, and Pane owner writes
162+ // below form one in-memory publication and cannot become a Move rejection.
290 idx := c.admission.sourceIndex163 idx := c.admission.sourceIndex
291 if len(c.source.tabs) > 1 {164 if len(c.source.tabs) > 1 {
292 c.source.prepareAttachmentViewsForRemovedTabLocked(moved, idx)165 c.source.prepareAttachmentViewsForRemovedTabLocked(moved, idx)
@@ -295,55 +168,51 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
295 c.destination.tabs = append(c.destination.tabs, moved)168 c.destination.tabs = append(c.destination.tabs, moved)
296 moved.size = c.admission.destinationSize169 moved.size = c.admission.destinationSize
297 moved.bumpLayoutGenerationLocked()170 moved.bumpLayoutGenerationLocked()
298- c.oldTabCancel = moved.cancel171+ oldTabCancel := moved.cancel
299 parent := c.destination.ctx172 parent := c.destination.ctx
300 if parent == nil {173 if parent == nil {
301 parent = d.serveCtx174 parent = d.serveCtx
302 }175 }
303 moved.ctx, moved.cancel = context.WithCancel(parent)176 moved.ctx, moved.cancel = context.WithCancel(parent)
177+
304 type ownerChange struct {178 type ownerChange struct {
305 pane *pane179 pane *pane
306 oldOwner paneEffectLease180 oldOwner paneEffectLease
307 newOwner paneEffectLease181 newOwner paneEffectLease
308 }182 }
309 ownerChanges := make([]ownerChange, 0, len(fencedPanes))183 ownerChanges := make([]ownerChange, 0, len(fencedPanes))
310- for _, p := range fencedPanes {184+ for _, pane := range fencedPanes {
311 floatingGeneration := uint64(0)185 floatingGeneration := uint64(0)
312- if p == moved.floating.pane {186+ if pane == moved.floating.pane {
313 floatingGeneration = moved.floating.generation187 floatingGeneration = moved.floating.generation
314 }188 }
315- oldOwner, newOwner := p.publishOwnerLocked(c.destination, moved, floatingGeneration)189+ oldOwner, newOwner := pane.publishOwnerLocked(c.destination, moved, floatingGeneration)
316- ownerChanges = append(ownerChanges, ownerChange{pane: p, oldOwner: oldOwner, newOwner: newOwner})190+ ownerChanges = append(ownerChanges, ownerChange{pane: pane, oldOwner: oldOwner, newOwner: newOwner})
317 }191 }
318 if d.beforeMoveTabCommit != nil {192 if d.beforeMoveTabCommit != nil {
319 d.beforeMoveTabCommit()193 d.beforeMoveTabCommit()
320 }194 }
195+ var syncCleanup syncTimerCleanup
321 for _, change := range ownerChanges {196 for _, change := range ownerChanges {
322- c.syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner))197+ syncCleanup.append(d.migratePaneSyncOwnerLocked(change.pane, change.oldOwner, change.newOwner))
323- }
324- if len(c.source.tabs) == 0 {
325- d.unregisterSessionLocked(c.source)
326- c.retiredAttachments = detachMoveAttachmentsLocked(c.source, c.admission.sourceTransports)
327- c.source.tabs = nil
328- d.purgeParkingForSessionLocked(c.source)
329- c.retiredParked = d.purgeParkedForSessionLocked(c.source)
330- }
331- c.sourceEmpty = len(c.source.tabs) == 0
332- if !c.source.ephemeral {
333- c.sourceMetadata = c.source.persistRecordLocked(max(d.nowUnixNano(), c.source.createdAt, int64(1))).MetadataUpdate()
334- c.sourceMetadataValid = true
335 }198 }
336- if !c.destination.ephemeral {199+
337- c.destMetadata = c.destination.persistRecordLocked(max(d.nowUnixNano(), c.destination.createdAt, int64(1))).MetadataUpdate()200+ return moveTopologyPublication{
338- c.destMetadataValid = true201+ sourceTab: moved,
339- }202+ destinationTab: moved,
340- return true203+ movedPane: firstMovePane(c.admission.panes),
204+ movedPanes: c.admission.panes,
205+ syncCleanup: syncCleanup,
206+ oldTabCancel: oldTabCancel,
207+ sourceTabRemoved: true,
208+ sourceEmpty: len(c.source.tabs) == 0,
209+ }, nil
341 }210 }
342 211
343 func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool {212 func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool {
344 current := make(map[*pane]struct{}, len(tb.panes)+1)213 current := make(map[*pane]struct{}, len(tb.panes)+1)
345- for _, p := range tb.panes {214+ for _, pane := range tb.panes {
346- current[p] = struct{}{}215+ current[pane] = struct{}{}
347 }216 }
348 if tb.floating.pane != nil && (tb.floating.state == floatingHidden || tb.floating.state == floatingVisible) {217 if tb.floating.pane != nil && (tb.floating.state == floatingHidden || tb.floating.state == floatingVisible) {
349 current[tb.floating.pane] = struct{}{}218 current[tb.floating.pane] = struct{}{}
@@ -351,13 +220,13 @@ func sameMoveTabPanesLocked(tb *tab, admitted, fenced []*pane) bool {
351 if len(current) != len(admitted) || len(current) != len(fenced) {220 if len(current) != len(admitted) || len(current) != len(fenced) {
352 return false221 return false
353 }222 }
354- for _, p := range admitted {223+ for _, pane := range admitted {
355- if _, ok := current[p]; !ok {224+ if _, ok := current[pane]; !ok {
356 return false225 return false
357 }226 }
358 }227 }
359- for _, p := range fenced {228+ for _, pane := range fenced {
360- if _, ok := current[p]; !ok {229+ if _, ok := current[pane]; !ok {
361 return false230 return false
362 }231 }
363 }232 }
A internal/usecase/daemon/move_transaction.go +316 -0
@@ -0,0 +1,316 @@
1+package daemon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// moveTopology is the private policy Interface consumed by moveTransaction.
10+// Admission runs under daemon and ordered Session locks and must be read-only.
11+// Publication runs under daemon, routing, ordered Session, and resize fences;
12+// it must finish every fallible check before its first write.
13+type moveTopology interface {
14+ transactionRequest() moveTransactionRequest
15+ validRequest() bool
16+ admitLocked(*Daemon, *session, *session) error
17+ afterAdmission(*Daemon)
18+ willRetireSource() bool
19+ resizeFences(*session, *session) *moveResizeFences
20+ publishLocked(*Daemon, *session, *session) (moveTopologyPublication, error)
21+}
22+
23+type moveTransactionRequest struct {
24+ operation string
25+ attachment *attachedClient
26+ attachmentCapability attachmentCapability
27+ source moveSessionLocator
28+ destination moveSessionLocator
29+ logAttrs []any
30+}
31+
32+// moveTopologyPublication contains immutable topology outputs needed after the
33+// in-memory publication point. No field grants authority to perform additional
34+// topology writes.
35+type moveTopologyPublication struct {
36+ sourceTab, destinationTab *tab
37+ movedPane *pane
38+ movedPanes []*pane
39+ syncCleanup syncTimerCleanup
40+ oldTabCancel context.CancelFunc
41+ sourceTabRemoved bool
42+ sourceEmpty bool
43+}
44+
45+// moveTransaction owns the shared pane/tab Move phase protocol. Topology and
46+// owner policy remain private Implementations behind moveTopology.
47+type moveTransaction struct {
48+ daemon *Daemon
49+ topology moveTopology
50+ request moveTransactionRequest
51+
52+ source *session
53+ destination *session
54+
55+ sourceAttachments []*attachedClient
56+ sourceTransports map[*attachedClient]transportSnapshot
57+ frozenEffects attachmentTransitionGuard
58+ effectsFrozen bool
59+
60+ publication moveTopologyPublication
61+ publishErr error
62+
63+ retiredParked []parkedAttachmentRetirement
64+ retiredAttachments []detachedAttachmentSnapshot
65+ sourceMetadata domain.CatalogueMetadataUpdate
66+ sourceMetadataOK bool
67+ destMetadata domain.CatalogueMetadataUpdate
68+ destMetadataOK bool
69+ sourceName string
70+ destinationName string
71+}
72+
73+func (d *Daemon) executeMove(topology moveTopology) (result error) {
74+ if d == nil || topology == nil {
75+ return errMovePaneInvalid
76+ }
77+ t := moveTransaction{daemon: d, topology: topology, request: topology.transactionRequest()}
78+ d.log.Info(t.request.operation+" move requested", t.request.logAttrs...)
79+ defer func() {
80+ result = normalizeMoveRejection(result)
81+ if result != nil {
82+ attrs := make([]any, 0, len(t.request.logAttrs)+2)
83+ attrs = append(attrs, "err", result)
84+ attrs = append(attrs, t.request.logAttrs...)
85+ d.log.Warn(t.request.operation+" move rejected", attrs...)
86+ }
87+ }()
88+ if !t.validRequest() {
89+ return errMovePaneInvalid
90+ }
91+ if err := t.resolveSessions(); err != nil {
92+ return err
93+ }
94+
95+ if d.beforeMoveDispatch != nil {
96+ d.beforeMoveDispatch()
97+ }
98+ unlockDispatch := lockMoveDispatch(t.source, t.destination)
99+ dispatchHeld := true
100+ defer func() {
101+ if dispatchHeld {
102+ unlockDispatch()
103+ }
104+ }()
105+
106+ reservation, err := d.reserveMoveLifecycles(t.source, t.destination)
107+ if err != nil {
108+ return errMoveStaleTarget
109+ }
110+ reservationHeld := true
111+ defer func() {
112+ if reservationHeld {
113+ reservation.Release()
114+ }
115+ }()
116+ if d.afterMoveLifecycleReserved != nil {
117+ d.afterMoveLifecycleReserved()
118+ }
119+
120+ if err := t.admit(); err != nil {
121+ return err
122+ }
123+ t.topology.afterAdmission(d)
124+ if err := t.freezeSourceAttachments(); err != nil {
125+ return err
126+ }
127+ defer func() {
128+ if t.effectsFrozen {
129+ t.frozenEffects.unfreeze()
130+ }
131+ }()
132+
133+ fences := t.topology.resizeFences(t.source, t.destination)
134+ if fences == nil || !fences.acquire(func() bool {
135+ t.publishErr = t.publishLocked()
136+ return t.publishErr == nil
137+ }) {
138+ if t.publishErr != nil {
139+ return t.publishErr
140+ }
141+ return errMoveStaleTarget
142+ }
143+ fences.Release()
144+
145+ postcommit := t.postcommitPlan(unlockDispatch, reservation)
146+ t.effectsFrozen = false
147+ dispatchHeld = false
148+ reservationHeld = false
149+ postcommit.execute(d)
150+ return nil
151+}
152+
153+func (t *moveTransaction) validRequest() bool {
154+ return t != nil && t.topology != nil && t.request.operation != "" &&
155+ t.request.source.ID != "" && t.request.destination.ID != "" && t.topology.validRequest()
156+}
157+
158+func (t *moveTransaction) resolveSessions() error {
159+ d := t.daemon
160+ d.mu.Lock()
161+ t.source = moveSessionForLocatorLocked(d, t.request.source)
162+ t.destination = moveSessionForLocatorLocked(d, t.request.destination)
163+ d.mu.Unlock()
164+ if t.source == nil || t.destination == nil {
165+ return errMoveStaleTarget
166+ }
167+ return nil
168+}
169+
170+func (t *moveTransaction) attachmentAuthorityCurrentLocked() bool {
171+ req := t.request
172+ if req.attachment != nil && !attachmentRegisteredLocked(t.source, req.attachment) {
173+ return false
174+ }
175+ if req.attachmentCapability.ac != nil &&
176+ (req.attachmentCapability.ac != req.attachment || !req.attachmentCapability.currentInSessionLocked(t.source)) {
177+ return false
178+ }
179+ return true
180+}
181+
182+// admit snapshots exact common Attachment participants while the topology
183+// Implementation snapshots only its private identity and layout state.
184+func (t *moveTransaction) admit() error {
185+ d := t.daemon
186+ d.mu.Lock()
187+ defer d.mu.Unlock()
188+ if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination {
189+ return errMoveStaleTarget
190+ }
191+ unlockSessions := lockAttachmentSessions(t.source, t.destination)
192+ defer unlockSessions()
193+ if !moveSessionLocatorCurrentLocked(t.source, t.request.source) ||
194+ !moveSessionLocatorCurrentLocked(t.destination, t.request.destination) ||
195+ !t.attachmentAuthorityCurrentLocked() {
196+ return errMoveStaleTarget
197+ }
198+ if err := t.topology.admitLocked(d, t.source, t.destination); err != nil {
199+ return err
200+ }
201+ t.sourceAttachments = t.source.snapshotAttachmentsLocked()
202+ t.sourceTransports = make(map[*attachedClient]transportSnapshot, len(t.sourceAttachments))
203+ for _, attachment := range t.sourceAttachments {
204+ t.sourceTransports[attachment] = attachment.transportSnapshot()
205+ }
206+ return nil
207+}
208+
209+// freezeSourceAttachments delegates effect admission and drain authority to the
210+// attachment lifecycle Module. Move uses nonblocking acquisition to preserve
211+// its ordering with final teardown.
212+func (t *moveTransaction) freezeSourceAttachments() error {
213+ if !t.topology.willRetireSource() || len(t.sourceAttachments) == 0 {
214+ return nil
215+ }
216+ interrupts := make([]attachmentTransportInterrupt, 0, len(t.sourceAttachments))
217+ for _, attachment := range t.sourceAttachments {
218+ if transport := t.sourceTransports[attachment]; transport.transport != nil {
219+ interrupts = append(interrupts, attachmentTransportInterrupt{ac: attachment, transport: transport})
220+ }
221+ }
222+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{
223+ interrupts: interrupts,
224+ nonblocking: true,
225+ afterFrozen: func(attachment *attachedClient) {
226+ if t.daemon.afterAttachmentEffectGateFrozen != nil {
227+ t.daemon.afterAttachmentEffectGateFrozen("move-"+t.request.operation, attachment)
228+ }
229+ },
230+ }, t.sourceAttachments...)
231+ if !frozen.acquired || !frozen.drained {
232+ frozen.unfreeze()
233+ return errMovePaneInvalid
234+ }
235+ t.frozenEffects = frozen
236+ t.effectsFrozen = true
237+ return nil
238+}
239+
240+// publishLocked is the only shared architecture-lock window. Topology
241+// Implementations perform all fallible private validation before their first
242+// write. Common retirement and metadata capture are non-failing in-memory work.
243+func (t *moveTransaction) publishLocked() error {
244+ d := t.daemon
245+ d.mu.Lock()
246+ defer d.mu.Unlock()
247+ if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination {
248+ return errMoveStaleTarget
249+ }
250+ d.notices.routingMu.Lock()
251+ defer d.notices.routingMu.Unlock()
252+ unlockSessions := lockAttachmentSessions(t.source, t.destination)
253+ defer unlockSessions()
254+ if !moveSessionLocatorCurrentLocked(t.source, t.request.source) ||
255+ !moveSessionLocatorCurrentLocked(t.destination, t.request.destination) ||
256+ !t.attachmentAuthorityCurrentLocked() {
257+ return errMoveStaleTarget
258+ }
259+ if t.topology.willRetireSource() && !sameMoveAttachmentsLocked(t.source, t.sourceAttachments) {
260+ return errMoveStaleTarget
261+ }
262+
263+ publication, err := t.topology.publishLocked(d, t.source, t.destination)
264+ if err != nil {
265+ return err
266+ }
267+ t.publication = publication
268+ if publication.sourceEmpty {
269+ d.unregisterSessionLocked(t.source)
270+ t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports)
271+ t.source.tabs = nil
272+ d.purgeParkingForSessionLocked(t.source)
273+ t.retiredParked = d.purgeParkedForSessionLocked(t.source)
274+ }
275+ t.sourceName = t.source.name
276+ t.destinationName = t.destination.name
277+ if !t.source.ephemeral {
278+ t.sourceMetadata = t.source.persistRecordLocked(max(d.nowUnixNano(), t.source.createdAt, int64(1))).MetadataUpdate()
279+ t.sourceMetadataOK = true
280+ }
281+ if !t.destination.ephemeral && t.destination != t.source {
282+ t.destMetadata = t.destination.persistRecordLocked(max(d.nowUnixNano(), t.destination.createdAt, int64(1))).MetadataUpdate()
283+ t.destMetadataOK = true
284+ }
285+ return nil
286+}
287+
288+func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *moveLifecycleReservation) movePostcommitPlan {
289+ publication := t.publication
290+ return movePostcommitPlan{
291+ source: t.source,
292+ destination: t.destination,
293+ sourceName: t.sourceName,
294+ destinationName: t.destinationName,
295+ sourceTab: publication.sourceTab,
296+ destinationTab: publication.destinationTab,
297+ movedPane: publication.movedPane,
298+ movedPanes: publication.movedPanes,
299+ operation: t.request.operation,
300+ sourceAttachments: t.sourceAttachments,
301+ syncCleanup: publication.syncCleanup,
302+ frozenEffects: t.frozenEffects,
303+ effectsFrozen: t.effectsFrozen,
304+ unlockDispatch: unlockDispatch,
305+ reservation: reservation,
306+ oldTabCancel: publication.oldTabCancel,
307+ sourceTabRemoved: publication.sourceTabRemoved,
308+ sourceEmpty: publication.sourceEmpty,
309+ retiredParked: t.retiredParked,
310+ retiredAttachments: t.retiredAttachments,
311+ sourceMetadata: t.sourceMetadata,
312+ sourceMetadataValid: t.sourceMetadataOK,
313+ destinationMetadata: t.destMetadata,
314+ destinationMetadataValid: t.destMetadataOK,
315+ }
316+}
@@ -0,0 +1,316 @@
1+package daemon
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// moveTopology is the private policy Interface consumed by moveTransaction.
10+// Admission runs under daemon and ordered Session locks and must be read-only.
11+// Publication runs under daemon, routing, ordered Session, and resize fences;
12+// it must finish every fallible check before its first write.
13+type moveTopology interface {
14+ transactionRequest() moveTransactionRequest
15+ validRequest() bool
16+ admitLocked(*Daemon, *session, *session) error
17+ afterAdmission(*Daemon)
18+ willRetireSource() bool
19+ resizeFences(*session, *session) *moveResizeFences
20+ publishLocked(*Daemon, *session, *session) (moveTopologyPublication, error)
21+}
22+
23+type moveTransactionRequest struct {
24+ operation string
25+ attachment *attachedClient
26+ attachmentCapability attachmentCapability
27+ source moveSessionLocator
28+ destination moveSessionLocator
29+ logAttrs []any
30+}
31+
32+// moveTopologyPublication contains immutable topology outputs needed after the
33+// in-memory publication point. No field grants authority to perform additional
34+// topology writes.
35+type moveTopologyPublication struct {
36+ sourceTab, destinationTab *tab
37+ movedPane *pane
38+ movedPanes []*pane
39+ syncCleanup syncTimerCleanup
40+ oldTabCancel context.CancelFunc
41+ sourceTabRemoved bool
42+ sourceEmpty bool
43+}
44+
45+// moveTransaction owns the shared pane/tab Move phase protocol. Topology and
46+// owner policy remain private Implementations behind moveTopology.
47+type moveTransaction struct {
48+ daemon *Daemon
49+ topology moveTopology
50+ request moveTransactionRequest
51+
52+ source *session
53+ destination *session
54+
55+ sourceAttachments []*attachedClient
56+ sourceTransports map[*attachedClient]transportSnapshot
57+ frozenEffects attachmentTransitionGuard
58+ effectsFrozen bool
59+
60+ publication moveTopologyPublication
61+ publishErr error
62+
63+ retiredParked []parkedAttachmentRetirement
64+ retiredAttachments []detachedAttachmentSnapshot
65+ sourceMetadata domain.CatalogueMetadataUpdate
66+ sourceMetadataOK bool
67+ destMetadata domain.CatalogueMetadataUpdate
68+ destMetadataOK bool
69+ sourceName string
70+ destinationName string
71+}
72+
73+func (d *Daemon) executeMove(topology moveTopology) (result error) {
74+ if d == nil || topology == nil {
75+ return errMovePaneInvalid
76+ }
77+ t := moveTransaction{daemon: d, topology: topology, request: topology.transactionRequest()}
78+ d.log.Info(t.request.operation+" move requested", t.request.logAttrs...)
79+ defer func() {
80+ result = normalizeMoveRejection(result)
81+ if result != nil {
82+ attrs := make([]any, 0, len(t.request.logAttrs)+2)
83+ attrs = append(attrs, "err", result)
84+ attrs = append(attrs, t.request.logAttrs...)
85+ d.log.Warn(t.request.operation+" move rejected", attrs...)
86+ }
87+ }()
88+ if !t.validRequest() {
89+ return errMovePaneInvalid
90+ }
91+ if err := t.resolveSessions(); err != nil {
92+ return err
93+ }
94+
95+ if d.beforeMoveDispatch != nil {
96+ d.beforeMoveDispatch()
97+ }
98+ unlockDispatch := lockMoveDispatch(t.source, t.destination)
99+ dispatchHeld := true
100+ defer func() {
101+ if dispatchHeld {
102+ unlockDispatch()
103+ }
104+ }()
105+
106+ reservation, err := d.reserveMoveLifecycles(t.source, t.destination)
107+ if err != nil {
108+ return errMoveStaleTarget
109+ }
110+ reservationHeld := true
111+ defer func() {
112+ if reservationHeld {
113+ reservation.Release()
114+ }
115+ }()
116+ if d.afterMoveLifecycleReserved != nil {
117+ d.afterMoveLifecycleReserved()
118+ }
119+
120+ if err := t.admit(); err != nil {
121+ return err
122+ }
123+ t.topology.afterAdmission(d)
124+ if err := t.freezeSourceAttachments(); err != nil {
125+ return err
126+ }
127+ defer func() {
128+ if t.effectsFrozen {
129+ t.frozenEffects.unfreeze()
130+ }
131+ }()
132+
133+ fences := t.topology.resizeFences(t.source, t.destination)
134+ if fences == nil || !fences.acquire(func() bool {
135+ t.publishErr = t.publishLocked()
136+ return t.publishErr == nil
137+ }) {
138+ if t.publishErr != nil {
139+ return t.publishErr
140+ }
141+ return errMoveStaleTarget
142+ }
143+ fences.Release()
144+
145+ postcommit := t.postcommitPlan(unlockDispatch, reservation)
146+ t.effectsFrozen = false
147+ dispatchHeld = false
148+ reservationHeld = false
149+ postcommit.execute(d)
150+ return nil
151+}
152+
153+func (t *moveTransaction) validRequest() bool {
154+ return t != nil && t.topology != nil && t.request.operation != "" &&
155+ t.request.source.ID != "" && t.request.destination.ID != "" && t.topology.validRequest()
156+}
157+
158+func (t *moveTransaction) resolveSessions() error {
159+ d := t.daemon
160+ d.mu.Lock()
161+ t.source = moveSessionForLocatorLocked(d, t.request.source)
162+ t.destination = moveSessionForLocatorLocked(d, t.request.destination)
163+ d.mu.Unlock()
164+ if t.source == nil || t.destination == nil {
165+ return errMoveStaleTarget
166+ }
167+ return nil
168+}
169+
170+func (t *moveTransaction) attachmentAuthorityCurrentLocked() bool {
171+ req := t.request
172+ if req.attachment != nil && !attachmentRegisteredLocked(t.source, req.attachment) {
173+ return false
174+ }
175+ if req.attachmentCapability.ac != nil &&
176+ (req.attachmentCapability.ac != req.attachment || !req.attachmentCapability.currentInSessionLocked(t.source)) {
177+ return false
178+ }
179+ return true
180+}
181+
182+// admit snapshots exact common Attachment participants while the topology
183+// Implementation snapshots only its private identity and layout state.
184+func (t *moveTransaction) admit() error {
185+ d := t.daemon
186+ d.mu.Lock()
187+ defer d.mu.Unlock()
188+ if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination {
189+ return errMoveStaleTarget
190+ }
191+ unlockSessions := lockAttachmentSessions(t.source, t.destination)
192+ defer unlockSessions()
193+ if !moveSessionLocatorCurrentLocked(t.source, t.request.source) ||
194+ !moveSessionLocatorCurrentLocked(t.destination, t.request.destination) ||
195+ !t.attachmentAuthorityCurrentLocked() {
196+ return errMoveStaleTarget
197+ }
198+ if err := t.topology.admitLocked(d, t.source, t.destination); err != nil {
199+ return err
200+ }
201+ t.sourceAttachments = t.source.snapshotAttachmentsLocked()
202+ t.sourceTransports = make(map[*attachedClient]transportSnapshot, len(t.sourceAttachments))
203+ for _, attachment := range t.sourceAttachments {
204+ t.sourceTransports[attachment] = attachment.transportSnapshot()
205+ }
206+ return nil
207+}
208+
209+// freezeSourceAttachments delegates effect admission and drain authority to the
210+// attachment lifecycle Module. Move uses nonblocking acquisition to preserve
211+// its ordering with final teardown.
212+func (t *moveTransaction) freezeSourceAttachments() error {
213+ if !t.topology.willRetireSource() || len(t.sourceAttachments) == 0 {
214+ return nil
215+ }
216+ interrupts := make([]attachmentTransportInterrupt, 0, len(t.sourceAttachments))
217+ for _, attachment := range t.sourceAttachments {
218+ if transport := t.sourceTransports[attachment]; transport.transport != nil {
219+ interrupts = append(interrupts, attachmentTransportInterrupt{ac: attachment, transport: transport})
220+ }
221+ }
222+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{
223+ interrupts: interrupts,
224+ nonblocking: true,
225+ afterFrozen: func(attachment *attachedClient) {
226+ if t.daemon.afterAttachmentEffectGateFrozen != nil {
227+ t.daemon.afterAttachmentEffectGateFrozen("move-"+t.request.operation, attachment)
228+ }
229+ },
230+ }, t.sourceAttachments...)
231+ if !frozen.acquired || !frozen.drained {
232+ frozen.unfreeze()
233+ return errMovePaneInvalid
234+ }
235+ t.frozenEffects = frozen
236+ t.effectsFrozen = true
237+ return nil
238+}
239+
240+// publishLocked is the only shared architecture-lock window. Topology
241+// Implementations perform all fallible private validation before their first
242+// write. Common retirement and metadata capture are non-failing in-memory work.
243+func (t *moveTransaction) publishLocked() error {
244+ d := t.daemon
245+ d.mu.Lock()
246+ defer d.mu.Unlock()
247+ if d.closing || d.sessions[t.source.id] != t.source || d.sessions[t.destination.id] != t.destination {
248+ return errMoveStaleTarget
249+ }
250+ d.notices.routingMu.Lock()
251+ defer d.notices.routingMu.Unlock()
252+ unlockSessions := lockAttachmentSessions(t.source, t.destination)
253+ defer unlockSessions()
254+ if !moveSessionLocatorCurrentLocked(t.source, t.request.source) ||
255+ !moveSessionLocatorCurrentLocked(t.destination, t.request.destination) ||
256+ !t.attachmentAuthorityCurrentLocked() {
257+ return errMoveStaleTarget
258+ }
259+ if t.topology.willRetireSource() && !sameMoveAttachmentsLocked(t.source, t.sourceAttachments) {
260+ return errMoveStaleTarget
261+ }
262+
263+ publication, err := t.topology.publishLocked(d, t.source, t.destination)
264+ if err != nil {
265+ return err
266+ }
267+ t.publication = publication
268+ if publication.sourceEmpty {
269+ d.unregisterSessionLocked(t.source)
270+ t.retiredAttachments = detachMoveAttachmentsLocked(t.source, t.sourceTransports)
271+ t.source.tabs = nil
272+ d.purgeParkingForSessionLocked(t.source)
273+ t.retiredParked = d.purgeParkedForSessionLocked(t.source)
274+ }
275+ t.sourceName = t.source.name
276+ t.destinationName = t.destination.name
277+ if !t.source.ephemeral {
278+ t.sourceMetadata = t.source.persistRecordLocked(max(d.nowUnixNano(), t.source.createdAt, int64(1))).MetadataUpdate()
279+ t.sourceMetadataOK = true
280+ }
281+ if !t.destination.ephemeral && t.destination != t.source {
282+ t.destMetadata = t.destination.persistRecordLocked(max(d.nowUnixNano(), t.destination.createdAt, int64(1))).MetadataUpdate()
283+ t.destMetadataOK = true
284+ }
285+ return nil
286+}
287+
288+func (t *moveTransaction) postcommitPlan(unlockDispatch func(), reservation *moveLifecycleReservation) movePostcommitPlan {
289+ publication := t.publication
290+ return movePostcommitPlan{
291+ source: t.source,
292+ destination: t.destination,
293+ sourceName: t.sourceName,
294+ destinationName: t.destinationName,
295+ sourceTab: publication.sourceTab,
296+ destinationTab: publication.destinationTab,
297+ movedPane: publication.movedPane,
298+ movedPanes: publication.movedPanes,
299+ operation: t.request.operation,
300+ sourceAttachments: t.sourceAttachments,
301+ syncCleanup: publication.syncCleanup,
302+ frozenEffects: t.frozenEffects,
303+ effectsFrozen: t.effectsFrozen,
304+ unlockDispatch: unlockDispatch,
305+ reservation: reservation,
306+ oldTabCancel: publication.oldTabCancel,
307+ sourceTabRemoved: publication.sourceTabRemoved,
308+ sourceEmpty: publication.sourceEmpty,
309+ retiredParked: t.retiredParked,
310+ retiredAttachments: t.retiredAttachments,
311+ sourceMetadata: t.sourceMetadata,
312+ sourceMetadataValid: t.sourceMetadataOK,
313+ destinationMetadata: t.destMetadata,
314+ destinationMetadataValid: t.destMetadataOK,
315+ }
316+}
A internal/usecase/daemon/move_transaction_test.go +112 -0
@@ -0,0 +1,112 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+func TestMoveTransactionRejectsStalePublicationWithoutLeakingAuthority(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ installRace func(*Daemon, *tab)
16+ move func(*Daemon, *session, *tab, *pane, *session, *tab) error
17+ }{
18+ {
19+ name: "pane",
20+ installRace: func(d *Daemon, movedTab *tab) {
21+ d.afterMovePaneSourceSnapshot = func() {
22+ movedTab.mu.Lock()
23+ movedTab.layoutGeneration++
24+ movedTab.mu.Unlock()
25+ }
26+ },
27+ move: func(d *Daemon, source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) error {
28+ return d.movePane(movePaneRequest{
29+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
30+ SourceTabID: domain.TabStableID(sourceTab.stableID),
31+ SourcePaneID: domain.PaneStableID(moved.stableID),
32+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
33+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
34+ })
35+ },
36+ },
37+ {
38+ name: "tab",
39+ installRace: func(d *Daemon, movedTab *tab) {
40+ d.afterMoveTabSourceSnapshot = func() {
41+ movedTab.mu.Lock()
42+ movedTab.layoutGeneration++
43+ movedTab.mu.Unlock()
44+ }
45+ },
46+ move: func(d *Daemon, source *session, sourceTab *tab, _ *pane, destination *session, _ *tab) error {
47+ return d.moveTab(moveTabRequest{
48+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
49+ SourceTabID: domain.TabStableID(sourceTab.stableID),
50+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
51+ })
52+ },
53+ },
54+ }
55+
56+ for _, tt := range tests {
57+ t.Run(tt.name, func(t *testing.T) {
58+ d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
59+ defer releasePTYs()
60+
61+ source.mu.Lock()
62+ beforeSourceTabs := append([]*tab(nil), source.tabs...)
63+ sourceAttachments := source.snapshotAttachmentsLocked()
64+ source.mu.Unlock()
65+ destination.mu.Lock()
66+ beforeDestinationTabs := append([]*tab(nil), destination.tabs...)
67+ destination.mu.Unlock()
68+ beforeOwner := moved.ownerSnapshot()
69+
70+ tt.installRace(d, sourceTab)
71+ err := tt.move(d, source, sourceTab, moved, destination, destinationTab)
72+ require.ErrorIs(t, err, errMoveStaleTarget)
73+
74+ var rejection *moveRejection
75+ require.ErrorAs(t, err, &rejection)
76+ require.Equal(t, moveRejectionStaleTarget, rejection.Reason)
77+
78+ source.mu.Lock()
79+ require.Equal(t, beforeSourceTabs, source.tabs)
80+ require.True(t, sameMoveAttachmentsLocked(source, sourceAttachments))
81+ source.mu.Unlock()
82+ destination.mu.Lock()
83+ require.Equal(t, beforeDestinationTabs, destination.tabs)
84+ destination.mu.Unlock()
85+ require.Same(t, beforeOwner, moved.ownerSnapshot())
86+
87+ d.moveLifecycleMu.Lock()
88+ require.Zero(t, d.moveLifecycleActive)
89+ d.moveLifecycleMu.Unlock()
90+ for _, sess := range []*session{source, destination} {
91+ sess.teardownMu.Lock()
92+ require.Zero(t, sess.moveReservations)
93+ sess.teardownMu.Unlock()
94+ }
95+ for name, lock := range map[string]*sync.Mutex{
96+ "source dispatch": &source.dispatchMu,
97+ "destination dispatch": &destination.dispatchMu,
98+ "source layout": &source.layoutApplyMu,
99+ "destination layout": &destination.layoutApplyMu,
100+ "moved tab layout": &sourceTab.layoutApplyMu,
101+ "destination tab layout": &destinationTab.layoutApplyMu,
102+ "moved pane resize": &moved.resizeMu,
103+ } {
104+ require.True(t, lock.TryLock(), "%s authority leaked after rejection", name)
105+ lock.Unlock()
106+ }
107+ for _, attachment := range sourceAttachments {
108+ requireAttachmentEffectGateRetired(t, attachment)
109+ }
110+ })
111+ }
112+}
@@ -0,0 +1,112 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+)
11+
12+func TestMoveTransactionRejectsStalePublicationWithoutLeakingAuthority(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ installRace func(*Daemon, *tab)
16+ move func(*Daemon, *session, *tab, *pane, *session, *tab) error
17+ }{
18+ {
19+ name: "pane",
20+ installRace: func(d *Daemon, movedTab *tab) {
21+ d.afterMovePaneSourceSnapshot = func() {
22+ movedTab.mu.Lock()
23+ movedTab.layoutGeneration++
24+ movedTab.mu.Unlock()
25+ }
26+ },
27+ move: func(d *Daemon, source *session, sourceTab *tab, moved *pane, destination *session, destinationTab *tab) error {
28+ return d.movePane(movePaneRequest{
29+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
30+ SourceTabID: domain.TabStableID(sourceTab.stableID),
31+ SourcePaneID: domain.PaneStableID(moved.stableID),
32+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
33+ DestinationTabID: domain.TabStableID(destinationTab.stableID),
34+ })
35+ },
36+ },
37+ {
38+ name: "tab",
39+ installRace: func(d *Daemon, movedTab *tab) {
40+ d.afterMoveTabSourceSnapshot = func() {
41+ movedTab.mu.Lock()
42+ movedTab.layoutGeneration++
43+ movedTab.mu.Unlock()
44+ }
45+ },
46+ move: func(d *Daemon, source *session, sourceTab *tab, _ *pane, destination *session, _ *tab) error {
47+ return d.moveTab(moveTabRequest{
48+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
49+ SourceTabID: domain.TabStableID(sourceTab.stableID),
50+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
51+ })
52+ },
53+ },
54+ }
55+
56+ for _, tt := range tests {
57+ t.Run(tt.name, func(t *testing.T) {
58+ d, source, sourceTab, moved, destination, destinationTab, releasePTYs := newMoveGateRaceFixture(t)
59+ defer releasePTYs()
60+
61+ source.mu.Lock()
62+ beforeSourceTabs := append([]*tab(nil), source.tabs...)
63+ sourceAttachments := source.snapshotAttachmentsLocked()
64+ source.mu.Unlock()
65+ destination.mu.Lock()
66+ beforeDestinationTabs := append([]*tab(nil), destination.tabs...)
67+ destination.mu.Unlock()
68+ beforeOwner := moved.ownerSnapshot()
69+
70+ tt.installRace(d, sourceTab)
71+ err := tt.move(d, source, sourceTab, moved, destination, destinationTab)
72+ require.ErrorIs(t, err, errMoveStaleTarget)
73+
74+ var rejection *moveRejection
75+ require.ErrorAs(t, err, &rejection)
76+ require.Equal(t, moveRejectionStaleTarget, rejection.Reason)
77+
78+ source.mu.Lock()
79+ require.Equal(t, beforeSourceTabs, source.tabs)
80+ require.True(t, sameMoveAttachmentsLocked(source, sourceAttachments))
81+ source.mu.Unlock()
82+ destination.mu.Lock()
83+ require.Equal(t, beforeDestinationTabs, destination.tabs)
84+ destination.mu.Unlock()
85+ require.Same(t, beforeOwner, moved.ownerSnapshot())
86+
87+ d.moveLifecycleMu.Lock()
88+ require.Zero(t, d.moveLifecycleActive)
89+ d.moveLifecycleMu.Unlock()
90+ for _, sess := range []*session{source, destination} {
91+ sess.teardownMu.Lock()
92+ require.Zero(t, sess.moveReservations)
93+ sess.teardownMu.Unlock()
94+ }
95+ for name, lock := range map[string]*sync.Mutex{
96+ "source dispatch": &source.dispatchMu,
97+ "destination dispatch": &destination.dispatchMu,
98+ "source layout": &source.layoutApplyMu,
99+ "destination layout": &destination.layoutApplyMu,
100+ "moved tab layout": &sourceTab.layoutApplyMu,
101+ "destination tab layout": &destinationTab.layoutApplyMu,
102+ "moved pane resize": &moved.resizeMu,
103+ } {
104+ require.True(t, lock.TryLock(), "%s authority leaked after rejection", name)
105+ lock.Unlock()
106+ }
107+ for _, attachment := range sourceAttachments {
108+ requireAttachmentEffectGateRetired(t, attachment)
109+ }
110+ })
111+ }
112+}

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