detached d3ad7ec

feat(daemon): construct remote view links

commit
d3ad7ec14ffac203d79460c2172391e72d950fcb
author
brice · · 2026-08-06 19:07 UTC
parent
52f10a1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +1117 -27
M internal/usecase/daemon/attachment_owner.go +7 -0
@@ -61,6 +61,13 @@ type remoteView struct {
6161 screen *vt.Screen
6262 metadata ports.SessionMeta
6363 displayOrigin string
64+ // link and linkGeneration fence every remote transport callback. They are
65+ // owned by the view rather than the local-session registry, and neither
66+ // is ever used while view.mu is held for transport I/O.
67+ link *remoteLink
68+ linkGeneration uint64
69+ output remoteOutputState
70+ resetRequested bool
6471 }
6572
6673 func (*remoteView) attachmentOwner() {}
@@ -61,6 +61,13 @@ type remoteView struct {
61 screen *vt.Screen61 screen *vt.Screen
62 metadata ports.SessionMeta62 metadata ports.SessionMeta
63 displayOrigin string63 displayOrigin string
64+ // link and linkGeneration fence every remote transport callback. They are
65+ // owned by the view rather than the local-session registry, and neither
66+ // is ever used while view.mu is held for transport I/O.
67+ link *remoteLink
68+ linkGeneration uint64
69+ output remoteOutputState
70+ resetRequested bool
64 }71 }
65 72
66 func (*remoteView) attachmentOwner() {}73 func (*remoteView) attachmentOwner() {}
M internal/usecase/daemon/client.go +10 -0
@@ -934,6 +934,16 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
934934 if sameSize || !token.attachmentEffectCurrent() || !view.resizeScreen(ac.sizeSnapshot()) {
935935 return false
936936 }
937+ resizePayload, err := ports.MarshalResize(ports.Resize{Size: contentSize(ac.sizeSnapshot())})
938+ if err != nil {
939+ return false
940+ }
941+ if err := d.sendRemoteViewFrame(view, ports.Frame{Type: ports.MsgResize, Payload: resizePayload}); err != nil {
942+ // A disconnected remote link must not suppress the attachment-local
943+ // rebase: retained content remains locally renderable while Phase 4
944+ // reconnect policy restores the remote transport.
945+ d.log.Warn("forwarding remote view resize failed", "endpoint", view.key.endpoint, "session", view.key.sessionName, "err", err)
946+ }
937947 return d.paintRemoteView(view, ac, true, token) == paintEmitted
938948 }
939949
@@ -934,6 +934,16 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
934 if sameSize || !token.attachmentEffectCurrent() || !view.resizeScreen(ac.sizeSnapshot()) {934 if sameSize || !token.attachmentEffectCurrent() || !view.resizeScreen(ac.sizeSnapshot()) {
935 return false935 return false
936 }936 }
937+ resizePayload, err := ports.MarshalResize(ports.Resize{Size: contentSize(ac.sizeSnapshot())})
938+ if err != nil {
939+ return false
940+ }
941+ if err := d.sendRemoteViewFrame(view, ports.Frame{Type: ports.MsgResize, Payload: resizePayload}); err != nil {
942+ // A disconnected remote link must not suppress the attachment-local
943+ // rebase: retained content remains locally renderable while Phase 4
944+ // reconnect policy restores the remote transport.
945+ d.log.Warn("forwarding remote view resize failed", "endpoint", view.key.endpoint, "session", view.key.sessionName, "err", err)
946+ }
937 return d.paintRemoteView(view, ac, true, token) == paintEmitted947 return d.paintRemoteView(view, ac, true, token) == paintEmitted
938 }948 }
939 949
M internal/usecase/daemon/daemon.go +49 -26
@@ -77,7 +77,11 @@ type Daemon struct {
7777 // sessions beyond concrete local *session values. Both are guarded by mu.
7878 remoteViews map[remoteViewID]*remoteView
7979 remoteViewsByKey map[remoteViewKey]remoteViewID
80- nextRemoteViewID remoteViewID
80+ // remoteViewConstructions reserves a remote key while its dial and
81+ // handshake run outside d.mu. A waiter never dials and can cancel without
82+ // disturbing the elected constructor.
83+ remoteViewConstructions map[remoteViewKey]*remoteViewConstruction
84+ nextRemoteViewID remoteViewID
8185 // creating reserves names while durable creation I/O runs without mu.
8286 creating map[string]struct{}
8387 nextID uint64
@@ -600,30 +604,31 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
600604 }
601605 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
602606 d := &Daemon{
603- sessions: make(map[domain.SessionID]*session),
604- stopped: make(map[string]stoppedSession),
605- remoteViews: make(map[remoteViewID]*remoteView),
606- remoteViewsByKey: make(map[remoteViewKey]remoteViewID),
607- creating: make(map[string]struct{}),
608- parked: make(map[uint64]*parkedAttachment),
609- parking: make(map[uint64]*parkingAttachment),
610- paneProcessCtx: paneProcessCtx,
611- paneProcessCancel: paneProcessCancel,
612- ptys: ptys,
613- clock: clock,
614- log: log,
615- baseEnv: os.Environ(),
616- shell: shell,
617- dirOrHome: dirOrHome,
618- done: make(chan struct{}),
619- restoreDone: make(chan struct{}),
620- animWake: make(chan struct{}, 1),
621- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
622- snapshotAdmitted: make(map[*snapshotCapture]struct{}),
623- snapshotWake: make(chan struct{}, 1),
624- notices: newNoticeCenter(),
625- remoteCatalog: newRemoteCatalogState(),
626- resumeParkGrace: defaultResumeParkGrace,
607+ sessions: make(map[domain.SessionID]*session),
608+ stopped: make(map[string]stoppedSession),
609+ remoteViews: make(map[remoteViewID]*remoteView),
610+ remoteViewsByKey: make(map[remoteViewKey]remoteViewID),
611+ remoteViewConstructions: make(map[remoteViewKey]*remoteViewConstruction),
612+ creating: make(map[string]struct{}),
613+ parked: make(map[uint64]*parkedAttachment),
614+ parking: make(map[uint64]*parkingAttachment),
615+ paneProcessCtx: paneProcessCtx,
616+ paneProcessCancel: paneProcessCancel,
617+ ptys: ptys,
618+ clock: clock,
619+ log: log,
620+ baseEnv: os.Environ(),
621+ shell: shell,
622+ dirOrHome: dirOrHome,
623+ done: make(chan struct{}),
624+ restoreDone: make(chan struct{}),
625+ animWake: make(chan struct{}, 1),
626+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
627+ snapshotAdmitted: make(map[*snapshotCapture]struct{}),
628+ snapshotWake: make(chan struct{}, 1),
629+ notices: newNoticeCenter(),
630+ remoteCatalog: newRemoteCatalogState(),
631+ resumeParkGrace: defaultResumeParkGrace,
627632 barScripts: &barScriptState{
628633 cfg: barConfigFromDomain(domain.Defaults().Bar),
629634 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -808,18 +813,36 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
808813 for _, view := range d.remoteViews {
809814 remoteViews = append(remoteViews, view)
810815 }
816+ constructions := make([]context.CancelFunc, 0, len(d.remoteViewConstructions))
817+ for _, construction := range d.remoteViewConstructions {
818+ if construction.cancel != nil {
819+ constructions = append(constructions, construction.cancel)
820+ }
821+ }
811822 clear(d.remoteViews)
812823 clear(d.remoteViewsByKey)
824+ clear(d.remoteViewConstructions)
813825 snapshot := d.sessionsSnapshotLocked()
814826 empty := len(snapshot) == 0
815827 d.mu.Unlock()
816828 // A remote view is not a daemon-liveness root. Retire each exact local
817829 // attachment after releasing d.mu so a token cannot remain valid and a
818- // blocked local-client Recv/Send cannot outlive daemon shutdown.
830+ // blocked local-client Recv/Send cannot outlive daemon shutdown. Candidate
831+ // construction uses the same exact-close rule, even before publication.
832+ for _, cancel := range constructions {
833+ cancel()
834+ }
835+ remoteLinks := make([]*remoteLink, 0, len(remoteViews))
819836 for _, view := range remoteViews {
820837 for _, ac := range view.close() {
821838 d.retireShutdownRemoteAttachment(view, ac, reason)
822839 }
840+ if link := d.stopRemoteViewLink(view); link != nil {
841+ remoteLinks = append(remoteLinks, link)
842+ }
843+ }
844+ for _, link := range remoteLinks {
845+ <-link.done
823846 }
824847 d.finishParkedAttachmentRetirements(parkedRetirements)
825848 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
@@ -77,7 +77,11 @@ type Daemon struct {
77 // sessions beyond concrete local *session values. Both are guarded by mu.77 // sessions beyond concrete local *session values. Both are guarded by mu.
78 remoteViews map[remoteViewID]*remoteView78 remoteViews map[remoteViewID]*remoteView
79 remoteViewsByKey map[remoteViewKey]remoteViewID79 remoteViewsByKey map[remoteViewKey]remoteViewID
80- nextRemoteViewID remoteViewID80+ // remoteViewConstructions reserves a remote key while its dial and
81+ // handshake run outside d.mu. A waiter never dials and can cancel without
82+ // disturbing the elected constructor.
83+ remoteViewConstructions map[remoteViewKey]*remoteViewConstruction
84+ nextRemoteViewID remoteViewID
81 // creating reserves names while durable creation I/O runs without mu.85 // creating reserves names while durable creation I/O runs without mu.
82 creating map[string]struct{}86 creating map[string]struct{}
83 nextID uint6487 nextID uint64
@@ -600,30 +604,31 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
600 }604 }
601 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())605 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
602 d := &Daemon{606 d := &Daemon{
603- sessions: make(map[domain.SessionID]*session),607+ sessions: make(map[domain.SessionID]*session),
604- stopped: make(map[string]stoppedSession),608+ stopped: make(map[string]stoppedSession),
605- remoteViews: make(map[remoteViewID]*remoteView),609+ remoteViews: make(map[remoteViewID]*remoteView),
606- remoteViewsByKey: make(map[remoteViewKey]remoteViewID),610+ remoteViewsByKey: make(map[remoteViewKey]remoteViewID),
607- creating: make(map[string]struct{}),611+ remoteViewConstructions: make(map[remoteViewKey]*remoteViewConstruction),
608- parked: make(map[uint64]*parkedAttachment),612+ creating: make(map[string]struct{}),
609- parking: make(map[uint64]*parkingAttachment),613+ parked: make(map[uint64]*parkedAttachment),
610- paneProcessCtx: paneProcessCtx,614+ parking: make(map[uint64]*parkingAttachment),
611- paneProcessCancel: paneProcessCancel,615+ paneProcessCtx: paneProcessCtx,
612- ptys: ptys,616+ paneProcessCancel: paneProcessCancel,
613- clock: clock,617+ ptys: ptys,
614- log: log,618+ clock: clock,
615- baseEnv: os.Environ(),619+ log: log,
616- shell: shell,620+ baseEnv: os.Environ(),
617- dirOrHome: dirOrHome,621+ shell: shell,
618- done: make(chan struct{}),622+ dirOrHome: dirOrHome,
619- restoreDone: make(chan struct{}),623+ done: make(chan struct{}),
620- animWake: make(chan struct{}, 1),624+ restoreDone: make(chan struct{}),
621- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),625+ animWake: make(chan struct{}, 1),
622- snapshotAdmitted: make(map[*snapshotCapture]struct{}),626+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
623- snapshotWake: make(chan struct{}, 1),627+ snapshotAdmitted: make(map[*snapshotCapture]struct{}),
624- notices: newNoticeCenter(),628+ snapshotWake: make(chan struct{}, 1),
625- remoteCatalog: newRemoteCatalogState(),629+ notices: newNoticeCenter(),
626- resumeParkGrace: defaultResumeParkGrace,630+ remoteCatalog: newRemoteCatalogState(),
631+ resumeParkGrace: defaultResumeParkGrace,
627 barScripts: &barScriptState{632 barScripts: &barScriptState{
628 cfg: barConfigFromDomain(domain.Defaults().Bar),633 cfg: barConfigFromDomain(domain.Defaults().Bar),
629 outputs: make(map[domain.SessionID]barScriptOutputs),634 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -808,18 +813,36 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
808 for _, view := range d.remoteViews {813 for _, view := range d.remoteViews {
809 remoteViews = append(remoteViews, view)814 remoteViews = append(remoteViews, view)
810 }815 }
816+ constructions := make([]context.CancelFunc, 0, len(d.remoteViewConstructions))
817+ for _, construction := range d.remoteViewConstructions {
818+ if construction.cancel != nil {
819+ constructions = append(constructions, construction.cancel)
820+ }
821+ }
811 clear(d.remoteViews)822 clear(d.remoteViews)
812 clear(d.remoteViewsByKey)823 clear(d.remoteViewsByKey)
824+ clear(d.remoteViewConstructions)
813 snapshot := d.sessionsSnapshotLocked()825 snapshot := d.sessionsSnapshotLocked()
814 empty := len(snapshot) == 0826 empty := len(snapshot) == 0
815 d.mu.Unlock()827 d.mu.Unlock()
816 // A remote view is not a daemon-liveness root. Retire each exact local828 // A remote view is not a daemon-liveness root. Retire each exact local
817 // attachment after releasing d.mu so a token cannot remain valid and a829 // attachment after releasing d.mu so a token cannot remain valid and a
818- // blocked local-client Recv/Send cannot outlive daemon shutdown.830+ // blocked local-client Recv/Send cannot outlive daemon shutdown. Candidate
831+ // construction uses the same exact-close rule, even before publication.
832+ for _, cancel := range constructions {
833+ cancel()
834+ }
835+ remoteLinks := make([]*remoteLink, 0, len(remoteViews))
819 for _, view := range remoteViews {836 for _, view := range remoteViews {
820 for _, ac := range view.close() {837 for _, ac := range view.close() {
821 d.retireShutdownRemoteAttachment(view, ac, reason)838 d.retireShutdownRemoteAttachment(view, ac, reason)
822 }839 }
840+ if link := d.stopRemoteViewLink(view); link != nil {
841+ remoteLinks = append(remoteLinks, link)
842+ }
843+ }
844+ for _, link := range remoteLinks {
845+ <-link.done
823 }846 }
824 d.finishParkedAttachmentRetirements(parkedRetirements)847 d.finishParkedAttachmentRetirements(parkedRetirements)
825 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))848 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
M internal/usecase/daemon/input.go +5 -1
@@ -21,10 +21,14 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
2121 d.handleInput(sess, ac, data)
2222 }
2323
24-func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {
24+func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, inputSeq uint64, data []byte) {
2525 if !token.attachmentEffectCurrent() {
2626 return
2727 }
28+ if view, remote := token.owner.(*remoteView); remote {
29+ d.handleRemoteViewInput(view, token, inputSeq, data)
30+ return
31+ }
2832 if token.ac.renderMode == ports.RenderModeProxiedContent {
2933 d.handleProxiedInputForAttachment(token, data)
3034 return
@@ -21,10 +21,14 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
21 d.handleInput(sess, ac, data)21 d.handleInput(sess, ac, data)
22 }22 }
23 23
24-func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {24+func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, inputSeq uint64, data []byte) {
25 if !token.attachmentEffectCurrent() {25 if !token.attachmentEffectCurrent() {
26 return26 return
27 }27 }
28+ if view, remote := token.owner.(*remoteView); remote {
29+ d.handleRemoteViewInput(view, token, inputSeq, data)
30+ return
31+ }
28 if token.ac.renderMode == ports.RenderModeProxiedContent {32 if token.ac.renderMode == ports.RenderModeProxiedContent {
29 d.handleProxiedInputForAttachment(token, data)33 d.handleProxiedInputForAttachment(token, data)
30 return34 return
A internal/usecase/daemon/remote_link.go +663 -0
@@ -0,0 +1,663 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "errors"
7+ "fmt"
8+ "sync"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/pkg/vt"
13+)
14+
15+var (
16+ errRemoteViewUnavailable = errors.New("remote view: link is unavailable")
17+ errRemoteViewStale = errors.New("remote view: link is stale")
18+)
19+
20+// remoteViewConstruction reserves one remote-view key while its candidate
21+// dials and handshakes. It is guarded by Daemon.mu. cancel is invoked only
22+// after the daemon lock is released, because it may interrupt adapter I/O.
23+type remoteViewConstruction struct {
24+ done chan struct{}
25+ cancel context.CancelFunc
26+ err error
27+}
28+
29+// remoteLink is one exact transport generation for a remote view. sendMu
30+// orders every outbound remote frame; view.mu guards whether this exact link
31+// remains current. Neither lock is held while transport I/O runs.
32+type remoteLink struct {
33+ view *remoteView
34+ generation uint64
35+ target domain.RemoteSessionTarget
36+ clientID [16]byte
37+ transport ports.Transport
38+ cancel context.CancelFunc
39+
40+ sendMu sync.Mutex
41+ startOnce sync.Once
42+ done chan struct{}
43+ active bool // guarded by view.mu
44+}
45+
46+// remoteOutputState is the remote-link equivalent of the thin client's
47+// dependency chain. It remains private to the daemon because this link owns a
48+// persistent VT rather than forwarding output bytes to a terminal.
49+type remoteOutputState struct {
50+ epoch uint64
51+ state uint64
52+ viewRevision uint64
53+ initialized bool
54+}
55+
56+func (s remoteOutputState) next(output ports.Output) (remoteOutputState, bool) {
57+ if output.Epoch == 0 {
58+ return remoteOutputState{}, false
59+ }
60+ if !s.initialized {
61+ if output.New == 0 {
62+ return s, true
63+ }
64+ if output.Base != 0 || !output.Full {
65+ return remoteOutputState{}, false
66+ }
67+ return remoteOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
68+ }
69+ if output.Epoch < s.epoch {
70+ return remoteOutputState{}, false
71+ }
72+ if output.Epoch == s.epoch {
73+ if output.ViewRevision != s.viewRevision {
74+ return remoteOutputState{}, false
75+ }
76+ if output.New == 0 {
77+ if output.Base != 0 || output.Full {
78+ return remoteOutputState{}, false
79+ }
80+ return s, true
81+ }
82+ if output.Full || output.Base != s.state || output.New != output.Base+1 {
83+ return remoteOutputState{}, false
84+ }
85+ return remoteOutputState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
86+ }
87+ if !output.Full || output.Base != 0 {
88+ return remoteOutputState{}, false
89+ }
90+ return remoteOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
91+}
92+
93+// openRemoteView returns the exact warm view for target or elects one
94+// constructor. The reservation is protected only by Daemon.mu; the dial and
95+// all handshake I/O happen after every architecture lock is released.
96+func (d *Daemon) openRemoteView(ctx context.Context, target domain.RemoteSessionTarget, size domain.Size) (*remoteView, error) {
97+ if d == nil {
98+ return nil, errors.New("remote view: nil daemon")
99+ }
100+ if ctx == nil {
101+ ctx = context.Background()
102+ }
103+ if err := target.Validate(); err != nil {
104+ return nil, fmt.Errorf("remote view: invalid target: %w", err)
105+ }
106+ if !size.Valid() {
107+ return nil, fmt.Errorf("remote view: invalid size %dx%d", size.Cols, size.Rows)
108+ }
109+ if d.remoteDialerFactory == nil {
110+ return nil, errors.New("remote view: remote dialer is not configured")
111+ }
112+ key, err := remoteViewKeyForTarget(target)
113+ if err != nil {
114+ return nil, fmt.Errorf("remote view: key: %w", err)
115+ }
116+
117+ for {
118+ if err := ctx.Err(); err != nil {
119+ return nil, err
120+ }
121+ d.mu.Lock()
122+ if existing := d.remoteViewByKeyLocked(key); existing != nil {
123+ d.mu.Unlock()
124+ return existing, nil
125+ }
126+ if d.closing {
127+ d.mu.Unlock()
128+ return nil, errors.New("remote view: daemon is shutting down")
129+ }
130+ if construction := d.remoteViewConstructions[key]; construction != nil {
131+ done := construction.done
132+ d.mu.Unlock()
133+ select {
134+ case <-ctx.Done():
135+ return nil, ctx.Err()
136+ case <-done:
137+ }
138+ if err := ctx.Err(); err != nil {
139+ return nil, err
140+ }
141+ d.mu.Lock()
142+ winner := d.remoteViewByKeyLocked(key)
143+ constructionErr := construction.err
144+ d.mu.Unlock()
145+ if winner != nil {
146+ return winner, nil
147+ }
148+ if constructionErr != nil && !errors.Is(constructionErr, context.Canceled) && !errors.Is(constructionErr, context.DeadlineExceeded) {
149+ return nil, constructionErr
150+ }
151+ // A canceled candidate never poisons an independent waiter. Re-elect
152+ // if the caller still wants this exact remote view.
153+ continue
154+ }
155+
156+ constructionCtx, cancel := context.WithCancel(ctx)
157+ construction := &remoteViewConstruction{done: make(chan struct{}), cancel: cancel}
158+ if d.remoteViewConstructions == nil {
159+ d.remoteViewConstructions = make(map[remoteViewKey]*remoteViewConstruction)
160+ }
161+ d.remoteViewConstructions[key] = construction
162+ d.mu.Unlock()
163+
164+ candidate, constructionErr := d.constructRemoteView(constructionCtx, target, size)
165+ if constructionErr == nil {
166+ constructionErr = ctx.Err()
167+ }
168+ cancel()
169+
170+ var winner *remoteView
171+ publishErr := constructionErr
172+ d.mu.Lock()
173+ if publishErr == nil {
174+ if d.closing {
175+ publishErr = errors.New("remote view: daemon is shutting down")
176+ } else if existing := d.remoteViewByKeyLocked(key); existing != nil {
177+ winner = existing
178+ } else if err := d.registerRemoteViewLocked(candidate); err != nil {
179+ publishErr = fmt.Errorf("remote view: publish candidate: %w", err)
180+ } else {
181+ winner = candidate
182+ // Starting the receiver while d.mu still protects publication keeps
183+ // shutdown from observing a registry-visible link with no worker
184+ // to join.
185+ d.startRemoteLink(candidate.link)
186+ }
187+ }
188+ construction.err = publishErr
189+ if d.remoteViewConstructions[key] == construction {
190+ delete(d.remoteViewConstructions, key)
191+ }
192+ close(construction.done)
193+ d.mu.Unlock()
194+
195+ if candidate != nil && candidate != winner {
196+ d.stopUnpublishedRemoteView(candidate)
197+ }
198+ if winner != nil {
199+ return winner, nil
200+ }
201+ return nil, publishErr
202+ }
203+}
204+
205+// constructRemoteView builds an unexposed candidate. Its caller owns a
206+// constructor reservation but holds no daemon, session, or view lock.
207+func (d *Daemon) constructRemoteView(ctx context.Context, target domain.RemoteSessionTarget, size domain.Size) (_ *remoteView, err error) {
208+ content := contentSize(size)
209+ if !content.Valid() {
210+ return nil, fmt.Errorf("remote view: invalid content size %dx%d", content.Cols, content.Rows)
211+ }
212+ key, err := remoteViewKeyForTarget(target)
213+ if err != nil {
214+ return nil, err
215+ }
216+ var clientID [16]byte
217+ if _, err := rand.Read(clientID[:]); err != nil {
218+ return nil, fmt.Errorf("remote view: generate client ID: %w", err)
219+ }
220+ root := context.Background()
221+ if d.serveCtx != nil {
222+ root = d.serveCtx
223+ }
224+ linkCtx, cancelLink := context.WithCancel(root)
225+ stopCallerCancellation := context.AfterFunc(ctx, cancelLink)
226+ defer stopCallerCancellation()
227+ view := &remoteView{
228+ key: key,
229+ displayOrigin: target.DisplayOrigin,
230+ screen: vt.NewScreen(content.Cols, content.Rows),
231+ linkGeneration: 1,
232+ }
233+ link := &remoteLink{
234+ view: view,
235+ generation: view.linkGeneration,
236+ target: target,
237+ clientID: clientID,
238+ cancel: cancelLink,
239+ done: make(chan struct{}),
240+ active: true,
241+ }
242+ view.link = link
243+ published := false
244+ defer func() {
245+ if !published {
246+ d.stopUnpublishedRemoteView(view)
247+ }
248+ }()
249+
250+ handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(linkCtx)
251+ defer finishHandshake()
252+ dialer, err := d.remoteDialerFactory.DialerForRemote(target.Endpoint, target.SessionName, d.remoteTransportMode, d.log)
253+ if err != nil {
254+ return nil, fmt.Errorf("remote view: select remote dialer: %w", err)
255+ }
256+ if dialer == nil {
257+ return nil, errors.New("remote view: remote dialer factory returned nil dialer")
258+ }
259+ transport, err := dialer.Dial(handshakeCtx)
260+ if err != nil {
261+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: dial: %w", err))
262+ }
263+ if transport == nil {
264+ return nil, errors.New("remote view: remote dialer returned nil transport")
265+ }
266+ link.transport = transport
267+ stopWatch := watchHandshakeTransport(handshakeCtx, transport)
268+ defer stopWatch()
269+
270+ helloTarget := target
271+ hello := ports.Hello{
272+ Version: ports.ProtocolVersion,
273+ Intent: ports.IntentAttach,
274+ ClientID: clientID,
275+ Name: target.SessionName,
276+ Size: content,
277+ RenderMode: ports.RenderModeProxiedContent,
278+ MaxOutputInFlight: remoteLinkOutputWindow(transport),
279+ RemoteTarget: &helloTarget,
280+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
281+ }
282+ if err := link.send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
283+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: send hello: %w", err))
284+ }
285+
286+ welcomeFrame, err := recvRemoteLinkHandshakeFrame(handshakeCtx, transport)
287+ if err != nil {
288+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: receive welcome: %w", err))
289+ }
290+ if err := validateRemoteLinkWelcome(welcomeFrame, target); err != nil {
291+ return nil, err
292+ }
293+ metadataFrame, err := recvRemoteLinkHandshakeFrame(handshakeCtx, transport)
294+ if err != nil {
295+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: receive metadata: %w", err))
296+ }
297+ metadata, err := validateInitialRemoteLinkMetadata(metadataFrame, target)
298+ if err != nil {
299+ return nil, err
300+ }
301+ if !view.applyRemoteMetadata(link, metadata, true) {
302+ return nil, errors.New("remote view: initial metadata became stale")
303+ }
304+ outputFrame, err := recvRemoteLinkHandshakeFrame(handshakeCtx, transport)
305+ if err != nil {
306+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: receive initial output: %w", err))
307+ }
308+ if outputFrame.Type != ports.MsgOutput {
309+ return nil, fmt.Errorf("remote view: third server frame is %d, want Output", outputFrame.Type)
310+ }
311+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
312+ if err != nil {
313+ return nil, fmt.Errorf("remote view: decode initial output: %w", err)
314+ }
315+ ack, reset, _, accepted := view.applyRemoteOutput(link, output)
316+ if !accepted || reset || output.New == 0 || !output.Full {
317+ return nil, errors.New("remote view: initial output is not an authoritative reset")
318+ }
319+ if ack != 0 {
320+ ackPayload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: ack})
321+ if err != nil {
322+ return nil, fmt.Errorf("remote view: encode initial output acknowledgement: %w", err)
323+ }
324+ if err := link.send(ports.Frame{Type: ports.MsgAck, Payload: ackPayload}); err != nil {
325+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: acknowledge initial output: %w", err))
326+ }
327+ }
328+ if err := handshakeCtx.Err(); err != nil {
329+ return nil, handshakeContextError(ctx, timedOut, err)
330+ }
331+ published = true
332+ return view, nil
333+}
334+
335+func remoteLinkOutputWindow(transport ports.Transport) uint8 {
336+ if _, datagram := transport.(ports.DatagramTransport); datagram {
337+ return 1
338+ }
339+ return maxUnackedOutputStates
340+}
341+
342+func recvRemoteLinkHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
343+ var frame ports.Frame
344+ err := boundedHandshakeOperation(ctx, transport, func() error {
345+ var recvErr error
346+ frame, recvErr = transport.Recv()
347+ return recvErr
348+ })
349+ if err != nil {
350+ return ports.Frame{}, err
351+ }
352+ return frame, nil
353+}
354+
355+func validateRemoteLinkWelcome(frame ports.Frame, target domain.RemoteSessionTarget) error {
356+ if frame.Type == ports.MsgError {
357+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
358+ if err != nil {
359+ return fmt.Errorf("remote view: malformed remote error: %w", err)
360+ }
361+ return fmt.Errorf("remote view: remote rejected handshake (%d): %s", remoteErr.Code, remoteErr.Text)
362+ }
363+ if frame.Type != ports.MsgWelcome {
364+ return fmt.Errorf("remote view: first server frame is %d, want Welcome", frame.Type)
365+ }
366+ welcome, err := ports.UnmarshalWelcome(frame.Payload)
367+ if err != nil {
368+ return fmt.Errorf("remote view: decode welcome: %w", err)
369+ }
370+ if welcome.SessionID == "" || welcome.SessionName != target.SessionName || welcome.RenderMode != ports.RenderModeProxiedContent {
371+ return errors.New("remote view: welcome identity or render mode mismatch")
372+ }
373+ return nil
374+}
375+
376+func validateInitialRemoteLinkMetadata(frame ports.Frame, target domain.RemoteSessionTarget) (ports.SessionMeta, error) {
377+ if frame.Type != ports.MsgSessionMeta {
378+ return ports.SessionMeta{}, fmt.Errorf("remote view: second server frame is %d, want SessionMeta", frame.Type)
379+ }
380+ metadata, err := ports.UnmarshalSessionMeta(frame.Payload)
381+ if err != nil {
382+ return ports.SessionMeta{}, fmt.Errorf("remote view: decode initial metadata: %w", err)
383+ }
384+ if metadata.LifecycleID != target.LifecycleID || metadata.SessionName != target.SessionName || metadata.Revision != 1 {
385+ return ports.SessionMeta{}, errors.New("remote view: metadata identity mismatch")
386+ }
387+ if !target.Stopped && metadata.ActiveTabID != target.LiveTabID {
388+ return ports.SessionMeta{}, errors.New("remote view: metadata active tab mismatch")
389+ }
390+ return metadata, nil
391+}
392+
393+func cloneRemoteSessionMeta(metadata ports.SessionMeta) ports.SessionMeta {
394+ metadata.Tabs = append([]ports.SessionTabMeta(nil), metadata.Tabs...)
395+ return metadata
396+}
397+
398+// applyRemoteMetadata commits one validated update only for the exact current
399+// link. The view lock protects content state only; no renderer or callback runs
400+// before it is released.
401+func (v *remoteView) applyRemoteMetadata(link *remoteLink, metadata ports.SessionMeta, initial bool) bool {
402+ if v == nil || link == nil {
403+ return false
404+ }
405+ v.mu.Lock()
406+ defer v.mu.Unlock()
407+ if v.closed || v.link != link || v.linkGeneration != link.generation {
408+ return false
409+ }
410+ if initial {
411+ if v.metadata.Revision != 0 {
412+ return false
413+ }
414+ } else if metadata.Revision <= v.metadata.Revision {
415+ return false
416+ }
417+ v.metadata = cloneRemoteSessionMeta(metadata)
418+ return true
419+}
420+
421+// applyRemoteOutput validates and applies state-bearing ANSI to the persistent
422+// private VT. It returns only immutable post-commit work; callers send ACK or
423+// reset frames and repaint attachments after view.mu is released.
424+func (v *remoteView) applyRemoteOutput(link *remoteLink, output ports.Output) (ack uint64, reset bool, attachments []*attachedClient, accepted bool) {
425+ if v == nil || link == nil {
426+ return 0, false, nil, false
427+ }
428+ v.mu.Lock()
429+ defer v.mu.Unlock()
430+ if v.closed || v.link != link || v.linkGeneration != link.generation {
431+ return 0, false, nil, false
432+ }
433+ next, valid := v.output.next(output)
434+ if !valid || (output.New != 0 && (v.screen == nil || v.screen.Frame.Width != output.Size.Cols || v.screen.Frame.Height != output.Size.Rows)) {
435+ if v.resetRequested {
436+ return 0, false, nil, false
437+ }
438+ v.resetRequested = true
439+ return 0, true, nil, false
440+ }
441+ if output.New == 0 {
442+ return 0, false, nil, true
443+ }
444+ v.output = next
445+ v.resetRequested = false
446+ if len(output.Data) != 0 {
447+ v.screen.Write(output.Data)
448+ }
449+ attachments = make([]*attachedClient, 0, len(v.attachments))
450+ for attachment := range v.attachments {
451+ attachments = append(attachments, attachment)
452+ }
453+ return output.New, false, attachments, true
454+}
455+
456+func (d *Daemon) startRemoteLink(link *remoteLink) {
457+ if d == nil || link == nil || link.transport == nil {
458+ return
459+ }
460+ link.startOnce.Do(func() {
461+ go d.runRemoteLink(link)
462+ })
463+}
464+
465+func (d *Daemon) runRemoteLink(link *remoteLink) {
466+ defer close(link.done)
467+ for {
468+ frame, err := link.transport.Recv()
469+ if err != nil {
470+ d.markRemoteLinkUnavailable(link)
471+ return
472+ }
473+ if err := d.handleRemoteLinkFrame(link, frame); err != nil {
474+ d.log.Warn("remote view link stopped", "endpoint", link.target.Endpoint, "session", link.target.SessionName, "err", err)
475+ d.markRemoteLinkUnavailable(link)
476+ return
477+ }
478+ }
479+}
480+
481+func (d *Daemon) handleRemoteLinkFrame(link *remoteLink, frame ports.Frame) error {
482+ switch frame.Type {
483+ case ports.MsgOutput:
484+ output, err := ports.UnmarshalOutput(frame.Payload)
485+ if err != nil {
486+ return fmt.Errorf("remote view: decode output: %w", err)
487+ }
488+ ack, reset, attachments, accepted := link.view.applyRemoteOutput(link, output)
489+ if reset {
490+ if err := link.send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}); err != nil {
491+ return fmt.Errorf("remote view: request output reset: %w", err)
492+ }
493+ return nil
494+ }
495+ if !accepted {
496+ return nil
497+ }
498+ if ack != 0 {
499+ ackPayload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: ack})
500+ if err != nil {
501+ return fmt.Errorf("remote view: encode output acknowledgement: %w", err)
502+ }
503+ if err := link.send(ports.Frame{Type: ports.MsgAck, Payload: ackPayload}); err != nil {
504+ return fmt.Errorf("remote view: acknowledge output: %w", err)
505+ }
506+ }
507+ for _, attachment := range attachments {
508+ token := attachmentOwnerToken(link.view, attachment, attachment.transport())
509+ if token.ac != nil {
510+ go d.paintRemoteView(link.view, attachment, false, token)
511+ }
512+ }
513+ return nil
514+ case ports.MsgSessionMeta:
515+ metadata, err := ports.UnmarshalSessionMeta(frame.Payload)
516+ if err != nil {
517+ return fmt.Errorf("remote view: decode metadata: %w", err)
518+ }
519+ if metadata.LifecycleID != link.target.LifecycleID || metadata.SessionName != link.target.SessionName || !link.view.applyRemoteMetadata(link, metadata, false) {
520+ return errors.New("remote view: invalid metadata update")
521+ }
522+ return nil
523+ case ports.MsgPing:
524+ if _, err := ports.UnmarshalPing(frame.Payload); err != nil {
525+ return fmt.Errorf("remote view: decode ping: %w", err)
526+ }
527+ return link.send(ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})})
528+ case ports.MsgPong:
529+ _, err := ports.UnmarshalPong(frame.Payload)
530+ return err
531+ case ports.MsgDetached:
532+ _, err := ports.UnmarshalDetached(frame.Payload)
533+ if err != nil {
534+ return fmt.Errorf("remote view: decode detached: %w", err)
535+ }
536+ return errors.New("remote view: remote session detached")
537+ case ports.MsgError:
538+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
539+ if err != nil {
540+ return fmt.Errorf("remote view: malformed remote error: %w", err)
541+ }
542+ return fmt.Errorf("remote view: remote error (%d): %s", remoteErr.Code, remoteErr.Text)
543+ default:
544+ return fmt.Errorf("remote view: unexpected server frame type %d", frame.Type)
545+ }
546+}
547+
548+// send serializes the complete outbound remote stream. It captures the exact
549+// current transport under view.mu, releases every architecture lock, then lets
550+// Close interrupt a blocked Send concurrently.
551+func (link *remoteLink) send(frame ports.Frame) error {
552+ if link == nil || link.view == nil {
553+ return errRemoteViewUnavailable
554+ }
555+ link.sendMu.Lock()
556+ view := link.view
557+ view.mu.Lock()
558+ current := !view.closed && view.link == link && view.linkGeneration == link.generation && link.active
559+ transport := link.transport
560+ view.mu.Unlock()
561+ if !current || transport == nil {
562+ link.sendMu.Unlock()
563+ return errRemoteViewStale
564+ }
565+ err := transport.Send(frame)
566+ link.sendMu.Unlock()
567+ if err != nil {
568+ link.cancel()
569+ _ = transport.Close()
570+ }
571+ return err
572+}
573+
574+// handleRemoteViewInput forwards the raw local-terminal bytes and their
575+// original sequence through the exact current remote link. No local key,
576+// overlay, mouse, PTY, or persistence path observes those bytes.
577+func (d *Daemon) handleRemoteViewInput(view *remoteView, token attachmentConnectionToken, inputSeq uint64, data []byte) {
578+ if view == nil || len(data) == 0 || !token.attachmentEffectCurrent() {
579+ return
580+ }
581+ payload := ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)})
582+ if err := d.sendRemoteViewFrame(view, ports.Frame{Type: ports.MsgInput, Payload: payload}); err != nil {
583+ d.log.Warn("forwarding remote view input failed", "endpoint", view.key.endpoint, "session", view.key.sessionName, "err", err)
584+ }
585+}
586+
587+// sendRemoteViewFrame snapshots the exact link while holding only view.mu;
588+// remoteLink.send performs the generation check and serialized transport I/O.
589+func (d *Daemon) sendRemoteViewFrame(view *remoteView, frame ports.Frame) error {
590+ if view == nil {
591+ return errRemoteViewUnavailable
592+ }
593+ view.mu.Lock()
594+ link := view.link
595+ view.mu.Unlock()
596+ if link == nil {
597+ return errRemoteViewUnavailable
598+ }
599+ return link.send(frame)
600+}
601+
602+func (d *Daemon) markRemoteLinkUnavailable(link *remoteLink) {
603+ if link == nil || link.view == nil {
604+ return
605+ }
606+ view := link.view
607+ view.mu.Lock()
608+ current := !view.closed && view.link == link && view.linkGeneration == link.generation
609+ if current {
610+ link.active = false
611+ }
612+ view.mu.Unlock()
613+ if current {
614+ link.cancel()
615+ _ = link.transport.Close()
616+ }
617+}
618+
619+// stopUnpublishedRemoteView is used for candidate failure and lost-election
620+// cleanup. It never touches the daemon registry and closes only the candidate
621+// transport generation after view.mu is released.
622+func (d *Daemon) stopUnpublishedRemoteView(view *remoteView) {
623+ if view == nil {
624+ return
625+ }
626+ view.mu.Lock()
627+ view.closed = true
628+ link := view.link
629+ view.link = nil
630+ view.linkGeneration++
631+ view.mu.Unlock()
632+ if link != nil {
633+ link.cancel()
634+ if link.transport != nil {
635+ _ = link.transport.Close()
636+ }
637+ }
638+}
639+
640+// stopRemoteViewLink interrupts only the exact currently installed remote
641+// transport. It is called after registry publication has been revoked, so it
642+// cannot affect a successor view or link generation. The returned link has a
643+// started worker, which the terminal shutdown path joins after Close.
644+func (d *Daemon) stopRemoteViewLink(view *remoteView) *remoteLink {
645+ if view == nil {
646+ return nil
647+ }
648+ view.mu.Lock()
649+ link := view.link
650+ if link != nil {
651+ view.link = nil
652+ view.linkGeneration++
653+ link.active = false
654+ }
655+ view.mu.Unlock()
656+ if link != nil {
657+ link.cancel()
658+ if link.transport != nil {
659+ _ = link.transport.Close()
660+ }
661+ }
662+ return link
663+}
@@ -0,0 +1,663 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "errors"
7+ "fmt"
8+ "sync"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/pkg/vt"
13+)
14+
15+var (
16+ errRemoteViewUnavailable = errors.New("remote view: link is unavailable")
17+ errRemoteViewStale = errors.New("remote view: link is stale")
18+)
19+
20+// remoteViewConstruction reserves one remote-view key while its candidate
21+// dials and handshakes. It is guarded by Daemon.mu. cancel is invoked only
22+// after the daemon lock is released, because it may interrupt adapter I/O.
23+type remoteViewConstruction struct {
24+ done chan struct{}
25+ cancel context.CancelFunc
26+ err error
27+}
28+
29+// remoteLink is one exact transport generation for a remote view. sendMu
30+// orders every outbound remote frame; view.mu guards whether this exact link
31+// remains current. Neither lock is held while transport I/O runs.
32+type remoteLink struct {
33+ view *remoteView
34+ generation uint64
35+ target domain.RemoteSessionTarget
36+ clientID [16]byte
37+ transport ports.Transport
38+ cancel context.CancelFunc
39+
40+ sendMu sync.Mutex
41+ startOnce sync.Once
42+ done chan struct{}
43+ active bool // guarded by view.mu
44+}
45+
46+// remoteOutputState is the remote-link equivalent of the thin client's
47+// dependency chain. It remains private to the daemon because this link owns a
48+// persistent VT rather than forwarding output bytes to a terminal.
49+type remoteOutputState struct {
50+ epoch uint64
51+ state uint64
52+ viewRevision uint64
53+ initialized bool
54+}
55+
56+func (s remoteOutputState) next(output ports.Output) (remoteOutputState, bool) {
57+ if output.Epoch == 0 {
58+ return remoteOutputState{}, false
59+ }
60+ if !s.initialized {
61+ if output.New == 0 {
62+ return s, true
63+ }
64+ if output.Base != 0 || !output.Full {
65+ return remoteOutputState{}, false
66+ }
67+ return remoteOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
68+ }
69+ if output.Epoch < s.epoch {
70+ return remoteOutputState{}, false
71+ }
72+ if output.Epoch == s.epoch {
73+ if output.ViewRevision != s.viewRevision {
74+ return remoteOutputState{}, false
75+ }
76+ if output.New == 0 {
77+ if output.Base != 0 || output.Full {
78+ return remoteOutputState{}, false
79+ }
80+ return s, true
81+ }
82+ if output.Full || output.Base != s.state || output.New != output.Base+1 {
83+ return remoteOutputState{}, false
84+ }
85+ return remoteOutputState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
86+ }
87+ if !output.Full || output.Base != 0 {
88+ return remoteOutputState{}, false
89+ }
90+ return remoteOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
91+}
92+
93+// openRemoteView returns the exact warm view for target or elects one
94+// constructor. The reservation is protected only by Daemon.mu; the dial and
95+// all handshake I/O happen after every architecture lock is released.
96+func (d *Daemon) openRemoteView(ctx context.Context, target domain.RemoteSessionTarget, size domain.Size) (*remoteView, error) {
97+ if d == nil {
98+ return nil, errors.New("remote view: nil daemon")
99+ }
100+ if ctx == nil {
101+ ctx = context.Background()
102+ }
103+ if err := target.Validate(); err != nil {
104+ return nil, fmt.Errorf("remote view: invalid target: %w", err)
105+ }
106+ if !size.Valid() {
107+ return nil, fmt.Errorf("remote view: invalid size %dx%d", size.Cols, size.Rows)
108+ }
109+ if d.remoteDialerFactory == nil {
110+ return nil, errors.New("remote view: remote dialer is not configured")
111+ }
112+ key, err := remoteViewKeyForTarget(target)
113+ if err != nil {
114+ return nil, fmt.Errorf("remote view: key: %w", err)
115+ }
116+
117+ for {
118+ if err := ctx.Err(); err != nil {
119+ return nil, err
120+ }
121+ d.mu.Lock()
122+ if existing := d.remoteViewByKeyLocked(key); existing != nil {
123+ d.mu.Unlock()
124+ return existing, nil
125+ }
126+ if d.closing {
127+ d.mu.Unlock()
128+ return nil, errors.New("remote view: daemon is shutting down")
129+ }
130+ if construction := d.remoteViewConstructions[key]; construction != nil {
131+ done := construction.done
132+ d.mu.Unlock()
133+ select {
134+ case <-ctx.Done():
135+ return nil, ctx.Err()
136+ case <-done:
137+ }
138+ if err := ctx.Err(); err != nil {
139+ return nil, err
140+ }
141+ d.mu.Lock()
142+ winner := d.remoteViewByKeyLocked(key)
143+ constructionErr := construction.err
144+ d.mu.Unlock()
145+ if winner != nil {
146+ return winner, nil
147+ }
148+ if constructionErr != nil && !errors.Is(constructionErr, context.Canceled) && !errors.Is(constructionErr, context.DeadlineExceeded) {
149+ return nil, constructionErr
150+ }
151+ // A canceled candidate never poisons an independent waiter. Re-elect
152+ // if the caller still wants this exact remote view.
153+ continue
154+ }
155+
156+ constructionCtx, cancel := context.WithCancel(ctx)
157+ construction := &remoteViewConstruction{done: make(chan struct{}), cancel: cancel}
158+ if d.remoteViewConstructions == nil {
159+ d.remoteViewConstructions = make(map[remoteViewKey]*remoteViewConstruction)
160+ }
161+ d.remoteViewConstructions[key] = construction
162+ d.mu.Unlock()
163+
164+ candidate, constructionErr := d.constructRemoteView(constructionCtx, target, size)
165+ if constructionErr == nil {
166+ constructionErr = ctx.Err()
167+ }
168+ cancel()
169+
170+ var winner *remoteView
171+ publishErr := constructionErr
172+ d.mu.Lock()
173+ if publishErr == nil {
174+ if d.closing {
175+ publishErr = errors.New("remote view: daemon is shutting down")
176+ } else if existing := d.remoteViewByKeyLocked(key); existing != nil {
177+ winner = existing
178+ } else if err := d.registerRemoteViewLocked(candidate); err != nil {
179+ publishErr = fmt.Errorf("remote view: publish candidate: %w", err)
180+ } else {
181+ winner = candidate
182+ // Starting the receiver while d.mu still protects publication keeps
183+ // shutdown from observing a registry-visible link with no worker
184+ // to join.
185+ d.startRemoteLink(candidate.link)
186+ }
187+ }
188+ construction.err = publishErr
189+ if d.remoteViewConstructions[key] == construction {
190+ delete(d.remoteViewConstructions, key)
191+ }
192+ close(construction.done)
193+ d.mu.Unlock()
194+
195+ if candidate != nil && candidate != winner {
196+ d.stopUnpublishedRemoteView(candidate)
197+ }
198+ if winner != nil {
199+ return winner, nil
200+ }
201+ return nil, publishErr
202+ }
203+}
204+
205+// constructRemoteView builds an unexposed candidate. Its caller owns a
206+// constructor reservation but holds no daemon, session, or view lock.
207+func (d *Daemon) constructRemoteView(ctx context.Context, target domain.RemoteSessionTarget, size domain.Size) (_ *remoteView, err error) {
208+ content := contentSize(size)
209+ if !content.Valid() {
210+ return nil, fmt.Errorf("remote view: invalid content size %dx%d", content.Cols, content.Rows)
211+ }
212+ key, err := remoteViewKeyForTarget(target)
213+ if err != nil {
214+ return nil, err
215+ }
216+ var clientID [16]byte
217+ if _, err := rand.Read(clientID[:]); err != nil {
218+ return nil, fmt.Errorf("remote view: generate client ID: %w", err)
219+ }
220+ root := context.Background()
221+ if d.serveCtx != nil {
222+ root = d.serveCtx
223+ }
224+ linkCtx, cancelLink := context.WithCancel(root)
225+ stopCallerCancellation := context.AfterFunc(ctx, cancelLink)
226+ defer stopCallerCancellation()
227+ view := &remoteView{
228+ key: key,
229+ displayOrigin: target.DisplayOrigin,
230+ screen: vt.NewScreen(content.Cols, content.Rows),
231+ linkGeneration: 1,
232+ }
233+ link := &remoteLink{
234+ view: view,
235+ generation: view.linkGeneration,
236+ target: target,
237+ clientID: clientID,
238+ cancel: cancelLink,
239+ done: make(chan struct{}),
240+ active: true,
241+ }
242+ view.link = link
243+ published := false
244+ defer func() {
245+ if !published {
246+ d.stopUnpublishedRemoteView(view)
247+ }
248+ }()
249+
250+ handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(linkCtx)
251+ defer finishHandshake()
252+ dialer, err := d.remoteDialerFactory.DialerForRemote(target.Endpoint, target.SessionName, d.remoteTransportMode, d.log)
253+ if err != nil {
254+ return nil, fmt.Errorf("remote view: select remote dialer: %w", err)
255+ }
256+ if dialer == nil {
257+ return nil, errors.New("remote view: remote dialer factory returned nil dialer")
258+ }
259+ transport, err := dialer.Dial(handshakeCtx)
260+ if err != nil {
261+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: dial: %w", err))
262+ }
263+ if transport == nil {
264+ return nil, errors.New("remote view: remote dialer returned nil transport")
265+ }
266+ link.transport = transport
267+ stopWatch := watchHandshakeTransport(handshakeCtx, transport)
268+ defer stopWatch()
269+
270+ helloTarget := target
271+ hello := ports.Hello{
272+ Version: ports.ProtocolVersion,
273+ Intent: ports.IntentAttach,
274+ ClientID: clientID,
275+ Name: target.SessionName,
276+ Size: content,
277+ RenderMode: ports.RenderModeProxiedContent,
278+ MaxOutputInFlight: remoteLinkOutputWindow(transport),
279+ RemoteTarget: &helloTarget,
280+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
281+ }
282+ if err := link.send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
283+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: send hello: %w", err))
284+ }
285+
286+ welcomeFrame, err := recvRemoteLinkHandshakeFrame(handshakeCtx, transport)
287+ if err != nil {
288+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: receive welcome: %w", err))
289+ }
290+ if err := validateRemoteLinkWelcome(welcomeFrame, target); err != nil {
291+ return nil, err
292+ }
293+ metadataFrame, err := recvRemoteLinkHandshakeFrame(handshakeCtx, transport)
294+ if err != nil {
295+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: receive metadata: %w", err))
296+ }
297+ metadata, err := validateInitialRemoteLinkMetadata(metadataFrame, target)
298+ if err != nil {
299+ return nil, err
300+ }
301+ if !view.applyRemoteMetadata(link, metadata, true) {
302+ return nil, errors.New("remote view: initial metadata became stale")
303+ }
304+ outputFrame, err := recvRemoteLinkHandshakeFrame(handshakeCtx, transport)
305+ if err != nil {
306+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: receive initial output: %w", err))
307+ }
308+ if outputFrame.Type != ports.MsgOutput {
309+ return nil, fmt.Errorf("remote view: third server frame is %d, want Output", outputFrame.Type)
310+ }
311+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
312+ if err != nil {
313+ return nil, fmt.Errorf("remote view: decode initial output: %w", err)
314+ }
315+ ack, reset, _, accepted := view.applyRemoteOutput(link, output)
316+ if !accepted || reset || output.New == 0 || !output.Full {
317+ return nil, errors.New("remote view: initial output is not an authoritative reset")
318+ }
319+ if ack != 0 {
320+ ackPayload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: ack})
321+ if err != nil {
322+ return nil, fmt.Errorf("remote view: encode initial output acknowledgement: %w", err)
323+ }
324+ if err := link.send(ports.Frame{Type: ports.MsgAck, Payload: ackPayload}); err != nil {
325+ return nil, handshakeContextError(ctx, timedOut, fmt.Errorf("remote view: acknowledge initial output: %w", err))
326+ }
327+ }
328+ if err := handshakeCtx.Err(); err != nil {
329+ return nil, handshakeContextError(ctx, timedOut, err)
330+ }
331+ published = true
332+ return view, nil
333+}
334+
335+func remoteLinkOutputWindow(transport ports.Transport) uint8 {
336+ if _, datagram := transport.(ports.DatagramTransport); datagram {
337+ return 1
338+ }
339+ return maxUnackedOutputStates
340+}
341+
342+func recvRemoteLinkHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
343+ var frame ports.Frame
344+ err := boundedHandshakeOperation(ctx, transport, func() error {
345+ var recvErr error
346+ frame, recvErr = transport.Recv()
347+ return recvErr
348+ })
349+ if err != nil {
350+ return ports.Frame{}, err
351+ }
352+ return frame, nil
353+}
354+
355+func validateRemoteLinkWelcome(frame ports.Frame, target domain.RemoteSessionTarget) error {
356+ if frame.Type == ports.MsgError {
357+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
358+ if err != nil {
359+ return fmt.Errorf("remote view: malformed remote error: %w", err)
360+ }
361+ return fmt.Errorf("remote view: remote rejected handshake (%d): %s", remoteErr.Code, remoteErr.Text)
362+ }
363+ if frame.Type != ports.MsgWelcome {
364+ return fmt.Errorf("remote view: first server frame is %d, want Welcome", frame.Type)
365+ }
366+ welcome, err := ports.UnmarshalWelcome(frame.Payload)
367+ if err != nil {
368+ return fmt.Errorf("remote view: decode welcome: %w", err)
369+ }
370+ if welcome.SessionID == "" || welcome.SessionName != target.SessionName || welcome.RenderMode != ports.RenderModeProxiedContent {
371+ return errors.New("remote view: welcome identity or render mode mismatch")
372+ }
373+ return nil
374+}
375+
376+func validateInitialRemoteLinkMetadata(frame ports.Frame, target domain.RemoteSessionTarget) (ports.SessionMeta, error) {
377+ if frame.Type != ports.MsgSessionMeta {
378+ return ports.SessionMeta{}, fmt.Errorf("remote view: second server frame is %d, want SessionMeta", frame.Type)
379+ }
380+ metadata, err := ports.UnmarshalSessionMeta(frame.Payload)
381+ if err != nil {
382+ return ports.SessionMeta{}, fmt.Errorf("remote view: decode initial metadata: %w", err)
383+ }
384+ if metadata.LifecycleID != target.LifecycleID || metadata.SessionName != target.SessionName || metadata.Revision != 1 {
385+ return ports.SessionMeta{}, errors.New("remote view: metadata identity mismatch")
386+ }
387+ if !target.Stopped && metadata.ActiveTabID != target.LiveTabID {
388+ return ports.SessionMeta{}, errors.New("remote view: metadata active tab mismatch")
389+ }
390+ return metadata, nil
391+}
392+
393+func cloneRemoteSessionMeta(metadata ports.SessionMeta) ports.SessionMeta {
394+ metadata.Tabs = append([]ports.SessionTabMeta(nil), metadata.Tabs...)
395+ return metadata
396+}
397+
398+// applyRemoteMetadata commits one validated update only for the exact current
399+// link. The view lock protects content state only; no renderer or callback runs
400+// before it is released.
401+func (v *remoteView) applyRemoteMetadata(link *remoteLink, metadata ports.SessionMeta, initial bool) bool {
402+ if v == nil || link == nil {
403+ return false
404+ }
405+ v.mu.Lock()
406+ defer v.mu.Unlock()
407+ if v.closed || v.link != link || v.linkGeneration != link.generation {
408+ return false
409+ }
410+ if initial {
411+ if v.metadata.Revision != 0 {
412+ return false
413+ }
414+ } else if metadata.Revision <= v.metadata.Revision {
415+ return false
416+ }
417+ v.metadata = cloneRemoteSessionMeta(metadata)
418+ return true
419+}
420+
421+// applyRemoteOutput validates and applies state-bearing ANSI to the persistent
422+// private VT. It returns only immutable post-commit work; callers send ACK or
423+// reset frames and repaint attachments after view.mu is released.
424+func (v *remoteView) applyRemoteOutput(link *remoteLink, output ports.Output) (ack uint64, reset bool, attachments []*attachedClient, accepted bool) {
425+ if v == nil || link == nil {
426+ return 0, false, nil, false
427+ }
428+ v.mu.Lock()
429+ defer v.mu.Unlock()
430+ if v.closed || v.link != link || v.linkGeneration != link.generation {
431+ return 0, false, nil, false
432+ }
433+ next, valid := v.output.next(output)
434+ if !valid || (output.New != 0 && (v.screen == nil || v.screen.Frame.Width != output.Size.Cols || v.screen.Frame.Height != output.Size.Rows)) {
435+ if v.resetRequested {
436+ return 0, false, nil, false
437+ }
438+ v.resetRequested = true
439+ return 0, true, nil, false
440+ }
441+ if output.New == 0 {
442+ return 0, false, nil, true
443+ }
444+ v.output = next
445+ v.resetRequested = false
446+ if len(output.Data) != 0 {
447+ v.screen.Write(output.Data)
448+ }
449+ attachments = make([]*attachedClient, 0, len(v.attachments))
450+ for attachment := range v.attachments {
451+ attachments = append(attachments, attachment)
452+ }
453+ return output.New, false, attachments, true
454+}
455+
456+func (d *Daemon) startRemoteLink(link *remoteLink) {
457+ if d == nil || link == nil || link.transport == nil {
458+ return
459+ }
460+ link.startOnce.Do(func() {
461+ go d.runRemoteLink(link)
462+ })
463+}
464+
465+func (d *Daemon) runRemoteLink(link *remoteLink) {
466+ defer close(link.done)
467+ for {
468+ frame, err := link.transport.Recv()
469+ if err != nil {
470+ d.markRemoteLinkUnavailable(link)
471+ return
472+ }
473+ if err := d.handleRemoteLinkFrame(link, frame); err != nil {
474+ d.log.Warn("remote view link stopped", "endpoint", link.target.Endpoint, "session", link.target.SessionName, "err", err)
475+ d.markRemoteLinkUnavailable(link)
476+ return
477+ }
478+ }
479+}
480+
481+func (d *Daemon) handleRemoteLinkFrame(link *remoteLink, frame ports.Frame) error {
482+ switch frame.Type {
483+ case ports.MsgOutput:
484+ output, err := ports.UnmarshalOutput(frame.Payload)
485+ if err != nil {
486+ return fmt.Errorf("remote view: decode output: %w", err)
487+ }
488+ ack, reset, attachments, accepted := link.view.applyRemoteOutput(link, output)
489+ if reset {
490+ if err := link.send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}); err != nil {
491+ return fmt.Errorf("remote view: request output reset: %w", err)
492+ }
493+ return nil
494+ }
495+ if !accepted {
496+ return nil
497+ }
498+ if ack != 0 {
499+ ackPayload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: ack})
500+ if err != nil {
501+ return fmt.Errorf("remote view: encode output acknowledgement: %w", err)
502+ }
503+ if err := link.send(ports.Frame{Type: ports.MsgAck, Payload: ackPayload}); err != nil {
504+ return fmt.Errorf("remote view: acknowledge output: %w", err)
505+ }
506+ }
507+ for _, attachment := range attachments {
508+ token := attachmentOwnerToken(link.view, attachment, attachment.transport())
509+ if token.ac != nil {
510+ go d.paintRemoteView(link.view, attachment, false, token)
511+ }
512+ }
513+ return nil
514+ case ports.MsgSessionMeta:
515+ metadata, err := ports.UnmarshalSessionMeta(frame.Payload)
516+ if err != nil {
517+ return fmt.Errorf("remote view: decode metadata: %w", err)
518+ }
519+ if metadata.LifecycleID != link.target.LifecycleID || metadata.SessionName != link.target.SessionName || !link.view.applyRemoteMetadata(link, metadata, false) {
520+ return errors.New("remote view: invalid metadata update")
521+ }
522+ return nil
523+ case ports.MsgPing:
524+ if _, err := ports.UnmarshalPing(frame.Payload); err != nil {
525+ return fmt.Errorf("remote view: decode ping: %w", err)
526+ }
527+ return link.send(ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})})
528+ case ports.MsgPong:
529+ _, err := ports.UnmarshalPong(frame.Payload)
530+ return err
531+ case ports.MsgDetached:
532+ _, err := ports.UnmarshalDetached(frame.Payload)
533+ if err != nil {
534+ return fmt.Errorf("remote view: decode detached: %w", err)
535+ }
536+ return errors.New("remote view: remote session detached")
537+ case ports.MsgError:
538+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
539+ if err != nil {
540+ return fmt.Errorf("remote view: malformed remote error: %w", err)
541+ }
542+ return fmt.Errorf("remote view: remote error (%d): %s", remoteErr.Code, remoteErr.Text)
543+ default:
544+ return fmt.Errorf("remote view: unexpected server frame type %d", frame.Type)
545+ }
546+}
547+
548+// send serializes the complete outbound remote stream. It captures the exact
549+// current transport under view.mu, releases every architecture lock, then lets
550+// Close interrupt a blocked Send concurrently.
551+func (link *remoteLink) send(frame ports.Frame) error {
552+ if link == nil || link.view == nil {
553+ return errRemoteViewUnavailable
554+ }
555+ link.sendMu.Lock()
556+ view := link.view
557+ view.mu.Lock()
558+ current := !view.closed && view.link == link && view.linkGeneration == link.generation && link.active
559+ transport := link.transport
560+ view.mu.Unlock()
561+ if !current || transport == nil {
562+ link.sendMu.Unlock()
563+ return errRemoteViewStale
564+ }
565+ err := transport.Send(frame)
566+ link.sendMu.Unlock()
567+ if err != nil {
568+ link.cancel()
569+ _ = transport.Close()
570+ }
571+ return err
572+}
573+
574+// handleRemoteViewInput forwards the raw local-terminal bytes and their
575+// original sequence through the exact current remote link. No local key,
576+// overlay, mouse, PTY, or persistence path observes those bytes.
577+func (d *Daemon) handleRemoteViewInput(view *remoteView, token attachmentConnectionToken, inputSeq uint64, data []byte) {
578+ if view == nil || len(data) == 0 || !token.attachmentEffectCurrent() {
579+ return
580+ }
581+ payload := ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)})
582+ if err := d.sendRemoteViewFrame(view, ports.Frame{Type: ports.MsgInput, Payload: payload}); err != nil {
583+ d.log.Warn("forwarding remote view input failed", "endpoint", view.key.endpoint, "session", view.key.sessionName, "err", err)
584+ }
585+}
586+
587+// sendRemoteViewFrame snapshots the exact link while holding only view.mu;
588+// remoteLink.send performs the generation check and serialized transport I/O.
589+func (d *Daemon) sendRemoteViewFrame(view *remoteView, frame ports.Frame) error {
590+ if view == nil {
591+ return errRemoteViewUnavailable
592+ }
593+ view.mu.Lock()
594+ link := view.link
595+ view.mu.Unlock()
596+ if link == nil {
597+ return errRemoteViewUnavailable
598+ }
599+ return link.send(frame)
600+}
601+
602+func (d *Daemon) markRemoteLinkUnavailable(link *remoteLink) {
603+ if link == nil || link.view == nil {
604+ return
605+ }
606+ view := link.view
607+ view.mu.Lock()
608+ current := !view.closed && view.link == link && view.linkGeneration == link.generation
609+ if current {
610+ link.active = false
611+ }
612+ view.mu.Unlock()
613+ if current {
614+ link.cancel()
615+ _ = link.transport.Close()
616+ }
617+}
618+
619+// stopUnpublishedRemoteView is used for candidate failure and lost-election
620+// cleanup. It never touches the daemon registry and closes only the candidate
621+// transport generation after view.mu is released.
622+func (d *Daemon) stopUnpublishedRemoteView(view *remoteView) {
623+ if view == nil {
624+ return
625+ }
626+ view.mu.Lock()
627+ view.closed = true
628+ link := view.link
629+ view.link = nil
630+ view.linkGeneration++
631+ view.mu.Unlock()
632+ if link != nil {
633+ link.cancel()
634+ if link.transport != nil {
635+ _ = link.transport.Close()
636+ }
637+ }
638+}
639+
640+// stopRemoteViewLink interrupts only the exact currently installed remote
641+// transport. It is called after registry publication has been revoked, so it
642+// cannot affect a successor view or link generation. The returned link has a
643+// started worker, which the terminal shutdown path joins after Close.
644+func (d *Daemon) stopRemoteViewLink(view *remoteView) *remoteLink {
645+ if view == nil {
646+ return nil
647+ }
648+ view.mu.Lock()
649+ link := view.link
650+ if link != nil {
651+ view.link = nil
652+ view.linkGeneration++
653+ link.active = false
654+ }
655+ view.mu.Unlock()
656+ if link != nil {
657+ link.cancel()
658+ if link.transport != nil {
659+ _ = link.transport.Close()
660+ }
661+ }
662+ return link
663+}
A internal/usecase/daemon/remote_link_test.go +383 -0
@@ -0,0 +1,383 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+type remoteLinkTestReceive struct {
17+ frame ports.Frame
18+ err error
19+}
20+
21+type remoteLinkTestTransport struct {
22+ recv chan remoteLinkTestReceive
23+ sent chan ports.Frame
24+ closed chan struct{}
25+ closeOnce sync.Once
26+}
27+
28+func newRemoteLinkTestTransport() *remoteLinkTestTransport {
29+ return &remoteLinkTestTransport{
30+ recv: make(chan remoteLinkTestReceive, 8),
31+ sent: make(chan ports.Frame, 8),
32+ closed: make(chan struct{}),
33+ }
34+}
35+
36+func (t *remoteLinkTestTransport) Send(frame ports.Frame) error {
37+ select {
38+ case <-t.closed:
39+ return io.EOF
40+ case t.sent <- frame:
41+ return nil
42+ }
43+}
44+
45+func (t *remoteLinkTestTransport) Recv() (ports.Frame, error) {
46+ select {
47+ case <-t.closed:
48+ return ports.Frame{}, io.EOF
49+ case received := <-t.recv:
50+ return received.frame, received.err
51+ }
52+}
53+
54+func (t *remoteLinkTestTransport) Close() error {
55+ t.closeOnce.Do(func() { close(t.closed) })
56+ return nil
57+}
58+
59+type remoteLinkTestDialer struct {
60+ transport ports.Transport
61+ started chan<- struct{}
62+ release <-chan struct{}
63+ once sync.Once
64+}
65+
66+func (d *remoteLinkTestDialer) Dial(ctx context.Context) (ports.Transport, error) {
67+ if d.started != nil {
68+ d.once.Do(func() { close(d.started) })
69+ }
70+ if d.release != nil {
71+ select {
72+ case <-ctx.Done():
73+ return nil, ctx.Err()
74+ case <-d.release:
75+ }
76+ }
77+ return d.transport, nil
78+}
79+
80+type remoteLinkTestFactory struct {
81+ dialer ports.Dialer
82+ calls atomic.Int32
83+}
84+
85+func (f *remoteLinkTestFactory) DialerForRemote(_ string, _ string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) {
86+ f.calls.Add(1)
87+ return f.dialer, nil
88+}
89+
90+func remoteLinkTestTarget() domain.RemoteSessionTarget {
91+ return domain.RemoteSessionTarget{
92+ Endpoint: "remote.example",
93+ DisplayOrigin: "remote.example",
94+ LifecycleID: domain.SessionLifecycleID{1},
95+ SessionName: "remote",
96+ LiveTabID: "tab-1",
97+ }
98+}
99+
100+func enqueueRemoteLinkHandshake(t *testing.T, transport *remoteLinkTestTransport, target domain.RemoteSessionTarget, size domain.Size) {
101+ t.Helper()
102+ content := contentSize(size)
103+ metaPayload, err := ports.MarshalSessionMeta(ports.SessionMeta{
104+ LifecycleID: target.LifecycleID,
105+ Revision: 1,
106+ SessionName: target.SessionName,
107+ ActiveTabID: target.LiveTabID,
108+ Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}},
109+ })
110+ require.NoError(t, err)
111+ outputPayload, err := ports.MarshalOutput(ports.Output{
112+ Epoch: 1, New: 1, Size: content, Full: true, Data: []byte("remote marker"),
113+ })
114+ require.NoError(t, err)
115+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
116+ SessionID: "remote-id", SessionName: target.SessionName, RenderMode: ports.RenderModeProxiedContent,
117+ Capabilities: ports.CapabilityResume,
118+ })}}
119+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: metaPayload}}
120+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgOutput, Payload: outputPayload}}
121+}
122+
123+func TestOpenRemoteViewPublishesHandshakeReadyCandidate(t *testing.T) {
124+ d := newTestDaemon(t, nil, stubClock{})
125+ target := remoteLinkTestTarget()
126+ size := domain.Size{Cols: 80, Rows: 24}
127+ transport := newRemoteLinkTestTransport()
128+ enqueueRemoteLinkHandshake(t, transport, target, size)
129+ factory := &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
130+ d.remoteDialerFactory = factory
131+ d.remoteTransportMode = ports.RemoteTransportUDP
132+
133+ view, err := d.openRemoteView(context.Background(), target, size)
134+ require.NoError(t, err)
135+ require.NotNil(t, view)
136+ require.Equal(t, int32(1), factory.calls.Load())
137+
138+ helloFrame := awaitFrame(t, transport.sent, ports.MsgHello)
139+ hello, err := ports.UnmarshalHello(helloFrame.Payload)
140+ require.NoError(t, err)
141+ require.Equal(t, ports.IntentAttach, hello.Intent)
142+ require.Equal(t, ports.RenderModeProxiedContent, hello.RenderMode)
143+ require.Equal(t, contentSize(size), hello.Size)
144+ require.Equal(t, ports.EnvironmentPolicyDaemonOwned, hello.EnvironmentPolicy)
145+ require.Equal(t, &target, hello.RemoteTarget)
146+
147+ ackFrame := awaitFrame(t, transport.sent, ports.MsgAck)
148+ ack, err := ports.UnmarshalAck(ackFrame.Payload)
149+ require.NoError(t, err)
150+ require.Equal(t, ports.Ack{Epoch: 1, State: 1}, ack)
151+
152+ d.mu.Lock()
153+ require.Same(t, view, d.remoteViewByKeyLocked(view.key))
154+ d.mu.Unlock()
155+ view.mu.Lock()
156+ require.Contains(t, screenLineText(view.screen, 0), "remote marker")
157+ require.Equal(t, target.LifecycleID, view.metadata.LifecycleID)
158+ require.Equal(t, target.LiveTabID, view.metadata.ActiveTabID)
159+ view.mu.Unlock()
160+
161+ require.NoError(t, transport.Close())
162+}
163+
164+func TestOpenRemoteViewCancellationClosesUnpublishedTransport(t *testing.T) {
165+ d := newTestDaemon(t, nil, stubClock{})
166+ target := remoteLinkTestTarget()
167+ size := domain.Size{Cols: 80, Rows: 24}
168+ transport := newRemoteLinkTestTransport()
169+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
170+
171+ ctx, cancel := context.WithCancel(context.Background())
172+ type result struct {
173+ view *remoteView
174+ err error
175+ }
176+ resultCh := make(chan result, 1)
177+ go func() {
178+ view, err := d.openRemoteView(ctx, target, size)
179+ resultCh <- result{view: view, err: err}
180+ }()
181+ awaitFrame(t, transport.sent, ports.MsgHello)
182+ cancel()
183+
184+ got := <-resultCh
185+ require.Nil(t, got.view)
186+ require.ErrorIs(t, got.err, context.Canceled)
187+ select {
188+ case <-transport.closed:
189+ default:
190+ t.Fatal("canceled candidate transport was not closed")
191+ }
192+ d.mu.Lock()
193+ require.Empty(t, d.remoteViews)
194+ require.Empty(t, d.remoteViewConstructions)
195+ d.mu.Unlock()
196+}
197+
198+func TestShutdownAllCancelsUnpublishedRemoteViewConstruction(t *testing.T) {
199+ d := newTestDaemon(t, nil, stubClock{})
200+ target := remoteLinkTestTarget()
201+ size := domain.Size{Cols: 80, Rows: 24}
202+ transport := newRemoteLinkTestTransport()
203+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
204+
205+ type result struct {
206+ view *remoteView
207+ err error
208+ }
209+ resultCh := make(chan result, 1)
210+ go func() {
211+ view, err := d.openRemoteView(context.Background(), target, size)
212+ resultCh <- result{view: view, err: err}
213+ }()
214+ awaitFrame(t, transport.sent, ports.MsgHello)
215+
216+ d.shutdownAll(ports.ReasonServerShutdown)
217+ got := awaitTestValue(t, resultCh, "remote view construction did not stop at shutdown")
218+ require.Nil(t, got.view)
219+ require.ErrorIs(t, got.err, context.Canceled)
220+ select {
221+ case <-transport.closed:
222+ default:
223+ t.Fatal("shutdown did not close the unpublished remote transport")
224+ }
225+ d.mu.Lock()
226+ require.Empty(t, d.remoteViews)
227+ require.Empty(t, d.remoteViewConstructions)
228+ d.mu.Unlock()
229+}
230+
231+func TestRemoteLinkAppliesAcceptedOutputAndRequestsOneResetForInvalidState(t *testing.T) {
232+ d := newTestDaemon(t, nil, stubClock{})
233+ target := remoteLinkTestTarget()
234+ size := domain.Size{Cols: 80, Rows: 24}
235+ transport := newRemoteLinkTestTransport()
236+ enqueueRemoteLinkHandshake(t, transport, target, size)
237+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
238+
239+ view, err := d.openRemoteView(context.Background(), target, size)
240+ require.NoError(t, err)
241+ awaitFrame(t, transport.sent, ports.MsgHello)
242+ awaitFrame(t, transport.sent, ports.MsgAck)
243+
244+ content := contentSize(size)
245+ incrementalPayload, err := ports.MarshalOutput(ports.Output{
246+ Epoch: 1, Base: 1, New: 2, Size: content, Data: []byte(" updated"),
247+ })
248+ require.NoError(t, err)
249+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgOutput, Payload: incrementalPayload}}
250+ ackFrame := awaitFrame(t, transport.sent, ports.MsgAck)
251+ ack, err := ports.UnmarshalAck(ackFrame.Payload)
252+ require.NoError(t, err)
253+ require.Equal(t, ports.Ack{Epoch: 1, State: 2}, ack)
254+ view.mu.Lock()
255+ require.Contains(t, screenLineText(view.screen, 0), "remote marker updated")
256+ view.mu.Unlock()
257+
258+ invalidPayload, err := ports.MarshalOutput(ports.Output{
259+ Epoch: 1, Base: 9, New: 10, Size: content, Data: []byte("must not apply"),
260+ })
261+ require.NoError(t, err)
262+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgOutput, Payload: invalidPayload}}
263+ awaitFrame(t, transport.sent, ports.MsgOutputResetRequest)
264+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgOutput, Payload: invalidPayload}}
265+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}}
266+ afterInvalid := awaitTestValue(t, transport.sent, "remote link did not process duplicate invalid output")
267+ require.Equal(t, ports.MsgPong, afterInvalid.Type, "duplicate invalid state must not trigger another reset")
268+
269+ view.mu.Lock()
270+ require.NotContains(t, screenLineText(view.screen, 0), "must not apply")
271+ view.mu.Unlock()
272+ require.NoError(t, transport.Close())
273+}
274+
275+func TestShutdownAllInterruptsPublishedRemoteViewLink(t *testing.T) {
276+ d := newTestDaemon(t, nil, stubClock{})
277+ target := remoteLinkTestTarget()
278+ size := domain.Size{Cols: 80, Rows: 24}
279+ transport := newRemoteLinkTestTransport()
280+ enqueueRemoteLinkHandshake(t, transport, target, size)
281+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
282+
283+ view, err := d.openRemoteView(context.Background(), target, size)
284+ require.NoError(t, err)
285+ awaitFrame(t, transport.sent, ports.MsgHello)
286+ awaitFrame(t, transport.sent, ports.MsgAck)
287+ view.mu.Lock()
288+ link := view.link
289+ view.mu.Unlock()
290+ require.NotNil(t, link)
291+
292+ d.shutdownAll(ports.ReasonServerShutdown)
293+ select {
294+ case <-transport.closed:
295+ default:
296+ t.Fatal("shutdown did not close the exact remote transport")
297+ }
298+ awaitTestCompletion(t, link.done, "remote link worker did not stop after shutdown")
299+ d.mu.Lock()
300+ require.Empty(t, d.remoteViews)
301+ d.mu.Unlock()
302+}
303+
304+func TestRemoteLinkForwardsRemoteViewInputAndContentResize(t *testing.T) {
305+ d := newTestDaemon(t, nil, stubClock{})
306+ target := remoteLinkTestTarget()
307+ size := domain.Size{Cols: 80, Rows: 24}
308+ transport := newRemoteLinkTestTransport()
309+ enqueueRemoteLinkHandshake(t, transport, target, size)
310+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
311+
312+ view, err := d.openRemoteView(context.Background(), target, size)
313+ require.NoError(t, err)
314+ awaitFrame(t, transport.sent, ports.MsgHello)
315+ awaitFrame(t, transport.sent, ports.MsgAck)
316+
317+ clientTransport, _ := newCapturingTransport(t)
318+ ac := &attachedClient{tr: clientTransport, output: newOutputStateStream(), size: size}
319+ ac.initOverlays()
320+ ac.setAttachmentOwner(view)
321+ view.mu.Lock()
322+ require.True(t, view.registerAttachmentLocked(ac))
323+ view.mu.Unlock()
324+ token := attachmentOwnerToken(view, ac, clientTransport)
325+ require.True(t, token.attachmentCurrent())
326+
327+ d.handleSequencedInputForAttachment(token, 42, []byte("remote input"))
328+ inputFrame := awaitFrame(t, transport.sent, ports.MsgInput)
329+ input, err := ports.UnmarshalInput(inputFrame.Payload)
330+ require.NoError(t, err)
331+ require.Equal(t, ports.Input{InputSeq: 42, Data: []byte("remote input")}, input)
332+
333+ require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 100, Rows: 30}))
334+ resizeFrame := awaitFrame(t, transport.sent, ports.MsgResize)
335+ resize, err := ports.UnmarshalResize(resizeFrame.Payload)
336+ require.NoError(t, err)
337+ require.Equal(t, domain.Size{Cols: 100, Rows: 28}, resize.Size)
338+
339+ require.NoError(t, transport.Close())
340+}
341+
342+func TestOpenRemoteViewElectsOneConstructorAndCanceledWaiterDoesNotPoisonWinner(t *testing.T) {
343+ d := newTestDaemon(t, nil, stubClock{})
344+ target := remoteLinkTestTarget()
345+ size := domain.Size{Cols: 80, Rows: 24}
346+ transport := newRemoteLinkTestTransport()
347+ enqueueRemoteLinkHandshake(t, transport, target, size)
348+ started := make(chan struct{})
349+ release := make(chan struct{})
350+ factory := &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport, started: started, release: release}}
351+ d.remoteDialerFactory = factory
352+
353+ type result struct {
354+ view *remoteView
355+ err error
356+ }
357+ ownerResult := make(chan result, 1)
358+ go func() {
359+ view, err := d.openRemoteView(context.Background(), target, size)
360+ ownerResult <- result{view: view, err: err}
361+ }()
362+ <-started
363+
364+ waiterCtx, cancelWaiter := context.WithCancel(context.Background())
365+ cancelWaiter()
366+ view, err := d.openRemoteView(waiterCtx, target, size)
367+ require.Nil(t, view)
368+ require.ErrorIs(t, err, context.Canceled)
369+ require.Equal(t, int32(1), factory.calls.Load())
370+
371+ close(release)
372+ owner := <-ownerResult
373+ require.NoError(t, owner.err)
374+ require.NotNil(t, owner.view)
375+ require.Equal(t, int32(1), factory.calls.Load())
376+
377+ warm, err := d.openRemoteView(context.Background(), target, size)
378+ require.NoError(t, err)
379+ require.Same(t, owner.view, warm)
380+ require.Equal(t, int32(1), factory.calls.Load())
381+
382+ require.NoError(t, transport.Close())
383+}
@@ -0,0 +1,383 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+type remoteLinkTestReceive struct {
17+ frame ports.Frame
18+ err error
19+}
20+
21+type remoteLinkTestTransport struct {
22+ recv chan remoteLinkTestReceive
23+ sent chan ports.Frame
24+ closed chan struct{}
25+ closeOnce sync.Once
26+}
27+
28+func newRemoteLinkTestTransport() *remoteLinkTestTransport {
29+ return &remoteLinkTestTransport{
30+ recv: make(chan remoteLinkTestReceive, 8),
31+ sent: make(chan ports.Frame, 8),
32+ closed: make(chan struct{}),
33+ }
34+}
35+
36+func (t *remoteLinkTestTransport) Send(frame ports.Frame) error {
37+ select {
38+ case <-t.closed:
39+ return io.EOF
40+ case t.sent <- frame:
41+ return nil
42+ }
43+}
44+
45+func (t *remoteLinkTestTransport) Recv() (ports.Frame, error) {
46+ select {
47+ case <-t.closed:
48+ return ports.Frame{}, io.EOF
49+ case received := <-t.recv:
50+ return received.frame, received.err
51+ }
52+}
53+
54+func (t *remoteLinkTestTransport) Close() error {
55+ t.closeOnce.Do(func() { close(t.closed) })
56+ return nil
57+}
58+
59+type remoteLinkTestDialer struct {
60+ transport ports.Transport
61+ started chan<- struct{}
62+ release <-chan struct{}
63+ once sync.Once
64+}
65+
66+func (d *remoteLinkTestDialer) Dial(ctx context.Context) (ports.Transport, error) {
67+ if d.started != nil {
68+ d.once.Do(func() { close(d.started) })
69+ }
70+ if d.release != nil {
71+ select {
72+ case <-ctx.Done():
73+ return nil, ctx.Err()
74+ case <-d.release:
75+ }
76+ }
77+ return d.transport, nil
78+}
79+
80+type remoteLinkTestFactory struct {
81+ dialer ports.Dialer
82+ calls atomic.Int32
83+}
84+
85+func (f *remoteLinkTestFactory) DialerForRemote(_ string, _ string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) {
86+ f.calls.Add(1)
87+ return f.dialer, nil
88+}
89+
90+func remoteLinkTestTarget() domain.RemoteSessionTarget {
91+ return domain.RemoteSessionTarget{
92+ Endpoint: "remote.example",
93+ DisplayOrigin: "remote.example",
94+ LifecycleID: domain.SessionLifecycleID{1},
95+ SessionName: "remote",
96+ LiveTabID: "tab-1",
97+ }
98+}
99+
100+func enqueueRemoteLinkHandshake(t *testing.T, transport *remoteLinkTestTransport, target domain.RemoteSessionTarget, size domain.Size) {
101+ t.Helper()
102+ content := contentSize(size)
103+ metaPayload, err := ports.MarshalSessionMeta(ports.SessionMeta{
104+ LifecycleID: target.LifecycleID,
105+ Revision: 1,
106+ SessionName: target.SessionName,
107+ ActiveTabID: target.LiveTabID,
108+ Tabs: []ports.SessionTabMeta{{ID: target.LiveTabID, Name: "main"}},
109+ })
110+ require.NoError(t, err)
111+ outputPayload, err := ports.MarshalOutput(ports.Output{
112+ Epoch: 1, New: 1, Size: content, Full: true, Data: []byte("remote marker"),
113+ })
114+ require.NoError(t, err)
115+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
116+ SessionID: "remote-id", SessionName: target.SessionName, RenderMode: ports.RenderModeProxiedContent,
117+ Capabilities: ports.CapabilityResume,
118+ })}}
119+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: metaPayload}}
120+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgOutput, Payload: outputPayload}}
121+}
122+
123+func TestOpenRemoteViewPublishesHandshakeReadyCandidate(t *testing.T) {
124+ d := newTestDaemon(t, nil, stubClock{})
125+ target := remoteLinkTestTarget()
126+ size := domain.Size{Cols: 80, Rows: 24}
127+ transport := newRemoteLinkTestTransport()
128+ enqueueRemoteLinkHandshake(t, transport, target, size)
129+ factory := &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
130+ d.remoteDialerFactory = factory
131+ d.remoteTransportMode = ports.RemoteTransportUDP
132+
133+ view, err := d.openRemoteView(context.Background(), target, size)
134+ require.NoError(t, err)
135+ require.NotNil(t, view)
136+ require.Equal(t, int32(1), factory.calls.Load())
137+
138+ helloFrame := awaitFrame(t, transport.sent, ports.MsgHello)
139+ hello, err := ports.UnmarshalHello(helloFrame.Payload)
140+ require.NoError(t, err)
141+ require.Equal(t, ports.IntentAttach, hello.Intent)
142+ require.Equal(t, ports.RenderModeProxiedContent, hello.RenderMode)
143+ require.Equal(t, contentSize(size), hello.Size)
144+ require.Equal(t, ports.EnvironmentPolicyDaemonOwned, hello.EnvironmentPolicy)
145+ require.Equal(t, &target, hello.RemoteTarget)
146+
147+ ackFrame := awaitFrame(t, transport.sent, ports.MsgAck)
148+ ack, err := ports.UnmarshalAck(ackFrame.Payload)
149+ require.NoError(t, err)
150+ require.Equal(t, ports.Ack{Epoch: 1, State: 1}, ack)
151+
152+ d.mu.Lock()
153+ require.Same(t, view, d.remoteViewByKeyLocked(view.key))
154+ d.mu.Unlock()
155+ view.mu.Lock()
156+ require.Contains(t, screenLineText(view.screen, 0), "remote marker")
157+ require.Equal(t, target.LifecycleID, view.metadata.LifecycleID)
158+ require.Equal(t, target.LiveTabID, view.metadata.ActiveTabID)
159+ view.mu.Unlock()
160+
161+ require.NoError(t, transport.Close())
162+}
163+
164+func TestOpenRemoteViewCancellationClosesUnpublishedTransport(t *testing.T) {
165+ d := newTestDaemon(t, nil, stubClock{})
166+ target := remoteLinkTestTarget()
167+ size := domain.Size{Cols: 80, Rows: 24}
168+ transport := newRemoteLinkTestTransport()
169+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
170+
171+ ctx, cancel := context.WithCancel(context.Background())
172+ type result struct {
173+ view *remoteView
174+ err error
175+ }
176+ resultCh := make(chan result, 1)
177+ go func() {
178+ view, err := d.openRemoteView(ctx, target, size)
179+ resultCh <- result{view: view, err: err}
180+ }()
181+ awaitFrame(t, transport.sent, ports.MsgHello)
182+ cancel()
183+
184+ got := <-resultCh
185+ require.Nil(t, got.view)
186+ require.ErrorIs(t, got.err, context.Canceled)
187+ select {
188+ case <-transport.closed:
189+ default:
190+ t.Fatal("canceled candidate transport was not closed")
191+ }
192+ d.mu.Lock()
193+ require.Empty(t, d.remoteViews)
194+ require.Empty(t, d.remoteViewConstructions)
195+ d.mu.Unlock()
196+}
197+
198+func TestShutdownAllCancelsUnpublishedRemoteViewConstruction(t *testing.T) {
199+ d := newTestDaemon(t, nil, stubClock{})
200+ target := remoteLinkTestTarget()
201+ size := domain.Size{Cols: 80, Rows: 24}
202+ transport := newRemoteLinkTestTransport()
203+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
204+
205+ type result struct {
206+ view *remoteView
207+ err error
208+ }
209+ resultCh := make(chan result, 1)
210+ go func() {
211+ view, err := d.openRemoteView(context.Background(), target, size)
212+ resultCh <- result{view: view, err: err}
213+ }()
214+ awaitFrame(t, transport.sent, ports.MsgHello)
215+
216+ d.shutdownAll(ports.ReasonServerShutdown)
217+ got := awaitTestValue(t, resultCh, "remote view construction did not stop at shutdown")
218+ require.Nil(t, got.view)
219+ require.ErrorIs(t, got.err, context.Canceled)
220+ select {
221+ case <-transport.closed:
222+ default:
223+ t.Fatal("shutdown did not close the unpublished remote transport")
224+ }
225+ d.mu.Lock()
226+ require.Empty(t, d.remoteViews)
227+ require.Empty(t, d.remoteViewConstructions)
228+ d.mu.Unlock()
229+}
230+
231+func TestRemoteLinkAppliesAcceptedOutputAndRequestsOneResetForInvalidState(t *testing.T) {
232+ d := newTestDaemon(t, nil, stubClock{})
233+ target := remoteLinkTestTarget()
234+ size := domain.Size{Cols: 80, Rows: 24}
235+ transport := newRemoteLinkTestTransport()
236+ enqueueRemoteLinkHandshake(t, transport, target, size)
237+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
238+
239+ view, err := d.openRemoteView(context.Background(), target, size)
240+ require.NoError(t, err)
241+ awaitFrame(t, transport.sent, ports.MsgHello)
242+ awaitFrame(t, transport.sent, ports.MsgAck)
243+
244+ content := contentSize(size)
245+ incrementalPayload, err := ports.MarshalOutput(ports.Output{
246+ Epoch: 1, Base: 1, New: 2, Size: content, Data: []byte(" updated"),
247+ })
248+ require.NoError(t, err)
249+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgOutput, Payload: incrementalPayload}}
250+ ackFrame := awaitFrame(t, transport.sent, ports.MsgAck)
251+ ack, err := ports.UnmarshalAck(ackFrame.Payload)
252+ require.NoError(t, err)
253+ require.Equal(t, ports.Ack{Epoch: 1, State: 2}, ack)
254+ view.mu.Lock()
255+ require.Contains(t, screenLineText(view.screen, 0), "remote marker updated")
256+ view.mu.Unlock()
257+
258+ invalidPayload, err := ports.MarshalOutput(ports.Output{
259+ Epoch: 1, Base: 9, New: 10, Size: content, Data: []byte("must not apply"),
260+ })
261+ require.NoError(t, err)
262+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgOutput, Payload: invalidPayload}}
263+ awaitFrame(t, transport.sent, ports.MsgOutputResetRequest)
264+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgOutput, Payload: invalidPayload}}
265+ transport.recv <- remoteLinkTestReceive{frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}}
266+ afterInvalid := awaitTestValue(t, transport.sent, "remote link did not process duplicate invalid output")
267+ require.Equal(t, ports.MsgPong, afterInvalid.Type, "duplicate invalid state must not trigger another reset")
268+
269+ view.mu.Lock()
270+ require.NotContains(t, screenLineText(view.screen, 0), "must not apply")
271+ view.mu.Unlock()
272+ require.NoError(t, transport.Close())
273+}
274+
275+func TestShutdownAllInterruptsPublishedRemoteViewLink(t *testing.T) {
276+ d := newTestDaemon(t, nil, stubClock{})
277+ target := remoteLinkTestTarget()
278+ size := domain.Size{Cols: 80, Rows: 24}
279+ transport := newRemoteLinkTestTransport()
280+ enqueueRemoteLinkHandshake(t, transport, target, size)
281+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
282+
283+ view, err := d.openRemoteView(context.Background(), target, size)
284+ require.NoError(t, err)
285+ awaitFrame(t, transport.sent, ports.MsgHello)
286+ awaitFrame(t, transport.sent, ports.MsgAck)
287+ view.mu.Lock()
288+ link := view.link
289+ view.mu.Unlock()
290+ require.NotNil(t, link)
291+
292+ d.shutdownAll(ports.ReasonServerShutdown)
293+ select {
294+ case <-transport.closed:
295+ default:
296+ t.Fatal("shutdown did not close the exact remote transport")
297+ }
298+ awaitTestCompletion(t, link.done, "remote link worker did not stop after shutdown")
299+ d.mu.Lock()
300+ require.Empty(t, d.remoteViews)
301+ d.mu.Unlock()
302+}
303+
304+func TestRemoteLinkForwardsRemoteViewInputAndContentResize(t *testing.T) {
305+ d := newTestDaemon(t, nil, stubClock{})
306+ target := remoteLinkTestTarget()
307+ size := domain.Size{Cols: 80, Rows: 24}
308+ transport := newRemoteLinkTestTransport()
309+ enqueueRemoteLinkHandshake(t, transport, target, size)
310+ d.remoteDialerFactory = &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport}}
311+
312+ view, err := d.openRemoteView(context.Background(), target, size)
313+ require.NoError(t, err)
314+ awaitFrame(t, transport.sent, ports.MsgHello)
315+ awaitFrame(t, transport.sent, ports.MsgAck)
316+
317+ clientTransport, _ := newCapturingTransport(t)
318+ ac := &attachedClient{tr: clientTransport, output: newOutputStateStream(), size: size}
319+ ac.initOverlays()
320+ ac.setAttachmentOwner(view)
321+ view.mu.Lock()
322+ require.True(t, view.registerAttachmentLocked(ac))
323+ view.mu.Unlock()
324+ token := attachmentOwnerToken(view, ac, clientTransport)
325+ require.True(t, token.attachmentCurrent())
326+
327+ d.handleSequencedInputForAttachment(token, 42, []byte("remote input"))
328+ inputFrame := awaitFrame(t, transport.sent, ports.MsgInput)
329+ input, err := ports.UnmarshalInput(inputFrame.Payload)
330+ require.NoError(t, err)
331+ require.Equal(t, ports.Input{InputSeq: 42, Data: []byte("remote input")}, input)
332+
333+ require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 100, Rows: 30}))
334+ resizeFrame := awaitFrame(t, transport.sent, ports.MsgResize)
335+ resize, err := ports.UnmarshalResize(resizeFrame.Payload)
336+ require.NoError(t, err)
337+ require.Equal(t, domain.Size{Cols: 100, Rows: 28}, resize.Size)
338+
339+ require.NoError(t, transport.Close())
340+}
341+
342+func TestOpenRemoteViewElectsOneConstructorAndCanceledWaiterDoesNotPoisonWinner(t *testing.T) {
343+ d := newTestDaemon(t, nil, stubClock{})
344+ target := remoteLinkTestTarget()
345+ size := domain.Size{Cols: 80, Rows: 24}
346+ transport := newRemoteLinkTestTransport()
347+ enqueueRemoteLinkHandshake(t, transport, target, size)
348+ started := make(chan struct{})
349+ release := make(chan struct{})
350+ factory := &remoteLinkTestFactory{dialer: &remoteLinkTestDialer{transport: transport, started: started, release: release}}
351+ d.remoteDialerFactory = factory
352+
353+ type result struct {
354+ view *remoteView
355+ err error
356+ }
357+ ownerResult := make(chan result, 1)
358+ go func() {
359+ view, err := d.openRemoteView(context.Background(), target, size)
360+ ownerResult <- result{view: view, err: err}
361+ }()
362+ <-started
363+
364+ waiterCtx, cancelWaiter := context.WithCancel(context.Background())
365+ cancelWaiter()
366+ view, err := d.openRemoteView(waiterCtx, target, size)
367+ require.Nil(t, view)
368+ require.ErrorIs(t, err, context.Canceled)
369+ require.Equal(t, int32(1), factory.calls.Load())
370+
371+ close(release)
372+ owner := <-ownerResult
373+ require.NoError(t, owner.err)
374+ require.NotNil(t, owner.view)
375+ require.Equal(t, int32(1), factory.calls.Load())
376+
377+ warm, err := d.openRemoteView(context.Background(), target, size)
378+ require.NoError(t, err)
379+ require.Same(t, owner.view, warm)
380+ require.Equal(t, int32(1), factory.calls.Load())
381+
382+ require.NoError(t, transport.Close())
383+}

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