feat(broker): serve selected-row previews over broker ipc
- commit
- 24e162b273f7c109c0f1e9531cad075b77beb07a
- author
- brice · · 2026-09-22 19:23 UTC
- parent
- 1a097f5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 211 | 211 | ctx context.Context | |
| 212 | 212 | cancel context.CancelFunc | |
| 213 | 213 | ||
| 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 | |
| 223 | 225 | // The connection's own stream tracker is the anti-replay authority for | |
| 224 | 226 | // this connection: it admits every identity at most once inside the bounded | |
| 225 | 227 | // window, whether the open is afterwards accepted or refused. | |
| @@ -269,6 +271,26 @@ func (c *client) dispatch(message brokerwire.ServerMessage) error { | |||
| 269 | 271 | c.publish(snapshot) | |
| 270 | 272 | } | |
| 271 | 273 | 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 | 294 | case brokerwire.OperationResult: | |
| 273 | 295 | if !c.scopeMatches(m.Epoch, m.Connection) { | |
| 274 | 296 | return errors.Join(ErrScopeMismatch, ErrProtocol) | |
| @@ -510,6 +532,47 @@ func (c *client) Subscribe() (ports.BrokerSubscription, error) { | |||
| 510 | 532 | // before any frame travels, so a duplicate and an identity evicted past the | |
| 511 | 533 | // window are both refused, while a concurrent lower identity that was allocated | |
| 512 | 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 | 576 | func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 514 | 577 | scoped, err := c.scopeRequest(request) | |
| 515 | 578 | if err != nil { | |
| @@ -884,6 +947,8 @@ func (c *client) closeWith(cause error) { | |||
| 884 | 947 | terminal := c.terminalErrorLocked() | |
| 885 | 948 | sub := c.sub | |
| 886 | 949 | c.sub = nil | |
| 950 | + | preview := c.preview | |
| 951 | + | c.preview = nil | |
| 887 | 952 | streams := make([]*clientStream, 0, len(c.streams)) | |
| 888 | 953 | for _, st := range c.streams { | |
| 889 | 954 | streams = append(streams, st) | |
| @@ -896,6 +961,9 @@ func (c *client) closeWith(cause error) { | |||
| 896 | 961 | if sub != nil { | |
| 897 | 962 | sub.Close() | |
| 898 | 963 | } | |
| 964 | + | if preview != nil { | |
| 965 | + | preview.Close() | |
| 966 | + | } | |
| 899 | 967 | close(c.done) | |
| 900 | 968 | }) | |
| 901 | 969 | } | |
| @@ -211,15 +211,17 @@ type client struct { | |||||
| 211 | ctx context.Context | 211 | ctx context.Context | ||
| 212 | cancel context.CancelFunc | 212 | cancel context.CancelFunc | ||
| 213 | 213 | ||||
| 214 | - | mu sync.Mutex | 214 | + | mu sync.Mutex |
| 215 | - | closed bool | 215 | + | closed bool |
| 216 | - | err error | 216 | + | err error |
| 217 | - | sub *clientSubscription | 217 | + | sub *clientSubscription |
| 218 | - | generation brokerwire.SubscriptionGeneration | 218 | + | generation brokerwire.SubscriptionGeneration |
| 219 | - | nextStream ports.BrokerStreamID | 219 | + | preview *clientPreviewSubscription |
| 220 | - | snapshot ports.BrokerSnapshot | 220 | + | previewGeneration ports.BrokerPreviewGeneration |
| 221 | - | pending map[ports.BrokerOperationID]chan operationResult | 221 | + | nextStream ports.BrokerStreamID |
| 222 | - | kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind | 222 | + | 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 for | 225 | // The connection's own stream tracker is the anti-replay authority for | ||
| 224 | // this connection: it admits every identity at most once inside the bounded | 226 | // 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 nil | 273 | 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 the | 532 | // 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 allocated | 533 | // 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.sub | 948 | sub := c.sub | ||
| 886 | c.sub = nil | 949 | 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() } | |||
| 423 | 423 | ||
| 424 | 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 | 457 | func (c *fakeCore) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 427 | 458 | c.mu.Lock() | |
| 428 | 459 | 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 { | |||
| 482 | 482 | cancel context.CancelFunc | |
| 483 | 483 | wg sync.WaitGroup | |
| 484 | 484 | ||
| 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 | |
| 487 | 490 | ||
| 488 | 491 | streamsMu sync.Mutex | |
| 489 | 492 | streams map[ports.BrokerStreamID]*serverStream | |
| @@ -749,6 +752,22 @@ func (s *serverSession) dispatch(message brokerwire.ClientMessage) error { | |||
| 749 | 752 | } | |
| 750 | 753 | s.closeStreamByPeer(m.Stream) | |
| 751 | 754 | 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 | 771 | default: | |
| 753 | 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 | 858 | func (s *serverSession) shutdown() { | |
| 840 | 859 | s.cancel() | |
| 841 | 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 | 871 | _ = s.transport.Close() | |
| 843 | 872 | s.closeStreams() | |
| 844 | 873 | _ = s.conn.BeginClose() | |
| @@ -936,6 +965,9 @@ func (s *serverSession) Snapshot() ports.BrokerSnapshot { return s.core.Snapshot | |||
| 936 | 965 | // protocol's Subscribe is driven by the connection's reader, which publishes | |
| 937 | 966 | // the broker core's newest snapshot for the client's generation. | |
| 938 | 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 | 972 | // OpenStream delegates one scope-checked stream request to the admitted core | |
| 941 | 973 | // service. The connection's own reader drives the same core through | |
| @@ -482,8 +482,11 @@ type serverSession struct { | |||||
| 482 | cancel context.CancelFunc | 482 | cancel context.CancelFunc | ||
| 483 | wg sync.WaitGroup | 483 | wg sync.WaitGroup | ||
| 484 | 484 | ||||
| 485 | - | subMu sync.Mutex | 485 | + | subMu sync.Mutex |
| 486 | - | pub *publisher | 486 | + | pub *publisher |
| 487 | + | preview ports.BrokerPreviewSubscription | |||
| 488 | + | previewGeneration ports.BrokerPreviewGeneration | |||
| 489 | + | previewForward *previewForwarder | |||
| 487 | 490 | ||||
| 488 | streamsMu sync.Mutex | 491 | streamsMu sync.Mutex | ||
| 489 | streams map[ports.BrokerStreamID]*serverStream | 492 | 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 nil | 754 | 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 publishes | 965 | // 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 core | 972 | // OpenStream delegates one scope-checked stream request to the admitted core | ||
| 941 | // service. The connection's own reader drives the same core through | 973 | // service. The connection's own reader drives the same core through | ||
M internal/adapters/ipc/listener.go +11 -2
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package ipc | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "crypto/sha256" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "net" | |
| @@ -179,12 +180,20 @@ func (l *unixListener) Addr() string { | |||
| 179 | 180 | ||
| 180 | 181 | // SocketDir returns the directory vev's daemon should place its socket in: | |
| 181 | 182 | // $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 | 188 | func SocketDir() string { | |
| 184 | 189 | uid := os.Getuid() | |
| 185 | 190 | ||
| 186 | 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 | 199 | runUser := fmt.Sprintf("/run/user/%d", uid) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package ipc | 1 | 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 directory | 182 | // $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) { | |||
| 181 | 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 | + | } | |
| @@ -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 | |||
| 165 | 165 | 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 | 172 | func (s *terminalCompositionService) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 169 | 173 | s.mu.Lock() | |
| 170 | 174 | if s.closed { | |
| @@ -165,6 +165,10 @@ func (s *terminalCompositionService) Subscribe() (ports.BrokerSubscription, erro | |||||
| 165 | return terminalCompositionSubscription{changed: s.changed}, nil | 165 | 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 | |||
| 267 | 267 | var configPath string | |
| 268 | 268 | var err error | |
| 269 | 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 | 276 | layout = productionBrokerLayout() | |
| 271 | 277 | configPath = productionBrokerConfigPath() | |
| 272 | 278 | } else { | |
| @@ -267,6 +267,12 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker | |||||
| 267 | var configPath string | 267 | var configPath string | ||
| 268 | var err error | 268 | 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 @@ | |||
| 1 | 1 | package app | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 4 | 7 | "path/filepath" | |
| 5 | 8 | ||
| 6 | 9 | "github.com/bnema/vev/internal/adapters/brokerconfig" | |
| @@ -9,6 +12,7 @@ import ( | |||
| 9 | 12 | "github.com/bnema/vev/internal/ports" | |
| 10 | 13 | "github.com/bnema/vev/internal/protocol" | |
| 11 | 14 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 15 | + | "github.com/bnema/vev/pkg/safedir" | |
| 12 | 16 | ) | |
| 13 | 17 | ||
| 14 | 18 | // productionBrokerLayout is the single production broker filesystem contract. | |
| @@ -18,6 +22,33 @@ func productionBrokerConfigPath() string { | |||
| 18 | 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 | 52 | func remoteBrokerPolicy(transport string) ports.BrokerPolicy { | |
| 22 | 53 | if transport == "stdio" { | |
| 23 | 54 | transport = "ssh-stdio" | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package app | 1 | 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 ( | |||
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/adapters/brokeripc" | |
| 9 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 11 | + | "github.com/bnema/vev/pkg/safedir" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| 12 | 14 | const productionBrokerStartupTimeout = 10 * time.Second | |
| @@ -15,6 +17,14 @@ const productionBrokerStartupTimeout = 10 * time.Second | |||
| 15 | 17 | // Process election and idle lifetime remain owned by the existing broker | |
| 16 | 18 | // launcher and broker.Supervisor; callers receive only the semantic façade. | |
| 17 | 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 | 28 | layout := productionBrokerLayout() | |
| 19 | 29 | request := brokerStatusRequest{ | |
| 20 | 30 | 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.Second | 14 | 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 broker | 17 | // 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 @@ | |||
| 1 | 1 | package app | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 4 | 5 | "path/filepath" | |
| 5 | 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 | 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 | 72 | func TestProductionBrokerLayoutUsesDedicatedRuntimeStateAndConfig(t *testing.T) { | |
| 27 | 73 | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 28 | 74 | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |
| @@ -1,9 +1,13 @@ | |||||
| 1 | package app | 1 | 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) { | |||
| 160 | 160 | 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 | 168 | func (s *readyScriptedService) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 164 | 169 | s.record("OpenStream") | |
| 165 | 170 | return nil, ports.BrokerAdmissionClosed | |
| @@ -160,6 +160,11 @@ func (s *readyScriptedService) Subscribe() (ports.BrokerSubscription, error) { | |||||
| 160 | return sub, nil | 160 | 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.BrokerAdmissionClosed | 170 | return nil, ports.BrokerAdmissionClosed | ||
M internal/app/run_broker_test.go +4 -0
| @@ -149,6 +149,10 @@ func (s *seamBrokerService) Subscribe() (ports.BrokerSubscription, error) { | |||
| 149 | 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 | 156 | func (s *seamBrokerService) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |
| 153 | 157 | s.mu.Lock() | |
| 154 | 158 | 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 { | |||
| 704 | 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 | 805 | // BrokerService is the client-facing broker façade. All clients reach | |
| 708 | 806 | // local and remote daemons through it, including the local daemon. | |
| 709 | 807 | // Snapshot access performs no I/O; request methods are non-blocking | |
| @@ -728,6 +826,7 @@ type BrokerService interface { | |||
| 728 | 826 | Err() error | |
| 729 | 827 | Snapshot() BrokerSnapshot | |
| 730 | 828 | Subscribe() (BrokerSubscription, error) | |
| 829 | + | SubscribePreview(request BrokerPreviewRequest) (BrokerPreviewSubscription, error) | |
| 731 | 830 | OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error) | |
| 732 | 831 | CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error | |
| 733 | 832 | // 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 reach | 805 | // 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-blocking | 807 | // Snapshot access performs no I/O; request methods are non-blocking | ||
| @@ -728,6 +826,7 @@ type BrokerService interface { | |||||
| 728 | Err() error | 826 | Err() error | ||
| 729 | Snapshot() BrokerSnapshot | 827 | 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) error | 831 | 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 | |||
| 118 | 118 | 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 | 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 | 272 | // The first argument is typically a *testing.T value. | |
| 123 | 273 | func NewMockBrokerService(t interface { | |
| @@ -780,6 +930,68 @@ func (_c *MockBrokerService_Subscribe_Call) RunAndReturn(run func() (ports.Broke | |||
| 780 | 930 | 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 | 995 | // UpdateHostPolicy provides a mock function for the type MockBrokerService | |
| 784 | 996 | func (_mock *MockBrokerService) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) { | |
| 785 | 997 | ret := _mock.Called(ctx, expected, policy) | |
| @@ -118,6 +118,156 @@ func (_c *MockBrokerSubscription_Close_Call) RunAndReturn(run func()) *MockBroke | |||||
| 118 | return _c | 118 | 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 _c | 930 | 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 MockBrokerService | 995 | // 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 @@ | |||
| 5 | 5 | package portsmocks | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 8 | 10 | "github.com/bnema/vev/internal/ports" | |
| 9 | 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 | 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 | 114 | // The first argument is typically a *testing.T value. | |
| 14 | 115 | func NewMockBrokerHostStore(t interface { | |
| @@ -5,10 +5,111 @@ | |||||
| 5 | package portsmocks | 5 | 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 { | |||
| 39 | 39 | registry *Registry | |
| 40 | 40 | pool *Pool | |
| 41 | 41 | supervisor *Supervisor | |
| 42 | + | clock ports.Clock | |
| 42 | 43 | } | |
| 43 | 44 | ||
| 44 | 45 | var _ ports.BrokerAuthority = (*Authority)(nil) | |
| @@ -52,14 +53,21 @@ var _ ports.BrokerAuthority = (*Authority)(nil) | |||
| 52 | 53 | // neither starts nor settles Run: serving connections without a live Run leaks | |
| 53 | 54 | // the writer and never flushes the newest staged publication, and a registry | |
| 54 | 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 | 57 | if epoch == 0 || nilDependency(registry) || nilDependency(pool) || nilDependency(supervisor) { | |
| 57 | 58 | return nil, errors.New("broker: invalid authority dependencies") | |
| 58 | 59 | } | |
| 59 | 60 | if registry.epoch != epoch || pool.epoch != epoch { | |
| 60 | 61 | return nil, errors.New("broker: authority epoch mismatch") | |
| 61 | 62 | } | |
| 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 | |
| 63 | 71 | } | |
| 64 | 72 | ||
| 65 | 73 | // AdmitClient admits exactly one accepted client connection. The supervisor | |
| @@ -86,7 +94,7 @@ func (a *Authority) AdmitClient(ctx context.Context) (ports.BrokerService, error | |||
| 86 | 94 | lease.Release() | |
| 87 | 95 | return nil, err | |
| 88 | 96 | } | |
| 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 | |
| 90 | 98 | } | |
| 91 | 99 | ||
| 92 | 100 | // Service is one admitted client connection: the core's service for exactly one | |
| @@ -98,6 +106,7 @@ type Service struct { | |||
| 98 | 106 | pool *Pool | |
| 99 | 107 | supervisor *Supervisor | |
| 100 | 108 | lease *Lease | |
| 109 | + | clock ports.Clock | |
| 101 | 110 | ||
| 102 | 111 | // ctx owns everything this connection started: in-flight setup opens and | |
| 103 | 112 | // live streams. It is derived from the supervisor root, never from the | |
| @@ -105,9 +114,11 @@ type Service struct { | |||
| 105 | 114 | ctx context.Context | |
| 106 | 115 | cancel context.CancelFunc | |
| 107 | 116 | ||
| 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 | |
| 111 | 122 | // nextStream is this connection's strictly increasing, never-zero logical | |
| 112 | 123 | // stream ID allocator. It is the only allocator on this connection: the | |
| 113 | 124 | // supervisor and the broker operations each read one identity here and carry | |
| @@ -133,10 +144,10 @@ type Service struct { | |||
| 133 | 144 | ||
| 134 | 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 | 148 | ctx, cancel := context.WithCancel(supervisor.RootContext()) | |
| 138 | 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 | 151 | ctx: ctx, cancel: cancel, subs: make(map[*serviceSubscription]struct{}), | |
| 141 | 152 | done: make(chan struct{}), | |
| 142 | 153 | } | |
| @@ -364,9 +375,13 @@ func (s *Service) Close() error { | |||
| 364 | 375 | for sub := range s.subs { | |
| 365 | 376 | subs = append(subs, sub) | |
| 366 | 377 | } | |
| 378 | + | preview := s.preview | |
| 367 | 379 | s.mu.Unlock() | |
| 368 | 380 | ||
| 369 | 381 | s.cancel() | |
| 382 | + | if preview != nil { | |
| 383 | + | preview.Close() | |
| 384 | + | } | |
| 370 | 385 | s.wg.Wait() | |
| 371 | 386 | for _, sub := range subs { | |
| 372 | 387 | sub.Close() | |
| @@ -39,6 +39,7 @@ type Authority struct { | |||||
| 39 | registry *Registry | 39 | registry *Registry | ||
| 40 | pool *Pool | 40 | pool *Pool | ||
| 41 | supervisor *Supervisor | 41 | 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 leaks | 53 | // neither starts nor settles Run: serving connections without a live Run leaks | ||
| 53 | // the writer and never flushes the newest staged publication, and a registry | 54 | // 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}, 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 | |||
| 63 | } | 71 | } | ||
| 64 | 72 | ||||
| 65 | // AdmitClient admits exactly one accepted client connection. The supervisor | 73 | // 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, err | 95 | return nil, err | ||
| 88 | } | 96 | } | ||
| 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 |
| 90 | } | 98 | } | ||
| 91 | 99 | ||||
| 92 | // Service is one admitted client connection: the core's service for exactly one | 100 | // Service is one admitted client connection: the core's service for exactly one | ||
| @@ -98,6 +106,7 @@ type Service struct { | |||||
| 98 | pool *Pool | 106 | pool *Pool | ||
| 99 | supervisor *Supervisor | 107 | supervisor *Supervisor | ||
| 100 | lease *Lease | 108 | lease *Lease | ||
| 109 | + | clock ports.Clock | |||
| 101 | 110 | ||||
| 102 | // ctx owns everything this connection started: in-flight setup opens and | 111 | // ctx owns everything this connection started: in-flight setup opens and | ||
| 103 | // live streams. It is derived from the supervisor root, never from the | 112 | // live streams. It is derived from the supervisor root, never from the | ||
| @@ -105,9 +114,11 @@ type Service struct { | |||||
| 105 | ctx context.Context | 114 | ctx context.Context | ||
| 106 | cancel context.CancelFunc | 115 | cancel context.CancelFunc | ||
| 107 | 116 | ||||
| 108 | - | mu sync.Mutex | 117 | + | mu sync.Mutex |
| 109 | - | closed bool | 118 | + | 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 logical | 122 | // nextStream is this connection's strictly increasing, never-zero logical | ||
| 112 | // stream ID allocator. It is the only allocator on this connection: the | 123 | // stream ID allocator. It is the only allocator on this connection: the | ||
| 113 | // supervisor and the broker operations each read one identity here and carry | 124 | // 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 | + | } | |||