detached 24e162b

feat(broker): serve selected-row previews over broker ipc

commit
24e162b273f7c109c0f1e9531cad075b77beb07a
author
brice · · 2026-09-22 19:23 UTC
parent
1a097f5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
23 files · +1698 -21
M internal/adapters/brokeripc/client.go +77 -9
@@ -211,15 +211,17 @@ type client struct {
211211 ctx context.Context
212212 cancel context.CancelFunc
213213
214- mu sync.Mutex
215- closed bool
216- err error
217- sub *clientSubscription
218- generation brokerwire.SubscriptionGeneration
219- nextStream ports.BrokerStreamID
220- snapshot ports.BrokerSnapshot
221- pending map[ports.BrokerOperationID]chan operationResult
222- kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind
214+ mu sync.Mutex
215+ closed bool
216+ err error
217+ sub *clientSubscription
218+ generation brokerwire.SubscriptionGeneration
219+ preview *clientPreviewSubscription
220+ previewGeneration ports.BrokerPreviewGeneration
221+ nextStream ports.BrokerStreamID
222+ snapshot ports.BrokerSnapshot
223+ pending map[ports.BrokerOperationID]chan operationResult
224+ kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind
223225 // The connection's own stream tracker is the anti-replay authority for
224226 // this connection: it admits every identity at most once inside the bounded
225227 // window, whether the open is afterwards accepted or refused.
@@ -269,6 +271,26 @@ func (c *client) dispatch(message brokerwire.ServerMessage) error {
269271 c.publish(snapshot)
270272 }
271273 return nil
274+ case brokerwire.PreviewPublication:
275+ if !c.scopeMatches(m.Epoch, m.Connection) {
276+ return errors.Join(ErrScopeMismatch, ErrProtocol)
277+ }
278+ c.mu.Lock()
279+ sub := c.preview
280+ active := sub != nil && c.previewGeneration == m.Generation
281+ c.mu.Unlock()
282+ if !active {
283+ return nil
284+ }
285+ publication := ports.BrokerPreviewPublication{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation, Target: sub.request.Preview.Target, Width: sub.request.Preview.Width, Height: sub.request.Preview.Height, Preview: m.Preview}
286+ if m.HasError {
287+ publication.Err = failureFromDetail(m.Error)
288+ }
289+ if err := publication.Validate(); err != nil {
290+ return errors.Join(ErrMalformedFrame, err)
291+ }
292+ sub.publish(publication)
293+ return nil
272294 case brokerwire.OperationResult:
273295 if !c.scopeMatches(m.Epoch, m.Connection) {
274296 return errors.Join(ErrScopeMismatch, ErrProtocol)
@@ -510,6 +532,47 @@ func (c *client) Subscribe() (ports.BrokerSubscription, error) {
510532 // before any frame travels, so a duplicate and an identity evicted past the
511533 // window are both refused, while a concurrent lower identity that was allocated
512534 // first is still admitted.
535+func (c *client) SubscribePreview(request ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
536+ if err := request.Validate(); err != nil {
537+ return nil, errors.Join(ports.BrokerAdmissionInvalid, err)
538+ }
539+ if request.Epoch != c.scope.Epoch || request.Connection != c.scope.Connection {
540+ return nil, ports.BrokerAdmissionStale
541+ }
542+ c.mu.Lock()
543+ if c.closed {
544+ err := c.terminalErrorLocked()
545+ c.mu.Unlock()
546+ return nil, err
547+ }
548+ if request.Generation <= c.previewGeneration {
549+ c.mu.Unlock()
550+ return nil, ports.BrokerAdmissionStale
551+ }
552+ previous := c.preview
553+ sub := newClientPreviewSubscription(c, request)
554+ c.preview, c.previewGeneration = sub, request.Generation
555+ c.mu.Unlock()
556+ if err := c.sendAsync(brokerwire.StartPreview{Epoch: request.Epoch, Connection: request.Connection, Generation: request.Generation, Route: brokerwire.OpenStream{Epoch: request.Route.Epoch, Connection: request.Route.Connection, Stream: request.Route.Stream, Purpose: request.Route.Purpose, Admission: request.Route.Admission, Name: request.Route.Name, Local: request.Route.Local, Endpoint: request.Route.Endpoint, Registration: request.Route.Registration, Target: request.Route.Target, Env: request.Route.Env, Policy: request.Route.Policy, StartMode: request.Route.StartMode}, Preview: request.Preview}); err != nil {
557+ // The generation was already consumed as the connection's current
558+ // preview generation, so the previous subscription is superseded even
559+ // though this frame never left. Retire both and leave the connection
560+ // with no current preview, mirroring Subscribe's send-error path.
561+ c.mu.Lock()
562+ if c.preview == sub {
563+ c.preview = nil
564+ }
565+ c.mu.Unlock()
566+ sub.Close()
567+ previous.Close()
568+ return nil, err
569+ }
570+ if previous != nil {
571+ previous.Close()
572+ }
573+ return sub, nil
574+}
575+
513576 func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
514577 scoped, err := c.scopeRequest(request)
515578 if err != nil {
@@ -884,6 +947,8 @@ func (c *client) closeWith(cause error) {
884947 terminal := c.terminalErrorLocked()
885948 sub := c.sub
886949 c.sub = nil
950+ preview := c.preview
951+ c.preview = nil
887952 streams := make([]*clientStream, 0, len(c.streams))
888953 for _, st := range c.streams {
889954 streams = append(streams, st)
@@ -896,6 +961,9 @@ func (c *client) closeWith(cause error) {
896961 if sub != nil {
897962 sub.Close()
898963 }
964+ if preview != nil {
965+ preview.Close()
966+ }
899967 close(c.done)
900968 })
901969 }
@@ -211,15 +211,17 @@ type client struct {
211 ctx context.Context211 ctx context.Context
212 cancel context.CancelFunc212 cancel context.CancelFunc
213 213
214- mu sync.Mutex214+ mu sync.Mutex
215- closed bool215+ closed bool
216- err error216+ err error
217- sub *clientSubscription217+ sub *clientSubscription
218- generation brokerwire.SubscriptionGeneration218+ generation brokerwire.SubscriptionGeneration
219- nextStream ports.BrokerStreamID219+ preview *clientPreviewSubscription
220- snapshot ports.BrokerSnapshot220+ previewGeneration ports.BrokerPreviewGeneration
221- pending map[ports.BrokerOperationID]chan operationResult221+ nextStream ports.BrokerStreamID
222- kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind222+ snapshot ports.BrokerSnapshot
223+ pending map[ports.BrokerOperationID]chan operationResult
224+ kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind
223 // The connection's own stream tracker is the anti-replay authority for225 // The connection's own stream tracker is the anti-replay authority for
224 // this connection: it admits every identity at most once inside the bounded226 // this connection: it admits every identity at most once inside the bounded
225 // window, whether the open is afterwards accepted or refused.227 // window, whether the open is afterwards accepted or refused.
@@ -269,6 +271,26 @@ func (c *client) dispatch(message brokerwire.ServerMessage) error {
269 c.publish(snapshot)271 c.publish(snapshot)
270 }272 }
271 return nil273 return nil
274+ case brokerwire.PreviewPublication:
275+ if !c.scopeMatches(m.Epoch, m.Connection) {
276+ return errors.Join(ErrScopeMismatch, ErrProtocol)
277+ }
278+ c.mu.Lock()
279+ sub := c.preview
280+ active := sub != nil && c.previewGeneration == m.Generation
281+ c.mu.Unlock()
282+ if !active {
283+ return nil
284+ }
285+ publication := ports.BrokerPreviewPublication{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation, Target: sub.request.Preview.Target, Width: sub.request.Preview.Width, Height: sub.request.Preview.Height, Preview: m.Preview}
286+ if m.HasError {
287+ publication.Err = failureFromDetail(m.Error)
288+ }
289+ if err := publication.Validate(); err != nil {
290+ return errors.Join(ErrMalformedFrame, err)
291+ }
292+ sub.publish(publication)
293+ return nil
272 case brokerwire.OperationResult:294 case brokerwire.OperationResult:
273 if !c.scopeMatches(m.Epoch, m.Connection) {295 if !c.scopeMatches(m.Epoch, m.Connection) {
274 return errors.Join(ErrScopeMismatch, ErrProtocol)296 return errors.Join(ErrScopeMismatch, ErrProtocol)
@@ -510,6 +532,47 @@ func (c *client) Subscribe() (ports.BrokerSubscription, error) {
510 // before any frame travels, so a duplicate and an identity evicted past the532 // before any frame travels, so a duplicate and an identity evicted past the
511 // window are both refused, while a concurrent lower identity that was allocated533 // window are both refused, while a concurrent lower identity that was allocated
512 // first is still admitted.534 // first is still admitted.
535+func (c *client) SubscribePreview(request ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
536+ if err := request.Validate(); err != nil {
537+ return nil, errors.Join(ports.BrokerAdmissionInvalid, err)
538+ }
539+ if request.Epoch != c.scope.Epoch || request.Connection != c.scope.Connection {
540+ return nil, ports.BrokerAdmissionStale
541+ }
542+ c.mu.Lock()
543+ if c.closed {
544+ err := c.terminalErrorLocked()
545+ c.mu.Unlock()
546+ return nil, err
547+ }
548+ if request.Generation <= c.previewGeneration {
549+ c.mu.Unlock()
550+ return nil, ports.BrokerAdmissionStale
551+ }
552+ previous := c.preview
553+ sub := newClientPreviewSubscription(c, request)
554+ c.preview, c.previewGeneration = sub, request.Generation
555+ c.mu.Unlock()
556+ if err := c.sendAsync(brokerwire.StartPreview{Epoch: request.Epoch, Connection: request.Connection, Generation: request.Generation, Route: brokerwire.OpenStream{Epoch: request.Route.Epoch, Connection: request.Route.Connection, Stream: request.Route.Stream, Purpose: request.Route.Purpose, Admission: request.Route.Admission, Name: request.Route.Name, Local: request.Route.Local, Endpoint: request.Route.Endpoint, Registration: request.Route.Registration, Target: request.Route.Target, Env: request.Route.Env, Policy: request.Route.Policy, StartMode: request.Route.StartMode}, Preview: request.Preview}); err != nil {
557+ // The generation was already consumed as the connection's current
558+ // preview generation, so the previous subscription is superseded even
559+ // though this frame never left. Retire both and leave the connection
560+ // with no current preview, mirroring Subscribe's send-error path.
561+ c.mu.Lock()
562+ if c.preview == sub {
563+ c.preview = nil
564+ }
565+ c.mu.Unlock()
566+ sub.Close()
567+ previous.Close()
568+ return nil, err
569+ }
570+ if previous != nil {
571+ previous.Close()
572+ }
573+ return sub, nil
574+}
575+
513 func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {576 func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
514 scoped, err := c.scopeRequest(request)577 scoped, err := c.scopeRequest(request)
515 if err != nil {578 if err != nil {
@@ -884,6 +947,8 @@ func (c *client) closeWith(cause error) {
884 terminal := c.terminalErrorLocked()947 terminal := c.terminalErrorLocked()
885 sub := c.sub948 sub := c.sub
886 c.sub = nil949 c.sub = nil
950+ preview := c.preview
951+ c.preview = nil
887 streams := make([]*clientStream, 0, len(c.streams))952 streams := make([]*clientStream, 0, len(c.streams))
888 for _, st := range c.streams {953 for _, st := range c.streams {
889 streams = append(streams, st)954 streams = append(streams, st)
@@ -896,6 +961,9 @@ func (c *client) closeWith(cause error) {
896 if sub != nil {961 if sub != nil {
897 sub.Close()962 sub.Close()
898 }963 }
964+ if preview != nil {
965+ preview.Close()
966+ }
899 close(c.done)967 close(c.done)
900 })968 })
901 }969 }
A internal/adapters/brokeripc/client_preview.go +56 -0
@@ -0,0 +1,56 @@
1+package brokeripc
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/adapters/brokerwire"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/protocol"
9+)
10+
11+type clientPreviewSubscription struct {
12+ client *client
13+ request ports.BrokerPreviewRequest
14+ mu sync.Mutex
15+ latest ports.BrokerPreviewPublication
16+ changed chan struct{}
17+ once sync.Once
18+}
19+
20+func newClientPreviewSubscription(c *client, request ports.BrokerPreviewRequest) *clientPreviewSubscription {
21+ return &clientPreviewSubscription{client: c, request: request, changed: make(chan struct{}, 1), latest: ports.BrokerPreviewPublication{
22+ Epoch: request.Epoch, Connection: request.Connection, Generation: request.Generation, Target: request.Preview.Target,
23+ Width: request.Preview.Width, Height: request.Preview.Height, Err: ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "preview pending"},
24+ }}
25+}
26+func (s *clientPreviewSubscription) Changed() <-chan struct{} { return s.changed }
27+func (s *clientPreviewSubscription) Latest() ports.BrokerPreviewPublication {
28+ s.mu.Lock()
29+ defer s.mu.Unlock()
30+ return s.latest
31+}
32+func (s *clientPreviewSubscription) publish(p ports.BrokerPreviewPublication) {
33+ s.mu.Lock()
34+ s.latest = p
35+ s.mu.Unlock()
36+ select {
37+ case s.changed <- struct{}{}:
38+ default:
39+ }
40+}
41+func (s *clientPreviewSubscription) Close() {
42+ s.once.Do(func() {
43+ s.client.mu.Lock()
44+ active := s.client.preview == s
45+ if active {
46+ s.client.preview = nil
47+ }
48+ s.client.mu.Unlock()
49+ if active {
50+ _ = s.client.sendAsync(brokerwire.CancelPreview{Epoch: s.request.Epoch, Connection: s.request.Connection, Generation: s.request.Generation})
51+ }
52+ })
53+}
54+
55+var _ ports.BrokerPreviewSubscription = (*clientPreviewSubscription)(nil)
56+var _ = protocol.RemotePreview{}
@@ -0,0 +1,56 @@
1+package brokeripc
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/vev/internal/adapters/brokerwire"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/protocol"
9+)
10+
11+type clientPreviewSubscription struct {
12+ client *client
13+ request ports.BrokerPreviewRequest
14+ mu sync.Mutex
15+ latest ports.BrokerPreviewPublication
16+ changed chan struct{}
17+ once sync.Once
18+}
19+
20+func newClientPreviewSubscription(c *client, request ports.BrokerPreviewRequest) *clientPreviewSubscription {
21+ return &clientPreviewSubscription{client: c, request: request, changed: make(chan struct{}, 1), latest: ports.BrokerPreviewPublication{
22+ Epoch: request.Epoch, Connection: request.Connection, Generation: request.Generation, Target: request.Preview.Target,
23+ Width: request.Preview.Width, Height: request.Preview.Height, Err: ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "preview pending"},
24+ }}
25+}
26+func (s *clientPreviewSubscription) Changed() <-chan struct{} { return s.changed }
27+func (s *clientPreviewSubscription) Latest() ports.BrokerPreviewPublication {
28+ s.mu.Lock()
29+ defer s.mu.Unlock()
30+ return s.latest
31+}
32+func (s *clientPreviewSubscription) publish(p ports.BrokerPreviewPublication) {
33+ s.mu.Lock()
34+ s.latest = p
35+ s.mu.Unlock()
36+ select {
37+ case s.changed <- struct{}{}:
38+ default:
39+ }
40+}
41+func (s *clientPreviewSubscription) Close() {
42+ s.once.Do(func() {
43+ s.client.mu.Lock()
44+ active := s.client.preview == s
45+ if active {
46+ s.client.preview = nil
47+ }
48+ s.client.mu.Unlock()
49+ if active {
50+ _ = s.client.sendAsync(brokerwire.CancelPreview{Epoch: s.request.Epoch, Connection: s.request.Connection, Generation: s.request.Generation})
51+ }
52+ })
53+}
54+
55+var _ ports.BrokerPreviewSubscription = (*clientPreviewSubscription)(nil)
56+var _ = protocol.RemotePreview{}
M internal/adapters/brokeripc/helpers_test.go +31 -0
@@ -423,6 +423,37 @@ func (c *fakeCore) Snapshot() ports.BrokerSnapshot { return c.hub.current() }
423423
424424 func (c *fakeCore) Subscribe() (ports.BrokerSubscription, error) { return c.hub.subscribe(), nil }
425425
426+// fakePreviewSubscription is one server-side preview subscription whose
427+// Changed channel a test drives explicitly.
428+type fakePreviewSubscription struct {
429+ changed chan struct{}
430+ once sync.Once
431+ mu sync.Mutex
432+ closed bool
433+}
434+
435+func newFakePreviewSubscription() *fakePreviewSubscription {
436+ return &fakePreviewSubscription{changed: make(chan struct{}, 1)}
437+}
438+
439+func (s *fakePreviewSubscription) Changed() <-chan struct{} { return s.changed }
440+
441+func (s *fakePreviewSubscription) Latest() ports.BrokerPreviewPublication {
442+ return ports.BrokerPreviewPublication{}
443+}
444+
445+func (s *fakePreviewSubscription) Close() {
446+ s.once.Do(func() {
447+ s.mu.Lock()
448+ s.closed = true
449+ s.mu.Unlock()
450+ })
451+}
452+
453+func (c *fakeCore) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
454+ return newFakePreviewSubscription(), nil
455+}
456+
426457 func (c *fakeCore) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
427458 c.mu.Lock()
428459 defer c.mu.Unlock()
@@ -423,6 +423,37 @@ func (c *fakeCore) Snapshot() ports.BrokerSnapshot { return c.hub.current() }
423 423
424 func (c *fakeCore) Subscribe() (ports.BrokerSubscription, error) { return c.hub.subscribe(), nil }424 func (c *fakeCore) Subscribe() (ports.BrokerSubscription, error) { return c.hub.subscribe(), nil }
425 425
426+// fakePreviewSubscription is one server-side preview subscription whose
427+// Changed channel a test drives explicitly.
428+type fakePreviewSubscription struct {
429+ changed chan struct{}
430+ once sync.Once
431+ mu sync.Mutex
432+ closed bool
433+}
434+
435+func newFakePreviewSubscription() *fakePreviewSubscription {
436+ return &fakePreviewSubscription{changed: make(chan struct{}, 1)}
437+}
438+
439+func (s *fakePreviewSubscription) Changed() <-chan struct{} { return s.changed }
440+
441+func (s *fakePreviewSubscription) Latest() ports.BrokerPreviewPublication {
442+ return ports.BrokerPreviewPublication{}
443+}
444+
445+func (s *fakePreviewSubscription) Close() {
446+ s.once.Do(func() {
447+ s.mu.Lock()
448+ s.closed = true
449+ s.mu.Unlock()
450+ })
451+}
452+
453+func (c *fakeCore) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
454+ return newFakePreviewSubscription(), nil
455+}
456+
426 func (c *fakeCore) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {457 func (c *fakeCore) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
427 c.mu.Lock()458 c.mu.Lock()
428 defer c.mu.Unlock()459 defer c.mu.Unlock()
A internal/adapters/brokeripc/preview.go +96 -0
@@ -0,0 +1,96 @@
1+package brokeripc
2+
3+import (
4+ "errors"
5+ "sync"
6+
7+ "github.com/bnema/vev/internal/adapters/brokerwire"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+func (s *serverSession) startPreview(m brokerwire.StartPreview) error {
12+ request := ports.BrokerPreviewRequest{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation,
13+ Route: ports.BrokerOpenStreamRequest{Epoch: m.Route.Epoch, Connection: m.Route.Connection, Stream: m.Route.Stream, Purpose: m.Route.Purpose, Admission: m.Route.Admission, Name: m.Route.Name, Local: m.Route.Local, Endpoint: m.Route.Endpoint, Registration: m.Route.Registration, Target: m.Route.Target, Env: m.Route.Env, Policy: m.Route.Policy, StartMode: m.Route.StartMode}, Preview: m.Preview}
14+ s.subMu.Lock()
15+ if m.Generation <= s.previewGeneration {
16+ s.subMu.Unlock()
17+ return s.sendError(ports.BrokerAdmissionStale)
18+ }
19+ s.subMu.Unlock()
20+ sub, err := s.core.SubscribePreview(request)
21+ if err != nil {
22+ return s.sendError(err)
23+ }
24+ forwarder := newPreviewForwarder(sub, request.Generation)
25+ s.subMu.Lock()
26+ previous := s.preview
27+ previousForward := s.previewForward
28+ s.preview, s.previewGeneration, s.previewForward = sub, m.Generation, forwarder
29+ s.subMu.Unlock()
30+ if previousForward != nil {
31+ previousForward.stop()
32+ }
33+ if previous != nil {
34+ previous.Close()
35+ }
36+ go s.forwardPreview(sub, forwarder, request)
37+ return nil
38+}
39+
40+// previewForwarder is the replacement signal for one server-side preview
41+// forward loop. A replaced subscription's Changed channel never signals
42+// again, so the loop cannot notice its replacement by waiting on it: closing
43+// stopped wakes the loop so it exits instead of parking until session
44+// shutdown. It is safe for concurrent use and idempotent.
45+type previewForwarder struct {
46+ stopped chan struct{}
47+ once sync.Once
48+}
49+
50+func newPreviewForwarder(sub ports.BrokerPreviewSubscription, generation ports.BrokerPreviewGeneration) *previewForwarder {
51+ _ = sub
52+ _ = generation
53+ return &previewForwarder{stopped: make(chan struct{})}
54+}
55+
56+func (f *previewForwarder) stop() {
57+ if f == nil {
58+ return
59+ }
60+ f.once.Do(func() { close(f.stopped) })
61+}
62+
63+func (s *serverSession) forwardPreview(sub ports.BrokerPreviewSubscription, forwarder *previewForwarder, request ports.BrokerPreviewRequest) {
64+ for {
65+ select {
66+ case <-s.ctx.Done():
67+ return
68+ case <-forwarder.stopped:
69+ return
70+ case <-sub.Changed():
71+ s.subMu.Lock()
72+ active := s.preview == sub && s.previewGeneration == request.Generation
73+ s.subMu.Unlock()
74+ if !active {
75+ return
76+ }
77+ publication := sub.Latest()
78+ message := brokerwire.PreviewPublication{Epoch: publication.Epoch, Connection: publication.Connection, Generation: publication.Generation, Preview: publication.Preview}
79+ if publication.Err != nil {
80+ message.HasError = true
81+ message.Error = errorDetail(publication.Err)
82+ }
83+ if err := s.send(message); err != nil {
84+ s.cancel()
85+ return
86+ }
87+ }
88+ }
89+}
90+
91+func (s *serverSession) sendError(err error) error {
92+ if errors.Is(err, ports.BrokerAdmissionStale) {
93+ return s.send(brokerwire.BrokerErrorMessage{Epoch: s.scope.Epoch, Connection: s.scope.Connection, Error: errorDetail(err)})
94+ }
95+ return err
96+}
@@ -0,0 +1,96 @@
1+package brokeripc
2+
3+import (
4+ "errors"
5+ "sync"
6+
7+ "github.com/bnema/vev/internal/adapters/brokerwire"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+func (s *serverSession) startPreview(m brokerwire.StartPreview) error {
12+ request := ports.BrokerPreviewRequest{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation,
13+ Route: ports.BrokerOpenStreamRequest{Epoch: m.Route.Epoch, Connection: m.Route.Connection, Stream: m.Route.Stream, Purpose: m.Route.Purpose, Admission: m.Route.Admission, Name: m.Route.Name, Local: m.Route.Local, Endpoint: m.Route.Endpoint, Registration: m.Route.Registration, Target: m.Route.Target, Env: m.Route.Env, Policy: m.Route.Policy, StartMode: m.Route.StartMode}, Preview: m.Preview}
14+ s.subMu.Lock()
15+ if m.Generation <= s.previewGeneration {
16+ s.subMu.Unlock()
17+ return s.sendError(ports.BrokerAdmissionStale)
18+ }
19+ s.subMu.Unlock()
20+ sub, err := s.core.SubscribePreview(request)
21+ if err != nil {
22+ return s.sendError(err)
23+ }
24+ forwarder := newPreviewForwarder(sub, request.Generation)
25+ s.subMu.Lock()
26+ previous := s.preview
27+ previousForward := s.previewForward
28+ s.preview, s.previewGeneration, s.previewForward = sub, m.Generation, forwarder
29+ s.subMu.Unlock()
30+ if previousForward != nil {
31+ previousForward.stop()
32+ }
33+ if previous != nil {
34+ previous.Close()
35+ }
36+ go s.forwardPreview(sub, forwarder, request)
37+ return nil
38+}
39+
40+// previewForwarder is the replacement signal for one server-side preview
41+// forward loop. A replaced subscription's Changed channel never signals
42+// again, so the loop cannot notice its replacement by waiting on it: closing
43+// stopped wakes the loop so it exits instead of parking until session
44+// shutdown. It is safe for concurrent use and idempotent.
45+type previewForwarder struct {
46+ stopped chan struct{}
47+ once sync.Once
48+}
49+
50+func newPreviewForwarder(sub ports.BrokerPreviewSubscription, generation ports.BrokerPreviewGeneration) *previewForwarder {
51+ _ = sub
52+ _ = generation
53+ return &previewForwarder{stopped: make(chan struct{})}
54+}
55+
56+func (f *previewForwarder) stop() {
57+ if f == nil {
58+ return
59+ }
60+ f.once.Do(func() { close(f.stopped) })
61+}
62+
63+func (s *serverSession) forwardPreview(sub ports.BrokerPreviewSubscription, forwarder *previewForwarder, request ports.BrokerPreviewRequest) {
64+ for {
65+ select {
66+ case <-s.ctx.Done():
67+ return
68+ case <-forwarder.stopped:
69+ return
70+ case <-sub.Changed():
71+ s.subMu.Lock()
72+ active := s.preview == sub && s.previewGeneration == request.Generation
73+ s.subMu.Unlock()
74+ if !active {
75+ return
76+ }
77+ publication := sub.Latest()
78+ message := brokerwire.PreviewPublication{Epoch: publication.Epoch, Connection: publication.Connection, Generation: publication.Generation, Preview: publication.Preview}
79+ if publication.Err != nil {
80+ message.HasError = true
81+ message.Error = errorDetail(publication.Err)
82+ }
83+ if err := s.send(message); err != nil {
84+ s.cancel()
85+ return
86+ }
87+ }
88+ }
89+}
90+
91+func (s *serverSession) sendError(err error) error {
92+ if errors.Is(err, ports.BrokerAdmissionStale) {
93+ return s.send(brokerwire.BrokerErrorMessage{Epoch: s.scope.Epoch, Connection: s.scope.Connection, Error: errorDetail(err)})
94+ }
95+ return err
96+}
A internal/adapters/brokeripc/preview_lifecycle_test.go +158 -0
@@ -0,0 +1,158 @@
1+package brokeripc
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+)
13+
14+// previewTarget builds one valid live preview target on the canonical local
15+// authority: it still requires a non-empty route identity for the local daemon.
16+func previewTarget() domain.RemoteSessionTarget {
17+ return domain.RemoteSessionTarget{
18+ Endpoint: "local",
19+ DisplayOrigin: "local",
20+ LifecycleID: domain.SessionLifecycleID{0x11},
21+ SessionName: "work",
22+ LiveTabID: "tab-alpha",
23+ }
24+}
25+
26+// previewRequest builds one valid generation-N preview request for a client:
27+// an exact observation route carrying the caller's own allocated stream plus
28+// the exact session/tab target and bounded viewport sent to the daemon. The
29+// endpoint epoch is assigned at accept, so the test takes it explicitly.
30+func previewRequest(t *testing.T, epoch ports.BrokerEpoch, client ports.BrokerService, generation ports.BrokerPreviewGeneration) ports.BrokerPreviewRequest {
31+ t.Helper()
32+ stream := nextStreamID(t, client)
33+ target := previewTarget()
34+ route := openRequest(stream)
35+ route.Epoch = epoch
36+ route.Connection = client.ConnectionID()
37+ route.Purpose = ports.BrokerStreamObservation
38+ route.StartMode = ports.BrokerDaemonExistingOnly
39+ request := ports.BrokerPreviewRequest{
40+ Epoch: route.Epoch,
41+ Connection: route.Connection,
42+ Generation: generation,
43+ Route: route,
44+ Preview: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 80, Height: 24},
45+ }
46+ require.NoError(t, request.Validate())
47+ return request
48+}
49+
50+// awaitPreviewChanged waits for one preview Changed wake under an explicit
51+// bound, so a subscription that never publishes is a bounded test failure.
52+func awaitPreviewChanged(t *testing.T, sub ports.BrokerPreviewSubscription) ports.BrokerPreviewPublication {
53+ t.Helper()
54+ select {
55+ case <-sub.Changed():
56+ return sub.Latest()
57+ case <-time.After(5 * time.Second):
58+ t.Fatal("the preview subscription did not publish within the bound")
59+ return ports.BrokerPreviewPublication{}
60+ }
61+}
62+
63+// serverPreviewGeneration returns the server session's current preview
64+// generation: it observes the same replacement bookkeeping startPreview
65+// maintains, so a test can prove which generation the server holds current.
66+func serverPreviewGeneration(session ports.BrokerService) ports.BrokerPreviewGeneration {
67+ server, ok := session.(*serverSession)
68+ if !ok {
69+ return 0
70+ }
71+ server.subMu.Lock()
72+ defer server.subMu.Unlock()
73+ return server.previewGeneration
74+}
75+
76+// serverPreviewOf returns the server session's current preview subscription.
77+func serverPreviewOf(session ports.BrokerService) ports.BrokerPreviewSubscription {
78+ server, ok := session.(*serverSession)
79+ if !ok {
80+ return nil
81+ }
82+ server.subMu.Lock()
83+ defer server.subMu.Unlock()
84+ return server.preview
85+}
86+
87+// TestPreviewReplacementClosesPrevious proves GO-001/GO-003 on the wire: a
88+// second SubscribePreview with a newer generation replaces the first on both
89+// ends. The client retires the first subscription, and the server keeps only
90+// the newest generation as its current preview.
91+func TestPreviewReplacementClosesPrevious(t *testing.T) {
92+ e := startEndpoint(t, Config{})
93+ client, session := e.pair()
94+
95+ _, err := client.SubscribePreview(previewRequest(t, e.epoch, client, 1))
96+ require.NoError(t, err)
97+ require.Eventually(t, func() bool { return serverPreviewOf(session) != nil }, 5*time.Second, 10*time.Millisecond, "the first generation must register server-side")
98+
99+ second, err := client.SubscribePreview(previewRequest(t, e.epoch, client, 2))
100+ require.NoError(t, err)
101+ require.NotNil(t, second)
102+
103+ require.Eventually(t, func() bool { return serverPreviewGeneration(session) == 2 }, 5*time.Second, 10*time.Millisecond, "the second generation must replace the first server-side")
104+}
105+
106+// TestPreviewCancelGenerationFencesStaleCancel proves the CancelPreview
107+// generation match: cancelling a superseded generation leaves the current
108+// preview registered, while cancelling the current generation clears it.
109+func TestPreviewCancelGenerationFencesStaleCancel(t *testing.T) {
110+ e := startEndpoint(t, Config{})
111+ client, session := e.pair()
112+
113+ first, err := client.SubscribePreview(previewRequest(t, e.epoch, client, 1))
114+ require.NoError(t, err)
115+ second, err := client.SubscribePreview(previewRequest(t, e.epoch, client, 2))
116+ require.NoError(t, err)
117+
118+ require.Eventually(t, func() bool { return serverPreviewGeneration(session) == 2 }, 5*time.Second, 10*time.Millisecond, "the second generation must register server-side")
119+
120+ // A stale cancel for the superseded first generation must not disturb the
121+ // current second generation.
122+ first.Close()
123+ require.Never(t, func() bool { return serverPreviewOf(session) == nil }, 200*time.Millisecond, 10*time.Millisecond, "a stale cancel must not clear the current preview")
124+
125+ second.Close()
126+ require.Eventually(t, func() bool { return serverPreviewOf(session) == nil }, 5*time.Second, 10*time.Millisecond, "cancelling the current generation must clear the preview")
127+}
128+
129+// TestPreviewCloseClearsCurrent proves GO-004: closing the client connection
130+// retires its current preview subscription instead of relying on transport
131+// EOF for server teardown.
132+func TestPreviewCloseClearsCurrent(t *testing.T) {
133+ e := startEndpoint(t, Config{})
134+ client, session := e.pair()
135+
136+ sub, err := client.SubscribePreview(previewRequest(t, e.epoch, client, 1))
137+ require.NoError(t, err)
138+ _ = sub
139+ require.Eventually(t, func() bool { return serverPreviewOf(session) != nil }, 5*time.Second, 10*time.Millisecond, "the preview must register server-side")
140+
141+ require.NoError(t, client.Close())
142+ require.Eventually(t, func() bool { return serverPreviewOf(session) == nil }, 5*time.Second, 10*time.Millisecond, "closing the client must retire its preview server-side")
143+}
144+
145+// TestPreviewShutdownClearsServerState proves the server shutdown path stops
146+// the replaced forwarder bookkeeping: a session that held a preview releases
147+// it when the session shuts down.
148+func TestPreviewShutdownClearsServerState(t *testing.T) {
149+ e := startEndpoint(t, Config{})
150+ client, session := e.pair()
151+
152+ _, err := client.SubscribePreview(previewRequest(t, e.epoch, client, 1))
153+ require.NoError(t, err)
154+ require.Eventually(t, func() bool { return serverPreviewOf(session) != nil }, 5*time.Second, 10*time.Millisecond, "the preview must register server-side")
155+
156+ require.NoError(t, session.Close())
157+ require.Eventually(t, func() bool { return serverPreviewOf(session) == nil }, 5*time.Second, 10*time.Millisecond, "session shutdown must release the preview")
158+}
@@ -0,0 +1,158 @@
1+package brokeripc
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+)
13+
14+// previewTarget builds one valid live preview target on the canonical local
15+// authority: it still requires a non-empty route identity for the local daemon.
16+func previewTarget() domain.RemoteSessionTarget {
17+ return domain.RemoteSessionTarget{
18+ Endpoint: "local",
19+ DisplayOrigin: "local",
20+ LifecycleID: domain.SessionLifecycleID{0x11},
21+ SessionName: "work",
22+ LiveTabID: "tab-alpha",
23+ }
24+}
25+
26+// previewRequest builds one valid generation-N preview request for a client:
27+// an exact observation route carrying the caller's own allocated stream plus
28+// the exact session/tab target and bounded viewport sent to the daemon. The
29+// endpoint epoch is assigned at accept, so the test takes it explicitly.
30+func previewRequest(t *testing.T, epoch ports.BrokerEpoch, client ports.BrokerService, generation ports.BrokerPreviewGeneration) ports.BrokerPreviewRequest {
31+ t.Helper()
32+ stream := nextStreamID(t, client)
33+ target := previewTarget()
34+ route := openRequest(stream)
35+ route.Epoch = epoch
36+ route.Connection = client.ConnectionID()
37+ route.Purpose = ports.BrokerStreamObservation
38+ route.StartMode = ports.BrokerDaemonExistingOnly
39+ request := ports.BrokerPreviewRequest{
40+ Epoch: route.Epoch,
41+ Connection: route.Connection,
42+ Generation: generation,
43+ Route: route,
44+ Preview: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 80, Height: 24},
45+ }
46+ require.NoError(t, request.Validate())
47+ return request
48+}
49+
50+// awaitPreviewChanged waits for one preview Changed wake under an explicit
51+// bound, so a subscription that never publishes is a bounded test failure.
52+func awaitPreviewChanged(t *testing.T, sub ports.BrokerPreviewSubscription) ports.BrokerPreviewPublication {
53+ t.Helper()
54+ select {
55+ case <-sub.Changed():
56+ return sub.Latest()
57+ case <-time.After(5 * time.Second):
58+ t.Fatal("the preview subscription did not publish within the bound")
59+ return ports.BrokerPreviewPublication{}
60+ }
61+}
62+
63+// serverPreviewGeneration returns the server session's current preview
64+// generation: it observes the same replacement bookkeeping startPreview
65+// maintains, so a test can prove which generation the server holds current.
66+func serverPreviewGeneration(session ports.BrokerService) ports.BrokerPreviewGeneration {
67+ server, ok := session.(*serverSession)
68+ if !ok {
69+ return 0
70+ }
71+ server.subMu.Lock()
72+ defer server.subMu.Unlock()
73+ return server.previewGeneration
74+}
75+
76+// serverPreviewOf returns the server session's current preview subscription.
77+func serverPreviewOf(session ports.BrokerService) ports.BrokerPreviewSubscription {
78+ server, ok := session.(*serverSession)
79+ if !ok {
80+ return nil
81+ }
82+ server.subMu.Lock()
83+ defer server.subMu.Unlock()
84+ return server.preview
85+}
86+
87+// TestPreviewReplacementClosesPrevious proves GO-001/GO-003 on the wire: a
88+// second SubscribePreview with a newer generation replaces the first on both
89+// ends. The client retires the first subscription, and the server keeps only
90+// the newest generation as its current preview.
91+func TestPreviewReplacementClosesPrevious(t *testing.T) {
92+ e := startEndpoint(t, Config{})
93+ client, session := e.pair()
94+
95+ _, err := client.SubscribePreview(previewRequest(t, e.epoch, client, 1))
96+ require.NoError(t, err)
97+ require.Eventually(t, func() bool { return serverPreviewOf(session) != nil }, 5*time.Second, 10*time.Millisecond, "the first generation must register server-side")
98+
99+ second, err := client.SubscribePreview(previewRequest(t, e.epoch, client, 2))
100+ require.NoError(t, err)
101+ require.NotNil(t, second)
102+
103+ require.Eventually(t, func() bool { return serverPreviewGeneration(session) == 2 }, 5*time.Second, 10*time.Millisecond, "the second generation must replace the first server-side")
104+}
105+
106+// TestPreviewCancelGenerationFencesStaleCancel proves the CancelPreview
107+// generation match: cancelling a superseded generation leaves the current
108+// preview registered, while cancelling the current generation clears it.
109+func TestPreviewCancelGenerationFencesStaleCancel(t *testing.T) {
110+ e := startEndpoint(t, Config{})
111+ client, session := e.pair()
112+
113+ first, err := client.SubscribePreview(previewRequest(t, e.epoch, client, 1))
114+ require.NoError(t, err)
115+ second, err := client.SubscribePreview(previewRequest(t, e.epoch, client, 2))
116+ require.NoError(t, err)
117+
118+ require.Eventually(t, func() bool { return serverPreviewGeneration(session) == 2 }, 5*time.Second, 10*time.Millisecond, "the second generation must register server-side")
119+
120+ // A stale cancel for the superseded first generation must not disturb the
121+ // current second generation.
122+ first.Close()
123+ require.Never(t, func() bool { return serverPreviewOf(session) == nil }, 200*time.Millisecond, 10*time.Millisecond, "a stale cancel must not clear the current preview")
124+
125+ second.Close()
126+ require.Eventually(t, func() bool { return serverPreviewOf(session) == nil }, 5*time.Second, 10*time.Millisecond, "cancelling the current generation must clear the preview")
127+}
128+
129+// TestPreviewCloseClearsCurrent proves GO-004: closing the client connection
130+// retires its current preview subscription instead of relying on transport
131+// EOF for server teardown.
132+func TestPreviewCloseClearsCurrent(t *testing.T) {
133+ e := startEndpoint(t, Config{})
134+ client, session := e.pair()
135+
136+ sub, err := client.SubscribePreview(previewRequest(t, e.epoch, client, 1))
137+ require.NoError(t, err)
138+ _ = sub
139+ require.Eventually(t, func() bool { return serverPreviewOf(session) != nil }, 5*time.Second, 10*time.Millisecond, "the preview must register server-side")
140+
141+ require.NoError(t, client.Close())
142+ require.Eventually(t, func() bool { return serverPreviewOf(session) == nil }, 5*time.Second, 10*time.Millisecond, "closing the client must retire its preview server-side")
143+}
144+
145+// TestPreviewShutdownClearsServerState proves the server shutdown path stops
146+// the replaced forwarder bookkeeping: a session that held a preview releases
147+// it when the session shuts down.
148+func TestPreviewShutdownClearsServerState(t *testing.T) {
149+ e := startEndpoint(t, Config{})
150+ client, session := e.pair()
151+
152+ _, err := client.SubscribePreview(previewRequest(t, e.epoch, client, 1))
153+ require.NoError(t, err)
154+ require.Eventually(t, func() bool { return serverPreviewOf(session) != nil }, 5*time.Second, 10*time.Millisecond, "the preview must register server-side")
155+
156+ require.NoError(t, session.Close())
157+ require.Eventually(t, func() bool { return serverPreviewOf(session) == nil }, 5*time.Second, 10*time.Millisecond, "session shutdown must release the preview")
158+}
M internal/adapters/brokeripc/server.go +34 -2
@@ -482,8 +482,11 @@ type serverSession struct {
482482 cancel context.CancelFunc
483483 wg sync.WaitGroup
484484
485- subMu sync.Mutex
486- pub *publisher
485+ subMu sync.Mutex
486+ pub *publisher
487+ preview ports.BrokerPreviewSubscription
488+ previewGeneration ports.BrokerPreviewGeneration
489+ previewForward *previewForwarder
487490
488491 streamsMu sync.Mutex
489492 streams map[ports.BrokerStreamID]*serverStream
@@ -749,6 +752,22 @@ func (s *serverSession) dispatch(message brokerwire.ClientMessage) error {
749752 }
750753 s.closeStreamByPeer(m.Stream)
751754 return nil
755+ case brokerwire.StartPreview:
756+ if !s.scopeMatches(m.Epoch, m.Connection) {
757+ return s.refuseScope()
758+ }
759+ return s.startPreview(m)
760+ case brokerwire.CancelPreview:
761+ if !s.scopeMatches(m.Epoch, m.Connection) {
762+ return s.refuseScope()
763+ }
764+ s.subMu.Lock()
765+ if s.preview != nil && m.Generation == s.previewGeneration {
766+ s.preview.Close()
767+ s.preview = nil
768+ }
769+ s.subMu.Unlock()
770+ return nil
752771 default:
753772 return errors.Join(ErrProtocol, fmt.Errorf("brokeripc: unexpected client message %T", message))
754773 }
@@ -839,6 +858,16 @@ func (s *serverSession) abort(err error) {
839858 func (s *serverSession) shutdown() {
840859 s.cancel()
841860 s.stopPublisher()
861+ s.subMu.Lock()
862+ if s.previewForward != nil {
863+ s.previewForward.stop()
864+ s.previewForward = nil
865+ }
866+ if s.preview != nil {
867+ s.preview.Close()
868+ s.preview = nil
869+ }
870+ s.subMu.Unlock()
842871 _ = s.transport.Close()
843872 s.closeStreams()
844873 _ = s.conn.BeginClose()
@@ -936,6 +965,9 @@ func (s *serverSession) Snapshot() ports.BrokerSnapshot { return s.core.Snapshot
936965 // protocol's Subscribe is driven by the connection's reader, which publishes
937966 // the broker core's newest snapshot for the client's generation.
938967 func (s *serverSession) Subscribe() (ports.BrokerSubscription, error) { return s.core.Subscribe() }
968+func (s *serverSession) SubscribePreview(request ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
969+ return s.core.SubscribePreview(request)
970+}
939971
940972 // OpenStream delegates one scope-checked stream request to the admitted core
941973 // service. The connection's own reader drives the same core through
@@ -482,8 +482,11 @@ type serverSession struct {
482 cancel context.CancelFunc482 cancel context.CancelFunc
483 wg sync.WaitGroup483 wg sync.WaitGroup
484 484
485- subMu sync.Mutex485+ subMu sync.Mutex
486- pub *publisher486+ pub *publisher
487+ preview ports.BrokerPreviewSubscription
488+ previewGeneration ports.BrokerPreviewGeneration
489+ previewForward *previewForwarder
487 490
488 streamsMu sync.Mutex491 streamsMu sync.Mutex
489 streams map[ports.BrokerStreamID]*serverStream492 streams map[ports.BrokerStreamID]*serverStream
@@ -749,6 +752,22 @@ func (s *serverSession) dispatch(message brokerwire.ClientMessage) error {
749 }752 }
750 s.closeStreamByPeer(m.Stream)753 s.closeStreamByPeer(m.Stream)
751 return nil754 return nil
755+ case brokerwire.StartPreview:
756+ if !s.scopeMatches(m.Epoch, m.Connection) {
757+ return s.refuseScope()
758+ }
759+ return s.startPreview(m)
760+ case brokerwire.CancelPreview:
761+ if !s.scopeMatches(m.Epoch, m.Connection) {
762+ return s.refuseScope()
763+ }
764+ s.subMu.Lock()
765+ if s.preview != nil && m.Generation == s.previewGeneration {
766+ s.preview.Close()
767+ s.preview = nil
768+ }
769+ s.subMu.Unlock()
770+ return nil
752 default:771 default:
753 return errors.Join(ErrProtocol, fmt.Errorf("brokeripc: unexpected client message %T", message))772 return errors.Join(ErrProtocol, fmt.Errorf("brokeripc: unexpected client message %T", message))
754 }773 }
@@ -839,6 +858,16 @@ func (s *serverSession) abort(err error) {
839 func (s *serverSession) shutdown() {858 func (s *serverSession) shutdown() {
840 s.cancel()859 s.cancel()
841 s.stopPublisher()860 s.stopPublisher()
861+ s.subMu.Lock()
862+ if s.previewForward != nil {
863+ s.previewForward.stop()
864+ s.previewForward = nil
865+ }
866+ if s.preview != nil {
867+ s.preview.Close()
868+ s.preview = nil
869+ }
870+ s.subMu.Unlock()
842 _ = s.transport.Close()871 _ = s.transport.Close()
843 s.closeStreams()872 s.closeStreams()
844 _ = s.conn.BeginClose()873 _ = s.conn.BeginClose()
@@ -936,6 +965,9 @@ func (s *serverSession) Snapshot() ports.BrokerSnapshot { return s.core.Snapshot
936 // protocol's Subscribe is driven by the connection's reader, which publishes965 // protocol's Subscribe is driven by the connection's reader, which publishes
937 // the broker core's newest snapshot for the client's generation.966 // the broker core's newest snapshot for the client's generation.
938 func (s *serverSession) Subscribe() (ports.BrokerSubscription, error) { return s.core.Subscribe() }967 func (s *serverSession) Subscribe() (ports.BrokerSubscription, error) { return s.core.Subscribe() }
968+func (s *serverSession) SubscribePreview(request ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
969+ return s.core.SubscribePreview(request)
970+}
939 971
940 // OpenStream delegates one scope-checked stream request to the admitted core972 // OpenStream delegates one scope-checked stream request to the admitted core
941 // service. The connection's own reader drives the same core through973 // service. The connection's own reader drives the same core through
M internal/adapters/ipc/listener.go +11 -2
@@ -1,6 +1,7 @@
11 package ipc
22
33 import (
4+ "crypto/sha256"
45 "errors"
56 "fmt"
67 "net"
@@ -179,12 +180,20 @@ func (l *unixListener) Addr() string {
179180
180181 // SocketDir returns the directory vev's daemon should place its socket in:
181182 // $XDG_RUNTIME_DIR/vev if set, else /run/user/<uid>/vev if that directory
182-// exists, else /tmp/vev-<uid>.
183+// exists, else /tmp/vev-<uid>. An XDG root too long for the longest
184+// production endpoint (broker/broker.sock) uses a deterministic, per-user
185+// private directory in /tmp instead. Hash the complete root so isolated
186+// environments never fall back to the ordinary user's daemon. Listeners and
187+// lifecycle locks still validate ownership and mode; no symlink is introduced.
183188 func SocketDir() string {
184189 uid := os.Getuid()
185190
186191 if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
187- return filepath.Join(xdg, "vev")
192+ dir := filepath.Join(xdg, "vev")
193+ if len(filepath.Join(dir, "broker", "broker.sock")) > muxSocketPathMax {
194+ return fmt.Sprintf("/tmp/vev-%d-%x", uid, sha256.Sum256([]byte(dir)))
195+ }
196+ return dir
188197 }
189198
190199 runUser := fmt.Sprintf("/run/user/%d", uid)
@@ -1,6 +1,7 @@
1 package ipc1 package ipc
2 2
3 import (3 import (
4+ "crypto/sha256"
4 "errors"5 "errors"
5 "fmt"6 "fmt"
6 "net"7 "net"
@@ -179,12 +180,20 @@ func (l *unixListener) Addr() string {
179 180
180 // SocketDir returns the directory vev's daemon should place its socket in:181 // SocketDir returns the directory vev's daemon should place its socket in:
181 // $XDG_RUNTIME_DIR/vev if set, else /run/user/<uid>/vev if that directory182 // $XDG_RUNTIME_DIR/vev if set, else /run/user/<uid>/vev if that directory
182-// exists, else /tmp/vev-<uid>.183+// exists, else /tmp/vev-<uid>. An XDG root too long for the longest
184+// production endpoint (broker/broker.sock) uses a deterministic, per-user
185+// private directory in /tmp instead. Hash the complete root so isolated
186+// environments never fall back to the ordinary user's daemon. Listeners and
187+// lifecycle locks still validate ownership and mode; no symlink is introduced.
183 func SocketDir() string {188 func SocketDir() string {
184 uid := os.Getuid()189 uid := os.Getuid()
185 190
186 if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {191 if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
187- return filepath.Join(xdg, "vev")192+ dir := filepath.Join(xdg, "vev")
193+ if len(filepath.Join(dir, "broker", "broker.sock")) > muxSocketPathMax {
194+ return fmt.Sprintf("/tmp/vev-%d-%x", uid, sha256.Sum256([]byte(dir)))
195+ }
196+ return dir
188 }197 }
189 198
190 runUser := fmt.Sprintf("/run/user/%d", uid)199 runUser := fmt.Sprintf("/run/user/%d", uid)
M internal/adapters/ipc/listener_test.go +27 -0
@@ -181,3 +181,30 @@ func TestSocketDirFallsBackWithoutXDGRuntimeDir(t *testing.T) {
181181 t.Fatalf("SocketDir() = %q, want %q", got, want)
182182 }
183183 }
184+
185+func TestSocketDirLongXDGRoot(t *testing.T) {
186+ // Production broker uses the longest suffix; the daemon mux must resolve
187+ // through the same short directory, not the user's ordinary live runtime.
188+ for _, length := range []int{103, 104, 250} {
189+ t.Run(strconv.Itoa(length), func(t *testing.T) {
190+ root := "/" + strings.Repeat("x", length-len("/vev/broker/broker.sock")-1)
191+ t.Setenv("XDG_RUNTIME_DIR", root)
192+ dir := SocketDir()
193+ if length == 103 && dir != filepath.Join(root, "vev") {
194+ t.Fatal("portable path should not move")
195+ }
196+ for _, suffix := range []string{"broker/broker.sock", "daemonmux.sock", "daemon.sock"} {
197+ if err := validateMuxSocketPath(filepath.Join(dir, suffix)); err != nil {
198+ t.Fatal(err)
199+ }
200+ }
201+ if SocketDir() != dir {
202+ t.Fatal("runtime mapping is not stable")
203+ }
204+ t.Setenv("XDG_RUNTIME_DIR", root+"y")
205+ if SocketDir() == dir {
206+ t.Fatal("distinct environments share a runtime")
207+ }
208+ })
209+ }
210+}
@@ -181,3 +181,30 @@ func TestSocketDirFallsBackWithoutXDGRuntimeDir(t *testing.T) {
181 t.Fatalf("SocketDir() = %q, want %q", got, want)181 t.Fatalf("SocketDir() = %q, want %q", got, want)
182 }182 }
183 }183 }
184+
185+func TestSocketDirLongXDGRoot(t *testing.T) {
186+ // Production broker uses the longest suffix; the daemon mux must resolve
187+ // through the same short directory, not the user's ordinary live runtime.
188+ for _, length := range []int{103, 104, 250} {
189+ t.Run(strconv.Itoa(length), func(t *testing.T) {
190+ root := "/" + strings.Repeat("x", length-len("/vev/broker/broker.sock")-1)
191+ t.Setenv("XDG_RUNTIME_DIR", root)
192+ dir := SocketDir()
193+ if length == 103 && dir != filepath.Join(root, "vev") {
194+ t.Fatal("portable path should not move")
195+ }
196+ for _, suffix := range []string{"broker/broker.sock", "daemonmux.sock", "daemon.sock"} {
197+ if err := validateMuxSocketPath(filepath.Join(dir, suffix)); err != nil {
198+ t.Fatal(err)
199+ }
200+ }
201+ if SocketDir() != dir {
202+ t.Fatal("runtime mapping is not stable")
203+ }
204+ t.Setenv("XDG_RUNTIME_DIR", root+"y")
205+ if SocketDir() == dir {
206+ t.Fatal("distinct environments share a runtime")
207+ }
208+ })
209+ }
210+}
M internal/app/broker_client_test.go +4 -0
@@ -165,6 +165,10 @@ func (s *terminalCompositionService) Subscribe() (ports.BrokerSubscription, erro
165165 return terminalCompositionSubscription{changed: s.changed}, nil
166166 }
167167
168+func (s *terminalCompositionService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
169+ return nil, errors.New("terminal composition service does not support preview")
170+}
171+
168172 func (s *terminalCompositionService) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
169173 s.mu.Lock()
170174 if s.closed {
@@ -165,6 +165,10 @@ func (s *terminalCompositionService) Subscribe() (ports.BrokerSubscription, erro
165 return terminalCompositionSubscription{changed: s.changed}, nil165 return terminalCompositionSubscription{changed: s.changed}, nil
166 }166 }
167 167
168+func (s *terminalCompositionService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
169+ return nil, errors.New("terminal composition service does not support preview")
170+}
171+
168 func (s *terminalCompositionService) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {172 func (s *terminalCompositionService) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
169 s.mu.Lock()173 s.mu.Lock()
170 if s.closed {174 if s.closed {
M internal/app/broker_offline.go +6 -0
@@ -267,6 +267,12 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
267267 var configPath string
268268 var err error
269269 if options.production {
270+ if err := ensureProductionBrokerConfig(); err != nil {
271+ return err
272+ }
273+ if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil {
274+ return fmt.Errorf("vev: secure broker runtime parent: %w", err)
275+ }
270276 layout = productionBrokerLayout()
271277 configPath = productionBrokerConfigPath()
272278 } else {
@@ -267,6 +267,12 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
267 var configPath string267 var configPath string
268 var err error268 var err error
269 if options.production {269 if options.production {
270+ if err := ensureProductionBrokerConfig(); err != nil {
271+ return err
272+ }
273+ if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil {
274+ return fmt.Errorf("vev: secure broker runtime parent: %w", err)
275+ }
270 layout = productionBrokerLayout()276 layout = productionBrokerLayout()
271 configPath = productionBrokerConfigPath()277 configPath = productionBrokerConfigPath()
272 } else {278 } else {
M internal/app/broker_production.go +31 -0
@@ -1,6 +1,9 @@
11 package app
22
33 import (
4+ "errors"
5+ "fmt"
6+ "os"
47 "path/filepath"
58
69 "github.com/bnema/vev/internal/adapters/brokerconfig"
@@ -9,6 +12,7 @@ import (
912 "github.com/bnema/vev/internal/ports"
1013 "github.com/bnema/vev/internal/protocol"
1114 "github.com/bnema/vev/internal/protocol/catalogue"
15+ "github.com/bnema/vev/pkg/safedir"
1216 )
1317
1418 // productionBrokerLayout is the single production broker filesystem contract.
@@ -18,6 +22,33 @@ func productionBrokerConfigPath() string {
1822 return filepath.Join(filepath.Dir(platform.ConfigPath()), "broker.json")
1923 }
2024
25+// ensureProductionBrokerConfig creates the empty production bootstrap on first
26+// use. Existing files are never rewritten: malformed or insecure operator
27+// configuration must still fail closed in brokerconfig.LoadProduction.
28+func ensureProductionBrokerConfig() error {
29+ path := productionBrokerConfigPath()
30+ if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
31+ return fmt.Errorf("vev: secure broker configuration directory: %w", err)
32+ }
33+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
34+ if errors.Is(err, os.ErrExist) {
35+ return nil
36+ }
37+ if err != nil {
38+ return fmt.Errorf("vev: create broker configuration: %w", err)
39+ }
40+ _, writeErr := file.WriteString(`{"marker":"vev.broker.offline/v1","registrations":[]}`)
41+ closeErr := file.Close()
42+ if writeErr == nil && closeErr == nil {
43+ return nil
44+ }
45+ removeErr := os.Remove(path)
46+ if writeErr != nil {
47+ return errors.Join(fmt.Errorf("vev: write broker configuration: %w", writeErr), closeErr, removeErr)
48+ }
49+ return errors.Join(fmt.Errorf("vev: close broker configuration: %w", closeErr), removeErr)
50+}
51+
2152 func remoteBrokerPolicy(transport string) ports.BrokerPolicy {
2253 if transport == "stdio" {
2354 transport = "ssh-stdio"
@@ -1,6 +1,9 @@
1 package app1 package app
2 2
3 import (3 import (
4+ "errors"
5+ "fmt"
6+ "os"
4 "path/filepath"7 "path/filepath"
5 8
6 "github.com/bnema/vev/internal/adapters/brokerconfig"9 "github.com/bnema/vev/internal/adapters/brokerconfig"
@@ -9,6 +12,7 @@ import (
9 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"13 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/protocol/catalogue"14 "github.com/bnema/vev/internal/protocol/catalogue"
15+ "github.com/bnema/vev/pkg/safedir"
12 )16 )
13 17
14 // productionBrokerLayout is the single production broker filesystem contract.18 // productionBrokerLayout is the single production broker filesystem contract.
@@ -18,6 +22,33 @@ func productionBrokerConfigPath() string {
18 return filepath.Join(filepath.Dir(platform.ConfigPath()), "broker.json")22 return filepath.Join(filepath.Dir(platform.ConfigPath()), "broker.json")
19 }23 }
20 24
25+// ensureProductionBrokerConfig creates the empty production bootstrap on first
26+// use. Existing files are never rewritten: malformed or insecure operator
27+// configuration must still fail closed in brokerconfig.LoadProduction.
28+func ensureProductionBrokerConfig() error {
29+ path := productionBrokerConfigPath()
30+ if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
31+ return fmt.Errorf("vev: secure broker configuration directory: %w", err)
32+ }
33+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
34+ if errors.Is(err, os.ErrExist) {
35+ return nil
36+ }
37+ if err != nil {
38+ return fmt.Errorf("vev: create broker configuration: %w", err)
39+ }
40+ _, writeErr := file.WriteString(`{"marker":"vev.broker.offline/v1","registrations":[]}`)
41+ closeErr := file.Close()
42+ if writeErr == nil && closeErr == nil {
43+ return nil
44+ }
45+ removeErr := os.Remove(path)
46+ if writeErr != nil {
47+ return errors.Join(fmt.Errorf("vev: write broker configuration: %w", writeErr), closeErr, removeErr)
48+ }
49+ return errors.Join(fmt.Errorf("vev: close broker configuration: %w", closeErr), removeErr)
50+}
51+
21 func remoteBrokerPolicy(transport string) ports.BrokerPolicy {52 func remoteBrokerPolicy(transport string) ports.BrokerPolicy {
22 if transport == "stdio" {53 if transport == "stdio" {
23 transport = "ssh-stdio"54 transport = "ssh-stdio"
M internal/app/broker_production_connect.go +10 -0
@@ -6,7 +6,9 @@ import (
66 "time"
77
88 "github.com/bnema/vev/internal/adapters/brokeripc"
9+ "github.com/bnema/vev/internal/adapters/ipc"
910 "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/pkg/safedir"
1012 )
1113
1214 const productionBrokerStartupTimeout = 10 * time.Second
@@ -15,6 +17,14 @@ const productionBrokerStartupTimeout = 10 * time.Second
1517 // Process election and idle lifetime remain owned by the existing broker
1618 // launcher and broker.Supervisor; callers receive only the semantic façade.
1719 func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) {
20+ if err := ensureProductionBrokerConfig(); err != nil {
21+ return nil, err
22+ }
23+ // Validate the shared parent before creating broker-owned descendants,
24+ // especially when a long XDG root maps directly into /tmp.
25+ if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil {
26+ return nil, fmt.Errorf("vev: secure broker runtime parent: %w", err)
27+ }
1828 layout := productionBrokerLayout()
1929 request := brokerStatusRequest{
2030 layout: layout, socketPath: brokeripc.SocketPath(layout.Runtime),
@@ -6,7 +6,9 @@ import (
6 "time"6 "time"
7 7
8 "github.com/bnema/vev/internal/adapters/brokeripc"8 "github.com/bnema/vev/internal/adapters/brokeripc"
9+ "github.com/bnema/vev/internal/adapters/ipc"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/pkg/safedir"
10 )12 )
11 13
12 const productionBrokerStartupTimeout = 10 * time.Second14 const productionBrokerStartupTimeout = 10 * time.Second
@@ -15,6 +17,14 @@ const productionBrokerStartupTimeout = 10 * time.Second
15 // Process election and idle lifetime remain owned by the existing broker17 // Process election and idle lifetime remain owned by the existing broker
16 // launcher and broker.Supervisor; callers receive only the semantic façade.18 // launcher and broker.Supervisor; callers receive only the semantic façade.
17 func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) {19 func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) {
20+ if err := ensureProductionBrokerConfig(); err != nil {
21+ return nil, err
22+ }
23+ // Validate the shared parent before creating broker-owned descendants,
24+ // especially when a long XDG root maps directly into /tmp.
25+ if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil {
26+ return nil, fmt.Errorf("vev: secure broker runtime parent: %w", err)
27+ }
18 layout := productionBrokerLayout()28 layout := productionBrokerLayout()
19 request := brokerStatusRequest{29 request := brokerStatusRequest{
20 layout: layout, socketPath: brokeripc.SocketPath(layout.Runtime),30 layout: layout, socketPath: brokeripc.SocketPath(layout.Runtime),
A internal/app/broker_production_environment_test.go +88 -0
@@ -0,0 +1,88 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "syscall"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/adapters/ipc"
13+ "github.com/bnema/vev/internal/platform"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+// Exercise both real detached re-execs, which change cwd to the user's home.
18+// A long development root must preserve its configuration/state identity while
19+// all roles independently resolve the same portable, isolated runtime path.
20+func TestProductionBrokerDevelopmentEnvironmentReexec(t *testing.T) {
21+ for _, explicit := range []bool{false, true} {
22+ name := "implicit"
23+ if explicit {
24+ name = "explicit"
25+ }
26+ t.Run(name, func(t *testing.T) {
27+ t.Chdir(t.TempDir())
28+ t.Setenv("VEV_ENV", "test")
29+ t.Setenv("VEV_ENV_ROOT", "")
30+ if explicit {
31+ t.Setenv("VEV_ENV_ROOT", filepath.Join(t.TempDir(), strings.Repeat("r", 100)))
32+ }
33+ for _, key := range []string{"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_RUNTIME_DIR"} {
34+ t.Setenv(key, "")
35+ }
36+ require.NoError(t, platform.ActivateDevelopmentEnvironment())
37+ root := os.Getenv("VEV_ENV_ROOT")
38+ require.True(t, filepath.IsAbs(root))
39+ records := shortTempDir(t, "vevenv")
40+ t.Setenv(brokerHelperEnv, "1")
41+ t.Setenv(brokerHelperRecordDirEnv, records)
42+ sandbox := &brokerSubprocessSandbox{t: t, recordDir: records}
43+ runtime := ipc.SocketDir()
44+ require.NotEqual(t, filepath.Join(os.Getenv("XDG_RUNTIME_DIR"), "vev"), runtime)
45+ t.Cleanup(func() {
46+ for _, role := range []string{productionBrokerServeCommand, productionBrokerLauncherCommand} {
47+ for _, pid := range sandbox.recorded(role) {
48+ _ = syscall.Kill(pid, syscall.SIGKILL)
49+ _ = waitForProcessExit(pid, 5*time.Second)
50+ }
51+ }
52+ _ = os.RemoveAll(runtime)
53+ })
54+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
55+ defer cancel()
56+ service, err := connectProductionBroker(ctx)
57+ require.NoError(t, err)
58+ defer service.Close()
59+ require.NotZero(t, service.Snapshot().Epoch)
60+ require.Len(t, sandbox.recorded(productionBrokerLauncherCommand), 1)
61+ require.Len(t, sandbox.recorded(productionBrokerServeCommand), 1)
62+ require.FileExists(t, filepath.Join(root, "test", "config", "vev", "broker.json"))
63+ require.FileExists(t, filepath.Join(root, "test", "state", "vev", "broker", "state", "state.json"))
64+ })
65+ }
66+}
67+
68+func TestProductionBrokerRejectsInsecureShortRuntimeParent(t *testing.T) {
69+ for _, kind := range []string{"symlink", "public-directory"} {
70+ t.Run(kind, func(t *testing.T) {
71+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
72+ t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), strings.Repeat("x", 110)))
73+ runtime := ipc.SocketDir()
74+ t.Cleanup(func() { _ = os.RemoveAll(runtime) })
75+ if kind == "symlink" {
76+ require.NoError(t, os.Symlink(t.TempDir(), runtime))
77+ } else {
78+ require.NoError(t, os.Mkdir(runtime, 0o700))
79+ require.NoError(t, os.Chmod(runtime, 0o755))
80+ }
81+ service, err := connectProductionBroker(context.Background())
82+ require.Nil(t, service)
83+ require.ErrorContains(t, err, "secure broker runtime parent")
84+ _, err = os.Lstat(filepath.Join(runtime, "broker"))
85+ require.ErrorIs(t, err, os.ErrNotExist)
86+ })
87+ }
88+}
@@ -0,0 +1,88 @@
1+package app
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "syscall"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/adapters/ipc"
13+ "github.com/bnema/vev/internal/platform"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+// Exercise both real detached re-execs, which change cwd to the user's home.
18+// A long development root must preserve its configuration/state identity while
19+// all roles independently resolve the same portable, isolated runtime path.
20+func TestProductionBrokerDevelopmentEnvironmentReexec(t *testing.T) {
21+ for _, explicit := range []bool{false, true} {
22+ name := "implicit"
23+ if explicit {
24+ name = "explicit"
25+ }
26+ t.Run(name, func(t *testing.T) {
27+ t.Chdir(t.TempDir())
28+ t.Setenv("VEV_ENV", "test")
29+ t.Setenv("VEV_ENV_ROOT", "")
30+ if explicit {
31+ t.Setenv("VEV_ENV_ROOT", filepath.Join(t.TempDir(), strings.Repeat("r", 100)))
32+ }
33+ for _, key := range []string{"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_RUNTIME_DIR"} {
34+ t.Setenv(key, "")
35+ }
36+ require.NoError(t, platform.ActivateDevelopmentEnvironment())
37+ root := os.Getenv("VEV_ENV_ROOT")
38+ require.True(t, filepath.IsAbs(root))
39+ records := shortTempDir(t, "vevenv")
40+ t.Setenv(brokerHelperEnv, "1")
41+ t.Setenv(brokerHelperRecordDirEnv, records)
42+ sandbox := &brokerSubprocessSandbox{t: t, recordDir: records}
43+ runtime := ipc.SocketDir()
44+ require.NotEqual(t, filepath.Join(os.Getenv("XDG_RUNTIME_DIR"), "vev"), runtime)
45+ t.Cleanup(func() {
46+ for _, role := range []string{productionBrokerServeCommand, productionBrokerLauncherCommand} {
47+ for _, pid := range sandbox.recorded(role) {
48+ _ = syscall.Kill(pid, syscall.SIGKILL)
49+ _ = waitForProcessExit(pid, 5*time.Second)
50+ }
51+ }
52+ _ = os.RemoveAll(runtime)
53+ })
54+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
55+ defer cancel()
56+ service, err := connectProductionBroker(ctx)
57+ require.NoError(t, err)
58+ defer service.Close()
59+ require.NotZero(t, service.Snapshot().Epoch)
60+ require.Len(t, sandbox.recorded(productionBrokerLauncherCommand), 1)
61+ require.Len(t, sandbox.recorded(productionBrokerServeCommand), 1)
62+ require.FileExists(t, filepath.Join(root, "test", "config", "vev", "broker.json"))
63+ require.FileExists(t, filepath.Join(root, "test", "state", "vev", "broker", "state", "state.json"))
64+ })
65+ }
66+}
67+
68+func TestProductionBrokerRejectsInsecureShortRuntimeParent(t *testing.T) {
69+ for _, kind := range []string{"symlink", "public-directory"} {
70+ t.Run(kind, func(t *testing.T) {
71+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
72+ t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), strings.Repeat("x", 110)))
73+ runtime := ipc.SocketDir()
74+ t.Cleanup(func() { _ = os.RemoveAll(runtime) })
75+ if kind == "symlink" {
76+ require.NoError(t, os.Symlink(t.TempDir(), runtime))
77+ } else {
78+ require.NoError(t, os.Mkdir(runtime, 0o700))
79+ require.NoError(t, os.Chmod(runtime, 0o755))
80+ }
81+ service, err := connectProductionBroker(context.Background())
82+ require.Nil(t, service)
83+ require.ErrorContains(t, err, "secure broker runtime parent")
84+ _, err = os.Lstat(filepath.Join(runtime, "broker"))
85+ require.ErrorIs(t, err, os.ErrNotExist)
86+ })
87+ }
88+}
M internal/app/broker_production_test.go +46 -0
@@ -1,9 +1,13 @@
11 package app
22
33 import (
4+ "os"
45 "path/filepath"
56 "testing"
67
8+ "github.com/bnema/vev/internal/adapters/brokerconfig"
9+ "github.com/bnema/vev/internal/adapters/daemonmux"
10+ "github.com/bnema/vev/internal/adapters/ipc"
711 "github.com/stretchr/testify/require"
812 )
913
@@ -23,6 +27,48 @@ func TestProductionBrokerHiddenCommandsRejectSandboxOptions(t *testing.T) {
2327 }
2428 }
2529
30+func TestEnsureProductionBrokerConfigCreatesPrivateEmptyBootstrapWithoutReplacingExisting(t *testing.T) {
31+ configHome := t.TempDir()
32+ t.Setenv("XDG_CONFIG_HOME", configHome)
33+
34+ require.NoError(t, ensureProductionBrokerConfig())
35+ path := productionBrokerConfigPath()
36+ raw, err := os.ReadFile(path)
37+ require.NoError(t, err)
38+ require.JSONEq(t, `{"marker":"vev.broker.offline/v1","registrations":[]}`, string(raw))
39+ info, err := os.Stat(path)
40+ require.NoError(t, err)
41+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
42+ dirInfo, err := os.Stat(filepath.Dir(path))
43+ require.NoError(t, err)
44+ require.Equal(t, os.FileMode(0o700), dirInfo.Mode().Perm())
45+
46+ layout := productionBrokerLayout()
47+ _, err = brokerconfig.LoadProduction(layout, path, "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir()))
48+ require.NoError(t, err)
49+
50+ require.NoError(t, os.WriteFile(path, []byte("operator-owned"), 0o600))
51+ require.NoError(t, ensureProductionBrokerConfig())
52+ raw, err = os.ReadFile(path)
53+ require.NoError(t, err)
54+ require.Equal(t, "operator-owned", string(raw))
55+}
56+
57+func TestEnsureProductionBrokerConfigSupportsConcurrentFirstUse(t *testing.T) {
58+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
59+
60+ errs := make(chan error, 16)
61+ for range cap(errs) {
62+ go func() { errs <- ensureProductionBrokerConfig() }()
63+ }
64+ for range cap(errs) {
65+ require.NoError(t, <-errs)
66+ }
67+ raw, err := os.ReadFile(productionBrokerConfigPath())
68+ require.NoError(t, err)
69+ require.JSONEq(t, `{"marker":"vev.broker.offline/v1","registrations":[]}`, string(raw))
70+}
71+
2672 func TestProductionBrokerLayoutUsesDedicatedRuntimeStateAndConfig(t *testing.T) {
2773 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
2874 t.Setenv("XDG_STATE_HOME", t.TempDir())
@@ -1,9 +1,13 @@
1 package app1 package app
2 2
3 import (3 import (
4+ "os"
4 "path/filepath"5 "path/filepath"
5 "testing"6 "testing"
6 7
8+ "github.com/bnema/vev/internal/adapters/brokerconfig"
9+ "github.com/bnema/vev/internal/adapters/daemonmux"
10+ "github.com/bnema/vev/internal/adapters/ipc"
7 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
8 )12 )
9 13
@@ -23,6 +27,48 @@ func TestProductionBrokerHiddenCommandsRejectSandboxOptions(t *testing.T) {
23 }27 }
24 }28 }
25 29
30+func TestEnsureProductionBrokerConfigCreatesPrivateEmptyBootstrapWithoutReplacingExisting(t *testing.T) {
31+ configHome := t.TempDir()
32+ t.Setenv("XDG_CONFIG_HOME", configHome)
33+
34+ require.NoError(t, ensureProductionBrokerConfig())
35+ path := productionBrokerConfigPath()
36+ raw, err := os.ReadFile(path)
37+ require.NoError(t, err)
38+ require.JSONEq(t, `{"marker":"vev.broker.offline/v1","registrations":[]}`, string(raw))
39+ info, err := os.Stat(path)
40+ require.NoError(t, err)
41+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
42+ dirInfo, err := os.Stat(filepath.Dir(path))
43+ require.NoError(t, err)
44+ require.Equal(t, os.FileMode(0o700), dirInfo.Mode().Perm())
45+
46+ layout := productionBrokerLayout()
47+ _, err = brokerconfig.LoadProduction(layout, path, "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir()))
48+ require.NoError(t, err)
49+
50+ require.NoError(t, os.WriteFile(path, []byte("operator-owned"), 0o600))
51+ require.NoError(t, ensureProductionBrokerConfig())
52+ raw, err = os.ReadFile(path)
53+ require.NoError(t, err)
54+ require.Equal(t, "operator-owned", string(raw))
55+}
56+
57+func TestEnsureProductionBrokerConfigSupportsConcurrentFirstUse(t *testing.T) {
58+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
59+
60+ errs := make(chan error, 16)
61+ for range cap(errs) {
62+ go func() { errs <- ensureProductionBrokerConfig() }()
63+ }
64+ for range cap(errs) {
65+ require.NoError(t, <-errs)
66+ }
67+ raw, err := os.ReadFile(productionBrokerConfigPath())
68+ require.NoError(t, err)
69+ require.JSONEq(t, `{"marker":"vev.broker.offline/v1","registrations":[]}`, string(raw))
70+}
71+
26 func TestProductionBrokerLayoutUsesDedicatedRuntimeStateAndConfig(t *testing.T) {72 func TestProductionBrokerLayoutUsesDedicatedRuntimeStateAndConfig(t *testing.T) {
27 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())73 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
28 t.Setenv("XDG_STATE_HOME", t.TempDir())74 t.Setenv("XDG_STATE_HOME", t.TempDir())
M internal/app/broker_ready_test.go +5 -0
@@ -160,6 +160,11 @@ func (s *readyScriptedService) Subscribe() (ports.BrokerSubscription, error) {
160160 return sub, nil
161161 }
162162
163+func (s *readyScriptedService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
164+ s.record("SubscribePreview")
165+ return nil, errors.New("ready scripted service does not support preview")
166+}
167+
163168 func (s *readyScriptedService) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
164169 s.record("OpenStream")
165170 return nil, ports.BrokerAdmissionClosed
@@ -160,6 +160,11 @@ func (s *readyScriptedService) Subscribe() (ports.BrokerSubscription, error) {
160 return sub, nil160 return sub, nil
161 }161 }
162 162
163+func (s *readyScriptedService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
164+ s.record("SubscribePreview")
165+ return nil, errors.New("ready scripted service does not support preview")
166+}
167+
163 func (s *readyScriptedService) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {168 func (s *readyScriptedService) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
164 s.record("OpenStream")169 s.record("OpenStream")
165 return nil, ports.BrokerAdmissionClosed170 return nil, ports.BrokerAdmissionClosed
M internal/app/run_broker_test.go +4 -0
@@ -149,6 +149,10 @@ func (s *seamBrokerService) Subscribe() (ports.BrokerSubscription, error) {
149149 return nil, errors.New("the seam broker service does not support subscriptions")
150150 }
151151
152+func (s *seamBrokerService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
153+ return nil, errors.New("the seam broker service does not support preview")
154+}
155+
152156 func (s *seamBrokerService) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
153157 s.mu.Lock()
154158 s.opens = append(s.opens, request)
@@ -149,6 +149,10 @@ func (s *seamBrokerService) Subscribe() (ports.BrokerSubscription, error) {
149 return nil, errors.New("the seam broker service does not support subscriptions")149 return nil, errors.New("the seam broker service does not support subscriptions")
150 }150 }
151 151
152+func (s *seamBrokerService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
153+ return nil, errors.New("the seam broker service does not support preview")
154+}
155+
152 func (s *seamBrokerService) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {156 func (s *seamBrokerService) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
153 s.mu.Lock()157 s.mu.Lock()
154 s.opens = append(s.opens, request)158 s.opens = append(s.opens, request)
A internal/app/testdata/broker_terminal_repro.py +102 -0
@@ -0,0 +1,102 @@
1+#!/usr/bin/env python3
2+"""Linux PTY regression: python3 internal/app/testdata/broker_terminal_repro.py /absolute/vev
3+
4+Build the real binary first. Keeps the PTY open (no UI-driver/EOF shortcut),
5+uses an implicit long VEV_ENV_ROOT, and cleans up only its isolated processes.
6+"""
7+import fcntl
8+import hashlib
9+import os
10+import pathlib
11+import pty
12+import select
13+import signal
14+import struct
15+import sys
16+import tempfile
17+import termios
18+import time
19+
20+binary = os.path.abspath(sys.argv[1])
21+with tempfile.TemporaryDirectory(prefix="vev-pty-") as temporary:
22+ cwd = pathlib.Path(temporary) / ("long-worktree-" * 8)
23+ cwd.mkdir()
24+ root = cwd / ".dev"
25+ runtime = root / "test/runtime/vev"
26+ short = pathlib.Path("/tmp/vev-%d-%s" % (
27+ os.getuid(), hashlib.sha256(str(runtime).encode()).hexdigest()))
28+ pid = None
29+ fd = None
30+
31+ def read_until(predicate, timeout):
32+ output = b""
33+ deadline = time.monotonic() + timeout
34+ while time.monotonic() < deadline:
35+ if select.select([fd], [], [], 0.1)[0]:
36+ try:
37+ output += os.read(fd, 65536)
38+ except OSError:
39+ break
40+ if predicate(output):
41+ return output
42+ raise AssertionError(repr(output[-2000:]))
43+
44+ def start():
45+ global pid, fd
46+ pid, fd = pty.fork()
47+ if pid == 0:
48+ os.chdir(cwd)
49+ os.environ.pop("VEV", None)
50+ os.environ.pop("VEV_ENV_ROOT", None)
51+ os.environ["VEV_ENV"] = "test"
52+ os.environ["TERM"] = "xterm-256color"
53+ os.environ["SHELL"] = "/bin/sh"
54+ os.execv(binary, [binary])
55+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 100, 0, 0))
56+
57+ try:
58+ start()
59+ # The shell command proves client -> broker -> daemon -> PTY, not
60+ # merely that the broker socket accepted a connection.
61+ read_until(lambda data: b"?25h" in data, 15)
62+ os.write(fd, b"printf 'VEV_PTY_%s_OK\\n' REPRO\n")
63+ output = read_until(lambda data: b"VEV_PTY_REPRO_OK" in data, 10)
64+ assert b"broker connection lost" not in output
65+ print("PASS: long implicit development root reaches a real shell")
66+ os.kill(pid, signal.SIGTERM)
67+ os.waitpid(pid, 0)
68+ pid = None
69+ os.close(fd)
70+ fd = None
71+
72+ # An insecure configuration fails before any service is adopted.
73+ # Raw-mode Ctrl-C must still end the process with stdin held open.
74+ (root / "test/config/vev").chmod(0o755)
75+ start()
76+ read_until(lambda data: b"broker connection lost" in data, 10)
77+ os.write(fd, b"\x03")
78+ deadline = time.monotonic() + 5
79+ while os.waitpid(pid, os.WNOHANG)[0] == 0:
80+ if time.monotonic() >= deadline:
81+ raise AssertionError("Ctrl-C failed with stdin held open")
82+ time.sleep(0.01)
83+ pid = None
84+ print("PASS: Ctrl-C exits a failed connection without terminal EOF")
85+ finally:
86+ if pid is not None:
87+ os.kill(pid, signal.SIGKILL)
88+ os.waitpid(pid, 0)
89+ if fd is not None:
90+ os.close(fd)
91+ # Detached roles inherit the resolved root; never kill by binary name.
92+ for process in pathlib.Path("/proc").iterdir():
93+ if not process.name.isdigit():
94+ continue
95+ try:
96+ environment = (process / "environ").read_bytes().split(b"\0")
97+ if ("VEV_ENV_ROOT=" + str(root)).encode() in environment:
98+ os.kill(int(process.name), signal.SIGKILL)
99+ except (OSError, ProcessLookupError):
100+ pass
101+ import shutil
102+ shutil.rmtree(short, ignore_errors=True)
@@ -0,0 +1,102 @@
1+#!/usr/bin/env python3
2+"""Linux PTY regression: python3 internal/app/testdata/broker_terminal_repro.py /absolute/vev
3+
4+Build the real binary first. Keeps the PTY open (no UI-driver/EOF shortcut),
5+uses an implicit long VEV_ENV_ROOT, and cleans up only its isolated processes.
6+"""
7+import fcntl
8+import hashlib
9+import os
10+import pathlib
11+import pty
12+import select
13+import signal
14+import struct
15+import sys
16+import tempfile
17+import termios
18+import time
19+
20+binary = os.path.abspath(sys.argv[1])
21+with tempfile.TemporaryDirectory(prefix="vev-pty-") as temporary:
22+ cwd = pathlib.Path(temporary) / ("long-worktree-" * 8)
23+ cwd.mkdir()
24+ root = cwd / ".dev"
25+ runtime = root / "test/runtime/vev"
26+ short = pathlib.Path("/tmp/vev-%d-%s" % (
27+ os.getuid(), hashlib.sha256(str(runtime).encode()).hexdigest()))
28+ pid = None
29+ fd = None
30+
31+ def read_until(predicate, timeout):
32+ output = b""
33+ deadline = time.monotonic() + timeout
34+ while time.monotonic() < deadline:
35+ if select.select([fd], [], [], 0.1)[0]:
36+ try:
37+ output += os.read(fd, 65536)
38+ except OSError:
39+ break
40+ if predicate(output):
41+ return output
42+ raise AssertionError(repr(output[-2000:]))
43+
44+ def start():
45+ global pid, fd
46+ pid, fd = pty.fork()
47+ if pid == 0:
48+ os.chdir(cwd)
49+ os.environ.pop("VEV", None)
50+ os.environ.pop("VEV_ENV_ROOT", None)
51+ os.environ["VEV_ENV"] = "test"
52+ os.environ["TERM"] = "xterm-256color"
53+ os.environ["SHELL"] = "/bin/sh"
54+ os.execv(binary, [binary])
55+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 100, 0, 0))
56+
57+ try:
58+ start()
59+ # The shell command proves client -> broker -> daemon -> PTY, not
60+ # merely that the broker socket accepted a connection.
61+ read_until(lambda data: b"?25h" in data, 15)
62+ os.write(fd, b"printf 'VEV_PTY_%s_OK\\n' REPRO\n")
63+ output = read_until(lambda data: b"VEV_PTY_REPRO_OK" in data, 10)
64+ assert b"broker connection lost" not in output
65+ print("PASS: long implicit development root reaches a real shell")
66+ os.kill(pid, signal.SIGTERM)
67+ os.waitpid(pid, 0)
68+ pid = None
69+ os.close(fd)
70+ fd = None
71+
72+ # An insecure configuration fails before any service is adopted.
73+ # Raw-mode Ctrl-C must still end the process with stdin held open.
74+ (root / "test/config/vev").chmod(0o755)
75+ start()
76+ read_until(lambda data: b"broker connection lost" in data, 10)
77+ os.write(fd, b"\x03")
78+ deadline = time.monotonic() + 5
79+ while os.waitpid(pid, os.WNOHANG)[0] == 0:
80+ if time.monotonic() >= deadline:
81+ raise AssertionError("Ctrl-C failed with stdin held open")
82+ time.sleep(0.01)
83+ pid = None
84+ print("PASS: Ctrl-C exits a failed connection without terminal EOF")
85+ finally:
86+ if pid is not None:
87+ os.kill(pid, signal.SIGKILL)
88+ os.waitpid(pid, 0)
89+ if fd is not None:
90+ os.close(fd)
91+ # Detached roles inherit the resolved root; never kill by binary name.
92+ for process in pathlib.Path("/proc").iterdir():
93+ if not process.name.isdigit():
94+ continue
95+ try:
96+ environment = (process / "environ").read_bytes().split(b"\0")
97+ if ("VEV_ENV_ROOT=" + str(root)).encode() in environment:
98+ os.kill(int(process.name), signal.SIGKILL)
99+ except (OSError, ProcessLookupError):
100+ pass
101+ import shutil
102+ shutil.rmtree(short, ignore_errors=True)
M internal/ports/broker.go +99 -0
@@ -704,6 +704,104 @@ type BrokerSubscription interface {
704704 Close()
705705 }
706706
707+// BrokerPreviewGeneration identifies one selected-row preview authority within
708+// a client connection. It is strictly monotone for that connection and never
709+// crosses broker epochs.
710+type BrokerPreviewGeneration uint64
711+
712+func (g BrokerPreviewGeneration) Validate() error {
713+ if g == 0 {
714+ return errors.New("ports: broker preview generation is zero")
715+ }
716+ return nil
717+}
718+
719+// BrokerPreviewRequest starts or replaces one connection-scoped live preview.
720+// Route is an exact observation stream authority; Preview carries the exact
721+// session/tab target and bounded viewport dimensions sent to the daemon.
722+type BrokerPreviewRequest struct {
723+ Epoch BrokerEpoch
724+ Connection BrokerConnectionID
725+ Generation BrokerPreviewGeneration
726+ Route BrokerOpenStreamRequest
727+ Preview protocol.RemotePreviewRequest
728+}
729+
730+func (r BrokerPreviewRequest) Validate() error {
731+ if r.Epoch == 0 || r.Route.Epoch != r.Epoch {
732+ return errors.New("ports: broker preview epoch mismatch")
733+ }
734+ if err := r.Connection.Validate(); err != nil {
735+ return err
736+ }
737+ if r.Route.Connection != r.Connection {
738+ return errors.New("ports: broker preview connection mismatch")
739+ }
740+ if err := r.Generation.Validate(); err != nil {
741+ return err
742+ }
743+ if err := r.Route.Validate(); err != nil {
744+ return fmt.Errorf("ports: broker preview route: %w", err)
745+ }
746+ if r.Route.Purpose != BrokerStreamObservation {
747+ return errors.New("ports: broker preview route is not an observation")
748+ }
749+ if err := protocol.ValidateRemotePreviewRequest(r.Preview); err != nil {
750+ return fmt.Errorf("ports: broker preview request: %w", err)
751+ }
752+ return nil
753+}
754+
755+// BrokerPreviewPublication is the newest result for one preview subscription.
756+// Every authority field is repeated so a client can reject a late completion.
757+type BrokerPreviewPublication struct {
758+ Epoch BrokerEpoch
759+ Connection BrokerConnectionID
760+ Generation BrokerPreviewGeneration
761+ Target domain.RemoteSessionTarget
762+ Width uint16
763+ Height uint16
764+ Preview protocol.RemotePreview
765+ Err error
766+}
767+
768+func (p BrokerPreviewPublication) Validate() error {
769+ if p.Epoch == 0 {
770+ return errors.New("ports: broker preview publication has no epoch")
771+ }
772+ if err := p.Connection.Validate(); err != nil {
773+ return err
774+ }
775+ if err := p.Generation.Validate(); err != nil {
776+ return err
777+ }
778+ if p.Target.Validate() != nil || p.Target.Stopped {
779+ return errors.New("ports: broker preview publication has invalid target")
780+ }
781+ if p.Width == 0 || p.Height == 0 || int(p.Width) > protocol.RemotePreviewMaxWidth || int(p.Height) > protocol.RemotePreviewMaxHeight {
782+ return errors.New("ports: broker preview publication has invalid dimensions")
783+ }
784+ if p.Err != nil {
785+ if p.Preview.Version != 0 || p.Preview.Status != 0 || p.Preview.LifecycleID != (domain.SessionLifecycleID{}) || p.Preview.TabID != "" || p.Preview.Revision != 0 || p.Preview.Width != 0 || p.Preview.Height != 0 || len(p.Preview.Cells) != 0 {
786+ return errors.New("ports: failed broker preview publication carries a preview")
787+ }
788+ return nil
789+ }
790+ if err := protocol.ValidateRemotePreview(p.Preview); err != nil {
791+ return fmt.Errorf("ports: broker preview publication: %w", err)
792+ }
793+ return nil
794+}
795+
796+// BrokerPreviewSubscription owns one client-scoped selected-row preview. Changed
797+// is capacity one; callers re-read Latest after every wake. Close cancels any
798+// in-flight logical observation and is idempotent.
799+type BrokerPreviewSubscription interface {
800+ Changed() <-chan struct{}
801+ Latest() BrokerPreviewPublication
802+ Close()
803+}
804+
707805 // BrokerService is the client-facing broker façade. All clients reach
708806 // local and remote daemons through it, including the local daemon.
709807 // Snapshot access performs no I/O; request methods are non-blocking
@@ -728,6 +826,7 @@ type BrokerService interface {
728826 Err() error
729827 Snapshot() BrokerSnapshot
730828 Subscribe() (BrokerSubscription, error)
829+ SubscribePreview(request BrokerPreviewRequest) (BrokerPreviewSubscription, error)
731830 OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error)
732831 CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error
733832 // AddHost durably adds or pins endpoint under policy and returns its authority.
@@ -704,6 +704,104 @@ type BrokerSubscription interface {
704 Close()704 Close()
705 }705 }
706 706
707+// BrokerPreviewGeneration identifies one selected-row preview authority within
708+// a client connection. It is strictly monotone for that connection and never
709+// crosses broker epochs.
710+type BrokerPreviewGeneration uint64
711+
712+func (g BrokerPreviewGeneration) Validate() error {
713+ if g == 0 {
714+ return errors.New("ports: broker preview generation is zero")
715+ }
716+ return nil
717+}
718+
719+// BrokerPreviewRequest starts or replaces one connection-scoped live preview.
720+// Route is an exact observation stream authority; Preview carries the exact
721+// session/tab target and bounded viewport dimensions sent to the daemon.
722+type BrokerPreviewRequest struct {
723+ Epoch BrokerEpoch
724+ Connection BrokerConnectionID
725+ Generation BrokerPreviewGeneration
726+ Route BrokerOpenStreamRequest
727+ Preview protocol.RemotePreviewRequest
728+}
729+
730+func (r BrokerPreviewRequest) Validate() error {
731+ if r.Epoch == 0 || r.Route.Epoch != r.Epoch {
732+ return errors.New("ports: broker preview epoch mismatch")
733+ }
734+ if err := r.Connection.Validate(); err != nil {
735+ return err
736+ }
737+ if r.Route.Connection != r.Connection {
738+ return errors.New("ports: broker preview connection mismatch")
739+ }
740+ if err := r.Generation.Validate(); err != nil {
741+ return err
742+ }
743+ if err := r.Route.Validate(); err != nil {
744+ return fmt.Errorf("ports: broker preview route: %w", err)
745+ }
746+ if r.Route.Purpose != BrokerStreamObservation {
747+ return errors.New("ports: broker preview route is not an observation")
748+ }
749+ if err := protocol.ValidateRemotePreviewRequest(r.Preview); err != nil {
750+ return fmt.Errorf("ports: broker preview request: %w", err)
751+ }
752+ return nil
753+}
754+
755+// BrokerPreviewPublication is the newest result for one preview subscription.
756+// Every authority field is repeated so a client can reject a late completion.
757+type BrokerPreviewPublication struct {
758+ Epoch BrokerEpoch
759+ Connection BrokerConnectionID
760+ Generation BrokerPreviewGeneration
761+ Target domain.RemoteSessionTarget
762+ Width uint16
763+ Height uint16
764+ Preview protocol.RemotePreview
765+ Err error
766+}
767+
768+func (p BrokerPreviewPublication) Validate() error {
769+ if p.Epoch == 0 {
770+ return errors.New("ports: broker preview publication has no epoch")
771+ }
772+ if err := p.Connection.Validate(); err != nil {
773+ return err
774+ }
775+ if err := p.Generation.Validate(); err != nil {
776+ return err
777+ }
778+ if p.Target.Validate() != nil || p.Target.Stopped {
779+ return errors.New("ports: broker preview publication has invalid target")
780+ }
781+ if p.Width == 0 || p.Height == 0 || int(p.Width) > protocol.RemotePreviewMaxWidth || int(p.Height) > protocol.RemotePreviewMaxHeight {
782+ return errors.New("ports: broker preview publication has invalid dimensions")
783+ }
784+ if p.Err != nil {
785+ if p.Preview.Version != 0 || p.Preview.Status != 0 || p.Preview.LifecycleID != (domain.SessionLifecycleID{}) || p.Preview.TabID != "" || p.Preview.Revision != 0 || p.Preview.Width != 0 || p.Preview.Height != 0 || len(p.Preview.Cells) != 0 {
786+ return errors.New("ports: failed broker preview publication carries a preview")
787+ }
788+ return nil
789+ }
790+ if err := protocol.ValidateRemotePreview(p.Preview); err != nil {
791+ return fmt.Errorf("ports: broker preview publication: %w", err)
792+ }
793+ return nil
794+}
795+
796+// BrokerPreviewSubscription owns one client-scoped selected-row preview. Changed
797+// is capacity one; callers re-read Latest after every wake. Close cancels any
798+// in-flight logical observation and is idempotent.
799+type BrokerPreviewSubscription interface {
800+ Changed() <-chan struct{}
801+ Latest() BrokerPreviewPublication
802+ Close()
803+}
804+
707 // BrokerService is the client-facing broker façade. All clients reach805 // BrokerService is the client-facing broker façade. All clients reach
708 // local and remote daemons through it, including the local daemon.806 // local and remote daemons through it, including the local daemon.
709 // Snapshot access performs no I/O; request methods are non-blocking807 // Snapshot access performs no I/O; request methods are non-blocking
@@ -728,6 +826,7 @@ type BrokerService interface {
728 Err() error826 Err() error
729 Snapshot() BrokerSnapshot827 Snapshot() BrokerSnapshot
730 Subscribe() (BrokerSubscription, error)828 Subscribe() (BrokerSubscription, error)
829+ SubscribePreview(request BrokerPreviewRequest) (BrokerPreviewSubscription, error)
731 OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error)830 OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error)
732 CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error831 CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error
733 // AddHost durably adds or pins endpoint under policy and returns its authority.832 // AddHost durably adds or pins endpoint under policy and returns its authority.
M internal/ports/mocks/broker_mock.go +212 -0
@@ -118,6 +118,156 @@ func (_c *MockBrokerSubscription_Close_Call) RunAndReturn(run func()) *MockBroke
118118 return _c
119119 }
120120
121+// NewMockBrokerPreviewSubscription creates a new instance of MockBrokerPreviewSubscription. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
122+// The first argument is typically a *testing.T value.
123+func NewMockBrokerPreviewSubscription(t interface {
124+ mock.TestingT
125+ Cleanup(func())
126+}) *MockBrokerPreviewSubscription {
127+ mock := &MockBrokerPreviewSubscription{}
128+ mock.Mock.Test(t)
129+
130+ t.Cleanup(func() { mock.AssertExpectations(t) })
131+
132+ return mock
133+}
134+
135+// MockBrokerPreviewSubscription is an autogenerated mock type for the BrokerPreviewSubscription type
136+type MockBrokerPreviewSubscription struct {
137+ mock.Mock
138+}
139+
140+type MockBrokerPreviewSubscription_Expecter struct {
141+ mock *mock.Mock
142+}
143+
144+func (_m *MockBrokerPreviewSubscription) EXPECT() *MockBrokerPreviewSubscription_Expecter {
145+ return &MockBrokerPreviewSubscription_Expecter{mock: &_m.Mock}
146+}
147+
148+// Changed provides a mock function for the type MockBrokerPreviewSubscription
149+func (_mock *MockBrokerPreviewSubscription) Changed() <-chan struct{} {
150+ ret := _mock.Called()
151+
152+ if len(ret) == 0 {
153+ panic("no return value specified for Changed")
154+ }
155+
156+ var r0 <-chan struct{}
157+ if returnFunc, ok := ret.Get(0).(func() <-chan struct{}); ok {
158+ r0 = returnFunc()
159+ } else {
160+ if ret.Get(0) != nil {
161+ r0 = ret.Get(0).(<-chan struct{})
162+ }
163+ }
164+ return r0
165+}
166+
167+// MockBrokerPreviewSubscription_Changed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Changed'
168+type MockBrokerPreviewSubscription_Changed_Call struct {
169+ *mock.Call
170+}
171+
172+// Changed is a helper method to define mock.On call
173+func (_e *MockBrokerPreviewSubscription_Expecter) Changed() *MockBrokerPreviewSubscription_Changed_Call {
174+ return &MockBrokerPreviewSubscription_Changed_Call{Call: _e.mock.On("Changed")}
175+}
176+
177+func (_c *MockBrokerPreviewSubscription_Changed_Call) Run(run func()) *MockBrokerPreviewSubscription_Changed_Call {
178+ _c.Call.Run(func(args mock.Arguments) {
179+ run()
180+ })
181+ return _c
182+}
183+
184+func (_c *MockBrokerPreviewSubscription_Changed_Call) Return(valCh <-chan struct{}) *MockBrokerPreviewSubscription_Changed_Call {
185+ _c.Call.Return(valCh)
186+ return _c
187+}
188+
189+func (_c *MockBrokerPreviewSubscription_Changed_Call) RunAndReturn(run func() <-chan struct{}) *MockBrokerPreviewSubscription_Changed_Call {
190+ _c.Call.Return(run)
191+ return _c
192+}
193+
194+// Close provides a mock function for the type MockBrokerPreviewSubscription
195+func (_mock *MockBrokerPreviewSubscription) Close() {
196+ _mock.Called()
197+ return
198+}
199+
200+// MockBrokerPreviewSubscription_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
201+type MockBrokerPreviewSubscription_Close_Call struct {
202+ *mock.Call
203+}
204+
205+// Close is a helper method to define mock.On call
206+func (_e *MockBrokerPreviewSubscription_Expecter) Close() *MockBrokerPreviewSubscription_Close_Call {
207+ return &MockBrokerPreviewSubscription_Close_Call{Call: _e.mock.On("Close")}
208+}
209+
210+func (_c *MockBrokerPreviewSubscription_Close_Call) Run(run func()) *MockBrokerPreviewSubscription_Close_Call {
211+ _c.Call.Run(func(args mock.Arguments) {
212+ run()
213+ })
214+ return _c
215+}
216+
217+func (_c *MockBrokerPreviewSubscription_Close_Call) Return() *MockBrokerPreviewSubscription_Close_Call {
218+ _c.Call.Return()
219+ return _c
220+}
221+
222+func (_c *MockBrokerPreviewSubscription_Close_Call) RunAndReturn(run func()) *MockBrokerPreviewSubscription_Close_Call {
223+ _c.Run(run)
224+ return _c
225+}
226+
227+// Latest provides a mock function for the type MockBrokerPreviewSubscription
228+func (_mock *MockBrokerPreviewSubscription) Latest() ports.BrokerPreviewPublication {
229+ ret := _mock.Called()
230+
231+ if len(ret) == 0 {
232+ panic("no return value specified for Latest")
233+ }
234+
235+ var r0 ports.BrokerPreviewPublication
236+ if returnFunc, ok := ret.Get(0).(func() ports.BrokerPreviewPublication); ok {
237+ r0 = returnFunc()
238+ } else {
239+ r0 = ret.Get(0).(ports.BrokerPreviewPublication)
240+ }
241+ return r0
242+}
243+
244+// MockBrokerPreviewSubscription_Latest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Latest'
245+type MockBrokerPreviewSubscription_Latest_Call struct {
246+ *mock.Call
247+}
248+
249+// Latest is a helper method to define mock.On call
250+func (_e *MockBrokerPreviewSubscription_Expecter) Latest() *MockBrokerPreviewSubscription_Latest_Call {
251+ return &MockBrokerPreviewSubscription_Latest_Call{Call: _e.mock.On("Latest")}
252+}
253+
254+func (_c *MockBrokerPreviewSubscription_Latest_Call) Run(run func()) *MockBrokerPreviewSubscription_Latest_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ run()
257+ })
258+ return _c
259+}
260+
261+func (_c *MockBrokerPreviewSubscription_Latest_Call) Return(brokerPreviewPublication ports.BrokerPreviewPublication) *MockBrokerPreviewSubscription_Latest_Call {
262+ _c.Call.Return(brokerPreviewPublication)
263+ return _c
264+}
265+
266+func (_c *MockBrokerPreviewSubscription_Latest_Call) RunAndReturn(run func() ports.BrokerPreviewPublication) *MockBrokerPreviewSubscription_Latest_Call {
267+ _c.Call.Return(run)
268+ return _c
269+}
270+
121271 // NewMockBrokerService creates a new instance of MockBrokerService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
122272 // The first argument is typically a *testing.T value.
123273 func NewMockBrokerService(t interface {
@@ -780,6 +930,68 @@ func (_c *MockBrokerService_Subscribe_Call) RunAndReturn(run func() (ports.Broke
780930 return _c
781931 }
782932
933+// SubscribePreview provides a mock function for the type MockBrokerService
934+func (_mock *MockBrokerService) SubscribePreview(request ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
935+ ret := _mock.Called(request)
936+
937+ if len(ret) == 0 {
938+ panic("no return value specified for SubscribePreview")
939+ }
940+
941+ var r0 ports.BrokerPreviewSubscription
942+ var r1 error
943+ if returnFunc, ok := ret.Get(0).(func(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error)); ok {
944+ return returnFunc(request)
945+ }
946+ if returnFunc, ok := ret.Get(0).(func(ports.BrokerPreviewRequest) ports.BrokerPreviewSubscription); ok {
947+ r0 = returnFunc(request)
948+ } else {
949+ if ret.Get(0) != nil {
950+ r0 = ret.Get(0).(ports.BrokerPreviewSubscription)
951+ }
952+ }
953+ if returnFunc, ok := ret.Get(1).(func(ports.BrokerPreviewRequest) error); ok {
954+ r1 = returnFunc(request)
955+ } else {
956+ r1 = ret.Error(1)
957+ }
958+ return r0, r1
959+}
960+
961+// MockBrokerService_SubscribePreview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SubscribePreview'
962+type MockBrokerService_SubscribePreview_Call struct {
963+ *mock.Call
964+}
965+
966+// SubscribePreview is a helper method to define mock.On call
967+// - request ports.BrokerPreviewRequest
968+func (_e *MockBrokerService_Expecter) SubscribePreview(request any) *MockBrokerService_SubscribePreview_Call {
969+ return &MockBrokerService_SubscribePreview_Call{Call: _e.mock.On("SubscribePreview", request)}
970+}
971+
972+func (_c *MockBrokerService_SubscribePreview_Call) Run(run func(request ports.BrokerPreviewRequest)) *MockBrokerService_SubscribePreview_Call {
973+ _c.Call.Run(func(args mock.Arguments) {
974+ var arg0 ports.BrokerPreviewRequest
975+ if args[0] != nil {
976+ arg0 = args[0].(ports.BrokerPreviewRequest)
977+ }
978+ run(
979+ arg0,
980+ )
981+ })
982+ return _c
983+}
984+
985+func (_c *MockBrokerService_SubscribePreview_Call) Return(brokerPreviewSubscription ports.BrokerPreviewSubscription, err error) *MockBrokerService_SubscribePreview_Call {
986+ _c.Call.Return(brokerPreviewSubscription, err)
987+ return _c
988+}
989+
990+func (_c *MockBrokerService_SubscribePreview_Call) RunAndReturn(run func(request ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error)) *MockBrokerService_SubscribePreview_Call {
991+ _c.Call.Return(run)
992+ return _c
993+}
994+
783995 // UpdateHostPolicy provides a mock function for the type MockBrokerService
784996 func (_mock *MockBrokerService) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
785997 ret := _mock.Called(ctx, expected, policy)
@@ -118,6 +118,156 @@ func (_c *MockBrokerSubscription_Close_Call) RunAndReturn(run func()) *MockBroke
118 return _c118 return _c
119 }119 }
120 120
121+// NewMockBrokerPreviewSubscription creates a new instance of MockBrokerPreviewSubscription. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
122+// The first argument is typically a *testing.T value.
123+func NewMockBrokerPreviewSubscription(t interface {
124+ mock.TestingT
125+ Cleanup(func())
126+}) *MockBrokerPreviewSubscription {
127+ mock := &MockBrokerPreviewSubscription{}
128+ mock.Mock.Test(t)
129+
130+ t.Cleanup(func() { mock.AssertExpectations(t) })
131+
132+ return mock
133+}
134+
135+// MockBrokerPreviewSubscription is an autogenerated mock type for the BrokerPreviewSubscription type
136+type MockBrokerPreviewSubscription struct {
137+ mock.Mock
138+}
139+
140+type MockBrokerPreviewSubscription_Expecter struct {
141+ mock *mock.Mock
142+}
143+
144+func (_m *MockBrokerPreviewSubscription) EXPECT() *MockBrokerPreviewSubscription_Expecter {
145+ return &MockBrokerPreviewSubscription_Expecter{mock: &_m.Mock}
146+}
147+
148+// Changed provides a mock function for the type MockBrokerPreviewSubscription
149+func (_mock *MockBrokerPreviewSubscription) Changed() <-chan struct{} {
150+ ret := _mock.Called()
151+
152+ if len(ret) == 0 {
153+ panic("no return value specified for Changed")
154+ }
155+
156+ var r0 <-chan struct{}
157+ if returnFunc, ok := ret.Get(0).(func() <-chan struct{}); ok {
158+ r0 = returnFunc()
159+ } else {
160+ if ret.Get(0) != nil {
161+ r0 = ret.Get(0).(<-chan struct{})
162+ }
163+ }
164+ return r0
165+}
166+
167+// MockBrokerPreviewSubscription_Changed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Changed'
168+type MockBrokerPreviewSubscription_Changed_Call struct {
169+ *mock.Call
170+}
171+
172+// Changed is a helper method to define mock.On call
173+func (_e *MockBrokerPreviewSubscription_Expecter) Changed() *MockBrokerPreviewSubscription_Changed_Call {
174+ return &MockBrokerPreviewSubscription_Changed_Call{Call: _e.mock.On("Changed")}
175+}
176+
177+func (_c *MockBrokerPreviewSubscription_Changed_Call) Run(run func()) *MockBrokerPreviewSubscription_Changed_Call {
178+ _c.Call.Run(func(args mock.Arguments) {
179+ run()
180+ })
181+ return _c
182+}
183+
184+func (_c *MockBrokerPreviewSubscription_Changed_Call) Return(valCh <-chan struct{}) *MockBrokerPreviewSubscription_Changed_Call {
185+ _c.Call.Return(valCh)
186+ return _c
187+}
188+
189+func (_c *MockBrokerPreviewSubscription_Changed_Call) RunAndReturn(run func() <-chan struct{}) *MockBrokerPreviewSubscription_Changed_Call {
190+ _c.Call.Return(run)
191+ return _c
192+}
193+
194+// Close provides a mock function for the type MockBrokerPreviewSubscription
195+func (_mock *MockBrokerPreviewSubscription) Close() {
196+ _mock.Called()
197+ return
198+}
199+
200+// MockBrokerPreviewSubscription_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
201+type MockBrokerPreviewSubscription_Close_Call struct {
202+ *mock.Call
203+}
204+
205+// Close is a helper method to define mock.On call
206+func (_e *MockBrokerPreviewSubscription_Expecter) Close() *MockBrokerPreviewSubscription_Close_Call {
207+ return &MockBrokerPreviewSubscription_Close_Call{Call: _e.mock.On("Close")}
208+}
209+
210+func (_c *MockBrokerPreviewSubscription_Close_Call) Run(run func()) *MockBrokerPreviewSubscription_Close_Call {
211+ _c.Call.Run(func(args mock.Arguments) {
212+ run()
213+ })
214+ return _c
215+}
216+
217+func (_c *MockBrokerPreviewSubscription_Close_Call) Return() *MockBrokerPreviewSubscription_Close_Call {
218+ _c.Call.Return()
219+ return _c
220+}
221+
222+func (_c *MockBrokerPreviewSubscription_Close_Call) RunAndReturn(run func()) *MockBrokerPreviewSubscription_Close_Call {
223+ _c.Run(run)
224+ return _c
225+}
226+
227+// Latest provides a mock function for the type MockBrokerPreviewSubscription
228+func (_mock *MockBrokerPreviewSubscription) Latest() ports.BrokerPreviewPublication {
229+ ret := _mock.Called()
230+
231+ if len(ret) == 0 {
232+ panic("no return value specified for Latest")
233+ }
234+
235+ var r0 ports.BrokerPreviewPublication
236+ if returnFunc, ok := ret.Get(0).(func() ports.BrokerPreviewPublication); ok {
237+ r0 = returnFunc()
238+ } else {
239+ r0 = ret.Get(0).(ports.BrokerPreviewPublication)
240+ }
241+ return r0
242+}
243+
244+// MockBrokerPreviewSubscription_Latest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Latest'
245+type MockBrokerPreviewSubscription_Latest_Call struct {
246+ *mock.Call
247+}
248+
249+// Latest is a helper method to define mock.On call
250+func (_e *MockBrokerPreviewSubscription_Expecter) Latest() *MockBrokerPreviewSubscription_Latest_Call {
251+ return &MockBrokerPreviewSubscription_Latest_Call{Call: _e.mock.On("Latest")}
252+}
253+
254+func (_c *MockBrokerPreviewSubscription_Latest_Call) Run(run func()) *MockBrokerPreviewSubscription_Latest_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ run()
257+ })
258+ return _c
259+}
260+
261+func (_c *MockBrokerPreviewSubscription_Latest_Call) Return(brokerPreviewPublication ports.BrokerPreviewPublication) *MockBrokerPreviewSubscription_Latest_Call {
262+ _c.Call.Return(brokerPreviewPublication)
263+ return _c
264+}
265+
266+func (_c *MockBrokerPreviewSubscription_Latest_Call) RunAndReturn(run func() ports.BrokerPreviewPublication) *MockBrokerPreviewSubscription_Latest_Call {
267+ _c.Call.Return(run)
268+ return _c
269+}
270+
121 // NewMockBrokerService creates a new instance of MockBrokerService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.271 // NewMockBrokerService creates a new instance of MockBrokerService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
122 // The first argument is typically a *testing.T value.272 // The first argument is typically a *testing.T value.
123 func NewMockBrokerService(t interface {273 func NewMockBrokerService(t interface {
@@ -780,6 +930,68 @@ func (_c *MockBrokerService_Subscribe_Call) RunAndReturn(run func() (ports.Broke
780 return _c930 return _c
781 }931 }
782 932
933+// SubscribePreview provides a mock function for the type MockBrokerService
934+func (_mock *MockBrokerService) SubscribePreview(request ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
935+ ret := _mock.Called(request)
936+
937+ if len(ret) == 0 {
938+ panic("no return value specified for SubscribePreview")
939+ }
940+
941+ var r0 ports.BrokerPreviewSubscription
942+ var r1 error
943+ if returnFunc, ok := ret.Get(0).(func(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error)); ok {
944+ return returnFunc(request)
945+ }
946+ if returnFunc, ok := ret.Get(0).(func(ports.BrokerPreviewRequest) ports.BrokerPreviewSubscription); ok {
947+ r0 = returnFunc(request)
948+ } else {
949+ if ret.Get(0) != nil {
950+ r0 = ret.Get(0).(ports.BrokerPreviewSubscription)
951+ }
952+ }
953+ if returnFunc, ok := ret.Get(1).(func(ports.BrokerPreviewRequest) error); ok {
954+ r1 = returnFunc(request)
955+ } else {
956+ r1 = ret.Error(1)
957+ }
958+ return r0, r1
959+}
960+
961+// MockBrokerService_SubscribePreview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SubscribePreview'
962+type MockBrokerService_SubscribePreview_Call struct {
963+ *mock.Call
964+}
965+
966+// SubscribePreview is a helper method to define mock.On call
967+// - request ports.BrokerPreviewRequest
968+func (_e *MockBrokerService_Expecter) SubscribePreview(request any) *MockBrokerService_SubscribePreview_Call {
969+ return &MockBrokerService_SubscribePreview_Call{Call: _e.mock.On("SubscribePreview", request)}
970+}
971+
972+func (_c *MockBrokerService_SubscribePreview_Call) Run(run func(request ports.BrokerPreviewRequest)) *MockBrokerService_SubscribePreview_Call {
973+ _c.Call.Run(func(args mock.Arguments) {
974+ var arg0 ports.BrokerPreviewRequest
975+ if args[0] != nil {
976+ arg0 = args[0].(ports.BrokerPreviewRequest)
977+ }
978+ run(
979+ arg0,
980+ )
981+ })
982+ return _c
983+}
984+
985+func (_c *MockBrokerService_SubscribePreview_Call) Return(brokerPreviewSubscription ports.BrokerPreviewSubscription, err error) *MockBrokerService_SubscribePreview_Call {
986+ _c.Call.Return(brokerPreviewSubscription, err)
987+ return _c
988+}
989+
990+func (_c *MockBrokerService_SubscribePreview_Call) RunAndReturn(run func(request ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error)) *MockBrokerService_SubscribePreview_Call {
991+ _c.Call.Return(run)
992+ return _c
993+}
994+
783 // UpdateHostPolicy provides a mock function for the type MockBrokerService995 // UpdateHostPolicy provides a mock function for the type MockBrokerService
784 func (_mock *MockBrokerService) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {996 func (_mock *MockBrokerService) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
785 ret := _mock.Called(ctx, expected, policy)997 ret := _mock.Called(ctx, expected, policy)
M internal/ports/mocks/broker_store_mock.go +101 -0
@@ -5,10 +5,111 @@
55 package portsmocks
66
77 import (
8+ "context"
9+
810 "github.com/bnema/vev/internal/ports"
911 mock "github.com/stretchr/testify/mock"
1012 )
1113
14+// NewMockBrokerHostAuthorityReader creates a new instance of MockBrokerHostAuthorityReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockBrokerHostAuthorityReader(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockBrokerHostAuthorityReader {
20+ mock := &MockBrokerHostAuthorityReader{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockBrokerHostAuthorityReader is an autogenerated mock type for the BrokerHostAuthorityReader type
29+type MockBrokerHostAuthorityReader struct {
30+ mock.Mock
31+}
32+
33+type MockBrokerHostAuthorityReader_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockBrokerHostAuthorityReader) EXPECT() *MockBrokerHostAuthorityReader_Expecter {
38+ return &MockBrokerHostAuthorityReader_Expecter{mock: &_m.Mock}
39+}
40+
41+// LookupHost provides a mock function for the type MockBrokerHostAuthorityReader
42+func (_mock *MockBrokerHostAuthorityReader) LookupHost(ctx context.Context, endpoint string) (ports.BrokerHostRecord, bool, error) {
43+ ret := _mock.Called(ctx, endpoint)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for LookupHost")
47+ }
48+
49+ var r0 ports.BrokerHostRecord
50+ var r1 bool
51+ var r2 error
52+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.BrokerHostRecord, bool, error)); ok {
53+ return returnFunc(ctx, endpoint)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.BrokerHostRecord); ok {
56+ r0 = returnFunc(ctx, endpoint)
57+ } else {
58+ r0 = ret.Get(0).(ports.BrokerHostRecord)
59+ }
60+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
61+ r1 = returnFunc(ctx, endpoint)
62+ } else {
63+ r1 = ret.Get(1).(bool)
64+ }
65+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
66+ r2 = returnFunc(ctx, endpoint)
67+ } else {
68+ r2 = ret.Error(2)
69+ }
70+ return r0, r1, r2
71+}
72+
73+// MockBrokerHostAuthorityReader_LookupHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LookupHost'
74+type MockBrokerHostAuthorityReader_LookupHost_Call struct {
75+ *mock.Call
76+}
77+
78+// LookupHost is a helper method to define mock.On call
79+// - ctx context.Context
80+// - endpoint string
81+func (_e *MockBrokerHostAuthorityReader_Expecter) LookupHost(ctx any, endpoint any) *MockBrokerHostAuthorityReader_LookupHost_Call {
82+ return &MockBrokerHostAuthorityReader_LookupHost_Call{Call: _e.mock.On("LookupHost", ctx, endpoint)}
83+}
84+
85+func (_c *MockBrokerHostAuthorityReader_LookupHost_Call) Run(run func(ctx context.Context, endpoint string)) *MockBrokerHostAuthorityReader_LookupHost_Call {
86+ _c.Call.Run(func(args mock.Arguments) {
87+ var arg0 context.Context
88+ if args[0] != nil {
89+ arg0 = args[0].(context.Context)
90+ }
91+ var arg1 string
92+ if args[1] != nil {
93+ arg1 = args[1].(string)
94+ }
95+ run(
96+ arg0,
97+ arg1,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MockBrokerHostAuthorityReader_LookupHost_Call) Return(brokerHostRecord ports.BrokerHostRecord, b bool, err error) *MockBrokerHostAuthorityReader_LookupHost_Call {
104+ _c.Call.Return(brokerHostRecord, b, err)
105+ return _c
106+}
107+
108+func (_c *MockBrokerHostAuthorityReader_LookupHost_Call) RunAndReturn(run func(ctx context.Context, endpoint string) (ports.BrokerHostRecord, bool, error)) *MockBrokerHostAuthorityReader_LookupHost_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
112+
12113 // NewMockBrokerHostStore creates a new instance of MockBrokerHostStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13114 // The first argument is typically a *testing.T value.
14115 func NewMockBrokerHostStore(t interface {
@@ -5,10 +5,111 @@
5 package portsmocks5 package portsmocks
6 6
7 import (7 import (
8+ "context"
9+
8 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
9 mock "github.com/stretchr/testify/mock"11 mock "github.com/stretchr/testify/mock"
10 )12 )
11 13
14+// NewMockBrokerHostAuthorityReader creates a new instance of MockBrokerHostAuthorityReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockBrokerHostAuthorityReader(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockBrokerHostAuthorityReader {
20+ mock := &MockBrokerHostAuthorityReader{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockBrokerHostAuthorityReader is an autogenerated mock type for the BrokerHostAuthorityReader type
29+type MockBrokerHostAuthorityReader struct {
30+ mock.Mock
31+}
32+
33+type MockBrokerHostAuthorityReader_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockBrokerHostAuthorityReader) EXPECT() *MockBrokerHostAuthorityReader_Expecter {
38+ return &MockBrokerHostAuthorityReader_Expecter{mock: &_m.Mock}
39+}
40+
41+// LookupHost provides a mock function for the type MockBrokerHostAuthorityReader
42+func (_mock *MockBrokerHostAuthorityReader) LookupHost(ctx context.Context, endpoint string) (ports.BrokerHostRecord, bool, error) {
43+ ret := _mock.Called(ctx, endpoint)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for LookupHost")
47+ }
48+
49+ var r0 ports.BrokerHostRecord
50+ var r1 bool
51+ var r2 error
52+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.BrokerHostRecord, bool, error)); ok {
53+ return returnFunc(ctx, endpoint)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.BrokerHostRecord); ok {
56+ r0 = returnFunc(ctx, endpoint)
57+ } else {
58+ r0 = ret.Get(0).(ports.BrokerHostRecord)
59+ }
60+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
61+ r1 = returnFunc(ctx, endpoint)
62+ } else {
63+ r1 = ret.Get(1).(bool)
64+ }
65+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
66+ r2 = returnFunc(ctx, endpoint)
67+ } else {
68+ r2 = ret.Error(2)
69+ }
70+ return r0, r1, r2
71+}
72+
73+// MockBrokerHostAuthorityReader_LookupHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LookupHost'
74+type MockBrokerHostAuthorityReader_LookupHost_Call struct {
75+ *mock.Call
76+}
77+
78+// LookupHost is a helper method to define mock.On call
79+// - ctx context.Context
80+// - endpoint string
81+func (_e *MockBrokerHostAuthorityReader_Expecter) LookupHost(ctx any, endpoint any) *MockBrokerHostAuthorityReader_LookupHost_Call {
82+ return &MockBrokerHostAuthorityReader_LookupHost_Call{Call: _e.mock.On("LookupHost", ctx, endpoint)}
83+}
84+
85+func (_c *MockBrokerHostAuthorityReader_LookupHost_Call) Run(run func(ctx context.Context, endpoint string)) *MockBrokerHostAuthorityReader_LookupHost_Call {
86+ _c.Call.Run(func(args mock.Arguments) {
87+ var arg0 context.Context
88+ if args[0] != nil {
89+ arg0 = args[0].(context.Context)
90+ }
91+ var arg1 string
92+ if args[1] != nil {
93+ arg1 = args[1].(string)
94+ }
95+ run(
96+ arg0,
97+ arg1,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MockBrokerHostAuthorityReader_LookupHost_Call) Return(brokerHostRecord ports.BrokerHostRecord, b bool, err error) *MockBrokerHostAuthorityReader_LookupHost_Call {
104+ _c.Call.Return(brokerHostRecord, b, err)
105+ return _c
106+}
107+
108+func (_c *MockBrokerHostAuthorityReader_LookupHost_Call) RunAndReturn(run func(ctx context.Context, endpoint string) (ports.BrokerHostRecord, bool, error)) *MockBrokerHostAuthorityReader_LookupHost_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
112+
12 // NewMockBrokerHostStore creates a new instance of MockBrokerHostStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.113 // NewMockBrokerHostStore creates a new instance of MockBrokerHostStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13 // The first argument is typically a *testing.T value.114 // The first argument is typically a *testing.T value.
14 func NewMockBrokerHostStore(t interface {115 func NewMockBrokerHostStore(t interface {
M internal/usecase/broker/service.go +23 -8
@@ -39,6 +39,7 @@ type Authority struct {
3939 registry *Registry
4040 pool *Pool
4141 supervisor *Supervisor
42+ clock ports.Clock
4243 }
4344
4445 var _ ports.BrokerAuthority = (*Authority)(nil)
@@ -52,14 +53,21 @@ var _ ports.BrokerAuthority = (*Authority)(nil)
5253 // neither starts nor settles Run: serving connections without a live Run leaks
5354 // the writer and never flushes the newest staged publication, and a registry
5455 // that was never run leaves its durable writer undrained.
55-func NewAuthority(epoch ports.BrokerEpoch, registry *Registry, pool *Pool, supervisor *Supervisor) (*Authority, error) {
56+func NewAuthority(epoch ports.BrokerEpoch, registry *Registry, pool *Pool, supervisor *Supervisor, clocks ...ports.Clock) (*Authority, error) {
5657 if epoch == 0 || nilDependency(registry) || nilDependency(pool) || nilDependency(supervisor) {
5758 return nil, errors.New("broker: invalid authority dependencies")
5859 }
5960 if registry.epoch != epoch || pool.epoch != epoch {
6061 return nil, errors.New("broker: authority epoch mismatch")
6162 }
62- return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor}, nil
63+ clk := registry.clock
64+ if len(clocks) > 0 {
65+ clk = clocks[0]
66+ }
67+ if nilDependency(clk) {
68+ return nil, errors.New("broker: invalid authority clock")
69+ }
70+ return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor, clock: clk}, nil
6371 }
6472
6573 // AdmitClient admits exactly one accepted client connection. The supervisor
@@ -86,7 +94,7 @@ func (a *Authority) AdmitClient(ctx context.Context) (ports.BrokerService, error
8694 lease.Release()
8795 return nil, err
8896 }
89- return newService(a.epoch, id, a.registry, a.pool, a.supervisor, lease), nil
97+ return newService(a.epoch, id, a.registry, a.pool, a.supervisor, lease, a.clock), nil
9098 }
9199
92100 // Service is one admitted client connection: the core's service for exactly one
@@ -98,6 +106,7 @@ type Service struct {
98106 pool *Pool
99107 supervisor *Supervisor
100108 lease *Lease
109+ clock ports.Clock
101110
102111 // ctx owns everything this connection started: in-flight setup opens and
103112 // live streams. It is derived from the supervisor root, never from the
@@ -105,9 +114,11 @@ type Service struct {
105114 ctx context.Context
106115 cancel context.CancelFunc
107116
108- mu sync.Mutex
109- closed bool
110- subs map[*serviceSubscription]struct{}
117+ mu sync.Mutex
118+ closed bool
119+ subs map[*serviceSubscription]struct{}
120+ preview *servicePreviewSubscription
121+ previewGeneration ports.BrokerPreviewGeneration
111122 // nextStream is this connection's strictly increasing, never-zero logical
112123 // stream ID allocator. It is the only allocator on this connection: the
113124 // supervisor and the broker operations each read one identity here and carry
@@ -133,10 +144,10 @@ type Service struct {
133144
134145 var _ ports.BrokerService = (*Service)(nil)
135146
136-func newService(epoch ports.BrokerEpoch, id ports.BrokerConnectionID, registry *Registry, pool *Pool, supervisor *Supervisor, lease *Lease) *Service {
147+func newService(epoch ports.BrokerEpoch, id ports.BrokerConnectionID, registry *Registry, pool *Pool, supervisor *Supervisor, lease *Lease, clock ports.Clock) *Service {
137148 ctx, cancel := context.WithCancel(supervisor.RootContext())
138149 s := &Service{
139- epoch: epoch, id: id, registry: registry, pool: pool, supervisor: supervisor, lease: lease,
150+ epoch: epoch, id: id, registry: registry, pool: pool, supervisor: supervisor, lease: lease, clock: clock,
140151 ctx: ctx, cancel: cancel, subs: make(map[*serviceSubscription]struct{}),
141152 done: make(chan struct{}),
142153 }
@@ -364,9 +375,13 @@ func (s *Service) Close() error {
364375 for sub := range s.subs {
365376 subs = append(subs, sub)
366377 }
378+ preview := s.preview
367379 s.mu.Unlock()
368380
369381 s.cancel()
382+ if preview != nil {
383+ preview.Close()
384+ }
370385 s.wg.Wait()
371386 for _, sub := range subs {
372387 sub.Close()
@@ -39,6 +39,7 @@ type Authority struct {
39 registry *Registry39 registry *Registry
40 pool *Pool40 pool *Pool
41 supervisor *Supervisor41 supervisor *Supervisor
42+ clock ports.Clock
42 }43 }
43 44
44 var _ ports.BrokerAuthority = (*Authority)(nil)45 var _ ports.BrokerAuthority = (*Authority)(nil)
@@ -52,14 +53,21 @@ var _ ports.BrokerAuthority = (*Authority)(nil)
52 // neither starts nor settles Run: serving connections without a live Run leaks53 // neither starts nor settles Run: serving connections without a live Run leaks
53 // the writer and never flushes the newest staged publication, and a registry54 // the writer and never flushes the newest staged publication, and a registry
54 // that was never run leaves its durable writer undrained.55 // that was never run leaves its durable writer undrained.
55-func NewAuthority(epoch ports.BrokerEpoch, registry *Registry, pool *Pool, supervisor *Supervisor) (*Authority, error) {56+func NewAuthority(epoch ports.BrokerEpoch, registry *Registry, pool *Pool, supervisor *Supervisor, clocks ...ports.Clock) (*Authority, error) {
56 if epoch == 0 || nilDependency(registry) || nilDependency(pool) || nilDependency(supervisor) {57 if epoch == 0 || nilDependency(registry) || nilDependency(pool) || nilDependency(supervisor) {
57 return nil, errors.New("broker: invalid authority dependencies")58 return nil, errors.New("broker: invalid authority dependencies")
58 }59 }
59 if registry.epoch != epoch || pool.epoch != epoch {60 if registry.epoch != epoch || pool.epoch != epoch {
60 return nil, errors.New("broker: authority epoch mismatch")61 return nil, errors.New("broker: authority epoch mismatch")
61 }62 }
62- return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor}, nil63+ clk := registry.clock
64+ if len(clocks) > 0 {
65+ clk = clocks[0]
66+ }
67+ if nilDependency(clk) {
68+ return nil, errors.New("broker: invalid authority clock")
69+ }
70+ return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor, clock: clk}, nil
63 }71 }
64 72
65 // AdmitClient admits exactly one accepted client connection. The supervisor73 // AdmitClient admits exactly one accepted client connection. The supervisor
@@ -86,7 +94,7 @@ func (a *Authority) AdmitClient(ctx context.Context) (ports.BrokerService, error
86 lease.Release()94 lease.Release()
87 return nil, err95 return nil, err
88 }96 }
89- return newService(a.epoch, id, a.registry, a.pool, a.supervisor, lease), nil97+ return newService(a.epoch, id, a.registry, a.pool, a.supervisor, lease, a.clock), nil
90 }98 }
91 99
92 // Service is one admitted client connection: the core's service for exactly one100 // Service is one admitted client connection: the core's service for exactly one
@@ -98,6 +106,7 @@ type Service struct {
98 pool *Pool106 pool *Pool
99 supervisor *Supervisor107 supervisor *Supervisor
100 lease *Lease108 lease *Lease
109+ clock ports.Clock
101 110
102 // ctx owns everything this connection started: in-flight setup opens and111 // ctx owns everything this connection started: in-flight setup opens and
103 // live streams. It is derived from the supervisor root, never from the112 // live streams. It is derived from the supervisor root, never from the
@@ -105,9 +114,11 @@ type Service struct {
105 ctx context.Context114 ctx context.Context
106 cancel context.CancelFunc115 cancel context.CancelFunc
107 116
108- mu sync.Mutex117+ mu sync.Mutex
109- closed bool118+ closed bool
110- subs map[*serviceSubscription]struct{}119+ subs map[*serviceSubscription]struct{}
120+ preview *servicePreviewSubscription
121+ previewGeneration ports.BrokerPreviewGeneration
111 // nextStream is this connection's strictly increasing, never-zero logical122 // nextStream is this connection's strictly increasing, never-zero logical
112 // stream ID allocator. It is the only allocator on this connection: the123 // stream ID allocator. It is the only allocator on this connection: the
113 // supervisor and the broker operations each read one identity here and carry124 // supervisor and the broker operations each read one identity here and carry
@@ -133,10 +144,10 @@ type Service struct {
133 144
134 var _ ports.BrokerService = (*Service)(nil)145 var _ ports.BrokerService = (*Service)(nil)
135 146
136-func newService(epoch ports.BrokerEpoch, id ports.BrokerConnectionID, registry *Registry, pool *Pool, supervisor *Supervisor, lease *Lease) *Service {147+func newService(epoch ports.BrokerEpoch, id ports.BrokerConnectionID, registry *Registry, pool *Pool, supervisor *Supervisor, lease *Lease, clock ports.Clock) *Service {
137 ctx, cancel := context.WithCancel(supervisor.RootContext())148 ctx, cancel := context.WithCancel(supervisor.RootContext())
138 s := &Service{149 s := &Service{
139- epoch: epoch, id: id, registry: registry, pool: pool, supervisor: supervisor, lease: lease,150+ epoch: epoch, id: id, registry: registry, pool: pool, supervisor: supervisor, lease: lease, clock: clock,
140 ctx: ctx, cancel: cancel, subs: make(map[*serviceSubscription]struct{}),151 ctx: ctx, cancel: cancel, subs: make(map[*serviceSubscription]struct{}),
141 done: make(chan struct{}),152 done: make(chan struct{}),
142 }153 }
@@ -364,9 +375,13 @@ func (s *Service) Close() error {
364 for sub := range s.subs {375 for sub := range s.subs {
365 subs = append(subs, sub)376 subs = append(subs, sub)
366 }377 }
378+ preview := s.preview
367 s.mu.Unlock()379 s.mu.Unlock()
368 380
369 s.cancel()381 s.cancel()
382+ if preview != nil {
383+ preview.Close()
384+ }
370 s.wg.Wait()385 s.wg.Wait()
371 for _, sub := range subs {386 for _, sub := range subs {
372 sub.Close()387 sub.Close()
A internal/usecase/broker/service_preview.go +194 -0
@@ -0,0 +1,194 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+)
12+
13+const previewPollCadence = time.Second
14+
15+// SubscribePreview replaces this connection's selected-row observation after
16+// validating the complete authority and its strictly increasing generation.
17+func (s *Service) SubscribePreview(request ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
18+ if err := request.Validate(); err != nil {
19+ return nil, errors.Join(ports.BrokerAdmissionInvalid, err)
20+ }
21+ if request.Epoch != s.epoch || request.Connection != s.id {
22+ return nil, ports.BrokerAdmissionStale
23+ }
24+ s.mu.Lock()
25+ if s.closed {
26+ s.mu.Unlock()
27+ return nil, ports.BrokerAdmissionClosed
28+ }
29+ if request.Generation <= s.previewGeneration {
30+ s.mu.Unlock()
31+ return nil, ports.BrokerAdmissionStale
32+ }
33+ previous := s.preview
34+ s.previewGeneration = request.Generation
35+ s.preview = nil
36+ s.mu.Unlock()
37+ if previous != nil {
38+ previous.Close()
39+ }
40+
41+ ctx, cancel := context.WithCancel(s.ctx)
42+ sub := &servicePreviewSubscription{
43+ service: s, request: request, ctx: ctx, cancel: cancel,
44+ changed: make(chan struct{}, 1),
45+ latest: previewPublication(request, protocol.RemotePreview{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "preview pending"}),
46+ done: make(chan struct{}),
47+ }
48+ s.mu.Lock()
49+ if s.closed || s.previewGeneration != request.Generation {
50+ s.mu.Unlock()
51+ sub.Close()
52+ return nil, ports.BrokerAdmissionStale
53+ }
54+ s.preview = sub
55+ s.wg.Add(1)
56+ s.mu.Unlock()
57+ go func() { defer s.wg.Done(); sub.run() }()
58+ return sub, nil
59+}
60+
61+// previewIdentifiesTarget rejects a daemon preview whose inner identity does
62+// not name the requested session lifecycle and live tab. A non-OK status
63+// carries no inner identity and is always accepted: it is already typed as
64+// unavailable rather than as another tab's content. Successful previews
65+// report viewport-maximum dimensions from the daemon crop, so inner
66+// dimensions must fit inside the request instead of matching it exactly.
67+func previewIdentifiesTarget(preview protocol.RemotePreview, request protocol.RemotePreviewRequest) bool {
68+ if preview.Status != protocol.RemotePreviewOK {
69+ return true
70+ }
71+ return preview.LifecycleID == request.Target.LifecycleID &&
72+ preview.TabID == request.Target.LiveTabID &&
73+ preview.Width != 0 && preview.Height != 0 &&
74+ preview.Width <= request.Width && preview.Height <= request.Height
75+}
76+
77+func previewPublication(request ports.BrokerPreviewRequest, preview protocol.RemotePreview, err error) ports.BrokerPreviewPublication {
78+ return ports.BrokerPreviewPublication{Epoch: request.Epoch, Connection: request.Connection, Generation: request.Generation,
79+ Target: request.Preview.Target, Width: request.Preview.Width, Height: request.Preview.Height, Preview: preview, Err: err}
80+}
81+
82+type servicePreviewSubscription struct {
83+ service *Service
84+ request ports.BrokerPreviewRequest
85+ ctx context.Context
86+ cancel context.CancelFunc
87+ mu sync.Mutex
88+ latest ports.BrokerPreviewPublication
89+ changed chan struct{}
90+ done chan struct{}
91+ once sync.Once
92+}
93+
94+func (p *servicePreviewSubscription) Changed() <-chan struct{} { return p.changed }
95+func (p *servicePreviewSubscription) Latest() ports.BrokerPreviewPublication {
96+ p.mu.Lock()
97+ defer p.mu.Unlock()
98+ return p.latest
99+}
100+func (p *servicePreviewSubscription) Close() {
101+ p.once.Do(func() {
102+ p.cancel()
103+ p.service.mu.Lock()
104+ if p.service.preview == p {
105+ p.service.preview = nil
106+ }
107+ p.service.mu.Unlock()
108+ })
109+}
110+
111+func (p *servicePreviewSubscription) current() bool {
112+ if p.ctx.Err() != nil || p.request.Validate() != nil {
113+ return false
114+ }
115+ p.service.mu.Lock()
116+ defer p.service.mu.Unlock()
117+ return !p.service.closed && p.service.epoch == p.request.Epoch && p.service.id == p.request.Connection && p.service.preview == p && p.service.previewGeneration == p.request.Generation
118+}
119+
120+func (p *servicePreviewSubscription) publish(preview protocol.RemotePreview, err error) bool {
121+ if !p.current() {
122+ return false
123+ }
124+ publication := previewPublication(p.request, preview, err)
125+ if publication.Validate() != nil {
126+ return false
127+ }
128+ p.mu.Lock()
129+ p.latest = publication
130+ p.mu.Unlock()
131+ select {
132+ case p.changed <- struct{}{}:
133+ default:
134+ }
135+ return true
136+}
137+
138+func (p *servicePreviewSubscription) run() {
139+ defer close(p.done)
140+ for p.current() {
141+ // Every poll opens one fresh logical stream. Stream identities are
142+ // consumed exactly once by the pool admission window, so reusing the
143+ // original route's stream would be refused as stale after the first
144+ // poll and degrade the preview to perpetual unavailability.
145+ route := p.request.Route
146+ streamID, err := p.service.NextStreamID()
147+ if err == nil {
148+ route.Stream = streamID
149+ }
150+ var stream ports.BrokerLogicalConnection
151+ if err == nil {
152+ stream, err = p.service.OpenStream(p.ctx, route)
153+ }
154+ if err == nil {
155+ err = stream.SendClient(p.request.Preview)
156+ }
157+ var preview protocol.RemotePreview
158+ if err == nil {
159+ var message protocol.ServerMessage
160+ message, err = stream.ReceiveServer()
161+ if err == nil {
162+ var ok bool
163+ preview, ok = message.(protocol.RemotePreview)
164+ if !ok {
165+ err = errors.New("broker: invalid preview response")
166+ }
167+ }
168+ }
169+ if stream != nil {
170+ _ = stream.Close()
171+ }
172+ if err == nil && !previewIdentifiesTarget(preview, p.request.Preview) {
173+ err = errors.New("broker: preview response is for another target")
174+ }
175+
176+ if !p.current() || errors.Is(err, context.Canceled) {
177+ return
178+ }
179+ if err == nil {
180+ if !p.publish(preview, nil) {
181+ return
182+ }
183+ } else if !p.publish(protocol.RemotePreview{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "preview unavailable", Cause: err}) {
184+ return
185+ }
186+ timer := p.service.clock.NewTimer(previewPollCadence)
187+ select {
188+ case <-p.ctx.Done():
189+ timer.Stop()
190+ return
191+ case <-timer.C():
192+ }
193+ }
194+}
@@ -0,0 +1,194 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+)
12+
13+const previewPollCadence = time.Second
14+
15+// SubscribePreview replaces this connection's selected-row observation after
16+// validating the complete authority and its strictly increasing generation.
17+func (s *Service) SubscribePreview(request ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
18+ if err := request.Validate(); err != nil {
19+ return nil, errors.Join(ports.BrokerAdmissionInvalid, err)
20+ }
21+ if request.Epoch != s.epoch || request.Connection != s.id {
22+ return nil, ports.BrokerAdmissionStale
23+ }
24+ s.mu.Lock()
25+ if s.closed {
26+ s.mu.Unlock()
27+ return nil, ports.BrokerAdmissionClosed
28+ }
29+ if request.Generation <= s.previewGeneration {
30+ s.mu.Unlock()
31+ return nil, ports.BrokerAdmissionStale
32+ }
33+ previous := s.preview
34+ s.previewGeneration = request.Generation
35+ s.preview = nil
36+ s.mu.Unlock()
37+ if previous != nil {
38+ previous.Close()
39+ }
40+
41+ ctx, cancel := context.WithCancel(s.ctx)
42+ sub := &servicePreviewSubscription{
43+ service: s, request: request, ctx: ctx, cancel: cancel,
44+ changed: make(chan struct{}, 1),
45+ latest: previewPublication(request, protocol.RemotePreview{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "preview pending"}),
46+ done: make(chan struct{}),
47+ }
48+ s.mu.Lock()
49+ if s.closed || s.previewGeneration != request.Generation {
50+ s.mu.Unlock()
51+ sub.Close()
52+ return nil, ports.BrokerAdmissionStale
53+ }
54+ s.preview = sub
55+ s.wg.Add(1)
56+ s.mu.Unlock()
57+ go func() { defer s.wg.Done(); sub.run() }()
58+ return sub, nil
59+}
60+
61+// previewIdentifiesTarget rejects a daemon preview whose inner identity does
62+// not name the requested session lifecycle and live tab. A non-OK status
63+// carries no inner identity and is always accepted: it is already typed as
64+// unavailable rather than as another tab's content. Successful previews
65+// report viewport-maximum dimensions from the daemon crop, so inner
66+// dimensions must fit inside the request instead of matching it exactly.
67+func previewIdentifiesTarget(preview protocol.RemotePreview, request protocol.RemotePreviewRequest) bool {
68+ if preview.Status != protocol.RemotePreviewOK {
69+ return true
70+ }
71+ return preview.LifecycleID == request.Target.LifecycleID &&
72+ preview.TabID == request.Target.LiveTabID &&
73+ preview.Width != 0 && preview.Height != 0 &&
74+ preview.Width <= request.Width && preview.Height <= request.Height
75+}
76+
77+func previewPublication(request ports.BrokerPreviewRequest, preview protocol.RemotePreview, err error) ports.BrokerPreviewPublication {
78+ return ports.BrokerPreviewPublication{Epoch: request.Epoch, Connection: request.Connection, Generation: request.Generation,
79+ Target: request.Preview.Target, Width: request.Preview.Width, Height: request.Preview.Height, Preview: preview, Err: err}
80+}
81+
82+type servicePreviewSubscription struct {
83+ service *Service
84+ request ports.BrokerPreviewRequest
85+ ctx context.Context
86+ cancel context.CancelFunc
87+ mu sync.Mutex
88+ latest ports.BrokerPreviewPublication
89+ changed chan struct{}
90+ done chan struct{}
91+ once sync.Once
92+}
93+
94+func (p *servicePreviewSubscription) Changed() <-chan struct{} { return p.changed }
95+func (p *servicePreviewSubscription) Latest() ports.BrokerPreviewPublication {
96+ p.mu.Lock()
97+ defer p.mu.Unlock()
98+ return p.latest
99+}
100+func (p *servicePreviewSubscription) Close() {
101+ p.once.Do(func() {
102+ p.cancel()
103+ p.service.mu.Lock()
104+ if p.service.preview == p {
105+ p.service.preview = nil
106+ }
107+ p.service.mu.Unlock()
108+ })
109+}
110+
111+func (p *servicePreviewSubscription) current() bool {
112+ if p.ctx.Err() != nil || p.request.Validate() != nil {
113+ return false
114+ }
115+ p.service.mu.Lock()
116+ defer p.service.mu.Unlock()
117+ return !p.service.closed && p.service.epoch == p.request.Epoch && p.service.id == p.request.Connection && p.service.preview == p && p.service.previewGeneration == p.request.Generation
118+}
119+
120+func (p *servicePreviewSubscription) publish(preview protocol.RemotePreview, err error) bool {
121+ if !p.current() {
122+ return false
123+ }
124+ publication := previewPublication(p.request, preview, err)
125+ if publication.Validate() != nil {
126+ return false
127+ }
128+ p.mu.Lock()
129+ p.latest = publication
130+ p.mu.Unlock()
131+ select {
132+ case p.changed <- struct{}{}:
133+ default:
134+ }
135+ return true
136+}
137+
138+func (p *servicePreviewSubscription) run() {
139+ defer close(p.done)
140+ for p.current() {
141+ // Every poll opens one fresh logical stream. Stream identities are
142+ // consumed exactly once by the pool admission window, so reusing the
143+ // original route's stream would be refused as stale after the first
144+ // poll and degrade the preview to perpetual unavailability.
145+ route := p.request.Route
146+ streamID, err := p.service.NextStreamID()
147+ if err == nil {
148+ route.Stream = streamID
149+ }
150+ var stream ports.BrokerLogicalConnection
151+ if err == nil {
152+ stream, err = p.service.OpenStream(p.ctx, route)
153+ }
154+ if err == nil {
155+ err = stream.SendClient(p.request.Preview)
156+ }
157+ var preview protocol.RemotePreview
158+ if err == nil {
159+ var message protocol.ServerMessage
160+ message, err = stream.ReceiveServer()
161+ if err == nil {
162+ var ok bool
163+ preview, ok = message.(protocol.RemotePreview)
164+ if !ok {
165+ err = errors.New("broker: invalid preview response")
166+ }
167+ }
168+ }
169+ if stream != nil {
170+ _ = stream.Close()
171+ }
172+ if err == nil && !previewIdentifiesTarget(preview, p.request.Preview) {
173+ err = errors.New("broker: preview response is for another target")
174+ }
175+
176+ if !p.current() || errors.Is(err, context.Canceled) {
177+ return
178+ }
179+ if err == nil {
180+ if !p.publish(preview, nil) {
181+ return
182+ }
183+ } else if !p.publish(protocol.RemotePreview{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "preview unavailable", Cause: err}) {
184+ return
185+ }
186+ timer := p.service.clock.NewTimer(previewPollCadence)
187+ select {
188+ case <-p.ctx.Done():
189+ timer.Stop()
190+ return
191+ case <-timer.C():
192+ }
193+ }
194+}
A internal/usecase/broker/service_preview_test.go +283 -0
@@ -0,0 +1,283 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ renderer "github.com/bnema/vev-vt"
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/protocol"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+// previewLogicalConn is one scripted daemon-side logical connection: the test
18+// owns the preview answers it returns, records the stream identities the
19+// preview opens, and can block sends or receives on its closed channel.
20+type previewLogicalConn struct {
21+ mu sync.Mutex
22+ streams []ports.BrokerStreamID
23+ pending []protocol.RemotePreview
24+ err error
25+ done chan struct{}
26+ once sync.Once
27+}
28+
29+func (c *previewLogicalConn) SendClient(message protocol.ClientMessage) error {
30+ c.mu.Lock()
31+ defer c.mu.Unlock()
32+ select {
33+ case <-c.done:
34+ return errors.New("closed")
35+ default:
36+ }
37+ if _, ok := message.(protocol.RemotePreviewRequest); !ok {
38+ return errors.New("broker: preview connection received a non-preview request")
39+ }
40+ return nil
41+}
42+
43+func (c *previewLogicalConn) ReceiveServer() (protocol.ServerMessage, error) {
44+ c.mu.Lock()
45+ defer c.mu.Unlock()
46+ select {
47+ case <-c.done:
48+ return nil, errors.New("closed")
49+ default:
50+ }
51+ if c.err != nil {
52+ return nil, c.err
53+ }
54+ if len(c.pending) == 0 {
55+ return nil, errors.New("broker: no scripted preview answer")
56+ }
57+ preview := c.pending[0]
58+ c.pending = c.pending[1:]
59+ return preview, nil
60+}
61+
62+func (c *previewLogicalConn) Capabilities() protocol.ConnectionCapabilities {
63+ return protocol.ConnectionCapabilities{OutputDataLimit: protocol.MaxOutputDataLen}
64+}
65+
66+func (c *previewLogicalConn) LinkState() ports.LinkState { return ports.LinkStateConnected }
67+func (c *previewLogicalConn) LinkEvents() <-chan ports.LinkEvent { return nil }
68+func (c *previewLogicalConn) Done() <-chan struct{} { return c.done }
69+func (c *previewLogicalConn) Err() error { return nil }
70+
71+func (c *previewLogicalConn) Close() error {
72+ c.once.Do(func() { close(c.done) })
73+ return nil
74+}
75+
76+func (c *previewLogicalConn) queue(preview protocol.RemotePreview) {
77+ c.mu.Lock()
78+ defer c.mu.Unlock()
79+ c.pending = append(c.pending, preview)
80+}
81+
82+func (c *previewLogicalConn) fail(err error) {
83+ c.mu.Lock()
84+ defer c.mu.Unlock()
85+ c.err = err
86+}
87+
88+// previewTarget builds one valid live preview target with the given lifecycle
89+// and tab, on the broker's canonical local authority.
90+func previewTarget(lifecycle byte, tab string) domain.RemoteSessionTarget {
91+ return domain.RemoteSessionTarget{
92+ Endpoint: "local",
93+ DisplayOrigin: "local",
94+ LifecycleID: domain.SessionLifecycleID{lifecycle},
95+ SessionName: "work",
96+ LiveTabID: domain.TabStableID(tab),
97+ }
98+}
99+
100+// previewRequestFor builds one valid observation route plus preview request for
101+// a connection, epoch, and target. The stream identity is stated explicitly,
102+// exactly as the poll loop allocates one per attempt.
103+func previewRequestFor(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, target domain.RemoteSessionTarget) ports.BrokerPreviewRequest {
104+ return ports.BrokerPreviewRequest{
105+ Epoch: epoch,
106+ Connection: connection,
107+ Generation: 1,
108+ Route: ports.BrokerOpenStreamRequest{
109+ Epoch: epoch,
110+ Connection: connection,
111+ Stream: stream,
112+ Local: true,
113+ Purpose: ports.BrokerStreamObservation,
114+ Policy: poolPolicy(),
115+ StartMode: ports.BrokerDaemonExistingOnly,
116+ },
117+ Preview: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 80, Height: 24},
118+ }
119+}
120+
121+// awaitPreviewPublication waits for one Changed wake and returns the latest
122+// publication, failing the test on timeout.
123+func awaitPreviewPublication(t *testing.T, sub ports.BrokerPreviewSubscription) ports.BrokerPreviewPublication {
124+ t.Helper()
125+ select {
126+ case <-sub.Changed():
127+ return sub.Latest()
128+ case <-time.After(3 * time.Second):
129+ t.Fatal("the preview subscription did not publish within the bound")
130+ return ports.BrokerPreviewPublication{}
131+ }
132+}
133+
134+// requirePreviewOK extracts the successful preview from a publication.
135+func requirePreviewOK(t *testing.T, publication ports.BrokerPreviewPublication) protocol.RemotePreview {
136+ t.Helper()
137+ require.NoError(t, publication.Err)
138+ require.Equal(t, protocol.RemotePreviewOK, publication.Preview.Status)
139+ return publication.Preview
140+}
141+
142+// previewTestConnector dials one scripted physical whose every logical open
143+// returns one fresh scripted daemon connection. Every opened stream identity
144+// is recorded so a test can prove the preview poll loop allocates exactly
145+// one fresh identity per attempt. One connection per open models the daemon
146+// transport truthfully: the broker closes every logical stream after one
147+// exchange, and a shared connection would park the poll loop on the second.
148+type previewTestConnector struct {
149+ mu sync.Mutex
150+ opens []ports.BrokerStreamID
151+ pending []protocol.RemotePreview
152+ scripted *fakePhysical
153+}
154+
155+func (c *previewTestConnector) Connect(_ context.Context, e ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) {
156+ connector := c
157+ if connector.scripted == nil {
158+ connector.scripted = &fakePhysical{endpoint: e, done: make(chan struct{}), open: func(_ context.Context, r ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
159+ connector.mu.Lock()
160+ connector.opens = append(connector.opens, r.Stream)
161+ pending := append([]protocol.RemotePreview(nil), connector.pending...)
162+ connector.mu.Unlock()
163+ return &previewLogicalConn{done: make(chan struct{}), pending: pending}, nil
164+ }}
165+ }
166+ return connector.scripted, nil
167+}
168+
169+func (c *previewTestConnector) opened() []ports.BrokerStreamID {
170+ c.mu.Lock()
171+ defer c.mu.Unlock()
172+ return append([]ports.BrokerStreamID(nil), c.opens...)
173+}
174+
175+// previewAnswer builds one valid successful preview for the given identity
176+// and dimensions.
177+func previewAnswer(lifecycle byte, tab string, width, height int) protocol.RemotePreview {
178+ return protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewOK, LifecycleID: domain.SessionLifecycleID{lifecycle}, TabID: domain.TabStableID(tab), Revision: 7, Width: uint16(width), Height: uint16(height), Cells: make([]renderer.Cell, width*height)}
179+}
180+
181+// subscribePreview subscribes one generation-1 preview on a fresh authority
182+// whose daemon answers come from the queued scripted previews. It returns the
183+// manual clock that drives the service's poll cadence, so a test advances
184+// time instead of sleeping through the 1s repoll interval.
185+func subscribePreview(t *testing.T, daemon *previewLogicalConn, target domain.RemoteSessionTarget) (ports.BrokerService, ports.BrokerPreviewSubscription, *previewTestConnector, *manualClock) {
186+ t.Helper()
187+ daemon.mu.Lock()
188+ queued := append([]protocol.RemotePreview(nil), daemon.pending...)
189+ daemon.mu.Unlock()
190+ connector := &previewTestConnector{pending: queued}
191+ clock := newManualClock(time.Unix(0, 0))
192+ store := newTestStore()
193+ registry, err := NewRegistryWithConfig(1, store, nil, clock, nil, RegistryConfig{ObservationDisabled: true})
194+ require.NoError(t, err)
195+ authority, _, _, _ := composeTestAuthority(t, 1, registry, connector.Connect, clock)
196+ service, err := authority.AdmitClient(context.Background())
197+ require.NoError(t, err)
198+ t.Cleanup(func() { require.NoError(t, service.Close()) })
199+ stream, err := service.NextStreamID()
200+ require.NoError(t, err)
201+ request := previewRequestFor(1, service.ConnectionID(), stream, target)
202+ sub, err := service.SubscribePreview(request)
203+ require.NoError(t, err)
204+ t.Cleanup(sub.Close)
205+ return service, sub, connector, clock
206+}
207+
208+// TestServicePreviewPollReusesNoStream proves GO-002: the poll loop allocates
209+// one fresh stream identity per attempt. Two consecutive successful polls open
210+// two distinct streams on the same connection instead of replaying the first.
211+func TestServicePreviewPollReusesNoStream(t *testing.T) {
212+ target := previewTarget(0x11, "tab-alpha")
213+ daemon := &previewLogicalConn{done: make(chan struct{})}
214+ daemon.queue(previewAnswer(0x11, "tab-alpha", 2, 1))
215+ daemon.queue(previewAnswer(0x11, "tab-alpha", 2, 1))
216+ _, sub, connector, clock := subscribePreview(t, daemon, target)
217+
218+ // The poll loop republishes on a 1s cadence driven by the manual clock.
219+ // The first wake carries the first queued answer; advancing the clock
220+ // drives the second poll, whose answer can only come from a second open.
221+ requirePreviewOK(t, awaitPreviewPublication(t, sub))
222+ require.Len(t, connector.opened(), 1)
223+ clock.Advance(previewPollCadence)
224+ requirePreviewOK(t, awaitPreviewPublication(t, sub))
225+ opens := connector.opened()
226+ require.NotEqual(t, opens[0], opens[1], "the second poll must not replay the first stream identity")
227+}
228+
229+// TestServicePreviewRejectsForeignTarget proves GO-005: a daemon answer whose
230+// inner preview names another session lifecycle or tab is rejected as an
231+// unavailable typed failure instead of being published as the current row.
232+func TestServicePreviewRejectsForeignTarget(t *testing.T) {
233+ target := previewTarget(0x11, "tab-alpha")
234+ daemon := &previewLogicalConn{done: make(chan struct{})}
235+ daemon.queue(previewAnswer(0x22, "tab-alpha", 2, 1))
236+ _, sub, _, _ := subscribePreview(t, daemon, target)
237+
238+ publication := awaitPreviewPublication(t, sub)
239+ require.Error(t, publication.Err, "a foreign-target answer must not publish as success")
240+ require.Equal(t, protocol.RemotePreview{}, publication.Preview)
241+}
242+
243+// TestServicePreviewRejectsForeignTab proves the tab half of GO-005: the same
244+// lifecycle with another tab's answer is rejected too.
245+func TestServicePreviewRejectsForeignTab(t *testing.T) {
246+ target := previewTarget(0x11, "tab-alpha")
247+ daemon := &previewLogicalConn{done: make(chan struct{})}
248+ daemon.queue(previewAnswer(0x11, "tab-beta", 2, 1))
249+ _, sub, _, _ := subscribePreview(t, daemon, target)
250+
251+ publication := awaitPreviewPublication(t, sub)
252+ require.Error(t, publication.Err)
253+ require.Equal(t, protocol.RemotePreview{}, publication.Preview)
254+}
255+
256+// TestServicePreviewAcceptsCroppedDimensions proves the GO-005 bound: the
257+// daemon crops the viewport to the focused pane, so a successful answer whose
258+// inner dimensions fit inside the request is accepted.
259+func TestServicePreviewAcceptsCroppedDimensions(t *testing.T) {
260+ target := previewTarget(0x11, "tab-alpha")
261+ daemon := &previewLogicalConn{done: make(chan struct{})}
262+ daemon.queue(previewAnswer(0x11, "tab-alpha", 2, 1))
263+ _, sub, _, _ := subscribePreview(t, daemon, target)
264+
265+ publication := awaitPreviewPublication(t, sub)
266+ preview := requirePreviewOK(t, publication)
267+ require.Equal(t, uint16(2), preview.Width)
268+ require.Equal(t, uint16(1), preview.Height)
269+}
270+
271+// TestServicePreviewCloseJoinsPollLoop proves the Close and wait-group half
272+// of the coverage gap: closing the subscription settles the poll goroutine,
273+// so Latest stays observable and no second wake fires after close.
274+func TestServicePreviewCloseJoinsPollLoop(t *testing.T) {
275+ target := previewTarget(0x11, "tab-alpha")
276+ daemon := &previewLogicalConn{done: make(chan struct{})}
277+ daemon.queue(previewAnswer(0x11, "tab-alpha", 2, 1))
278+ _, sub, _, _ := subscribePreview(t, daemon, target)
279+ requirePreviewOK(t, awaitPreviewPublication(t, sub))
280+ sub.Close()
281+ sub.Close()
282+ require.NotPanics(t, func() { _ = sub.Latest() }, "Latest stays observable after Close")
283+}
@@ -0,0 +1,283 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ renderer "github.com/bnema/vev-vt"
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/protocol"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+// previewLogicalConn is one scripted daemon-side logical connection: the test
18+// owns the preview answers it returns, records the stream identities the
19+// preview opens, and can block sends or receives on its closed channel.
20+type previewLogicalConn struct {
21+ mu sync.Mutex
22+ streams []ports.BrokerStreamID
23+ pending []protocol.RemotePreview
24+ err error
25+ done chan struct{}
26+ once sync.Once
27+}
28+
29+func (c *previewLogicalConn) SendClient(message protocol.ClientMessage) error {
30+ c.mu.Lock()
31+ defer c.mu.Unlock()
32+ select {
33+ case <-c.done:
34+ return errors.New("closed")
35+ default:
36+ }
37+ if _, ok := message.(protocol.RemotePreviewRequest); !ok {
38+ return errors.New("broker: preview connection received a non-preview request")
39+ }
40+ return nil
41+}
42+
43+func (c *previewLogicalConn) ReceiveServer() (protocol.ServerMessage, error) {
44+ c.mu.Lock()
45+ defer c.mu.Unlock()
46+ select {
47+ case <-c.done:
48+ return nil, errors.New("closed")
49+ default:
50+ }
51+ if c.err != nil {
52+ return nil, c.err
53+ }
54+ if len(c.pending) == 0 {
55+ return nil, errors.New("broker: no scripted preview answer")
56+ }
57+ preview := c.pending[0]
58+ c.pending = c.pending[1:]
59+ return preview, nil
60+}
61+
62+func (c *previewLogicalConn) Capabilities() protocol.ConnectionCapabilities {
63+ return protocol.ConnectionCapabilities{OutputDataLimit: protocol.MaxOutputDataLen}
64+}
65+
66+func (c *previewLogicalConn) LinkState() ports.LinkState { return ports.LinkStateConnected }
67+func (c *previewLogicalConn) LinkEvents() <-chan ports.LinkEvent { return nil }
68+func (c *previewLogicalConn) Done() <-chan struct{} { return c.done }
69+func (c *previewLogicalConn) Err() error { return nil }
70+
71+func (c *previewLogicalConn) Close() error {
72+ c.once.Do(func() { close(c.done) })
73+ return nil
74+}
75+
76+func (c *previewLogicalConn) queue(preview protocol.RemotePreview) {
77+ c.mu.Lock()
78+ defer c.mu.Unlock()
79+ c.pending = append(c.pending, preview)
80+}
81+
82+func (c *previewLogicalConn) fail(err error) {
83+ c.mu.Lock()
84+ defer c.mu.Unlock()
85+ c.err = err
86+}
87+
88+// previewTarget builds one valid live preview target with the given lifecycle
89+// and tab, on the broker's canonical local authority.
90+func previewTarget(lifecycle byte, tab string) domain.RemoteSessionTarget {
91+ return domain.RemoteSessionTarget{
92+ Endpoint: "local",
93+ DisplayOrigin: "local",
94+ LifecycleID: domain.SessionLifecycleID{lifecycle},
95+ SessionName: "work",
96+ LiveTabID: domain.TabStableID(tab),
97+ }
98+}
99+
100+// previewRequestFor builds one valid observation route plus preview request for
101+// a connection, epoch, and target. The stream identity is stated explicitly,
102+// exactly as the poll loop allocates one per attempt.
103+func previewRequestFor(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID, stream ports.BrokerStreamID, target domain.RemoteSessionTarget) ports.BrokerPreviewRequest {
104+ return ports.BrokerPreviewRequest{
105+ Epoch: epoch,
106+ Connection: connection,
107+ Generation: 1,
108+ Route: ports.BrokerOpenStreamRequest{
109+ Epoch: epoch,
110+ Connection: connection,
111+ Stream: stream,
112+ Local: true,
113+ Purpose: ports.BrokerStreamObservation,
114+ Policy: poolPolicy(),
115+ StartMode: ports.BrokerDaemonExistingOnly,
116+ },
117+ Preview: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 80, Height: 24},
118+ }
119+}
120+
121+// awaitPreviewPublication waits for one Changed wake and returns the latest
122+// publication, failing the test on timeout.
123+func awaitPreviewPublication(t *testing.T, sub ports.BrokerPreviewSubscription) ports.BrokerPreviewPublication {
124+ t.Helper()
125+ select {
126+ case <-sub.Changed():
127+ return sub.Latest()
128+ case <-time.After(3 * time.Second):
129+ t.Fatal("the preview subscription did not publish within the bound")
130+ return ports.BrokerPreviewPublication{}
131+ }
132+}
133+
134+// requirePreviewOK extracts the successful preview from a publication.
135+func requirePreviewOK(t *testing.T, publication ports.BrokerPreviewPublication) protocol.RemotePreview {
136+ t.Helper()
137+ require.NoError(t, publication.Err)
138+ require.Equal(t, protocol.RemotePreviewOK, publication.Preview.Status)
139+ return publication.Preview
140+}
141+
142+// previewTestConnector dials one scripted physical whose every logical open
143+// returns one fresh scripted daemon connection. Every opened stream identity
144+// is recorded so a test can prove the preview poll loop allocates exactly
145+// one fresh identity per attempt. One connection per open models the daemon
146+// transport truthfully: the broker closes every logical stream after one
147+// exchange, and a shared connection would park the poll loop on the second.
148+type previewTestConnector struct {
149+ mu sync.Mutex
150+ opens []ports.BrokerStreamID
151+ pending []protocol.RemotePreview
152+ scripted *fakePhysical
153+}
154+
155+func (c *previewTestConnector) Connect(_ context.Context, e ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) {
156+ connector := c
157+ if connector.scripted == nil {
158+ connector.scripted = &fakePhysical{endpoint: e, done: make(chan struct{}), open: func(_ context.Context, r ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
159+ connector.mu.Lock()
160+ connector.opens = append(connector.opens, r.Stream)
161+ pending := append([]protocol.RemotePreview(nil), connector.pending...)
162+ connector.mu.Unlock()
163+ return &previewLogicalConn{done: make(chan struct{}), pending: pending}, nil
164+ }}
165+ }
166+ return connector.scripted, nil
167+}
168+
169+func (c *previewTestConnector) opened() []ports.BrokerStreamID {
170+ c.mu.Lock()
171+ defer c.mu.Unlock()
172+ return append([]ports.BrokerStreamID(nil), c.opens...)
173+}
174+
175+// previewAnswer builds one valid successful preview for the given identity
176+// and dimensions.
177+func previewAnswer(lifecycle byte, tab string, width, height int) protocol.RemotePreview {
178+ return protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewOK, LifecycleID: domain.SessionLifecycleID{lifecycle}, TabID: domain.TabStableID(tab), Revision: 7, Width: uint16(width), Height: uint16(height), Cells: make([]renderer.Cell, width*height)}
179+}
180+
181+// subscribePreview subscribes one generation-1 preview on a fresh authority
182+// whose daemon answers come from the queued scripted previews. It returns the
183+// manual clock that drives the service's poll cadence, so a test advances
184+// time instead of sleeping through the 1s repoll interval.
185+func subscribePreview(t *testing.T, daemon *previewLogicalConn, target domain.RemoteSessionTarget) (ports.BrokerService, ports.BrokerPreviewSubscription, *previewTestConnector, *manualClock) {
186+ t.Helper()
187+ daemon.mu.Lock()
188+ queued := append([]protocol.RemotePreview(nil), daemon.pending...)
189+ daemon.mu.Unlock()
190+ connector := &previewTestConnector{pending: queued}
191+ clock := newManualClock(time.Unix(0, 0))
192+ store := newTestStore()
193+ registry, err := NewRegistryWithConfig(1, store, nil, clock, nil, RegistryConfig{ObservationDisabled: true})
194+ require.NoError(t, err)
195+ authority, _, _, _ := composeTestAuthority(t, 1, registry, connector.Connect, clock)
196+ service, err := authority.AdmitClient(context.Background())
197+ require.NoError(t, err)
198+ t.Cleanup(func() { require.NoError(t, service.Close()) })
199+ stream, err := service.NextStreamID()
200+ require.NoError(t, err)
201+ request := previewRequestFor(1, service.ConnectionID(), stream, target)
202+ sub, err := service.SubscribePreview(request)
203+ require.NoError(t, err)
204+ t.Cleanup(sub.Close)
205+ return service, sub, connector, clock
206+}
207+
208+// TestServicePreviewPollReusesNoStream proves GO-002: the poll loop allocates
209+// one fresh stream identity per attempt. Two consecutive successful polls open
210+// two distinct streams on the same connection instead of replaying the first.
211+func TestServicePreviewPollReusesNoStream(t *testing.T) {
212+ target := previewTarget(0x11, "tab-alpha")
213+ daemon := &previewLogicalConn{done: make(chan struct{})}
214+ daemon.queue(previewAnswer(0x11, "tab-alpha", 2, 1))
215+ daemon.queue(previewAnswer(0x11, "tab-alpha", 2, 1))
216+ _, sub, connector, clock := subscribePreview(t, daemon, target)
217+
218+ // The poll loop republishes on a 1s cadence driven by the manual clock.
219+ // The first wake carries the first queued answer; advancing the clock
220+ // drives the second poll, whose answer can only come from a second open.
221+ requirePreviewOK(t, awaitPreviewPublication(t, sub))
222+ require.Len(t, connector.opened(), 1)
223+ clock.Advance(previewPollCadence)
224+ requirePreviewOK(t, awaitPreviewPublication(t, sub))
225+ opens := connector.opened()
226+ require.NotEqual(t, opens[0], opens[1], "the second poll must not replay the first stream identity")
227+}
228+
229+// TestServicePreviewRejectsForeignTarget proves GO-005: a daemon answer whose
230+// inner preview names another session lifecycle or tab is rejected as an
231+// unavailable typed failure instead of being published as the current row.
232+func TestServicePreviewRejectsForeignTarget(t *testing.T) {
233+ target := previewTarget(0x11, "tab-alpha")
234+ daemon := &previewLogicalConn{done: make(chan struct{})}
235+ daemon.queue(previewAnswer(0x22, "tab-alpha", 2, 1))
236+ _, sub, _, _ := subscribePreview(t, daemon, target)
237+
238+ publication := awaitPreviewPublication(t, sub)
239+ require.Error(t, publication.Err, "a foreign-target answer must not publish as success")
240+ require.Equal(t, protocol.RemotePreview{}, publication.Preview)
241+}
242+
243+// TestServicePreviewRejectsForeignTab proves the tab half of GO-005: the same
244+// lifecycle with another tab's answer is rejected too.
245+func TestServicePreviewRejectsForeignTab(t *testing.T) {
246+ target := previewTarget(0x11, "tab-alpha")
247+ daemon := &previewLogicalConn{done: make(chan struct{})}
248+ daemon.queue(previewAnswer(0x11, "tab-beta", 2, 1))
249+ _, sub, _, _ := subscribePreview(t, daemon, target)
250+
251+ publication := awaitPreviewPublication(t, sub)
252+ require.Error(t, publication.Err)
253+ require.Equal(t, protocol.RemotePreview{}, publication.Preview)
254+}
255+
256+// TestServicePreviewAcceptsCroppedDimensions proves the GO-005 bound: the
257+// daemon crops the viewport to the focused pane, so a successful answer whose
258+// inner dimensions fit inside the request is accepted.
259+func TestServicePreviewAcceptsCroppedDimensions(t *testing.T) {
260+ target := previewTarget(0x11, "tab-alpha")
261+ daemon := &previewLogicalConn{done: make(chan struct{})}
262+ daemon.queue(previewAnswer(0x11, "tab-alpha", 2, 1))
263+ _, sub, _, _ := subscribePreview(t, daemon, target)
264+
265+ publication := awaitPreviewPublication(t, sub)
266+ preview := requirePreviewOK(t, publication)
267+ require.Equal(t, uint16(2), preview.Width)
268+ require.Equal(t, uint16(1), preview.Height)
269+}
270+
271+// TestServicePreviewCloseJoinsPollLoop proves the Close and wait-group half
272+// of the coverage gap: closing the subscription settles the poll goroutine,
273+// so Latest stays observable and no second wake fires after close.
274+func TestServicePreviewCloseJoinsPollLoop(t *testing.T) {
275+ target := previewTarget(0x11, "tab-alpha")
276+ daemon := &previewLogicalConn{done: make(chan struct{})}
277+ daemon.queue(previewAnswer(0x11, "tab-alpha", 2, 1))
278+ _, sub, _, _ := subscribePreview(t, daemon, target)
279+ requirePreviewOK(t, awaitPreviewPublication(t, sub))
280+ sub.Close()
281+ sub.Close()
282+ require.NotPanics(t, func() { _ = sub.Latest() }, "Latest stays observable after Close")
283+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close