detached 2a4b043

feat(daemon): compose remote view content locally

commit
2a4b043afa0cc7ae7ee9bbba448a774b077ab62e
author
brice · · 2026-08-06 17:37 UTC
parent
7d74423
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +482 -17
M internal/usecase/daemon/attachment_owner.go +13 -6
@@ -5,6 +5,8 @@ import (
55 "sync"
66
77 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/vt"
810 )
911
1012 // attachmentOwner is the sole mutable owner binding for an attached client.
@@ -44,16 +46,21 @@ func remoteViewKeyForTarget(target domain.RemoteSessionTarget) (remoteViewKey, e
4446 }, nil
4547 }
4648
47-// remoteView is intentionally attachment-facing only. Link state, remote VT
48-// content, metadata, and warm lifecycle are added in Phase 4; keeping this
49-// type free of local session state prevents accidental PTY/persistence reuse.
49+// remoteView is intentionally attachment-facing only. Its private VT and
50+// presentation metadata are the remote content boundary: neither carries a
51+// local PTY, persistence, tab tree, or render coordinator. The remote-link
52+// lifecycle owns writes to them in Phase 4; local composition only snapshots
53+// them while holding this mutex.
5054 type remoteView struct {
5155 id remoteViewID
5256 key remoteViewKey
5357
54- mu sync.Mutex
55- closed bool
56- attachments map[*attachedClient]struct{}
58+ mu sync.Mutex
59+ closed bool
60+ attachments map[*attachedClient]struct{}
61+ screen *vt.Screen
62+ metadata ports.SessionMeta
63+ displayOrigin string
5764 }
5865
5966 func (*remoteView) attachmentOwner() {}
@@ -5,6 +5,8 @@ import (
5 "sync"5 "sync"
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/vt"
8 )10 )
9 11
10 // attachmentOwner is the sole mutable owner binding for an attached client.12 // attachmentOwner is the sole mutable owner binding for an attached client.
@@ -44,16 +46,21 @@ func remoteViewKeyForTarget(target domain.RemoteSessionTarget) (remoteViewKey, e
44 }, nil46 }, nil
45 }47 }
46 48
47-// remoteView is intentionally attachment-facing only. Link state, remote VT49+// remoteView is intentionally attachment-facing only. Its private VT and
48-// content, metadata, and warm lifecycle are added in Phase 4; keeping this50+// presentation metadata are the remote content boundary: neither carries a
49-// type free of local session state prevents accidental PTY/persistence reuse.51+// local PTY, persistence, tab tree, or render coordinator. The remote-link
52+// lifecycle owns writes to them in Phase 4; local composition only snapshots
53+// them while holding this mutex.
50 type remoteView struct {54 type remoteView struct {
51 id remoteViewID55 id remoteViewID
52 key remoteViewKey56 key remoteViewKey
53 57
54- mu sync.Mutex58+ mu sync.Mutex
55- closed bool59+ closed bool
56- attachments map[*attachedClient]struct{}60+ attachments map[*attachedClient]struct{}
61+ screen *vt.Screen
62+ metadata ports.SessionMeta
63+ displayOrigin string
57 }64 }
58 65
59 func (*remoteView) attachmentOwner() {}66 func (*remoteView) attachmentOwner() {}
M internal/usecase/daemon/attachment_owner_test.go +24 -0
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/vev/internal/domain"
77 "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/pkg/vt"
89 "github.com/stretchr/testify/require"
910 )
1011
@@ -88,6 +89,29 @@ func TestTransitionToRemoteViewPublishesOneAuthoritativeOwner(t *testing.T) {
8889 require.Same(t, source, ac.previousOwner.Get())
8990 }
9091
92+func TestRemoteViewFirstPaintComposesPrivateContentUnderLocalChrome(t *testing.T) {
93+ d, source, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
94+ view := &remoteView{
95+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
96+ screen: vt.NewScreen(80, 22),
97+ }
98+ view.screen.Write([]byte("remote content"))
99+ d.mu.Lock()
100+ require.NoError(t, d.registerRemoteViewLocked(view))
101+ d.mu.Unlock()
102+
103+ token, err := d.transitionToRemoteView(source.attachmentToken(ac, ac.transport()), view)
104+ require.NoError(t, err)
105+ require.True(t, d.firstPaintForTransition(token))
106+
107+ output := mustApplyOutput(t, vt.NewScreen(80, 24), awaitOutputFrameWithoutSleep(t, sends))
108+ require.True(t, output.Full)
109+ terminal := vt.NewScreen(80, 24)
110+ terminal.Write(output.Data)
111+ require.Contains(t, rowText(terminal.Snapshot().Row(0)), "remote")
112+ require.Contains(t, rowText(terminal.Snapshot().Row(1)), "remote content")
113+}
114+
91115 func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) {
92116 d := newTestDaemon(t, nil, stubClock{})
93117 view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/pkg/vt"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
9 )10 )
10 11
@@ -88,6 +89,29 @@ func TestTransitionToRemoteViewPublishesOneAuthoritativeOwner(t *testing.T) {
88 require.Same(t, source, ac.previousOwner.Get())89 require.Same(t, source, ac.previousOwner.Get())
89 }90 }
90 91
92+func TestRemoteViewFirstPaintComposesPrivateContentUnderLocalChrome(t *testing.T) {
93+ d, source, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
94+ view := &remoteView{
95+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
96+ screen: vt.NewScreen(80, 22),
97+ }
98+ view.screen.Write([]byte("remote content"))
99+ d.mu.Lock()
100+ require.NoError(t, d.registerRemoteViewLocked(view))
101+ d.mu.Unlock()
102+
103+ token, err := d.transitionToRemoteView(source.attachmentToken(ac, ac.transport()), view)
104+ require.NoError(t, err)
105+ require.True(t, d.firstPaintForTransition(token))
106+
107+ output := mustApplyOutput(t, vt.NewScreen(80, 24), awaitOutputFrameWithoutSleep(t, sends))
108+ require.True(t, output.Full)
109+ terminal := vt.NewScreen(80, 24)
110+ terminal.Write(output.Data)
111+ require.Contains(t, rowText(terminal.Snapshot().Row(0)), "remote")
112+ require.Contains(t, rowText(terminal.Snapshot().Row(1)), "remote content")
113+}
114+
91 func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) {115 func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) {
92 d := newTestDaemon(t, nil, stubClock{})116 d := newTestDaemon(t, nil, stubClock{})
93 view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}117 view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
M internal/usecase/daemon/client.go +3 -0
@@ -788,6 +788,9 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
788788 token.ac.captureFrames = nil
789789 token.ac.sendMu.Unlock()
790790 }
791+ if view, remote := token.owner.(*remoteView); remote {
792+ return d.paintRemoteView(view, token.ac, true, token) == paintEmitted
793+ }
791794 sess := token.localSession()
792795 if sess == nil {
793796 return false
@@ -788,6 +788,9 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
788 token.ac.captureFrames = nil788 token.ac.captureFrames = nil
789 token.ac.sendMu.Unlock()789 token.ac.sendMu.Unlock()
790 }790 }
791+ if view, remote := token.owner.(*remoteView); remote {
792+ return d.paintRemoteView(view, token.ac, true, token) == paintEmitted
793+ }
791 sess := token.localSession()794 sess := token.localSession()
792 if sess == nil {795 if sess == nil {
793 return false796 return false
M internal/usecase/daemon/remote_attachment_transition.go +93 -0
@@ -71,3 +71,96 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target
7171 token.ac.recordPreviousOwner(source)
7272 return published, nil
7373 }
74+
75+// transitionFromRemoteView moves an attachment from one exact daemon-owned
76+// remote view back to its previous local session. The remote view has no
77+// coordinator lease, so only the target session coordinator is attached during
78+// this frozen publication.
79+func (d *Daemon) transitionFromRemoteView(token attachmentConnectionToken, source *remoteView, target *session) (attachmentConnectionToken, error) {
80+ if d == nil || source == nil || target == nil || token.ac == nil || token.transport.transport == nil {
81+ return attachmentConnectionToken{}, errAttachmentTransition
82+ }
83+ if token.effect != nil {
84+ d.endActionAttachmentEffect(token.effect, "remote-view-back")
85+ }
86+ frozen := freezeAttachmentEffectGates(token.ac)
87+ defer frozen.unfreeze()
88+ if !frozen.acquired || !frozen.drained {
89+ return attachmentConnectionToken{}, errAttachmentTransition
90+ }
91+
92+ targetCore := target.core()
93+ if targetCore == nil {
94+ return attachmentConnectionToken{}, errAttachmentTransition
95+ }
96+ d.mu.Lock()
97+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(source) || d.sessions[targetCore.id] != target {
98+ d.mu.Unlock()
99+ return attachmentConnectionToken{}, errAttachmentTransition
100+ }
101+ d.notices.routingMu.Lock()
102+ target.mu.Lock()
103+ targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(target)
104+ if targetCoordinator == nil {
105+ target.mu.Unlock()
106+ d.notices.routingMu.Unlock()
107+ d.mu.Unlock()
108+ return attachmentConnectionToken{}, errAttachmentTransition
109+ }
110+ targetCoordinator.mu.Lock()
111+ // remoteView.mu is last: remote owners must never acquire daemon, session,
112+ // or coordinator locks while they are held.
113+ source.mu.Lock()
114+ unlock := func() {
115+ source.mu.Unlock()
116+ targetCoordinator.mu.Unlock()
117+ target.mu.Unlock()
118+ d.notices.routingMu.Unlock()
119+ d.mu.Unlock()
120+ }
121+ isCurrent := func() bool {
122+ _, sourceRegistered := source.attachments[token.ac]
123+ return sameAttachmentOwner(token.owner, source) &&
124+ token.generation == token.ac.connectionGeneration.Load() &&
125+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) &&
126+ token.ac.transportSnapshotCurrent(token.transport) &&
127+ sourceRegistered && !source.closed &&
128+ sameAttachmentOwner(token.ac.previousOwner.Get(), target) &&
129+ !attachmentRegisteredLocked(target, token.ac)
130+ }
131+ if !isCurrent() || targetCoordinator.torndown {
132+ unlock()
133+ return attachmentConnectionToken{}, errAttachmentTransition
134+ }
135+ // Attach session membership before the coordinator lease. The attachment
136+ // gate is frozen and the local owner remains remote until the final owner
137+ // publication, so this temporary overlap is unobservable to effects.
138+ if !registerAttachmentSessionLocked(target, token.ac) {
139+ unlock()
140+ return attachmentConnectionToken{}, errAttachmentTransition
141+ }
142+ lease := targetCoordinator.attachWithReadinessLocked(token.ac, true)
143+ if lease == nil {
144+ unregisterAttachmentSessionLocked(target, token.ac)
145+ unlock()
146+ return attachmentConnectionToken{}, errAttachmentTransition
147+ }
148+ source.unregisterAttachmentLocked(token.ac)
149+ generation := token.ac.connectionGeneration.Add(1)
150+ token.ac.setAttachmentOwner(target)
151+ published := attachmentConnectionToken{
152+ owner: target,
153+ ac: token.ac,
154+ generation: generation,
155+ transport: token.transport,
156+ lease: lease,
157+ rebase: true,
158+ }
159+ token.ac.publishFrozenAttachmentCapability(published)
160+ unlock()
161+
162+ // Record the reverse toggle while the attachment gate is still frozen, so a
163+ // concurrent handoff cannot observe the new local owner without its history.
164+ token.ac.recordPreviousOwner(source)
165+ return published, nil
166+}
@@ -71,3 +71,96 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target
71 token.ac.recordPreviousOwner(source)71 token.ac.recordPreviousOwner(source)
72 return published, nil72 return published, nil
73 }73 }
74+
75+// transitionFromRemoteView moves an attachment from one exact daemon-owned
76+// remote view back to its previous local session. The remote view has no
77+// coordinator lease, so only the target session coordinator is attached during
78+// this frozen publication.
79+func (d *Daemon) transitionFromRemoteView(token attachmentConnectionToken, source *remoteView, target *session) (attachmentConnectionToken, error) {
80+ if d == nil || source == nil || target == nil || token.ac == nil || token.transport.transport == nil {
81+ return attachmentConnectionToken{}, errAttachmentTransition
82+ }
83+ if token.effect != nil {
84+ d.endActionAttachmentEffect(token.effect, "remote-view-back")
85+ }
86+ frozen := freezeAttachmentEffectGates(token.ac)
87+ defer frozen.unfreeze()
88+ if !frozen.acquired || !frozen.drained {
89+ return attachmentConnectionToken{}, errAttachmentTransition
90+ }
91+
92+ targetCore := target.core()
93+ if targetCore == nil {
94+ return attachmentConnectionToken{}, errAttachmentTransition
95+ }
96+ d.mu.Lock()
97+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(source) || d.sessions[targetCore.id] != target {
98+ d.mu.Unlock()
99+ return attachmentConnectionToken{}, errAttachmentTransition
100+ }
101+ d.notices.routingMu.Lock()
102+ target.mu.Lock()
103+ targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(target)
104+ if targetCoordinator == nil {
105+ target.mu.Unlock()
106+ d.notices.routingMu.Unlock()
107+ d.mu.Unlock()
108+ return attachmentConnectionToken{}, errAttachmentTransition
109+ }
110+ targetCoordinator.mu.Lock()
111+ // remoteView.mu is last: remote owners must never acquire daemon, session,
112+ // or coordinator locks while they are held.
113+ source.mu.Lock()
114+ unlock := func() {
115+ source.mu.Unlock()
116+ targetCoordinator.mu.Unlock()
117+ target.mu.Unlock()
118+ d.notices.routingMu.Unlock()
119+ d.mu.Unlock()
120+ }
121+ isCurrent := func() bool {
122+ _, sourceRegistered := source.attachments[token.ac]
123+ return sameAttachmentOwner(token.owner, source) &&
124+ token.generation == token.ac.connectionGeneration.Load() &&
125+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) &&
126+ token.ac.transportSnapshotCurrent(token.transport) &&
127+ sourceRegistered && !source.closed &&
128+ sameAttachmentOwner(token.ac.previousOwner.Get(), target) &&
129+ !attachmentRegisteredLocked(target, token.ac)
130+ }
131+ if !isCurrent() || targetCoordinator.torndown {
132+ unlock()
133+ return attachmentConnectionToken{}, errAttachmentTransition
134+ }
135+ // Attach session membership before the coordinator lease. The attachment
136+ // gate is frozen and the local owner remains remote until the final owner
137+ // publication, so this temporary overlap is unobservable to effects.
138+ if !registerAttachmentSessionLocked(target, token.ac) {
139+ unlock()
140+ return attachmentConnectionToken{}, errAttachmentTransition
141+ }
142+ lease := targetCoordinator.attachWithReadinessLocked(token.ac, true)
143+ if lease == nil {
144+ unregisterAttachmentSessionLocked(target, token.ac)
145+ unlock()
146+ return attachmentConnectionToken{}, errAttachmentTransition
147+ }
148+ source.unregisterAttachmentLocked(token.ac)
149+ generation := token.ac.connectionGeneration.Add(1)
150+ token.ac.setAttachmentOwner(target)
151+ published := attachmentConnectionToken{
152+ owner: target,
153+ ac: token.ac,
154+ generation: generation,
155+ transport: token.transport,
156+ lease: lease,
157+ rebase: true,
158+ }
159+ token.ac.publishFrozenAttachmentCapability(published)
160+ unlock()
161+
162+ // Record the reverse toggle while the attachment gate is still frozen, so a
163+ // concurrent handoff cannot observe the new local owner without its history.
164+ token.ac.recordPreviousOwner(source)
165+ return published, nil
166+}
A internal/usecase/daemon/remote_attachment_transition_back_test.go +95 -0
@@ -0,0 +1,95 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func remoteBackFixture(t *testing.T) (*Daemon, *session, *attachedClient, *remoteView, attachmentConnectionToken) {
11+ t.Helper()
12+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
13+ view := &remoteView{key: remoteViewKey{
14+ endpoint: "host",
15+ lifecycleID: domain.SessionLifecycleID{1},
16+ sessionName: "remote",
17+ }}
18+ d.mu.Lock()
19+ require.NoError(t, d.registerRemoteViewLocked(view))
20+ d.mu.Unlock()
21+
22+ token, err := d.transitionToRemoteView(local.attachmentToken(ac, ac.transport()), view)
23+ require.NoError(t, err)
24+ return d, local, ac, view, token
25+}
26+
27+func TestBackSessionForAttachmentReturnsFromRemoteViewToPreviousLocal(t *testing.T) {
28+ d, local, ac, view, token := remoteBackFixture(t)
29+
30+ require.NoError(t, d.backSessionForAttachment(token))
31+
32+ require.Same(t, local, ac.currentAttachmentSession())
33+ require.True(t, local.attachmentRegistered(ac))
34+ require.False(t, view.attachmentRegistered(ac))
35+ require.Same(t, view, ac.previousOwner.Get(), "reverse navigation records the remote owner for toggling back")
36+
37+ current := local.attachmentToken(ac, ac.transport())
38+ require.True(t, current.current())
39+ require.NotNil(t, current.lease, "returning to a local owner restores its coordinator lease")
40+}
41+
42+func TestBackSessionForAttachmentFailsClosedForStaleOrNonLocalPredecessor(t *testing.T) {
43+ tests := []struct {
44+ name string
45+ prep func(*Daemon, *session, *attachedClient, *remoteView)
46+ check func(*testing.T, *session, *attachedClient, *remoteView)
47+ }{
48+ {
49+ name: "stale local predecessor",
50+ prep: func(d *Daemon, local *session, _ *attachedClient, _ *remoteView) {
51+ replacement := &session{sessionCore: sessionCore{id: local.id, name: local.name}}
52+ d.mu.Lock()
53+ d.sessions[local.id] = replacement
54+ d.mu.Unlock()
55+ },
56+ check: func(t *testing.T, _ *session, ac *attachedClient, view *remoteView) {
57+ require.Same(t, view, ac.currentAttachmentOwner())
58+ require.True(t, view.attachmentRegistered(ac))
59+ require.Nil(t, ac.previousOwner.Get(), "stale local history is discarded without changing ownership")
60+ },
61+ },
62+ {
63+ name: "non-local predecessor",
64+ prep: func(_ *Daemon, _ *session, ac *attachedClient, _ *remoteView) {
65+ ac.previousOwner.Set(&remoteView{id: 99})
66+ },
67+ check: func(t *testing.T, _ *session, ac *attachedClient, view *remoteView) {
68+ other := ac.previousOwner.Get()
69+ require.Same(t, view, ac.currentAttachmentOwner())
70+ require.True(t, view.attachmentRegistered(ac))
71+ require.IsType(t, &remoteView{}, other)
72+ },
73+ },
74+ }
75+
76+ for _, tt := range tests {
77+ t.Run(tt.name, func(t *testing.T) {
78+ d, local, ac, view, token := remoteBackFixture(t)
79+ tt.prep(d, local, ac, view)
80+
81+ require.NoError(t, d.backSessionForAttachment(token))
82+ tt.check(t, local, ac, view)
83+ })
84+ }
85+}
86+
87+func TestBackSessionForAttachmentRejectsReboundRemoteToken(t *testing.T) {
88+ d, local, ac, view, token := remoteBackFixture(t)
89+ ac.replaceTransport(&closeTrackingTransport{})
90+
91+ require.NoError(t, d.backSessionForAttachment(token))
92+ require.Same(t, view, ac.currentAttachmentOwner())
93+ require.True(t, view.attachmentRegistered(ac))
94+ require.Same(t, local, ac.previousOwner.Get(), "a stale token must not consume valid history")
95+}
@@ -0,0 +1,95 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func remoteBackFixture(t *testing.T) (*Daemon, *session, *attachedClient, *remoteView, attachmentConnectionToken) {
11+ t.Helper()
12+ d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
13+ view := &remoteView{key: remoteViewKey{
14+ endpoint: "host",
15+ lifecycleID: domain.SessionLifecycleID{1},
16+ sessionName: "remote",
17+ }}
18+ d.mu.Lock()
19+ require.NoError(t, d.registerRemoteViewLocked(view))
20+ d.mu.Unlock()
21+
22+ token, err := d.transitionToRemoteView(local.attachmentToken(ac, ac.transport()), view)
23+ require.NoError(t, err)
24+ return d, local, ac, view, token
25+}
26+
27+func TestBackSessionForAttachmentReturnsFromRemoteViewToPreviousLocal(t *testing.T) {
28+ d, local, ac, view, token := remoteBackFixture(t)
29+
30+ require.NoError(t, d.backSessionForAttachment(token))
31+
32+ require.Same(t, local, ac.currentAttachmentSession())
33+ require.True(t, local.attachmentRegistered(ac))
34+ require.False(t, view.attachmentRegistered(ac))
35+ require.Same(t, view, ac.previousOwner.Get(), "reverse navigation records the remote owner for toggling back")
36+
37+ current := local.attachmentToken(ac, ac.transport())
38+ require.True(t, current.current())
39+ require.NotNil(t, current.lease, "returning to a local owner restores its coordinator lease")
40+}
41+
42+func TestBackSessionForAttachmentFailsClosedForStaleOrNonLocalPredecessor(t *testing.T) {
43+ tests := []struct {
44+ name string
45+ prep func(*Daemon, *session, *attachedClient, *remoteView)
46+ check func(*testing.T, *session, *attachedClient, *remoteView)
47+ }{
48+ {
49+ name: "stale local predecessor",
50+ prep: func(d *Daemon, local *session, _ *attachedClient, _ *remoteView) {
51+ replacement := &session{sessionCore: sessionCore{id: local.id, name: local.name}}
52+ d.mu.Lock()
53+ d.sessions[local.id] = replacement
54+ d.mu.Unlock()
55+ },
56+ check: func(t *testing.T, _ *session, ac *attachedClient, view *remoteView) {
57+ require.Same(t, view, ac.currentAttachmentOwner())
58+ require.True(t, view.attachmentRegistered(ac))
59+ require.Nil(t, ac.previousOwner.Get(), "stale local history is discarded without changing ownership")
60+ },
61+ },
62+ {
63+ name: "non-local predecessor",
64+ prep: func(_ *Daemon, _ *session, ac *attachedClient, _ *remoteView) {
65+ ac.previousOwner.Set(&remoteView{id: 99})
66+ },
67+ check: func(t *testing.T, _ *session, ac *attachedClient, view *remoteView) {
68+ other := ac.previousOwner.Get()
69+ require.Same(t, view, ac.currentAttachmentOwner())
70+ require.True(t, view.attachmentRegistered(ac))
71+ require.IsType(t, &remoteView{}, other)
72+ },
73+ },
74+ }
75+
76+ for _, tt := range tests {
77+ t.Run(tt.name, func(t *testing.T) {
78+ d, local, ac, view, token := remoteBackFixture(t)
79+ tt.prep(d, local, ac, view)
80+
81+ require.NoError(t, d.backSessionForAttachment(token))
82+ tt.check(t, local, ac, view)
83+ })
84+ }
85+}
86+
87+func TestBackSessionForAttachmentRejectsReboundRemoteToken(t *testing.T) {
88+ d, local, ac, view, token := remoteBackFixture(t)
89+ ac.replaceTransport(&closeTrackingTransport{})
90+
91+ require.NoError(t, d.backSessionForAttachment(token))
92+ require.Same(t, view, ac.currentAttachmentOwner())
93+ require.True(t, view.attachmentRegistered(ac))
94+ require.Same(t, local, ac.previousOwner.Get(), "a stale token must not consume valid history")
95+}
A internal/usecase/daemon/remote_render.go +198 -0
@@ -0,0 +1,198 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+ "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/bnema/vev/pkg/vt"
9+)
10+
11+const remoteContentPaneID = "remote-content"
12+
13+type remoteViewRenderSnapshot struct {
14+ frame renderer.Frame
15+ cursor vt.CursorSnapshot
16+ title string
17+ tabs []ports.SessionTabMeta
18+ activeID domain.TabStableID
19+ origin string
20+}
21+
22+// renderSnapshot is the remote-content ownership boundary. It copies every
23+// mutable VT and metadata value while view.mu is held, then releases that lock
24+// before any attachment, overlay, renderer, or transport work begins.
25+func (v *remoteView) renderSnapshot(size domain.Size) (remoteViewRenderSnapshot, bool) {
26+ if v == nil || !size.Valid() {
27+ return remoteViewRenderSnapshot{}, false
28+ }
29+ v.mu.Lock()
30+ defer v.mu.Unlock()
31+ if v.closed {
32+ return remoteViewRenderSnapshot{}, false
33+ }
34+ if v.screen == nil {
35+ v.screen = vt.NewScreen(size.Cols, size.Rows)
36+ } else if v.screen.Frame.Width != size.Cols || v.screen.Frame.Height != size.Rows {
37+ v.screen.Resize(size.Cols, size.Rows)
38+ }
39+ screen := v.screen.Snapshot()
40+ frame := renderer.NewFrame(screen.Columns(), screen.Rows())
41+ for y := range frame.Height {
42+ copy(frame.Row(y), screen.BorrowedRow(y))
43+ }
44+ return remoteViewRenderSnapshot{
45+ frame: frame,
46+ cursor: screen.Cursor(),
47+ title: screen.Title(),
48+ tabs: append([]ports.SessionTabMeta(nil), v.metadata.Tabs...),
49+ activeID: v.metadata.ActiveTabID,
50+ origin: v.displayOrigin,
51+ }, true
52+}
53+
54+func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statusSnapshot {
55+ name := ""
56+ if view != nil {
57+ name = view.key.sessionName
58+ if snap.origin == "" {
59+ snap.origin = view.key.endpoint
60+ }
61+ }
62+ if snap.origin != "" {
63+ name += " at " + snap.origin
64+ }
65+ status := statusSnapshot{session: name, tabs: make([]statusTab, 0, max(1, len(snap.tabs)))}
66+ for _, tab := range snap.tabs {
67+ status.tabs = append(status.tabs, statusTab{
68+ name: tab.Name,
69+ active: tab.ID == snap.activeID,
70+ attention: tab.Attention,
71+ })
72+ }
73+ if len(status.tabs) == 0 {
74+ tabName := "1"
75+ if view != nil && view.key.sessionName != "" {
76+ tabName = view.key.sessionName
77+ }
78+ status.tabs = append(status.tabs, statusTab{name: tabName, paneTitle: snap.title, active: true})
79+ }
80+ return status
81+}
82+
83+// captureRemoteViewRenderState builds one local chrome composition from an
84+// immutable private remote VT snapshot. The one synthetic pane is deliberately
85+// content-only: local pane titles, layout, and PTY state cannot leak into a
86+// remote view.
87+func (d *Daemon) captureRemoteViewRenderState(view *remoteView, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
88+ if view == nil || ac == nil {
89+ return nil, false
90+ }
91+ window := ac.sizeSnapshot()
92+ content := contentSize(window)
93+ snapshot, ok := view.renderSnapshot(content)
94+ if !ok || !attachmentOwnerRegistered(view, ac) || !sameAttachmentOwner(ac.currentAttachmentOwner(), view) {
95+ return nil, false
96+ }
97+ placement := domain.Rect{Width: content.Cols, Height: content.Rows}
98+ bars := request.bars
99+ bars.status = remoteStatusSnapshot(view, snapshot)
100+ if d != nil {
101+ bars.attentionFrame = d.attentionFrame()
102+ bars.mru = d.recentSessions(nil)
103+ }
104+ state := &capturedRenderState{
105+ attachment: ac,
106+ lease: request.lease,
107+ window: window,
108+ reset: request.reset,
109+ bars: bars,
110+ theme: bars.theme,
111+ styles: request.styles,
112+ styleGeneration: request.styleGeneration,
113+ overlays: request.overlays,
114+ preview: request.preview,
115+ layout: capturedTabLayout{area: placement, focus: remoteContentPaneID, valid: true},
116+ floatingGeneration: 0,
117+ panes: []capturedPaneRenderState{{
118+ id: remoteContentPaneID,
119+ frame: snapshot.frame,
120+ placement: layout.Placement{ID: remoteContentPaneID, Content: placement},
121+ focused: true,
122+ damage: []renderer.Damage{renderer.FullRedraw()},
123+ }},
124+ cursor: capturedCursorInputs{
125+ row: snapshot.cursor.Row,
126+ col: snapshot.cursor.Col,
127+ style: snapshot.cursor.Style,
128+ hasStyle: snapshot.cursor.StyleSet,
129+ visible: snapshot.cursor.Visible,
130+ renderable: placement.Width > 0 && placement.Height > 0,
131+ content: placement,
132+ },
133+ }
134+ return state, true
135+}
136+
137+// paintRemoteView composes daemon-owned bars and overlays over the private
138+// remote terminal image. It uses the same output-state stream as local
139+// rendering, but never consults local session, pane, or coordinator state.
140+func (d *Daemon) paintRemoteView(view *remoteView, ac *attachedClient, reset bool, token attachmentConnectionToken) paintResult {
141+ if view == nil || ac == nil || !sameAttachmentOwner(token.owner, view) {
142+ return paintRejected
143+ }
144+ ticket, admitted := ac.beginAttachmentEffect(token)
145+ if !admitted {
146+ return paintRejected
147+ }
148+ token.effect = ticket
149+ marks := d.newRuntimeMarkBatch()
150+ marks.attachmentEffect = ticket
151+ defer func() {
152+ ticket.End()
153+ marks.flush()
154+ }()
155+
156+ ac.sendMu.Lock()
157+ if !token.attachmentEffectCurrent() || ac.output == nil {
158+ ac.sendMu.Unlock()
159+ return paintRejected
160+ }
161+ ac.output.syncCapacityLocked()
162+ if ac.output.atCapacity() {
163+ ac.sendMu.Unlock()
164+ return paintBlockedCapacity
165+ }
166+ ac.initOverlays()
167+ overlays := ac.overlays.SnapshotForRender()
168+ defer overlays.Unlock()
169+ statusFeedback := overlays.statusFeedback
170+ if statusFeedback == "" && overlays.resizeActive {
171+ statusFeedback = "resize unavailable for remote view"
172+ }
173+ applied := ac.getAppliedTheme()
174+ bars := barState{statusFeedback: statusFeedback, theme: applied.Raw}
175+ capturedOverlays := capturedOverlayRenderState{
176+ copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
177+ pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
178+ resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
179+ }
180+ state, ok := d.captureRemoteViewRenderState(view, ac, renderCaptureRequest{
181+ bars: bars,
182+ overlays: capturedOverlays,
183+ preview: snapshotPickerPreview(nil),
184+ styles: applied.Resolved.Styles,
185+ styleGeneration: applied.Generation,
186+ reset: reset,
187+ })
188+ if !ok {
189+ ac.sendMu.Unlock()
190+ return paintRejected
191+ }
192+ captureOverlayLayers(state, overlays, d.currentPaletteConfig())
193+ composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
194+ if d.emitAttachmentFrame(view, ac, state, composed, &marks) {
195+ return paintEmitted
196+ }
197+ return paintRejected
198+}
@@ -0,0 +1,198 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+ "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/bnema/vev/pkg/vt"
9+)
10+
11+const remoteContentPaneID = "remote-content"
12+
13+type remoteViewRenderSnapshot struct {
14+ frame renderer.Frame
15+ cursor vt.CursorSnapshot
16+ title string
17+ tabs []ports.SessionTabMeta
18+ activeID domain.TabStableID
19+ origin string
20+}
21+
22+// renderSnapshot is the remote-content ownership boundary. It copies every
23+// mutable VT and metadata value while view.mu is held, then releases that lock
24+// before any attachment, overlay, renderer, or transport work begins.
25+func (v *remoteView) renderSnapshot(size domain.Size) (remoteViewRenderSnapshot, bool) {
26+ if v == nil || !size.Valid() {
27+ return remoteViewRenderSnapshot{}, false
28+ }
29+ v.mu.Lock()
30+ defer v.mu.Unlock()
31+ if v.closed {
32+ return remoteViewRenderSnapshot{}, false
33+ }
34+ if v.screen == nil {
35+ v.screen = vt.NewScreen(size.Cols, size.Rows)
36+ } else if v.screen.Frame.Width != size.Cols || v.screen.Frame.Height != size.Rows {
37+ v.screen.Resize(size.Cols, size.Rows)
38+ }
39+ screen := v.screen.Snapshot()
40+ frame := renderer.NewFrame(screen.Columns(), screen.Rows())
41+ for y := range frame.Height {
42+ copy(frame.Row(y), screen.BorrowedRow(y))
43+ }
44+ return remoteViewRenderSnapshot{
45+ frame: frame,
46+ cursor: screen.Cursor(),
47+ title: screen.Title(),
48+ tabs: append([]ports.SessionTabMeta(nil), v.metadata.Tabs...),
49+ activeID: v.metadata.ActiveTabID,
50+ origin: v.displayOrigin,
51+ }, true
52+}
53+
54+func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statusSnapshot {
55+ name := ""
56+ if view != nil {
57+ name = view.key.sessionName
58+ if snap.origin == "" {
59+ snap.origin = view.key.endpoint
60+ }
61+ }
62+ if snap.origin != "" {
63+ name += " at " + snap.origin
64+ }
65+ status := statusSnapshot{session: name, tabs: make([]statusTab, 0, max(1, len(snap.tabs)))}
66+ for _, tab := range snap.tabs {
67+ status.tabs = append(status.tabs, statusTab{
68+ name: tab.Name,
69+ active: tab.ID == snap.activeID,
70+ attention: tab.Attention,
71+ })
72+ }
73+ if len(status.tabs) == 0 {
74+ tabName := "1"
75+ if view != nil && view.key.sessionName != "" {
76+ tabName = view.key.sessionName
77+ }
78+ status.tabs = append(status.tabs, statusTab{name: tabName, paneTitle: snap.title, active: true})
79+ }
80+ return status
81+}
82+
83+// captureRemoteViewRenderState builds one local chrome composition from an
84+// immutable private remote VT snapshot. The one synthetic pane is deliberately
85+// content-only: local pane titles, layout, and PTY state cannot leak into a
86+// remote view.
87+func (d *Daemon) captureRemoteViewRenderState(view *remoteView, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
88+ if view == nil || ac == nil {
89+ return nil, false
90+ }
91+ window := ac.sizeSnapshot()
92+ content := contentSize(window)
93+ snapshot, ok := view.renderSnapshot(content)
94+ if !ok || !attachmentOwnerRegistered(view, ac) || !sameAttachmentOwner(ac.currentAttachmentOwner(), view) {
95+ return nil, false
96+ }
97+ placement := domain.Rect{Width: content.Cols, Height: content.Rows}
98+ bars := request.bars
99+ bars.status = remoteStatusSnapshot(view, snapshot)
100+ if d != nil {
101+ bars.attentionFrame = d.attentionFrame()
102+ bars.mru = d.recentSessions(nil)
103+ }
104+ state := &capturedRenderState{
105+ attachment: ac,
106+ lease: request.lease,
107+ window: window,
108+ reset: request.reset,
109+ bars: bars,
110+ theme: bars.theme,
111+ styles: request.styles,
112+ styleGeneration: request.styleGeneration,
113+ overlays: request.overlays,
114+ preview: request.preview,
115+ layout: capturedTabLayout{area: placement, focus: remoteContentPaneID, valid: true},
116+ floatingGeneration: 0,
117+ panes: []capturedPaneRenderState{{
118+ id: remoteContentPaneID,
119+ frame: snapshot.frame,
120+ placement: layout.Placement{ID: remoteContentPaneID, Content: placement},
121+ focused: true,
122+ damage: []renderer.Damage{renderer.FullRedraw()},
123+ }},
124+ cursor: capturedCursorInputs{
125+ row: snapshot.cursor.Row,
126+ col: snapshot.cursor.Col,
127+ style: snapshot.cursor.Style,
128+ hasStyle: snapshot.cursor.StyleSet,
129+ visible: snapshot.cursor.Visible,
130+ renderable: placement.Width > 0 && placement.Height > 0,
131+ content: placement,
132+ },
133+ }
134+ return state, true
135+}
136+
137+// paintRemoteView composes daemon-owned bars and overlays over the private
138+// remote terminal image. It uses the same output-state stream as local
139+// rendering, but never consults local session, pane, or coordinator state.
140+func (d *Daemon) paintRemoteView(view *remoteView, ac *attachedClient, reset bool, token attachmentConnectionToken) paintResult {
141+ if view == nil || ac == nil || !sameAttachmentOwner(token.owner, view) {
142+ return paintRejected
143+ }
144+ ticket, admitted := ac.beginAttachmentEffect(token)
145+ if !admitted {
146+ return paintRejected
147+ }
148+ token.effect = ticket
149+ marks := d.newRuntimeMarkBatch()
150+ marks.attachmentEffect = ticket
151+ defer func() {
152+ ticket.End()
153+ marks.flush()
154+ }()
155+
156+ ac.sendMu.Lock()
157+ if !token.attachmentEffectCurrent() || ac.output == nil {
158+ ac.sendMu.Unlock()
159+ return paintRejected
160+ }
161+ ac.output.syncCapacityLocked()
162+ if ac.output.atCapacity() {
163+ ac.sendMu.Unlock()
164+ return paintBlockedCapacity
165+ }
166+ ac.initOverlays()
167+ overlays := ac.overlays.SnapshotForRender()
168+ defer overlays.Unlock()
169+ statusFeedback := overlays.statusFeedback
170+ if statusFeedback == "" && overlays.resizeActive {
171+ statusFeedback = "resize unavailable for remote view"
172+ }
173+ applied := ac.getAppliedTheme()
174+ bars := barState{statusFeedback: statusFeedback, theme: applied.Raw}
175+ capturedOverlays := capturedOverlayRenderState{
176+ copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
177+ pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
178+ resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
179+ }
180+ state, ok := d.captureRemoteViewRenderState(view, ac, renderCaptureRequest{
181+ bars: bars,
182+ overlays: capturedOverlays,
183+ preview: snapshotPickerPreview(nil),
184+ styles: applied.Resolved.Styles,
185+ styleGeneration: applied.Generation,
186+ reset: reset,
187+ })
188+ if !ok {
189+ ac.sendMu.Unlock()
190+ return paintRejected
191+ }
192+ captureOverlayLayers(state, overlays, d.currentPaletteConfig())
193+ composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
194+ if d.emitAttachmentFrame(view, ac, state, composed, &marks) {
195+ return paintEmitted
196+ }
197+ return paintRejected
198+}
M internal/usecase/daemon/render_pipeline.go +28 -10
@@ -426,9 +426,19 @@ func commitDamageReceipts(receipts []damageReceipt) {
426426 }
427427 }
428428
429-// emitFrame is the sole side-effecting half of the pipeline. The caller holds
430-// sendMu for the complete capture/compose/emit transaction.
429+// emitFrame is the local-session compatibility entry point for the sole
430+// side-effecting half of the pipeline. The caller holds sendMu for the
431+// complete capture/compose/emit transaction.
431432 func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
433+ return d.emitAttachmentFrame(entry, ac, state, composed, batches...)
434+}
435+
436+// emitAttachmentFrame commits a composed attachment frame only while its
437+// exact owner binding remains current. It deliberately knows nothing about
438+// session panes or remote VT state; those belong to capture. A coordinator
439+// lease remains a local-session capability and is therefore rejected for
440+// remote owners.
441+func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
432442 var ownedMarks runtimeMarkBatch
433443 var marks *runtimeMarkBatch
434444 if len(batches) != 0 {
@@ -440,22 +450,20 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
440450 // follows emitFrame's release of attachment ownership.
441451 defer marks.flush()
442452 }
443- if entry == nil || entry.core() == nil || ac == nil || state == nil {
453+ if normalizeAttachmentOwner(owner) == nil || ac == nil || state == nil {
444454 if ac != nil {
445455 ac.sendMu.Unlock()
446456 }
447457 return false
448458 }
449- entry.core().mu.Lock()
450- _, owned := entry.core().attachments[ac]
451- entry.core().mu.Unlock()
452- if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
459+ if !attachmentOwnerRegistered(owner, ac) || state.attachment != ac || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) {
453460 ac.sendMu.Unlock()
454461 return false
455462 }
456463 if state.lease != nil {
464+ entry := localSession(owner)
457465 rc := attachmentRenderCoordinator(entry)
458- if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
466+ if entry == nil || rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
459467 ac.sendMu.Unlock()
460468 return false
461469 }
@@ -476,7 +484,13 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
476484 endDiff(0, err == nil)
477485 if err != nil {
478486 ac.sendMu.Unlock()
479- d.log.Error("render draw failed", "err", err, "session", entry.core().name)
487+ d.log.Error("render draw failed", "err", err, "session", attachmentOwnerName(owner))
488+ entry := localSession(owner)
489+ if entry == nil {
490+ // A remote view has no session-scoped notice history or coordinator.
491+ // Its next remote publication will retry from the retained private VT.
492+ return true
493+ }
480494 // Without a coordinator reportError repaints synchronously. Suppress only
481495 // that nested notice repaint; leave the guard before returning so a later,
482496 // independent failed transaction can still notify the user.
@@ -561,7 +575,11 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
561575 // admission first. Detachment freezes the gate and therefore cannot mutate
562576 // ownership until any enclosing admitted operation has also ended.
563577 if marks.attachmentEffect == nil {
564- d.detachOnSendError(entry, ac, sendTr)
578+ if entry := localSession(owner); entry != nil {
579+ d.detachOnSendError(entry, ac, sendTr)
580+ } else if view, remote := owner.(*remoteView); remote {
581+ d.clientGoneRemote(view, attachmentOwnerToken(view, ac, sendTr), false)
582+ }
565583 } else {
566584 // Capture the exact admitted capability, including its coordinator
567585 // lease, before End permits a new attachment publication. Reserve
@@ -426,9 +426,19 @@ func commitDamageReceipts(receipts []damageReceipt) {
426 }426 }
427 }427 }
428 428
429-// emitFrame is the sole side-effecting half of the pipeline. The caller holds429+// emitFrame is the local-session compatibility entry point for the sole
430-// sendMu for the complete capture/compose/emit transaction.430+// side-effecting half of the pipeline. The caller holds sendMu for the
431+// complete capture/compose/emit transaction.
431 func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {432 func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
433+ return d.emitAttachmentFrame(entry, ac, state, composed, batches...)
434+}
435+
436+// emitAttachmentFrame commits a composed attachment frame only while its
437+// exact owner binding remains current. It deliberately knows nothing about
438+// session panes or remote VT state; those belong to capture. A coordinator
439+// lease remains a local-session capability and is therefore rejected for
440+// remote owners.
441+func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
432 var ownedMarks runtimeMarkBatch442 var ownedMarks runtimeMarkBatch
433 var marks *runtimeMarkBatch443 var marks *runtimeMarkBatch
434 if len(batches) != 0 {444 if len(batches) != 0 {
@@ -440,22 +450,20 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
440 // follows emitFrame's release of attachment ownership.450 // follows emitFrame's release of attachment ownership.
441 defer marks.flush()451 defer marks.flush()
442 }452 }
443- if entry == nil || entry.core() == nil || ac == nil || state == nil {453+ if normalizeAttachmentOwner(owner) == nil || ac == nil || state == nil {
444 if ac != nil {454 if ac != nil {
445 ac.sendMu.Unlock()455 ac.sendMu.Unlock()
446 }456 }
447 return false457 return false
448 }458 }
449- entry.core().mu.Lock()459+ if !attachmentOwnerRegistered(owner, ac) || state.attachment != ac || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) {
450- _, owned := entry.core().attachments[ac]
451- entry.core().mu.Unlock()
452- if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
453 ac.sendMu.Unlock()460 ac.sendMu.Unlock()
454 return false461 return false
455 }462 }
456 if state.lease != nil {463 if state.lease != nil {
464+ entry := localSession(owner)
457 rc := attachmentRenderCoordinator(entry)465 rc := attachmentRenderCoordinator(entry)
458- if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {466+ if entry == nil || rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
459 ac.sendMu.Unlock()467 ac.sendMu.Unlock()
460 return false468 return false
461 }469 }
@@ -476,7 +484,13 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
476 endDiff(0, err == nil)484 endDiff(0, err == nil)
477 if err != nil {485 if err != nil {
478 ac.sendMu.Unlock()486 ac.sendMu.Unlock()
479- d.log.Error("render draw failed", "err", err, "session", entry.core().name)487+ d.log.Error("render draw failed", "err", err, "session", attachmentOwnerName(owner))
488+ entry := localSession(owner)
489+ if entry == nil {
490+ // A remote view has no session-scoped notice history or coordinator.
491+ // Its next remote publication will retry from the retained private VT.
492+ return true
493+ }
480 // Without a coordinator reportError repaints synchronously. Suppress only494 // Without a coordinator reportError repaints synchronously. Suppress only
481 // that nested notice repaint; leave the guard before returning so a later,495 // that nested notice repaint; leave the guard before returning so a later,
482 // independent failed transaction can still notify the user.496 // independent failed transaction can still notify the user.
@@ -561,7 +575,11 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
561 // admission first. Detachment freezes the gate and therefore cannot mutate575 // admission first. Detachment freezes the gate and therefore cannot mutate
562 // ownership until any enclosing admitted operation has also ended.576 // ownership until any enclosing admitted operation has also ended.
563 if marks.attachmentEffect == nil {577 if marks.attachmentEffect == nil {
564- d.detachOnSendError(entry, ac, sendTr)578+ if entry := localSession(owner); entry != nil {
579+ d.detachOnSendError(entry, ac, sendTr)
580+ } else if view, remote := owner.(*remoteView); remote {
581+ d.clientGoneRemote(view, attachmentOwnerToken(view, ac, sendTr), false)
582+ }
565 } else {583 } else {
566 // Capture the exact admitted capability, including its coordinator584 // Capture the exact admitted capability, including its coordinator
567 // lease, before End permits a new attachment publication. Reserve585 // lease, before End permits a new attachment publication. Reserve
M internal/usecase/daemon/session_back.go +28 -1
@@ -10,8 +10,35 @@ import (
1010 // owner is retained in the owner history but is not interpreted as a local
1111 // session; remote back navigation is published with remote transitions.
1212 func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
13+ if d == nil || token.ac == nil {
14+ return nil
15+ }
16+ if view, remote := token.owner.(*remoteView); remote {
17+ previous := token.ac.previousOwner.Get()
18+ target := localSession(previous)
19+ if target == nil {
20+ // A non-local predecessor is not a valid reverse target. Keep it as
21+ // stable history for the owner-specific transition that can interpret it.
22+ return nil
23+ }
24+ if target.core() == nil || d.sessionByID(target.core().id) != target {
25+ token.ac.clearPreviousOwnerIf(previous)
26+ return nil
27+ }
28+ published, err := d.transitionFromRemoteView(token, view, target)
29+ if err != nil {
30+ if !token.current() {
31+ return nil
32+ }
33+ return err
34+ }
35+ d.touchMRU(target)
36+ d.firstPaintForTransition(published)
37+ return nil
38+ }
39+
1340 current := token.localSession()
14- if d == nil || current == nil || token.ac == nil {
41+ if current == nil {
1542 return nil
1643 }
1744 previous := token.ac.previousOwner.Get()
@@ -10,8 +10,35 @@ import (
10 // owner is retained in the owner history but is not interpreted as a local10 // owner is retained in the owner history but is not interpreted as a local
11 // session; remote back navigation is published with remote transitions.11 // session; remote back navigation is published with remote transitions.
12 func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {12 func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
13+ if d == nil || token.ac == nil {
14+ return nil
15+ }
16+ if view, remote := token.owner.(*remoteView); remote {
17+ previous := token.ac.previousOwner.Get()
18+ target := localSession(previous)
19+ if target == nil {
20+ // A non-local predecessor is not a valid reverse target. Keep it as
21+ // stable history for the owner-specific transition that can interpret it.
22+ return nil
23+ }
24+ if target.core() == nil || d.sessionByID(target.core().id) != target {
25+ token.ac.clearPreviousOwnerIf(previous)
26+ return nil
27+ }
28+ published, err := d.transitionFromRemoteView(token, view, target)
29+ if err != nil {
30+ if !token.current() {
31+ return nil
32+ }
33+ return err
34+ }
35+ d.touchMRU(target)
36+ d.firstPaintForTransition(published)
37+ return nil
38+ }
39+
13 current := token.localSession()40 current := token.localSession()
14- if d == nil || current == nil || token.ac == nil {41+ if current == nil {
15 return nil42 return nil
16 }43 }
17 previous := token.ac.previousOwner.Get()44 previous := token.ac.previousOwner.Get()

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 2

Tags 13

↑↓ moveenter openesc close