detached 0471f4f

feat(protocol): add broker IPC wire contract

commit
0471f4ffabec0494b28757c652e9bef92e66b8da
author
brice · · 2026-09-16 14:59 UTC
parent
e8cd8f6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +10903 -9
M docs/architecture.md +33 -2
@@ -21,6 +21,15 @@ vev uses a hexagonal core with typed session messages at the client and daemon b
2121 (PreambleRequest first, PreambleResponse acceptance, exact version,
2222 negotiated ceilings, one absolute deadline), direction checks, and
2323 decode-failure classification.
24+- `internal/adapters/brokerwire`: translates between typed broker messages
25+ and the separate broker Protobuf conversation (`schema/broker.proto`;
26+ client tags 101-110, server tags 201-209, disjoint from every session
27+ tag, each direction its own closed `oneof`). It owns the stateless
28+ broker codec over `wire.ScanEnvelope`, the broker preamble (roles 3/4,
29+ exact `protocol.Version`, 1 MiB..16 MiB / 1..64 KiB ceilings, 4 KiB
30+ byte-length bound), multipart snapshot assembly, per-connection operation
31+ and stream trackers, and the bounded admission lock. It is not activated
32+ by production composition until P3.2.
2433 - `internal/adapters/quic`: one-stream QUIC carriage (TLS 1.3, epoch ALPN,
2534 exact SHA-256 pin, single bidirectional stream, bounded admission) plus
2635 the short-lived SSH bootstrap (ephemeral certificate, 32-byte token,
@@ -29,7 +38,7 @@ vev uses a hexagonal core with typed session messages at the client and daemon b
2938 - `internal/adapters/uidriver`: owns strict JSONL decoding, response serialization, bounded controller queues, private Unix sockets, peer credentials, and the stdio bridge. It consumes `ports.UIService` and never creates an attachment.
3039 - `internal/adapters/webterm`: owns the authenticated loopback HTTP/WebSocket frontend, browser event encoding, VT-backed terminal and transactional HTML output. It implements `ports.Terminal`; application composition supplies the ordinary client runner.
3140 - `internal/adapters/uiterm`: owns the immutable VT mirror/snapshot and deterministic headless terminal. The concrete terminal writer (`adapters/term`) taps successful writes and flushes into this sink only when observation is enabled.
32-- `internal/adapters`: IPC, QUIC, SSH stdio, PTY, terminal, VT-backed UI terminal/observation, JSONL UI-driver sockets, persistence-facing, and observability implementations. `streamframe` owns the shared 4-byte big-endian length framing of one complete Protobuf envelope used by every stream carriage.
41+- `internal/adapters`: IPC, QUIC, SSH stdio, PTY, terminal, VT-backed UI terminal/observation, JSONL UI-driver sockets, persistence-facing, broker-wire, and observability implementations. `streamframe` owns the shared 4-byte big-endian length framing of one complete Protobuf envelope used by every stream carriage.
3342 - `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, injects typed ports into use cases, and owns explicit UI-driver endpoint launch configuration.
3443 - `pkg`: reusable packages that never import `internal`.
3544
@@ -101,7 +110,29 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
101110 `go tool buf generate`, and handle the new variant in `sessionwire`.
102111 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
103112 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
104-- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `51`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
113+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `53`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
114+
115+## Broker wire core (Plan 001 P3.1, not activated)
116+
117+`internal/adapters/brokerwire` owns the broker's separate Protobuf
118+conversation end to end: client tags 101-110 and server tags 201-209 are
119+disjoint from every session tag, and each direction is its own closed
120+directional `oneof` union. A broker connection opens with exactly one
121+broker preamble (roles 3/4) that carries the same magic, epoch, exact
122+`protocol.Version`, and 1 MiB..16 MiB / 1..64 KiB ceilings as the session
123+handshake; a serialized preamble is bounded to 4 KiB (`wire.PreambleLimit`,
124+re-exported through `brokerwire.CheckPreambleSize`) before scan or
125+allocation. The broker negotiates no optional capabilities, so nonzero
126+capability bits are refused with code 7.
127+
128+The package is stateful only where P3.1 requires it: per-connection state
129+(ready/close, one subscription generation series, one assembler), multipart
130+snapshot assembly with an 80 MiB staged ceiling that charges at least 64
131+bytes per tab, and bounded operation/stream trackers. Every worker method
132+holds the connection lock across both the ready check and the tracker
133+mutation, so a racing close can never admit work after shutdown. The codec
134+itself stays stateless over `wire.ScanEnvelope`. No socket, framing pump,
135+or P3.2 transport is introduced here.
105136
106137 ## Broker pool core (Plan 001 P2.2, not activated)
107138
M docs/protocol.md +21 -0
@@ -86,6 +86,27 @@ Command requests additionally carry a 10-second result deadline
8686 (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per
8787 connection and correlated by `RequestID`.
8888
89+## Broker negotiation
90+
91+Broker connections are a separate conversation from the session protocol.
92+`internal/adapters/brokerwire` owns it: client tags 101-110 and server tags
93+201-209 are disjoint from every session tag, and each direction is its own
94+closed directional `oneof` union. A broker connection opens with exactly one
95+broker preamble (`PreambleRequest` client → server, `PreambleResponse` server
96+→ client) using broker roles 3/4. The preamble carries the same magic
97+(`wire.PreambleMagic`), epoch (`wire.ProtocolEpoch`), exact `protocol.Version`
98+equality, and negotiated ceilings as the session handshake (envelope
99+1 MiB..16 MiB, stream chunk 1..64 KiB), and the serialized preamble is
100+bounded to 4 KiB (`wire.PreambleLimit`, exposed as
101+`brokerwire.CheckPreambleSize`) before scan or allocation.
102+
103+Refusals use the shared `PreambleRejectionCode` taxonomy. There is no
104+capability negotiation in P3.1: nonzero capability bits are refused as
105+code 7 (`limit refused`), and code 6 (`out of order`) is reserved for the
106+connection dispatcher, which owns framing-order detection and never surfaces
107+it from the stateless codec. Brokerwire is not activated by production
108+composition until P3.2.
109+
89110 ## Output, ACK, and flow control
90111
91112 An `Output` state is `(Epoch, Base, New, Echo, ViewRevision, Size, Full,
A internal/adapters/brokerwire/codec.go +1348 -0
@@ -0,0 +1,1348 @@
1+package brokerwire
2+
3+// Directional envelope adaptation (P3.1).
4+//
5+// EncodeClient wraps one adapter-local client value in its directional
6+// BrokerClientEnvelope; DecodeClient unwraps and validates. EncodeServer
7+// and DecodeServer do the same for the server direction. Every convert
8+// path uses an explicit taxonomy switch: unknown variants and
9+// wrong-direction payloads fail with ErrWrongDirection before any
10+// mutation. Decode paths run strict wire.ScanEnvelope before generated
11+// unmarshal, then validate IDs, UTF-8/control/bidi text, bounds, times,
12+// environment/policy, chunks, and snapshot parts.
13+//
14+// The codecs are stateless: no connection state, multipart assembler,
15+// operation tracker, stream lifecycle, socket, framing pump, or P3.2
16+// transport lives here. Envelope sizes ride under the caller's negotiated
17+// ceiling; stream chunks ride under the negotiated chunk ceiling.
18+
19+import (
20+ "math"
21+
22+ "google.golang.org/protobuf/proto"
23+
24+ "github.com/bnema/vev/internal/domain"
25+ "github.com/bnema/vev/internal/ports"
26+ "github.com/bnema/vev/internal/protocol"
27+ "github.com/bnema/vev/internal/protocol/catalogue"
28+ "github.com/bnema/vev/internal/protocol/wire"
29+)
30+
31+// EncodeClient wraps one client message in its serialized directional
32+// envelope. The payload must fit maxEnvelopeBytes; stream chunks must fit
33+// maxChunkBytes.
34+func EncodeClient(message ClientMessage, maxEnvelopeBytes, maxChunkBytes uint64) ([]byte, error) {
35+ envelope, err := encodeClientEnvelope(message, maxChunkBytes)
36+ if err != nil {
37+ return nil, err
38+ }
39+ raw, err := proto.Marshal(envelope)
40+ if err != nil {
41+ return nil, err
42+ }
43+ if err := checkBrokerEnvelopeCeiling(raw, maxEnvelopeBytes); err != nil {
44+ return nil, err
45+ }
46+ return raw, nil
47+}
48+
49+// DecodeClient unwraps one serialized client envelope into its
50+// adapter-local value. Strict scanning runs before generated unmarshal;
51+// wrong-direction payloads fail with ErrWrongDirection.
52+func DecodeClient(payload []byte, maxEnvelopeBytes, maxChunkBytes uint64) (ClientMessage, error) {
53+ if err := checkBrokerEnvelopeCeiling(payload, maxEnvelopeBytes); err != nil {
54+ return nil, err
55+ }
56+ envelope := &wire.BrokerClientEnvelope{}
57+ if err := wire.ScanEnvelope(envelope, payload); err != nil {
58+ return nil, err
59+ }
60+ if err := (proto.UnmarshalOptions{DiscardUnknown: false}.Unmarshal(payload, envelope)); err != nil {
61+ return nil, err
62+ }
63+ return decodeClientEnvelope(envelope, maxChunkBytes)
64+}
65+
66+// EncodeServer wraps one server message in its serialized directional
67+// envelope.
68+func EncodeServer(message ServerMessage, maxEnvelopeBytes, maxChunkBytes uint64) ([]byte, error) {
69+ envelope, err := encodeServerEnvelope(message, maxChunkBytes)
70+ if err != nil {
71+ return nil, err
72+ }
73+ raw, err := proto.Marshal(envelope)
74+ if err != nil {
75+ return nil, err
76+ }
77+ if err := checkBrokerEnvelopeCeiling(raw, maxEnvelopeBytes); err != nil {
78+ return nil, err
79+ }
80+ return raw, nil
81+}
82+
83+// DecodeServer unwraps one serialized server envelope into its
84+// adapter-local value.
85+func DecodeServer(payload []byte, maxEnvelopeBytes, maxChunkBytes uint64) (ServerMessage, error) {
86+ if err := checkBrokerEnvelopeCeiling(payload, maxEnvelopeBytes); err != nil {
87+ return nil, err
88+ }
89+ envelope := &wire.BrokerServerEnvelope{}
90+ if err := wire.ScanEnvelope(envelope, payload); err != nil {
91+ return nil, err
92+ }
93+ if err := (proto.UnmarshalOptions{DiscardUnknown: false}.Unmarshal(payload, envelope)); err != nil {
94+ return nil, err
95+ }
96+ return decodeServerEnvelope(envelope, maxChunkBytes)
97+}
98+
99+func encodeClientEnvelope(message ClientMessage, maxChunkBytes uint64) (*wire.BrokerClientEnvelope, error) {
100+ if message == nil {
101+ return nil, ErrInvalidMessage
102+ }
103+ switch m := message.(type) {
104+ case Register:
105+ return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_Register{Register: &wire.Register{}}}, nil
106+ case *Register:
107+ if m == nil {
108+ return nil, ErrInvalidMessage
109+ }
110+ return encodeClientEnvelope(*m, maxChunkBytes)
111+ case Subscribe:
112+ if m.Epoch == 0 {
113+ return nil, ErrInvalidMessage
114+ }
115+ if err := m.Connection.Validate(); err != nil {
116+ return nil, ErrInvalidMessage
117+ }
118+ return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_Subscribe{Subscribe: &wire.Subscribe{
119+ Scope: scopeToWire(m.Epoch, m.Connection), Generation: m.Generation,
120+ }}}, nil
121+ case *Subscribe:
122+ if m == nil {
123+ return nil, ErrInvalidMessage
124+ }
125+ return encodeClientEnvelope(*m, maxChunkBytes)
126+ case Resync:
127+ if m.Epoch == 0 {
128+ return nil, ErrInvalidMessage
129+ }
130+ if err := m.Connection.Validate(); err != nil {
131+ return nil, ErrInvalidMessage
132+ }
133+ return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_Resync{Resync: &wire.Resync{
134+ Scope: scopeToWire(m.Epoch, m.Connection), Generation: m.Generation,
135+ }}}, nil
136+ case *Resync:
137+ if m == nil {
138+ return nil, ErrInvalidMessage
139+ }
140+ return encodeClientEnvelope(*m, maxChunkBytes)
141+ case Unsubscribe:
142+ if m.Epoch == 0 {
143+ return nil, ErrInvalidMessage
144+ }
145+ if err := m.Connection.Validate(); err != nil {
146+ return nil, ErrInvalidMessage
147+ }
148+ return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_Unsubscribe{Unsubscribe: &wire.Unsubscribe{
149+ Scope: scopeToWire(m.Epoch, m.Connection), Generation: m.Generation,
150+ }}}, nil
151+ case *Unsubscribe:
152+ if m == nil {
153+ return nil, ErrInvalidMessage
154+ }
155+ return encodeClientEnvelope(*m, maxChunkBytes)
156+ case AddHost:
157+ converted, err := addHostToWire(m)
158+ if err != nil {
159+ return nil, err
160+ }
161+ return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_AddHost{AddHost: converted}}, nil
162+ case *AddHost:
163+ if m == nil {
164+ return nil, ErrInvalidMessage
165+ }
166+ return encodeClientEnvelope(*m, maxChunkBytes)
167+ case RemoveHost:
168+ converted, err := removeHostToWire(m)
169+ if err != nil {
170+ return nil, err
171+ }
172+ return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_RemoveHost{RemoveHost: converted}}, nil
173+ case *RemoveHost:
174+ if m == nil {
175+ return nil, ErrInvalidMessage
176+ }
177+ return encodeClientEnvelope(*m, maxChunkBytes)
178+ case Reconcile:
179+ converted, err := reconcileToWire(m)
180+ if err != nil {
181+ return nil, err
182+ }
183+ return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_Reconcile{Reconcile: converted}}, nil
184+ case *Reconcile:
185+ if m == nil {
186+ return nil, ErrInvalidMessage
187+ }
188+ return encodeClientEnvelope(*m, maxChunkBytes)
189+ case OpenStream:
190+ converted, err := openStreamToWire(m)
191+ if err != nil {
192+ return nil, err
193+ }
194+ return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_OpenStream{OpenStream: converted}}, nil
195+ case *OpenStream:
196+ if m == nil {
197+ return nil, ErrInvalidMessage
198+ }
199+ return encodeClientEnvelope(*m, maxChunkBytes)
200+ case ClientStreamData:
201+ converted, err := clientStreamDataToWire(m, maxChunkBytes)
202+ if err != nil {
203+ return nil, err
204+ }
205+ return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_ClientStreamData{ClientStreamData: converted}}, nil
206+ case *ClientStreamData:
207+ if m == nil {
208+ return nil, ErrInvalidMessage
209+ }
210+ return encodeClientEnvelope(*m, maxChunkBytes)
211+ case CloseStream:
212+ if m.Epoch == 0 {
213+ return nil, ErrInvalidMessage
214+ }
215+ if err := m.Connection.Validate(); err != nil {
216+ return nil, ErrInvalidMessage
217+ }
218+ if err := m.Stream.Validate(); err != nil {
219+ return nil, ErrInvalidMessage
220+ }
221+ return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_CloseStream{CloseStream: &wire.CloseStream{
222+ Ref: refToWire(m.Epoch, m.Connection, m.Stream),
223+ }}}, nil
224+ case *CloseStream:
225+ if m == nil {
226+ return nil, ErrInvalidMessage
227+ }
228+ return encodeClientEnvelope(*m, maxChunkBytes)
229+ default:
230+ return nil, ErrWrongDirection
231+ }
232+}
233+
234+func decodeClientEnvelope(envelope *wire.BrokerClientEnvelope, maxChunkBytes uint64) (ClientMessage, error) {
235+ if envelope == nil {
236+ return nil, ErrInvalidMessage
237+ }
238+ switch payload := envelope.Payload.(type) {
239+ case *wire.BrokerClientEnvelope_Register:
240+ if payload.Register == nil {
241+ return nil, ErrInvalidMessage
242+ }
243+ return Register{}, nil
244+ case *wire.BrokerClientEnvelope_Subscribe:
245+ return subscribeFromWire(payload.Subscribe)
246+ case *wire.BrokerClientEnvelope_Resync:
247+ return resyncFromWire(payload.Resync)
248+ case *wire.BrokerClientEnvelope_Unsubscribe:
249+ return unsubscribeFromWire(payload.Unsubscribe)
250+ case *wire.BrokerClientEnvelope_AddHost:
251+ return addHostFromWire(payload.AddHost)
252+ case *wire.BrokerClientEnvelope_RemoveHost:
253+ return removeHostFromWire(payload.RemoveHost)
254+ case *wire.BrokerClientEnvelope_Reconcile:
255+ return reconcileFromWire(payload.Reconcile)
256+ case *wire.BrokerClientEnvelope_OpenStream:
257+ return openStreamFromWire(payload.OpenStream)
258+ case *wire.BrokerClientEnvelope_ClientStreamData:
259+ return clientStreamDataFromWire(payload.ClientStreamData, maxChunkBytes)
260+ case *wire.BrokerClientEnvelope_CloseStream:
261+ if payload.CloseStream == nil {
262+ return nil, ErrInvalidMessage
263+ }
264+ epoch, connection, stream, err := refFromWire(payload.CloseStream.GetRef())
265+ if err != nil {
266+ return nil, ErrInvalidMessage
267+ }
268+ return CloseStream{Epoch: epoch, Connection: connection, Stream: stream}, nil
269+ default:
270+ return nil, ErrWrongDirection
271+ }
272+}
273+
274+func addHostToWire(m AddHost) (*wire.AddHost, error) {
275+ if m.Epoch == 0 {
276+ return nil, ErrInvalidMessage
277+ }
278+ if err := m.Connection.Validate(); err != nil {
279+ return nil, ErrInvalidMessage
280+ }
281+ if err := m.Operation.Validate(); err != nil {
282+ return nil, ErrInvalidMessage
283+ }
284+ if err := validateBrokerEndpoint(m.Endpoint); err != nil {
285+ return nil, err
286+ }
287+ return &wire.AddHost{
288+ Scope: scopeToWire(m.Epoch, m.Connection),
289+ OperationId: operationToWire(m.Operation),
290+ Endpoint: m.Endpoint,
291+ }, nil
292+}
293+
294+func addHostFromWire(message *wire.AddHost) (AddHost, error) {
295+ var out AddHost
296+ if message == nil {
297+ return out, ErrInvalidMessage
298+ }
299+ epoch, connection, err := scopeFromWire(message.GetScope())
300+ if err != nil {
301+ return AddHost{}, ErrInvalidMessage
302+ }
303+ operation, err := operationFromWire(message.GetOperationId())
304+ if err != nil {
305+ return AddHost{}, ErrInvalidMessage
306+ }
307+ if err := validateBrokerEndpoint(message.GetEndpoint()); err != nil {
308+ return AddHost{}, err
309+ }
310+ return AddHost{Epoch: epoch, Connection: connection, Operation: operation, Endpoint: message.GetEndpoint()}, nil
311+}
312+
313+func removeHostToWire(m RemoveHost) (*wire.RemoveHost, error) {
314+ if m.Epoch == 0 {
315+ return nil, ErrInvalidMessage
316+ }
317+ if err := m.Connection.Validate(); err != nil {
318+ return nil, ErrInvalidMessage
319+ }
320+ if err := m.Operation.Validate(); err != nil {
321+ return nil, ErrInvalidMessage
322+ }
323+ if err := validateBrokerEndpoint(m.Endpoint); err != nil {
324+ return nil, err
325+ }
326+ return &wire.RemoveHost{
327+ Scope: scopeToWire(m.Epoch, m.Connection),
328+ OperationId: operationToWire(m.Operation),
329+ Endpoint: m.Endpoint,
330+ }, nil
331+}
332+
333+func removeHostFromWire(message *wire.RemoveHost) (RemoveHost, error) {
334+ var out RemoveHost
335+ if message == nil {
336+ return out, ErrInvalidMessage
337+ }
338+ epoch, connection, err := scopeFromWire(message.GetScope())
339+ if err != nil {
340+ return RemoveHost{}, ErrInvalidMessage
341+ }
342+ operation, err := operationFromWire(message.GetOperationId())
343+ if err != nil {
344+ return RemoveHost{}, ErrInvalidMessage
345+ }
346+ if err := validateBrokerEndpoint(message.GetEndpoint()); err != nil {
347+ return RemoveHost{}, err
348+ }
349+ return RemoveHost{Epoch: epoch, Connection: connection, Operation: operation, Endpoint: message.GetEndpoint()}, nil
350+}
351+
352+func reconcileToWire(m Reconcile) (*wire.Reconcile, error) {
353+ if m.Epoch == 0 {
354+ return nil, ErrInvalidMessage
355+ }
356+ if err := m.Connection.Validate(); err != nil {
357+ return nil, ErrInvalidMessage
358+ }
359+ if err := m.Registration.Validate(); err != nil {
360+ return nil, ErrInvalidMessage
361+ }
362+ return &wire.Reconcile{
363+ Scope: scopeToWire(m.Epoch, m.Connection),
364+ Registration: registrationToWire(m.Registration),
365+ }, nil
366+}
367+
368+func reconcileFromWire(message *wire.Reconcile) (Reconcile, error) {
369+ var out Reconcile
370+ if message == nil {
371+ return out, ErrInvalidMessage
372+ }
373+ epoch, connection, err := scopeFromWire(message.GetScope())
374+ if err != nil {
375+ return Reconcile{}, ErrInvalidMessage
376+ }
377+ registration, err := registrationFromWire(message.GetRegistration())
378+ if err != nil {
379+ return Reconcile{}, ErrInvalidMessage
380+ }
381+ return Reconcile{Epoch: epoch, Connection: connection, Registration: registration}, nil
382+}
383+
384+func subscribeFromWire(message *wire.Subscribe) (Subscribe, error) {
385+ var out Subscribe
386+ if message == nil {
387+ return out, ErrInvalidMessage
388+ }
389+ epoch, connection, err := scopeFromWire(message.GetScope())
390+ if err != nil {
391+ return Subscribe{}, ErrInvalidMessage
392+ }
393+ return Subscribe{Epoch: epoch, Connection: connection, Generation: message.GetGeneration()}, nil
394+}
395+
396+func resyncFromWire(message *wire.Resync) (Resync, error) {
397+ var out Resync
398+ if message == nil {
399+ return out, ErrInvalidMessage
400+ }
401+ epoch, connection, err := scopeFromWire(message.GetScope())
402+ if err != nil {
403+ return Resync{}, ErrInvalidMessage
404+ }
405+ return Resync{Epoch: epoch, Connection: connection, Generation: message.GetGeneration()}, nil
406+}
407+
408+func unsubscribeFromWire(message *wire.Unsubscribe) (Unsubscribe, error) {
409+ var out Unsubscribe
410+ if message == nil {
411+ return out, ErrInvalidMessage
412+ }
413+ epoch, connection, err := scopeFromWire(message.GetScope())
414+ if err != nil {
415+ return Unsubscribe{}, ErrInvalidMessage
416+ }
417+ return Unsubscribe{Epoch: epoch, Connection: connection, Generation: message.GetGeneration()}, nil
418+}
419+
420+func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
421+ request := ports.BrokerOpenStreamRequest{
422+ Epoch: m.Epoch, Purpose: m.Purpose, Local: m.Local,
423+ Connection: m.Connection, Stream: m.Stream,
424+ Endpoint: m.Endpoint, Registration: m.Registration,
425+ Target: m.Target, Env: m.Env, Policy: m.Policy,
426+ }
427+ if err := request.Validate(); err != nil {
428+ // Distinguish bound refusals from semantic refusals.
429+ if len(m.Env) > ports.BrokerMaxEnvEntries {
430+ return nil, ErrTooLarge
431+ }
432+ for _, entry := range m.Env {
433+ if len(entry) > ports.BrokerMaxEnvEntryBytes {
434+ return nil, ErrTooLarge
435+ }
436+ }
437+ return nil, ErrInvalidMessage
438+ }
439+ if err := checkEnvEntries(m.Env); err != nil {
440+ return nil, err
441+ }
442+ var purpose uint32
443+ switch m.Purpose {
444+ case ports.BrokerStreamAttachment:
445+ purpose = 1
446+ case ports.BrokerStreamControl:
447+ purpose = 2
448+ case ports.BrokerStreamObservation:
449+ purpose = 3
450+ default:
451+ return nil, ErrInvalidMessage
452+ }
453+ out := &wire.OpenStream{
454+ Ref: refToWire(m.Epoch, m.Connection, m.Stream),
455+ Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint,
456+ Env: append([]string(nil), m.Env...),
457+ Policy: policyToWire(m.Policy),
458+ }
459+ if !m.Local {
460+ out.Registration = registrationToWire(m.Registration)
461+ }
462+ if m.Purpose == ports.BrokerStreamAttachment {
463+ target := m.Target
464+ out.Target = exactTargetToWire(target)
465+ }
466+ return out, nil
467+}
468+
469+func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
470+ var out OpenStream
471+ if message == nil {
472+ return out, ErrInvalidMessage
473+ }
474+ epoch, connection, stream, err := refFromWire(message.GetRef())
475+ if err != nil {
476+ return OpenStream{}, ErrInvalidMessage
477+ }
478+ var purpose ports.BrokerStreamPurpose
479+ switch message.GetPurpose() {
480+ case 1:
481+ purpose = ports.BrokerStreamAttachment
482+ case 2:
483+ purpose = ports.BrokerStreamControl
484+ case 3:
485+ purpose = ports.BrokerStreamObservation
486+ default:
487+ return OpenStream{}, ErrInvalidMessage
488+ }
489+ local := message.GetLocal()
490+ var registration domain.RemoteRegistration
491+ if !local {
492+ registration, err = registrationFromWire(message.GetRegistration())
493+ if err != nil {
494+ return OpenStream{}, ErrInvalidMessage
495+ }
496+ } else if message.GetRegistration() != nil {
497+ return OpenStream{}, ErrInvalidMessage
498+ }
499+ var target protocol.ExactSessionTarget
500+ if purpose == ports.BrokerStreamAttachment {
501+ target, err = exactTargetFromWire(message.GetTarget())
502+ if err != nil {
503+ return OpenStream{}, ErrInvalidMessage
504+ }
505+ } else if message.GetTarget() != nil {
506+ return OpenStream{}, ErrInvalidMessage
507+ }
508+ env := append([]string(nil), message.GetEnv()...)
509+ if err := checkEnvEntries(env); err != nil {
510+ return OpenStream{}, err
511+ }
512+ if purpose != ports.BrokerStreamAttachment && len(env) != 0 {
513+ return OpenStream{}, ErrInvalidMessage
514+ }
515+ policy, err := policyFromWire(message.GetPolicy())
516+ if err != nil {
517+ return OpenStream{}, ErrInvalidMessage
518+ }
519+ endpoint := message.GetEndpoint()
520+ candidate := OpenStream{
521+ Epoch: epoch, Connection: connection, Stream: stream,
522+ Purpose: purpose, Local: local, Endpoint: endpoint,
523+ Registration: registration, Target: target, Env: env, Policy: policy,
524+ }
525+ request := ports.BrokerOpenStreamRequest{
526+ Epoch: candidate.Epoch, Purpose: candidate.Purpose, Local: candidate.Local,
527+ Connection: candidate.Connection, Stream: candidate.Stream,
528+ Endpoint: candidate.Endpoint, Registration: candidate.Registration,
529+ Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy,
530+ }
531+ if err := request.Validate(); err != nil {
532+ return OpenStream{}, ErrInvalidMessage
533+ }
534+ return candidate, nil
535+}
536+
537+func clientStreamDataToWire(m ClientStreamData, maxChunkBytes uint64) (*wire.ClientStreamData, error) {
538+ if m.Epoch == 0 {
539+ return nil, ErrInvalidMessage
540+ }
541+ if err := m.Connection.Validate(); err != nil {
542+ return nil, ErrInvalidMessage
543+ }
544+ if err := m.Stream.Validate(); err != nil {
545+ return nil, ErrInvalidMessage
546+ }
547+ if uint64(len(m.Data)) > maxChunkBytes {
548+ return nil, ErrTooLarge
549+ }
550+ if uint64(len(m.Data)) > MaxStreamChunkBytes {
551+ return nil, ErrTooLarge
552+ }
553+ if len(m.Data) == 0 {
554+ return nil, ErrInvalidMessage
555+ }
556+ return &wire.ClientStreamData{
557+ Ref: refToWire(m.Epoch, m.Connection, m.Stream),
558+ Data: append([]byte(nil), m.Data...),
559+ }, nil
560+}
561+
562+func clientStreamDataFromWire(message *wire.ClientStreamData, maxChunkBytes uint64) (ClientStreamData, error) {
563+ var out ClientStreamData
564+ if message == nil {
565+ return out, ErrInvalidMessage
566+ }
567+ epoch, connection, stream, err := refFromWire(message.GetRef())
568+ if err != nil {
569+ return ClientStreamData{}, ErrInvalidMessage
570+ }
571+ data := message.GetData()
572+ if uint64(len(data)) > maxChunkBytes {
573+ return ClientStreamData{}, ErrTooLarge
574+ }
575+ if uint64(len(data)) > MaxStreamChunkBytes {
576+ return ClientStreamData{}, ErrTooLarge
577+ }
578+ if len(data) == 0 {
579+ return ClientStreamData{}, ErrInvalidMessage
580+ }
581+ return ClientStreamData{Epoch: epoch, Connection: connection, Stream: stream, Data: append([]byte(nil), data...)}, nil
582+}
583+
584+func encodeServerEnvelope(message ServerMessage, maxChunkBytes uint64) (*wire.BrokerServerEnvelope, error) {
585+ if message == nil {
586+ return nil, ErrInvalidMessage
587+ }
588+ switch m := message.(type) {
589+ case Registered:
590+ if m.Epoch == 0 {
591+ return nil, ErrInvalidMessage
592+ }
593+ if err := m.Connection.Validate(); err != nil {
594+ return nil, ErrInvalidMessage
595+ }
596+ return &wire.BrokerServerEnvelope{Payload: &wire.BrokerServerEnvelope_Registered{Registered: &wire.Registered{
597+ Scope: scopeToWire(m.Epoch, m.Connection),
598+ }}}, nil
599+ case *Registered:
600+ if m == nil {
601+ return nil, ErrInvalidMessage
602+ }
603+ return encodeServerEnvelope(*m, maxChunkBytes)
604+ case SnapshotPart:
605+ converted, err := snapshotPartToWire(m)
606+ if err != nil {
607+ return nil, err
608+ }
609+ return &wire.BrokerServerEnvelope{Payload: &wire.BrokerServerEnvelope_SnapshotPart{SnapshotPart: converted}}, nil
610+ case *SnapshotPart:
611+ if m == nil {
612+ return nil, ErrInvalidMessage
613+ }
614+ return encodeServerEnvelope(*m, maxChunkBytes)
615+ case OperationResult:
616+ converted, err := operationResultToWire(m)
617+ if err != nil {
618+ return nil, err
619+ }
620+ return &wire.BrokerServerEnvelope{Payload: &wire.BrokerServerEnvelope_OperationResult{OperationResult: converted}}, nil
621+ case *OperationResult:
622+ if m == nil {
623+ return nil, ErrInvalidMessage
624+ }
625+ return encodeServerEnvelope(*m, maxChunkBytes)
626+ case StreamOpened:
627+ if m.Epoch == 0 {
628+ return nil, ErrInvalidMessage
629+ }
630+ if err := m.Connection.Validate(); err != nil {
631+ return nil, ErrInvalidMessage
632+ }
633+ if err := m.Stream.Validate(); err != nil {
634+ return nil, ErrInvalidMessage
635+ }
636+ return &wire.BrokerServerEnvelope{Payload: &wire.BrokerServerEnvelope_StreamOpened{StreamOpened: &wire.StreamOpened{
637+ Ref: refToWire(m.Epoch, m.Connection, m.Stream),
638+ }}}, nil
639+ case *StreamOpened:
640+ if m == nil {
641+ return nil, ErrInvalidMessage
642+ }
643+ return encodeServerEnvelope(*m, maxChunkBytes)
644+ case ServerStreamData:
645+ converted, err := serverStreamDataToWire(m, maxChunkBytes)
646+ if err != nil {
647+ return nil, err
648+ }
649+ return &wire.BrokerServerEnvelope{Payload: &wire.BrokerServerEnvelope_ServerStreamData{ServerStreamData: converted}}, nil
650+ case *ServerStreamData:
651+ if m == nil {
652+ return nil, ErrInvalidMessage
653+ }
654+ return encodeServerEnvelope(*m, maxChunkBytes)
655+ case StreamClosed:
656+ converted, err := streamClosedToWire(m)
657+ if err != nil {
658+ return nil, err
659+ }
660+ return &wire.BrokerServerEnvelope{Payload: &wire.BrokerServerEnvelope_StreamClosed{StreamClosed: converted}}, nil
661+ case *StreamClosed:
662+ if m == nil {
663+ return nil, ErrInvalidMessage
664+ }
665+ return encodeServerEnvelope(*m, maxChunkBytes)
666+ case Progress:
667+ converted, err := progressToWire(m)
668+ if err != nil {
669+ return nil, err
670+ }
671+ return &wire.BrokerServerEnvelope{Payload: &wire.BrokerServerEnvelope_Progress{Progress: converted}}, nil
672+ case *Progress:
673+ if m == nil {
674+ return nil, ErrInvalidMessage
675+ }
676+ return encodeServerEnvelope(*m, maxChunkBytes)
677+ case BrokerErrorMessage:
678+ if m.Epoch == 0 {
679+ return nil, ErrInvalidMessage
680+ }
681+ if err := m.Connection.Validate(); err != nil {
682+ return nil, ErrInvalidMessage
683+ }
684+ if err := m.Error.validate(); err != nil {
685+ return nil, err
686+ }
687+ return &wire.BrokerServerEnvelope{Payload: &wire.BrokerServerEnvelope_BrokerErrorMessage{BrokerErrorMessage: &wire.BrokerErrorMessage{
688+ Scope: scopeToWire(m.Epoch, m.Connection), Error: errorDetailToWire(m.Error),
689+ }}}, nil
690+ case *BrokerErrorMessage:
691+ if m == nil {
692+ return nil, ErrInvalidMessage
693+ }
694+ return encodeServerEnvelope(*m, maxChunkBytes)
695+ case Shutdown:
696+ converted, err := shutdownToWire(m)
697+ if err != nil {
698+ return nil, err
699+ }
700+ return &wire.BrokerServerEnvelope{Payload: &wire.BrokerServerEnvelope_Shutdown{Shutdown: converted}}, nil
701+ case *Shutdown:
702+ if m == nil {
703+ return nil, ErrInvalidMessage
704+ }
705+ return encodeServerEnvelope(*m, maxChunkBytes)
706+ default:
707+ return nil, ErrWrongDirection
708+ }
709+}
710+
711+func decodeServerEnvelope(envelope *wire.BrokerServerEnvelope, maxChunkBytes uint64) (ServerMessage, error) {
712+ if envelope == nil {
713+ return nil, ErrInvalidMessage
714+ }
715+ switch payload := envelope.Payload.(type) {
716+ case *wire.BrokerServerEnvelope_Registered:
717+ if payload.Registered == nil {
718+ return nil, ErrInvalidMessage
719+ }
720+ epoch, connection, err := scopeFromWire(payload.Registered.GetScope())
721+ if err != nil {
722+ return nil, ErrInvalidMessage
723+ }
724+ return Registered{Epoch: epoch, Connection: connection}, nil
725+ case *wire.BrokerServerEnvelope_SnapshotPart:
726+ return snapshotPartFromWire(payload.SnapshotPart)
727+ case *wire.BrokerServerEnvelope_OperationResult:
728+ return operationResultFromWire(payload.OperationResult)
729+ case *wire.BrokerServerEnvelope_StreamOpened:
730+ if payload.StreamOpened == nil {
731+ return nil, ErrInvalidMessage
732+ }
733+ epoch, connection, stream, err := refFromWire(payload.StreamOpened.GetRef())
734+ if err != nil {
735+ return nil, ErrInvalidMessage
736+ }
737+ return StreamOpened{Epoch: epoch, Connection: connection, Stream: stream}, nil
738+ case *wire.BrokerServerEnvelope_ServerStreamData:
739+ return serverStreamDataFromWire(payload.ServerStreamData, maxChunkBytes)
740+ case *wire.BrokerServerEnvelope_StreamClosed:
741+ return streamClosedFromWire(payload.StreamClosed)
742+ case *wire.BrokerServerEnvelope_Progress:
743+ return progressFromWire(payload.Progress)
744+ case *wire.BrokerServerEnvelope_BrokerErrorMessage:
745+ if payload.BrokerErrorMessage == nil {
746+ return nil, ErrInvalidMessage
747+ }
748+ epoch, connection, err := scopeFromWire(payload.BrokerErrorMessage.GetScope())
749+ if err != nil {
750+ return nil, ErrInvalidMessage
751+ }
752+ detail, err := errorDetailFromWire(payload.BrokerErrorMessage.GetError())
753+ if err != nil {
754+ return nil, ErrInvalidMessage
755+ }
756+ return BrokerErrorMessage{Epoch: epoch, Connection: connection, Error: detail}, nil
757+ case *wire.BrokerServerEnvelope_Shutdown:
758+ return shutdownFromWire(payload.Shutdown)
759+ default:
760+ return nil, ErrWrongDirection
761+ }
762+}
763+
764+func snapshotPartToWire(m SnapshotPart) (*wire.SnapshotPart, error) {
765+ if m.Epoch == 0 {
766+ return nil, ErrInvalidMessage
767+ }
768+ if err := m.Connection.Validate(); err != nil {
769+ return nil, ErrInvalidMessage
770+ }
771+ if m.Revision == 0 {
772+ return nil, ErrInvalidMessage
773+ }
774+ if m.Part == nil {
775+ return nil, ErrInvalidMessage
776+ }
777+ out := &wire.SnapshotPart{
778+ Scope: scopeToWire(m.Epoch, m.Connection),
779+ Generation: m.Generation, Revision: uint64(m.Revision), Index: m.Index,
780+ }
781+ switch part := m.Part.(type) {
782+ case SnapshotBegin:
783+ if part.HostCount > ports.BrokerMaxHosts ||
784+ part.SessionCount > ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost ||
785+ part.TombstoneCount > ports.BrokerMaxTombstones {
786+ return nil, ErrTooLarge
787+ }
788+ out.Part = &wire.SnapshotPart_Begin{Begin: &wire.SnapshotBegin{
789+ HostCount: part.HostCount, SessionCount: part.SessionCount, TombstoneCount: part.TombstoneCount,
790+ }}
791+ case *SnapshotBegin:
792+ if part == nil {
793+ return nil, ErrInvalidMessage
794+ }
795+ return snapshotPartToWire(SnapshotPart{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation, Revision: m.Revision, Index: m.Index, Part: *part})
796+ case SnapshotHostPart:
797+ converted, err := snapshotHostToWire(part)
798+ if err != nil {
799+ return nil, err
800+ }
801+ out.Part = &wire.SnapshotPart_Host{Host: converted}
802+ case *SnapshotHostPart:
803+ if part == nil {
804+ return nil, ErrInvalidMessage
805+ }
806+ return snapshotPartToWire(SnapshotPart{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation, Revision: m.Revision, Index: m.Index, Part: *part})
807+ case SnapshotSessionPart:
808+ converted, err := snapshotSessionToWire(part)
809+ if err != nil {
810+ return nil, err
811+ }
812+ out.Part = &wire.SnapshotPart_Session{Session: converted}
813+ case *SnapshotSessionPart:
814+ if part == nil {
815+ return nil, ErrInvalidMessage
816+ }
817+ return snapshotPartToWire(SnapshotPart{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation, Revision: m.Revision, Index: m.Index, Part: *part})
818+ case SnapshotTombstonePart:
819+ if part.RetiredRevision == 0 {
820+ return nil, ErrInvalidMessage
821+ }
822+ if err := part.Registration.Validate(); err != nil {
823+ return nil, ErrInvalidMessage
824+ }
825+ out.Part = &wire.SnapshotPart_Tombstone{Tombstone: &wire.SnapshotTombstone{
826+ TombstoneIndex: part.TombstoneIndex,
827+ Registration: registrationToWire(part.Registration),
828+ RetiredRevision: uint64(part.RetiredRevision),
829+ }}
830+ case *SnapshotTombstonePart:
831+ if part == nil {
832+ return nil, ErrInvalidMessage
833+ }
834+ return snapshotPartToWire(SnapshotPart{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation, Revision: m.Revision, Index: m.Index, Part: *part})
835+ case SnapshotEnd:
836+ out.Part = &wire.SnapshotPart_End{End: &wire.SnapshotEnd{}}
837+ case *SnapshotEnd:
838+ if part == nil {
839+ return nil, ErrInvalidMessage
840+ }
841+ return snapshotPartToWire(SnapshotPart{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation, Revision: m.Revision, Index: m.Index, Part: *part})
842+ default:
843+ return nil, ErrWrongDirection
844+ }
845+ return out, nil
846+}
847+
848+func snapshotPartFromWire(message *wire.SnapshotPart) (SnapshotPart, error) {
849+ var out SnapshotPart
850+ if message == nil {
851+ return out, ErrInvalidMessage
852+ }
853+ epoch, connection, err := scopeFromWire(message.GetScope())
854+ if err != nil {
855+ return SnapshotPart{}, ErrInvalidMessage
856+ }
857+ if message.GetRevision() == 0 {
858+ return SnapshotPart{}, ErrInvalidMessage
859+ }
860+ part := SnapshotPart{
861+ Epoch: epoch, Connection: connection,
862+ Generation: message.GetGeneration(), Revision: ports.BrokerRevision(message.GetRevision()),
863+ Index: message.GetIndex(),
864+ }
865+ switch payload := message.GetPart().(type) {
866+ case *wire.SnapshotPart_Begin:
867+ if payload.Begin == nil {
868+ return SnapshotPart{}, ErrInvalidMessage
869+ }
870+ if payload.Begin.GetHostCount() > ports.BrokerMaxHosts ||
871+ payload.Begin.GetSessionCount() > ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost ||
872+ payload.Begin.GetTombstoneCount() > ports.BrokerMaxTombstones {
873+ return SnapshotPart{}, ErrTooLarge
874+ }
875+ part.Part = SnapshotBegin{
876+ HostCount: payload.Begin.GetHostCount(),
877+ SessionCount: payload.Begin.GetSessionCount(),
878+ TombstoneCount: payload.Begin.GetTombstoneCount(),
879+ }
880+ case *wire.SnapshotPart_Host:
881+ if payload.Host == nil {
882+ return SnapshotPart{}, ErrInvalidMessage
883+ }
884+ host, index, sessionCount, err := snapshotHostFromWire(payload.Host)
885+ if err != nil {
886+ return SnapshotPart{}, err
887+ }
888+ part.Part = SnapshotHostPart{HostIndex: index, Host: host, SessionCount: sessionCount}
889+ case *wire.SnapshotPart_Session:
890+ if payload.Session == nil {
891+ return SnapshotPart{}, ErrInvalidMessage
892+ }
893+ session, hostIndex, sessionIndex, err := snapshotSessionFromWire(payload.Session)
894+ if err != nil {
895+ return SnapshotPart{}, err
896+ }
897+ part.Part = SnapshotSessionPart{HostIndex: hostIndex, SessionIndex: sessionIndex, Session: session}
898+ case *wire.SnapshotPart_Tombstone:
899+ if payload.Tombstone == nil {
900+ return SnapshotPart{}, ErrInvalidMessage
901+ }
902+ registration, err := registrationFromWire(payload.Tombstone.GetRegistration())
903+ if err != nil {
904+ return SnapshotPart{}, ErrInvalidMessage
905+ }
906+ if payload.Tombstone.GetRetiredRevision() == 0 {
907+ return SnapshotPart{}, ErrInvalidMessage
908+ }
909+ part.Part = SnapshotTombstonePart{
910+ TombstoneIndex: payload.Tombstone.GetTombstoneIndex(),
911+ Registration: registration,
912+ RetiredRevision: ports.BrokerRevision(payload.Tombstone.GetRetiredRevision()),
913+ }
914+ case *wire.SnapshotPart_End:
915+ if payload.End == nil {
916+ return SnapshotPart{}, ErrInvalidMessage
917+ }
918+ part.Part = SnapshotEnd{}
919+ default:
920+ return SnapshotPart{}, ErrWrongDirection
921+ }
922+ return part, nil
923+}
924+
925+func snapshotHostToWire(part SnapshotHostPart) (*wire.SnapshotHost, error) {
926+ if part.HostIndex >= ports.BrokerMaxHosts {
927+ return nil, ErrTooLarge
928+ }
929+ host := part.Host
930+ if err := validateSnapshotHostFields(host); err != nil {
931+ return nil, err
932+ }
933+ if err := host.Registration.Validate(); err != nil {
934+ return nil, ErrInvalidMessage
935+ }
936+ if host.Endpoint != host.Registration.Endpoint {
937+ return nil, ErrInvalidMessage
938+ }
939+ if err := ports.ValidateDurableHostProjection(host); err != nil {
940+ return nil, ErrInvalidMessage
941+ }
942+ if len(host.Sessions) != 0 {
943+ return nil, ErrInvalidMessage
944+ }
945+ if part.SessionCount > ports.BrokerMaxSessionsPerHost {
946+ return nil, ErrTooLarge
947+ }
948+ lastAttemptSeconds, lastAttemptNanos := brokerTimeToWire(host.LastAttempt)
949+ lastSuccessSeconds, lastSuccessNanos := brokerTimeToWire(host.LastSuccess)
950+ nextDueSeconds, nextDueNanos := brokerTimeToWire(host.NextDue)
951+ return &wire.SnapshotHost{
952+ HostIndex: part.HostIndex, Endpoint: host.Endpoint, DisplayOrigin: host.DisplayOrigin,
953+ Rank: uint32(host.Rank), Registration: registrationToWire(host.Registration),
954+ Availability: uint32(host.Availability), Checking: host.Checking,
955+ LastAttempt: &wire.BrokerTimestamp{Seconds: lastAttemptSeconds, Nanos: lastAttemptNanos},
956+ LastSuccess: &wire.BrokerTimestamp{Seconds: lastSuccessSeconds, Nanos: lastSuccessNanos},
957+ NextDue: &wire.BrokerTimestamp{Seconds: nextDueSeconds, Nanos: nextDueNanos},
958+ ConsecutiveFailures: uint64(host.ConsecutiveFailures), FailureEpisode: host.FailureEpisode,
959+ FailureKind: uint32(host.LastFailure.Kind),
960+ InventoryKnown: host.InventoryKnown, SessionCount: part.SessionCount,
961+ }, nil
962+}
963+
964+func snapshotHostFromWire(message *wire.SnapshotHost) (ports.RemoteHostSnapshot, uint32, uint32, error) {
965+ var host ports.RemoteHostSnapshot
966+ if message == nil {
967+ return host, 0, 0, ErrInvalidMessage
968+ }
969+ if message.GetHostIndex() >= ports.BrokerMaxHosts {
970+ return ports.RemoteHostSnapshot{}, 0, 0, ErrTooLarge
971+ }
972+ endpoint := message.GetEndpoint()
973+ if err := validateBrokerEndpoint(endpoint); err != nil {
974+ return ports.RemoteHostSnapshot{}, 0, 0, err
975+ }
976+ origin := message.GetDisplayOrigin()
977+ if err := validateBrokerOrigin(origin); err != nil {
978+ return ports.RemoteHostSnapshot{}, 0, 0, err
979+ }
980+ registration, err := registrationFromWire(message.GetRegistration())
981+ if err != nil {
982+ return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
983+ }
984+ if endpoint != registration.Endpoint {
985+ return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
986+ }
987+ availability, err := brokerEnum8[domain.RemoteAvailability](message.GetAvailability())
988+ if err != nil {
989+ return ports.RemoteHostSnapshot{}, 0, 0, err
990+ }
991+ if availability < domain.RemoteAvailabilityUnknown || availability > domain.RemoteAvailabilityInvalidResponse {
992+ return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
993+ }
994+ failureKind, err := brokerEnum8[domain.RemoteFailureKind](message.GetFailureKind())
995+ if err != nil {
996+ return ports.RemoteHostSnapshot{}, 0, 0, err
997+ }
998+ if failureKind > domain.RemoteFailureInvalidResponse {
999+ return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
1000+ }
1001+ lastAttempt, err := brokerTimeFromWire(message.GetLastAttempt().GetSeconds(), message.GetLastAttempt().GetNanos())
1002+ if err != nil {
1003+ return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
1004+ }
1005+ lastSuccess, err := brokerTimeFromWire(message.GetLastSuccess().GetSeconds(), message.GetLastSuccess().GetNanos())
1006+ if err != nil {
1007+ return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
1008+ }
1009+ nextDue, err := brokerTimeFromWire(message.GetNextDue().GetSeconds(), message.GetNextDue().GetNanos())
1010+ if err != nil {
1011+ return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
1012+ }
1013+ if message.GetConsecutiveFailures() > math.MaxUint32 {
1014+ return ports.RemoteHostSnapshot{}, 0, 0, errConvertRange
1015+ }
1016+ sessionCount := message.GetSessionCount()
1017+ if sessionCount > ports.BrokerMaxSessionsPerHost {
1018+ return ports.RemoteHostSnapshot{}, 0, 0, ErrTooLarge
1019+ }
1020+ host = ports.RemoteHostSnapshot{
1021+ Endpoint: endpoint, DisplayOrigin: origin, Rank: int(message.GetRank()),
1022+ Registration: registration, Availability: availability, Checking: message.GetChecking(),
1023+ LastAttempt: lastAttempt, LastSuccess: lastSuccess, NextDue: nextDue,
1024+ ConsecutiveFailures: uint(message.GetConsecutiveFailures()), FailureEpisode: message.GetFailureEpisode(),
1025+ LastFailure: domain.RemoteFailure{Kind: failureKind},
1026+ InventoryKnown: message.GetInventoryKnown(),
1027+ Sessions: []catalogue.RemoteCatalogSession{},
1028+ }
1029+ if err := validateSnapshotHostFields(host); err != nil {
1030+ return ports.RemoteHostSnapshot{}, 0, 0, err
1031+ }
1032+ return host, message.GetHostIndex(), message.GetSessionCount(), nil
1033+}
1034+
1035+func snapshotSessionToWire(part SnapshotSessionPart) (*wire.SnapshotSession, error) {
1036+ if part.HostIndex >= ports.BrokerMaxHosts {
1037+ return nil, ErrTooLarge
1038+ }
1039+ if part.SessionIndex >= ports.BrokerMaxSessionsPerHost {
1040+ return nil, ErrTooLarge
1041+ }
1042+ if err := validateCatalogSession(part.Session); err != nil {
1043+ return nil, err
1044+ }
1045+ return &wire.SnapshotSession{
1046+ HostIndex: part.HostIndex, SessionIndex: part.SessionIndex,
1047+ Session: catalogSessionToWire(part.Session),
1048+ }, nil
1049+}
1050+
1051+func snapshotSessionFromWire(message *wire.SnapshotSession) (catalogue.RemoteCatalogSession, uint32, uint32, error) {
1052+ var session catalogue.RemoteCatalogSession
1053+ if message == nil {
1054+ return session, 0, 0, ErrInvalidMessage
1055+ }
1056+ if message.GetHostIndex() >= ports.BrokerMaxHosts {
1057+ return catalogue.RemoteCatalogSession{}, 0, 0, ErrTooLarge
1058+ }
1059+ if message.GetSessionIndex() >= ports.BrokerMaxSessionsPerHost {
1060+ return catalogue.RemoteCatalogSession{}, 0, 0, ErrTooLarge
1061+ }
1062+ converted, err := catalogSessionFromWire(message.GetSession())
1063+ if err != nil {
1064+ return catalogue.RemoteCatalogSession{}, 0, 0, err
1065+ }
1066+ return converted, message.GetHostIndex(), message.GetSessionIndex(), nil
1067+}
1068+
1069+func operationResultToWire(m OperationResult) (*wire.OperationResult, error) {
1070+ if m.Epoch == 0 {
1071+ return nil, ErrInvalidMessage
1072+ }
1073+ if err := m.Connection.Validate(); err != nil {
1074+ return nil, ErrInvalidMessage
1075+ }
1076+ if err := m.Operation.Validate(); err != nil {
1077+ return nil, ErrInvalidMessage
1078+ }
1079+ if err := m.Outcome.Validate(); err != nil {
1080+ return nil, ErrInvalidMessage
1081+ }
1082+ var outcome uint32
1083+ switch m.Outcome {
1084+ case ports.BrokerOutcomeOK:
1085+ outcome = 1
1086+ case ports.BrokerOutcomeFailed:
1087+ outcome = 2
1088+ case ports.BrokerOutcomeUnknown:
1089+ outcome = 3
1090+ default:
1091+ return nil, ErrInvalidMessage
1092+ }
1093+ result := ports.BrokerOperationResult{Operation: m.Operation, Outcome: m.Outcome}
1094+ if m.HasError {
1095+ if err := m.Error.validate(); err != nil {
1096+ return nil, err
1097+ }
1098+ result.Text = m.Error.Text
1099+ } else if m.Error != (ErrorDetail{}) {
1100+ // HasError=false is exactly the absent detail: a nonzero
1101+ // ErrorDetail would otherwise be silently dropped instead of
1102+ // travelling, so the whole result fails closed.
1103+ return nil, ErrInvalidMessage
1104+ }
1105+ if err := result.Validate(); err != nil {
1106+ return nil, ErrInvalidMessage
1107+ }
1108+ out := &wire.OperationResult{
1109+ Scope: scopeToWire(m.Epoch, m.Connection),
1110+ OperationId: operationToWire(m.Operation),
1111+ Outcome: outcome, Removed: m.Removed,
1112+ }
1113+ if m.HasError {
1114+ out.Error = errorDetailToWire(m.Error)
1115+ }
1116+ return out, nil
1117+}
1118+
1119+func operationResultFromWire(message *wire.OperationResult) (OperationResult, error) {
1120+ var out OperationResult
1121+ if message == nil {
1122+ return out, ErrInvalidMessage
1123+ }
1124+ epoch, connection, err := scopeFromWire(message.GetScope())
1125+ if err != nil {
1126+ return OperationResult{}, ErrInvalidMessage
1127+ }
1128+ operation, err := operationFromWire(message.GetOperationId())
1129+ if err != nil {
1130+ return OperationResult{}, ErrInvalidMessage
1131+ }
1132+ var outcome ports.BrokerMutationOutcome
1133+ switch message.GetOutcome() {
1134+ case 1:
1135+ outcome = ports.BrokerOutcomeOK
1136+ case 2:
1137+ outcome = ports.BrokerOutcomeFailed
1138+ case 3:
1139+ outcome = ports.BrokerOutcomeUnknown
1140+ default:
1141+ return OperationResult{}, ErrInvalidMessage
1142+ }
1143+ result := OperationResult{
1144+ Epoch: epoch, Connection: connection, Operation: operation,
1145+ Outcome: outcome, Removed: message.GetRemoved(),
1146+ }
1147+ if message.GetError() != nil {
1148+ detail, err := errorDetailFromWire(message.GetError())
1149+ if err != nil {
1150+ return OperationResult{}, ErrInvalidMessage
1151+ }
1152+ result.Error = detail
1153+ result.HasError = true
1154+ }
1155+ semantic := ports.BrokerOperationResult{Operation: result.Operation, Outcome: result.Outcome}
1156+ if result.HasError {
1157+ semantic.Text = result.Error.Text
1158+ }
1159+ if err := semantic.Validate(); err != nil {
1160+ return OperationResult{}, ErrInvalidMessage
1161+ }
1162+ return result, nil
1163+}
1164+
1165+func serverStreamDataToWire(m ServerStreamData, maxChunkBytes uint64) (*wire.ServerStreamData, error) {
1166+ if m.Epoch == 0 {
1167+ return nil, ErrInvalidMessage
1168+ }
1169+ if err := m.Connection.Validate(); err != nil {
1170+ return nil, ErrInvalidMessage
1171+ }
1172+ if err := m.Stream.Validate(); err != nil {
1173+ return nil, ErrInvalidMessage
1174+ }
1175+ if uint64(len(m.Data)) > maxChunkBytes {
1176+ return nil, ErrTooLarge
1177+ }
1178+ if uint64(len(m.Data)) > MaxStreamChunkBytes {
1179+ return nil, ErrTooLarge
1180+ }
1181+ if len(m.Data) == 0 {
1182+ return nil, ErrInvalidMessage
1183+ }
1184+ return &wire.ServerStreamData{
1185+ Ref: refToWire(m.Epoch, m.Connection, m.Stream),
1186+ Data: append([]byte(nil), m.Data...),
1187+ }, nil
1188+}
1189+
1190+func serverStreamDataFromWire(message *wire.ServerStreamData, maxChunkBytes uint64) (ServerStreamData, error) {
1191+ var out ServerStreamData
1192+ if message == nil {
1193+ return out, ErrInvalidMessage
1194+ }
1195+ epoch, connection, stream, err := refFromWire(message.GetRef())
1196+ if err != nil {
1197+ return ServerStreamData{}, ErrInvalidMessage
1198+ }
1199+ data := message.GetData()
1200+ if uint64(len(data)) > maxChunkBytes {
1201+ return ServerStreamData{}, ErrTooLarge
1202+ }
1203+ if uint64(len(data)) > MaxStreamChunkBytes {
1204+ return ServerStreamData{}, ErrTooLarge
1205+ }
1206+ if len(data) == 0 {
1207+ return ServerStreamData{}, ErrInvalidMessage
1208+ }
1209+ return ServerStreamData{Epoch: epoch, Connection: connection, Stream: stream, Data: append([]byte(nil), data...)}, nil
1210+}
1211+
1212+func streamClosedToWire(m StreamClosed) (*wire.StreamClosed, error) {
1213+ if m.Epoch == 0 {
1214+ return nil, ErrInvalidMessage
1215+ }
1216+ if err := m.Connection.Validate(); err != nil {
1217+ return nil, ErrInvalidMessage
1218+ }
1219+ if err := m.Stream.Validate(); err != nil {
1220+ return nil, ErrInvalidMessage
1221+ }
1222+ if m.HasError {
1223+ if err := m.Error.validate(); err != nil {
1224+ return nil, err
1225+ }
1226+ } else if m.Error != (ErrorDetail{}) {
1227+ // An orderly close carries no detail at all; a nonzero one under
1228+ // HasError=false is a caller bug that must fail closed.
1229+ return nil, ErrInvalidMessage
1230+ }
1231+ out := &wire.StreamClosed{Ref: refToWire(m.Epoch, m.Connection, m.Stream)}
1232+ if m.HasError {
1233+ out.Error = errorDetailToWire(m.Error)
1234+ }
1235+ return out, nil
1236+}
1237+
1238+func streamClosedFromWire(message *wire.StreamClosed) (StreamClosed, error) {
1239+ var out StreamClosed
1240+ if message == nil {
1241+ return out, ErrInvalidMessage
1242+ }
1243+ epoch, connection, stream, err := refFromWire(message.GetRef())
1244+ if err != nil {
1245+ return StreamClosed{}, ErrInvalidMessage
1246+ }
1247+ result := StreamClosed{Epoch: epoch, Connection: connection, Stream: stream}
1248+ if message.GetError() != nil {
1249+ detail, err := errorDetailFromWire(message.GetError())
1250+ if err != nil {
1251+ return StreamClosed{}, ErrInvalidMessage
1252+ }
1253+ result.Error = detail
1254+ result.HasError = true
1255+ }
1256+ return result, nil
1257+}
1258+
1259+func progressToWire(m Progress) (*wire.Progress, error) {
1260+ if m.Epoch == 0 {
1261+ return nil, ErrInvalidMessage
1262+ }
1263+ if err := m.Connection.Validate(); err != nil {
1264+ return nil, ErrInvalidMessage
1265+ }
1266+ if err := m.Stream.Validate(); err != nil {
1267+ return nil, ErrInvalidMessage
1268+ }
1269+ if err := m.Phase.validate(); err != nil {
1270+ return nil, ErrInvalidMessage
1271+ }
1272+ if err := validateBrokerDisplayText(m.Text, ports.BrokerMaxErrorBytes); err != nil {
1273+ return nil, err
1274+ }
1275+ return &wire.Progress{
1276+ Ref: refToWire(m.Epoch, m.Connection, m.Stream),
1277+ Phase: uint32(m.Phase), Text: m.Text,
1278+ }, nil
1279+}
1280+
1281+func progressFromWire(message *wire.Progress) (Progress, error) {
1282+ var out Progress
1283+ if message == nil {
1284+ return out, ErrInvalidMessage
1285+ }
1286+ epoch, connection, stream, err := refFromWire(message.GetRef())
1287+ if err != nil {
1288+ return Progress{}, ErrInvalidMessage
1289+ }
1290+ phase := BrokerProgressPhase(message.GetPhase())
1291+ if err := phase.validate(); err != nil {
1292+ return Progress{}, ErrInvalidMessage
1293+ }
1294+ if err := validateBrokerDisplayText(message.GetText(), ports.BrokerMaxErrorBytes); err != nil {
1295+ return Progress{}, err
1296+ }
1297+ return Progress{Epoch: epoch, Connection: connection, Stream: stream, Phase: phase, Text: message.GetText()}, nil
1298+}
1299+
1300+func shutdownToWire(m Shutdown) (*wire.Shutdown, error) {
1301+ if m.Epoch == 0 {
1302+ return nil, ErrInvalidMessage
1303+ }
1304+ if err := m.Connection.Validate(); err != nil {
1305+ return nil, ErrInvalidMessage
1306+ }
1307+ if err := m.Reason.validate(); err != nil {
1308+ return nil, ErrInvalidMessage
1309+ }
1310+ if err := validateBrokerDisplayText(m.Text, ports.BrokerMaxErrorBytes); err != nil {
1311+ return nil, err
1312+ }
1313+ var reason uint32
1314+ switch m.Reason {
1315+ case ShutdownIdleExit:
1316+ reason = 1
1317+ case ShutdownTerminating:
1318+ reason = 2
1319+ }
1320+ return &wire.Shutdown{
1321+ Scope: scopeToWire(m.Epoch, m.Connection),
1322+ Reason: reason, Text: m.Text,
1323+ }, nil
1324+}
1325+
1326+func shutdownFromWire(message *wire.Shutdown) (Shutdown, error) {
1327+ var out Shutdown
1328+ if message == nil {
1329+ return out, ErrInvalidMessage
1330+ }
1331+ epoch, connection, err := scopeFromWire(message.GetScope())
1332+ if err != nil {
1333+ return Shutdown{}, ErrInvalidMessage
1334+ }
1335+ var reason ShutdownReason
1336+ switch message.GetReason() {
1337+ case 1:
1338+ reason = ShutdownIdleExit
1339+ case 2:
1340+ reason = ShutdownTerminating
1341+ default:
1342+ return Shutdown{}, ErrInvalidMessage
1343+ }
1344+ if err := validateBrokerDisplayText(message.GetText(), ports.BrokerMaxErrorBytes); err != nil {
1345+ return Shutdown{}, err
1346+ }
1347+ return Shutdown{Epoch: epoch, Connection: connection, Reason: reason, Text: message.GetText()}, nil
1348+}
A internal/adapters/brokerwire/codec_test.go +871 -0
@@ -0,0 +1,871 @@
1+package brokerwire
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "google.golang.org/protobuf/encoding/protowire"
10+ "google.golang.org/protobuf/proto"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+ "github.com/bnema/vev/internal/protocol/catalogue"
16+ "github.com/bnema/vev/internal/protocol/wire"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func testConnectionID(seed byte) ports.BrokerConnectionID {
21+ var id ports.BrokerConnectionID
22+ for i := range id {
23+ id[i] = seed + byte(i)
24+ }
25+ return id
26+}
27+
28+func testOperationID(seed byte) ports.BrokerOperationID {
29+ var id ports.BrokerOperationID
30+ for i := range id {
31+ id[i] = seed + byte(i)
32+ }
33+ return id
34+}
35+
36+func testRegistration() domain.RemoteRegistration {
37+ return domain.RemoteRegistration{
38+ Endpoint: "dev@host:22",
39+ Incarnation: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
40+ Generation: 9,
41+ }
42+}
43+
44+func testPolicy() ports.BrokerPolicy {
45+ return ports.BrokerPolicy{
46+ ProtocolVersion: protocol.Version,
47+ CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
48+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
49+ Transport: "quic",
50+ Trust: "pinned",
51+ Launch: "agent",
52+ Isolation: "user",
53+ }
54+}
55+
56+func testTarget() protocol.ExactSessionTarget {
57+ return protocol.ExactSessionTarget{
58+ LifecycleID: domain.SessionLifecycleID{1, 2, 3},
59+ SessionName: "work",
60+ }
61+}
62+
63+func testCatalogSession() catalogue.RemoteCatalogSession {
64+ return catalogue.RemoteCatalogSession{
65+ LifecycleID: domain.SessionLifecycleID{9, 8, 7},
66+ Name: "work",
67+ State: catalogue.RemoteCatalogSessionUp,
68+ Ephemeral: true,
69+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "shell", Detail: "1 pane", Attention: true}},
70+ Attached: true,
71+ LastUsedSeq: 4,
72+ ActiveTabID: "tab-1",
73+ Reason: domain.RemoteReasonRefreshing,
74+ }
75+}
76+
77+func testHostSnapshot() ports.RemoteHostSnapshot {
78+ return ports.RemoteHostSnapshot{
79+ Endpoint: "dev@host:22",
80+ DisplayOrigin: "dev@host",
81+ Rank: 2,
82+ Registration: testRegistration(),
83+ Availability: domain.RemoteAvailabilityReachable,
84+ Checking: true,
85+ LastAttempt: time.Unix(1700000000, 1).UTC(),
86+ LastSuccess: time.Unix(1700000001, 2).UTC(),
87+ NextDue: time.Unix(1700000002, 3).UTC(),
88+ FailureEpisode: 6,
89+ LastFailure: domain.RemoteFailure{Kind: domain.RemoteFailureTransport},
90+ InventoryKnown: true,
91+ Sessions: []catalogue.RemoteCatalogSession{},
92+ }
93+}
94+
95+func testErrorDetail() ErrorDetail {
96+ return ErrorDetail{Code: ports.BrokerErrorUnavailable, Text: "dial refused", AdmissionCode: 1, FailureKind: domain.RemoteFailureTransport}
97+}
98+
99+// unknownClientMessage and unknownServerMessage prove the directional
100+// codecs fail closed on concrete types outside their closed union.
101+type unknownClientMessage struct{}
102+
103+func (unknownClientMessage) brokerClientMessage() {}
104+
105+type unknownServerMessage struct{}
106+
107+func (unknownServerMessage) brokerServerMessage() {}
108+
109+const (
110+ testEnvelopeCeiling = wire.AbsoluteEnvelopeLimit
111+ testChunkCeiling = MaxStreamChunkBytes
112+)
113+
114+func mustEncodeClient(t *testing.T, message ClientMessage) []byte {
115+ t.Helper()
116+ raw, err := EncodeClient(message, testEnvelopeCeiling, testChunkCeiling)
117+ require.NoError(t, err)
118+ return raw
119+}
120+
121+func mustEncodeServer(t *testing.T, message ServerMessage) []byte {
122+ t.Helper()
123+ raw, err := EncodeServer(message, testEnvelopeCeiling, testChunkCeiling)
124+ require.NoError(t, err)
125+ return raw
126+}
127+
128+// TestBrokerPreambleByteLengthHelper proves the exposed 4 KiB byte-length
129+// helper accepts at the boundary and refuses one byte over, for both encoded
130+// and received preambles.
131+func TestBrokerPreambleByteLengthHelper(t *testing.T) {
132+ require.Equal(t, wire.PreambleLimit, BrokerPreambleLimit)
133+ require.Equal(t, 4<<10, BrokerPreambleLimit)
134+ require.NoError(t, CheckPreambleSize(nil))
135+ require.NoError(t, CheckPreambleSize(make([]byte, BrokerPreambleLimit)))
136+ require.ErrorIs(t, CheckPreambleSize(make([]byte, BrokerPreambleLimit+1)), ErrPreambleRejected)
137+
138+ encoded, err := proto.Marshal(EncodePreambleRequest(defaultBrokerCeilings()))
139+ require.NoError(t, err)
140+ require.NoError(t, CheckPreambleSize(encoded))
141+ response, err := proto.Marshal(EncodePreambleResponse(true, defaultBrokerCeilings(), 0))
142+ require.NoError(t, err)
143+ require.NoError(t, CheckPreambleSize(response))
144+}
145+
146+// TestBrokerPreambleRoles proves the broker preamble uses roles 3/4, the
147+// existing magic/epoch/exact version, zero capability bits, the 4 KiB
148+// preamble bound, and precise refusal mapping.
149+func TestBrokerPreambleRoles(t *testing.T) {
150+ t.Run("client role is 3 and server role is 4", func(t *testing.T) {
151+ request := EncodePreambleRequest(defaultBrokerCeilings())
152+ require.Equal(t, BrokerRoleClient, request.GetRole().GetRole())
153+ require.Equal(t, uint32(3), request.GetRole().GetRole())
154+ response := EncodePreambleResponse(true, defaultBrokerCeilings(), 0)
155+ require.Equal(t, BrokerRoleServer, response.GetRole().GetRole())
156+ require.Equal(t, uint32(4), response.GetRole().GetRole())
157+ })
158+ t.Run("magic epoch version match session contract", func(t *testing.T) {
159+ request := EncodePreambleRequest(defaultBrokerCeilings())
160+ require.Equal(t, wire.PreambleMagic, request.GetMagic())
161+ require.Equal(t, wire.ProtocolEpoch, request.GetEpoch())
162+ require.Equal(t, uint32(protocol.Version), request.GetVersion())
163+ require.Zero(t, request.GetCapabilityBits())
164+ response := EncodePreambleResponse(true, defaultBrokerCeilings(), 0)
165+ require.Equal(t, wire.PreambleMagic, response.GetMagic())
166+ require.Equal(t, wire.ProtocolEpoch, response.GetEpoch())
167+ require.Equal(t, uint32(protocol.Version), response.GetVersion())
168+ require.Zero(t, response.GetCapabilityBits())
169+ })
170+ t.Run("preamble fits 4KiB", func(t *testing.T) {
171+ raw, err := proto.Marshal(EncodePreambleRequest(defaultBrokerCeilings()))
172+ require.NoError(t, err)
173+ require.LessOrEqual(t, len(raw), BrokerPreambleLimit)
174+ require.Equal(t, 4<<10, BrokerPreambleLimit)
175+ raw, err = proto.Marshal(EncodePreambleResponse(true, defaultBrokerCeilings(), 0))
176+ require.NoError(t, err)
177+ require.LessOrEqual(t, len(raw), BrokerPreambleLimit)
178+ })
179+ t.Run("session roles rejected", func(t *testing.T) {
180+ request := EncodePreambleRequest(defaultBrokerCeilings())
181+ request.Role.Role = 1
182+ _, err := DecodePreambleRequest(request)
183+ require.ErrorIs(t, err, ErrPreambleRejected)
184+ require.Equal(t, RejectionWrongRole, RejectionCodeFor(request, err))
185+ request.Role.Role = 2
186+ _, err = DecodePreambleRequest(request)
187+ require.ErrorIs(t, err, ErrPreambleRejected)
188+ response := EncodePreambleResponse(true, defaultBrokerCeilings(), 0)
189+ response.Role.Role = 2
190+ _, err = DecodePreambleResponse(response)
191+ require.ErrorIs(t, err, ErrPreambleRejected)
192+ })
193+ t.Run("round trip negotiates minima", func(t *testing.T) {
194+ local := defaultBrokerCeilings()
195+ remote := brokerCeilings{maxReceiveEnvelopeBytes: testEnvelopeCeiling / 2, streamChunkLimit: 1024}
196+ decoded, err := DecodePreambleRequest(EncodePreambleRequest(remote))
197+ require.NoError(t, err)
198+ require.Equal(t, remote, decoded.Ceilings)
199+ require.Equal(t, remote, effectiveBrokerCeilings(local, decoded.Ceilings))
200+ resp, err := DecodePreambleResponse(EncodePreambleResponse(true, remote, 0))
201+ require.NoError(t, err)
202+ require.True(t, resp.Accepted)
203+ require.Equal(t, remote, resp.Ceilings)
204+ })
205+ t.Run("advertised envelope window 1MiB..16MiB", func(t *testing.T) {
206+ require.Equal(t, uint64(1<<20), uint64(MinBrokerEnvelopeBytes))
207+ require.Equal(t, uint64(16<<20), uint64(MaxBrokerEnvelopeBytes))
208+ for _, advertised := range []uint64{0, MinBrokerEnvelopeBytes - 1, MaxBrokerEnvelopeBytes + 1} {
209+ request := EncodePreambleRequest(defaultBrokerCeilings())
210+ request.MaxReceiveEnvelopeBytes = advertised
211+ _, err := DecodePreambleRequest(request)
212+ require.ErrorIs(t, err, ErrPreambleRejected)
213+ require.Equal(t, RejectionLimitRefused, RejectionCodeFor(request, err))
214+ }
215+ for _, advertised := range []uint64{MinBrokerEnvelopeBytes, MaxBrokerEnvelopeBytes} {
216+ request := EncodePreambleRequest(defaultBrokerCeilings())
217+ request.MaxReceiveEnvelopeBytes = advertised
218+ _, err := DecodePreambleRequest(request)
219+ require.NoError(t, err)
220+ }
221+ })
222+ t.Run("stream chunk window 1..64KiB", func(t *testing.T) {
223+ require.Equal(t, uint64(64<<10), uint64(MaxStreamChunkBytes))
224+ for _, advertised := range []uint64{0, MaxStreamChunkBytes + 1} {
225+ request := EncodePreambleRequest(defaultBrokerCeilings())
226+ request.OutputDataLimit = advertised
227+ _, err := DecodePreambleRequest(request)
228+ require.ErrorIs(t, err, ErrPreambleRejected)
229+ require.Equal(t, RejectionLimitRefused, RejectionCodeFor(request, err))
230+ }
231+ for _, advertised := range []uint64{MinStreamChunkBytes, MaxStreamChunkBytes} {
232+ request := EncodePreambleRequest(defaultBrokerCeilings())
233+ request.OutputDataLimit = advertised
234+ _, err := DecodePreambleRequest(request)
235+ require.NoError(t, err)
236+ }
237+ })
238+ t.Run("caps zero", func(t *testing.T) {
239+ request := EncodePreambleRequest(defaultBrokerCeilings())
240+ request.CapabilityBits = 1
241+ _, err := DecodePreambleRequest(request)
242+ require.ErrorIs(t, err, ErrPreambleRejected)
243+ require.Equal(t, RejectionLimitRefused, RejectionCodeFor(request, err))
244+ response := EncodePreambleResponse(true, defaultBrokerCeilings(), 0)
245+ response.CapabilityBits = 7
246+ _, err = DecodePreambleResponse(response)
247+ require.ErrorIs(t, err, ErrPreambleRejected)
248+ })
249+ t.Run("refusal mapping is precise", func(t *testing.T) {
250+ cases := []struct {
251+ name string
252+ mutate func(*wire.PreambleRequest)
253+ want uint32
254+ }{
255+ {"bad magic", func(r *wire.PreambleRequest) { r.Magic = 0xDEAD }, RejectionBadMagic},
256+ {"epoch", func(r *wire.PreambleRequest) { r.Epoch++ }, RejectionEpochMismatch},
257+ {"version", func(r *wire.PreambleRequest) { r.Version++ }, RejectionVersionMismatch},
258+ {"role", func(r *wire.PreambleRequest) { r.Role.Role = 9 }, RejectionWrongRole},
259+ {"caps", func(r *wire.PreambleRequest) { r.CapabilityBits = 1 }, RejectionLimitRefused},
260+ {"envelope floor", func(r *wire.PreambleRequest) { r.MaxReceiveEnvelopeBytes = 1 }, RejectionLimitRefused},
261+ {"chunk", func(r *wire.PreambleRequest) { r.OutputDataLimit = 0 }, RejectionLimitRefused},
262+ }
263+ for _, tc := range cases {
264+ t.Run(tc.name, func(t *testing.T) {
265+ request := EncodePreambleRequest(defaultBrokerCeilings())
266+ tc.mutate(request)
267+ _, err := DecodePreambleRequest(request)
268+ require.ErrorIs(t, err, ErrPreambleRejected)
269+ require.Equal(t, tc.want, RejectionCodeFor(request, err))
270+ })
271+ }
272+ // Refusal responses decode as rejected with their code.
273+ response := EncodePreambleResponse(false, defaultBrokerCeilings(), RejectionVersionMismatch)
274+ require.False(t, response.GetAccepted())
275+ require.Equal(t, RejectionVersionMismatch, response.GetRejection().GetCode())
276+ decoded, err := DecodePreambleResponse(response)
277+ require.ErrorIs(t, err, ErrPreambleRejected)
278+ require.Equal(t, RejectionVersionMismatch, decoded.Code)
279+ })
280+ t.Run("nil preambles rejected", func(t *testing.T) {
281+ _, err := DecodePreambleRequest(nil)
282+ require.ErrorIs(t, err, ErrPreambleRejected)
283+ _, err = DecodePreambleResponse(nil)
284+ require.ErrorIs(t, err, ErrPreambleRejected)
285+ })
286+}
287+
288+// TestBrokerClientVariants inventories every client variant tag.
289+func TestBrokerClientVariants(t *testing.T) {
290+ connection := testConnectionID(0x11)
291+ operation := testOperationID(0x44)
292+ messages := []struct {
293+ name string
294+ message ClientMessage
295+ tag protowire.Number
296+ }{
297+ {"register", Register{}, 101},
298+ {"subscribe", Subscribe{Epoch: 7, Connection: connection, Generation: 1}, 102},
299+ {"resync", Resync{Epoch: 7, Connection: connection, Generation: 2}, 103},
300+ {"unsubscribe", Unsubscribe{Epoch: 7, Connection: connection, Generation: 3}, 104},
301+ {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 105},
302+ {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 106},
303+ {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107},
304+ {"open_stream", OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"TERM=xterm"}, Policy: testPolicy()}, 108},
305+ {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109},
306+ {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110},
307+ }
308+ require.Len(t, messages, 10)
309+ for _, tc := range messages {
310+ t.Run(tc.name, func(t *testing.T) {
311+ raw := mustEncodeClient(t, tc.message)
312+ number, wireType, consumed := protowire.ConsumeTag(raw)
313+ require.Greater(t, consumed, 0)
314+ require.Equal(t, tc.tag, number)
315+ require.Equal(t, protowire.BytesType, wireType)
316+ decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
317+ require.NoError(t, err)
318+ require.Equal(t, tc.message, decoded)
319+ })
320+ }
321+}
322+
323+// TestBrokerServerVariants inventories every server variant tag.
324+func TestBrokerServerVariants(t *testing.T) {
325+ connection := testConnectionID(0x11)
326+ operation := testOperationID(0x77)
327+ messages := []struct {
328+ name string
329+ message ServerMessage
330+ tag protowire.Number
331+ }{
332+ {"registered", Registered{Epoch: 7, Connection: connection}, 201},
333+ {"snapshot_begin", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 0, Part: SnapshotBegin{HostCount: 1, SessionCount: 2, TombstoneCount: 0}}, 202},
334+ {"snapshot_host", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotHostPart{HostIndex: 0, Host: testHostSnapshot(), SessionCount: 1}}, 202},
335+ {"snapshot_session", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: testCatalogSession()}}, 202},
336+ {"snapshot_tombstone", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 3, Part: SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 4}}, 202},
337+ {"snapshot_end", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 4, Part: SnapshotEnd{}}, 202},
338+ {"operation_result", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Removed: true, Error: testErrorDetail(), HasError: true}, 203},
339+ {"stream_opened", StreamOpened{Epoch: 7, Connection: connection, Stream: 3}, 204},
340+ {"server_stream_data", ServerStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 205},
341+ {"stream_closed", StreamClosed{Epoch: 7, Connection: connection, Stream: 3, Error: testErrorDetail(), HasError: true}, 206},
342+ {"stream_closed_orderly", StreamClosed{Epoch: 7, Connection: connection, Stream: 4}, 206},
343+ {"progress", Progress{Epoch: 7, Connection: connection, Stream: 3, Phase: BrokerProgressProbing, Text: "probing"}, 207},
344+ {"broker_error", BrokerErrorMessage{Epoch: 7, Connection: connection, Error: testErrorDetail()}, 208},
345+ {"shutdown", Shutdown{Epoch: 7, Connection: connection, Reason: ShutdownTerminating, Text: "stopping"}, 209},
346+ }
347+ for _, tc := range messages {
348+ t.Run(tc.name, func(t *testing.T) {
349+ raw := mustEncodeServer(t, tc.message)
350+ number, wireType, consumed := protowire.ConsumeTag(raw)
351+ require.Greater(t, consumed, 0)
352+ require.Equal(t, tc.tag, number)
353+ require.Equal(t, protowire.BytesType, wireType)
354+ decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
355+ require.NoError(t, err)
356+ require.Equal(t, tc.message, decoded)
357+ })
358+ }
359+}
360+
361+// TestBrokerDownSessionEmptyTabsRoundTrip proves a down session with an
362+// empty-present tab list survives a full encode/decode round trip: proto3
363+// repeated fields carry no presence, so the decoder must map an absent
364+// field back to an empty-present list rather than refusing it.
365+func TestBrokerDownSessionEmptyTabsRoundTrip(t *testing.T) {
366+ connection := testConnectionID(0x11)
367+ session := catalogue.RemoteCatalogSession{
368+ LifecycleID: domain.SessionLifecycleID{1, 2, 3},
369+ Name: "down",
370+ State: catalogue.RemoteCatalogSessionDown,
371+ Tabs: []catalogue.RemoteCatalogTab{},
372+ }
373+ message := SnapshotPart{
374+ Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2,
375+ Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: session},
376+ }
377+ raw := mustEncodeServer(t, message)
378+ decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
379+ require.NoError(t, err)
380+ require.Equal(t, message, decoded)
381+
382+ part, ok := decoded.(SnapshotPart)
383+ require.True(t, ok)
384+ sessionPart, ok := part.Part.(SnapshotSessionPart)
385+ require.True(t, ok)
386+ require.NotNil(t, sessionPart.Session.Tabs, "empty tabs decode as empty-present, never nil")
387+ require.Empty(t, sessionPart.Session.Tabs)
388+}
389+
390+// TestBrokerTabIndexBound proves a tab index at the per-session maximum is a
391+// range refusal, not a semantic one: valid indexes are 0..max-1.
392+func TestBrokerTabIndexBound(t *testing.T) {
393+ connection := testConnectionID(0x11)
394+ envelope := &wire.BrokerServerEnvelope{Payload: &wire.BrokerServerEnvelope_SnapshotPart{SnapshotPart: &wire.SnapshotPart{
395+ Scope: &wire.BrokerScope{BrokerEpoch: 7, ConnectionId: connection[:]},
396+ Generation: 2,
397+ Revision: 5,
398+ Index: 2,
399+ Part: &wire.SnapshotPart_Session{Session: &wire.SnapshotSession{
400+ HostIndex: 0, SessionIndex: 0,
401+ Session: &wire.BrokerCatalogSession{
402+ LifecycleId: []byte{1, 2, 3},
403+ Name: "down",
404+ State: 2,
405+ Tabs: []*wire.BrokerCatalogTab{{Id: "tab", Index: catalogue.RemoteCatalogMaxTabsPerSess, Name: "tab"}},
406+ },
407+ }},
408+ }}}
409+ raw, err := proto.Marshal(envelope)
410+ require.NoError(t, err)
411+ _, err = DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
412+ require.ErrorIs(t, err, errConvertRange)
413+}
414+
415+// mutateHostIndex re-encodes one host snapshot part envelope with its host
416+// index overwritten, so decode can be fed a value encode would never emit.
417+func mutateHostIndex(t *testing.T, part SnapshotPart, hostIndex uint32) []byte {
418+ t.Helper()
419+ raw := mustEncodeServer(t, part)
420+ envelope := &wire.BrokerServerEnvelope{}
421+ require.NoError(t, wire.ScanEnvelope(envelope, raw))
422+ require.NoError(t, proto.Unmarshal(raw, envelope))
423+ envelope.GetSnapshotPart().GetHost().HostIndex = hostIndex
424+ mutated, err := proto.Marshal(envelope)
425+ require.NoError(t, err)
426+ return mutated
427+}
428+
429+// mutateSessionIndexes re-encodes one session snapshot part envelope with its
430+// host and session indexes overwritten.
431+func mutateSessionIndexes(t *testing.T, part SnapshotPart, hostIndex, sessionIndex uint32) []byte {
432+ t.Helper()
433+ raw := mustEncodeServer(t, part)
434+ envelope := &wire.BrokerServerEnvelope{}
435+ require.NoError(t, wire.ScanEnvelope(envelope, raw))
436+ require.NoError(t, proto.Unmarshal(raw, envelope))
437+ envelope.GetSnapshotPart().GetSession().HostIndex = hostIndex
438+ envelope.GetSnapshotPart().GetSession().SessionIndex = sessionIndex
439+ mutated, err := proto.Marshal(envelope)
440+ require.NoError(t, err)
441+ return mutated
442+}
443+
444+// TestBrokerSnapshotIndexBounds proves snapshot part indexes are strict
445+// ranges: max-1 is accepted on both encode and decode, and max is refused as a
446+// range refusal on both paths. Host indexes are bounded by BrokerMaxHosts and
447+// session indexes by BrokerMaxSessionsPerHost.
448+func TestBrokerSnapshotIndexBounds(t *testing.T) {
449+ connection := testConnectionID(0x11)
450+ hostPart := func(hostIndex uint32) SnapshotPart {
451+ return SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotHostPart{HostIndex: hostIndex, Host: testHostSnapshot()}}
452+ }
453+ sessionPart := func(hostIndex, sessionIndex uint32) SnapshotPart {
454+ return SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: hostIndex, SessionIndex: sessionIndex, Session: testCatalogSession()}}
455+ }
456+
457+ t.Run("host part at max-1 accepted", func(t *testing.T) {
458+ message := hostPart(ports.BrokerMaxHosts - 1)
459+ raw := mustEncodeServer(t, message)
460+ decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
461+ require.NoError(t, err)
462+ require.Equal(t, message, decoded)
463+ decoded, err = DecodeServer(mutateHostIndex(t, message, ports.BrokerMaxHosts-1), testEnvelopeCeiling, testChunkCeiling)
464+ require.NoError(t, err)
465+ require.Equal(t, message, decoded)
466+ })
467+
468+ t.Run("host part at max refused", func(t *testing.T) {
469+ _, err := EncodeServer(hostPart(ports.BrokerMaxHosts), testEnvelopeCeiling, testChunkCeiling)
470+ require.ErrorIs(t, err, ErrTooLarge)
471+ _, err = DecodeServer(mutateHostIndex(t, hostPart(0), ports.BrokerMaxHosts), testEnvelopeCeiling, testChunkCeiling)
472+ require.ErrorIs(t, err, ErrTooLarge)
473+ })
474+
475+ t.Run("host part at uint32 maximum refused", func(t *testing.T) {
476+ _, err := EncodeServer(hostPart(^uint32(0)), testEnvelopeCeiling, testChunkCeiling)
477+ require.ErrorIs(t, err, ErrTooLarge)
478+ _, err = DecodeServer(mutateHostIndex(t, hostPart(0), ^uint32(0)), testEnvelopeCeiling, testChunkCeiling)
479+ require.ErrorIs(t, err, ErrTooLarge)
480+ })
481+
482+ t.Run("session part at max-1 accepted", func(t *testing.T) {
483+ message := sessionPart(ports.BrokerMaxHosts-1, ports.BrokerMaxSessionsPerHost-1)
484+ raw := mustEncodeServer(t, message)
485+ decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
486+ require.NoError(t, err)
487+ require.Equal(t, message, decoded)
488+ decoded, err = DecodeServer(mutateSessionIndexes(t, message, ports.BrokerMaxHosts-1, ports.BrokerMaxSessionsPerHost-1), testEnvelopeCeiling, testChunkCeiling)
489+ require.NoError(t, err)
490+ require.Equal(t, message, decoded)
491+ })
492+
493+ t.Run("session part host index at max refused", func(t *testing.T) {
494+ _, err := EncodeServer(sessionPart(ports.BrokerMaxHosts, 0), testEnvelopeCeiling, testChunkCeiling)
495+ require.ErrorIs(t, err, ErrTooLarge)
496+ _, err = DecodeServer(mutateSessionIndexes(t, sessionPart(0, 0), ports.BrokerMaxHosts, 0), testEnvelopeCeiling, testChunkCeiling)
497+ require.ErrorIs(t, err, ErrTooLarge)
498+ })
499+
500+ t.Run("session part session index at max refused", func(t *testing.T) {
501+ _, err := EncodeServer(sessionPart(0, ports.BrokerMaxSessionsPerHost), testEnvelopeCeiling, testChunkCeiling)
502+ require.ErrorIs(t, err, ErrTooLarge)
503+ _, err = DecodeServer(mutateSessionIndexes(t, sessionPart(0, 0), 0, ports.BrokerMaxSessionsPerHost), testEnvelopeCeiling, testChunkCeiling)
504+ require.ErrorIs(t, err, ErrTooLarge)
505+ })
506+}
507+
508+// TestBrokerGoldenVectors pins byte-for-byte wire encodings.
509+func TestBrokerGoldenVectors(t *testing.T) {
510+ connection := testConnectionID(0x11)
511+ raw := mustEncodeClient(t, Register{})
512+ decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
513+ require.NoError(t, err)
514+ require.Equal(t, Register{}, decoded)
515+ // Re-encode is deterministic: the same message marshals identically.
516+ again, err := EncodeClient(Register{}, testEnvelopeCeiling, testChunkCeiling)
517+ require.NoError(t, err)
518+ require.Equal(t, raw, again)
519+
520+ sub := Subscribe{Epoch: 7, Connection: connection, Generation: 1 << 40}
521+ rawSub := mustEncodeClient(t, sub)
522+ envelope := &wire.BrokerClientEnvelope{}
523+ require.NoError(t, wire.ScanEnvelope(envelope, rawSub))
524+ require.NoError(t, proto.Unmarshal(rawSub, envelope))
525+ require.Equal(t, uint64(7), envelope.GetSubscribe().GetScope().GetBrokerEpoch())
526+ require.Equal(t, uint64(1<<40), envelope.GetSubscribe().GetGeneration())
527+ back, err := DecodeClient(rawSub, testEnvelopeCeiling, testChunkCeiling)
528+ require.NoError(t, err)
529+ require.Equal(t, sub, back)
530+}
531+
532+// TestBrokerBounds proves stateless bound refusals.
533+func TestBrokerBounds(t *testing.T) {
534+ connection := testConnectionID(0x11)
535+ t.Run("envelope ceiling", func(t *testing.T) {
536+ raw := mustEncodeClient(t, Register{})
537+ _, err := DecodeClient(raw, uint64(len(raw)-1), testChunkCeiling)
538+ require.ErrorIs(t, err, wire.ErrScanLength)
539+ _, err = DecodeClient(make([]byte, testEnvelopeCeiling+1), testEnvelopeCeiling, testChunkCeiling)
540+ require.ErrorIs(t, err, wire.ErrScanLength)
541+ })
542+ t.Run("chunk bound", func(t *testing.T) {
543+ big := bytes.Repeat([]byte{0x66}, int(MaxStreamChunkBytes)+1)
544+ _, err := EncodeClient(ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: big}, testEnvelopeCeiling, testEnvelopeCeiling)
545+ require.ErrorIs(t, err, ErrTooLarge)
546+ _, err = EncodeServer(ServerStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: big}, testEnvelopeCeiling, testEnvelopeCeiling)
547+ require.ErrorIs(t, err, ErrTooLarge)
548+ // A negotiated chunk below the absolute max also refuses.
549+ raw := mustEncodeClient(t, ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("12345678")})
550+ _, err = DecodeClient(raw, testEnvelopeCeiling, 4)
551+ require.ErrorIs(t, err, ErrTooLarge)
552+ // Empty chunk is invalid, not too large.
553+ _, err = EncodeClient(ClientStreamData{Epoch: 7, Connection: connection, Stream: 3}, testEnvelopeCeiling, testChunkCeiling)
554+ require.ErrorIs(t, err, ErrInvalidMessage)
555+ })
556+ t.Run("env entries", func(t *testing.T) {
557+ many := make([]string, ports.BrokerMaxEnvEntries+1)
558+ for i := range many {
559+ many[i] = "A=B"
560+ }
561+ _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: many, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
562+ require.ErrorIs(t, err, ErrTooLarge)
563+ oversize := []string{"K=" + strings.Repeat("x", ports.BrokerMaxEnvEntryBytes)}
564+ _, err = EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: oversize, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
565+ require.ErrorIs(t, err, ErrTooLarge)
566+ })
567+ t.Run("error text", func(t *testing.T) {
568+ oversize := testErrorDetail()
569+ oversize.Text = strings.Repeat("e", ports.BrokerMaxErrorBytes+1)
570+ _, err := EncodeServer(BrokerErrorMessage{Epoch: 7, Connection: connection, Error: oversize}, testEnvelopeCeiling, testChunkCeiling)
571+ require.ErrorIs(t, err, ErrTooLarge)
572+ })
573+ t.Run("snapshot counts", func(t *testing.T) {
574+ _, err := EncodeServer(SnapshotPart{Epoch: 7, Connection: connection, Generation: 1, Revision: 1, Index: 0, Part: SnapshotBegin{HostCount: ports.BrokerMaxHosts + 1}}, testEnvelopeCeiling, testChunkCeiling)
575+ require.ErrorIs(t, err, ErrTooLarge)
576+ })
577+ t.Run("control env carries no attachment state", func(t *testing.T) {
578+ _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), Target: testTarget()}, testEnvelopeCeiling, testChunkCeiling)
579+ require.ErrorIs(t, err, ErrInvalidMessage)
580+ })
581+}
582+
583+// TestBrokerValidationNegatives is the table of semantic refusals: IDs,
584+// UTF-8/control/bidi text, taxonomy, times, env/policy, snapshot parts.
585+func TestBrokerValidationNegatives(t *testing.T) {
586+ connection := testConnectionID(0x11)
587+ t.Run("zero scope", func(t *testing.T) {
588+ _, err := EncodeClient(Subscribe{Epoch: 0, Connection: connection}, testEnvelopeCeiling, testChunkCeiling)
589+ require.ErrorIs(t, err, ErrInvalidMessage)
590+ _, err = EncodeClient(Subscribe{Epoch: 7}, testEnvelopeCeiling, testChunkCeiling)
591+ require.ErrorIs(t, err, ErrInvalidMessage)
592+ _, err = EncodeServer(Registered{Epoch: 7}, testEnvelopeCeiling, testChunkCeiling)
593+ require.ErrorIs(t, err, ErrInvalidMessage)
594+ })
595+ t.Run("zero stream", func(t *testing.T) {
596+ _, err := EncodeClient(CloseStream{Epoch: 7, Connection: connection}, testEnvelopeCeiling, testChunkCeiling)
597+ require.ErrorIs(t, err, ErrInvalidMessage)
598+ })
599+ t.Run("error detail never travels without HasError", func(t *testing.T) {
600+ detail := testErrorDetail()
601+ // HasError=false is exactly the absent detail, whatever the outcome
602+ // and whatever field of the detail is nonzero.
603+ for name, stray := range map[string]ErrorDetail{
604+ "full detail": detail,
605+ "code only": {Code: ports.BrokerErrorTimeout},
606+ "text only": {Text: "late"},
607+ "admission only": {AdmissionCode: 2},
608+ "failure kind only": {FailureKind: domain.RemoteFailureTransport},
609+ } {
610+ t.Run(name, func(t *testing.T) {
611+ for _, outcome := range []ports.BrokerMutationOutcome{ports.BrokerOutcomeOK, ports.BrokerOutcomeFailed, ports.BrokerOutcomeUnknown} {
612+ _, err := EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: outcome, Error: stray}, testEnvelopeCeiling, testChunkCeiling)
613+ require.ErrorIs(t, err, ErrInvalidMessage)
614+ }
615+ _, err := EncodeServer(StreamClosed{Epoch: 7, Connection: connection, Stream: 3, Error: stray}, testEnvelopeCeiling, testChunkCeiling)
616+ require.ErrorIs(t, err, ErrInvalidMessage)
617+ })
618+ }
619+ // The zero detail is the canonical absent value and still encodes; a
620+ // declared detail still round-trips exactly.
621+ _, err := EncodeServer(StreamClosed{Epoch: 7, Connection: connection, Stream: 3}, testEnvelopeCeiling, testChunkCeiling)
622+ require.NoError(t, err)
623+ _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK}, testEnvelopeCeiling, testChunkCeiling)
624+ require.NoError(t, err)
625+ declared := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeFailed, Error: detail, HasError: true}
626+ decoded, err := DecodeServer(mustEncodeServer(t, declared), testEnvelopeCeiling, testChunkCeiling)
627+ require.NoError(t, err)
628+ require.Equal(t, declared, decoded)
629+ })
630+ t.Run("invalid error detail refused on every encode path", func(t *testing.T) {
631+ badAdmission := testErrorDetail()
632+ badAdmission.AdmissionCode = MaxBrokerAdmissionCode + 1
633+ badKind := testErrorDetail()
634+ badKind.FailureKind = MaxBrokerFailureKind + 1
635+ badCode := testErrorDetail()
636+ badCode.Code = 0
637+ for name, detail := range map[string]ErrorDetail{
638+ "admission over maximum": badAdmission,
639+ "failure kind over bound": badKind,
640+ "invalid error code": badCode,
641+ } {
642+ t.Run(name, func(t *testing.T) {
643+ _, err := EncodeServer(BrokerErrorMessage{Epoch: 7, Connection: connection, Error: detail}, testEnvelopeCeiling, testChunkCeiling)
644+ require.ErrorIs(t, err, ErrInvalidMessage)
645+ _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeFailed, Error: detail, HasError: true}, testEnvelopeCeiling, testChunkCeiling)
646+ require.ErrorIs(t, err, ErrInvalidMessage)
647+ _, err = EncodeServer(StreamClosed{Epoch: 7, Connection: connection, Stream: 3, Error: detail, HasError: true}, testEnvelopeCeiling, testChunkCeiling)
648+ require.ErrorIs(t, err, ErrInvalidMessage)
649+ })
650+ }
651+ // The closed taxonomy boundaries are accepted.
652+ boundary := testErrorDetail()
653+ boundary.AdmissionCode = MaxBrokerAdmissionCode
654+ boundary.FailureKind = MaxBrokerFailureKind
655+ _, err := EncodeServer(BrokerErrorMessage{Epoch: 7, Connection: connection, Error: boundary}, testEnvelopeCeiling, testChunkCeiling)
656+ require.NoError(t, err)
657+ })
658+ t.Run("bad endpoint", func(t *testing.T) {
659+ _, err := EncodeClient(AddHost{Epoch: 7, Connection: connection, Operation: testOperationID(1), Endpoint: "not a host"}, testEnvelopeCeiling, testChunkCeiling)
660+ require.ErrorIs(t, err, ErrInvalidMessage)
661+ })
662+ t.Run("taxonomy switches", func(t *testing.T) {
663+ raw := mustEncodeClient(t, OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()})
664+ envelope := &wire.BrokerClientEnvelope{}
665+ require.NoError(t, wire.ScanEnvelope(envelope, raw))
666+ require.NoError(t, proto.Unmarshal(raw, envelope))
667+ envelope.GetOpenStream().Purpose = 9
668+ mutated, err := proto.Marshal(envelope)
669+ require.NoError(t, err)
670+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
671+ require.Error(t, err)
672+
673+ rawErr := mustEncodeServer(t, BrokerErrorMessage{Epoch: 7, Connection: connection, Error: testErrorDetail()})
674+ server := &wire.BrokerServerEnvelope{}
675+ require.NoError(t, wire.ScanEnvelope(server, rawErr))
676+ require.NoError(t, proto.Unmarshal(rawErr, server))
677+ server.GetBrokerErrorMessage().GetError().Code = 99
678+ mutated, err = proto.Marshal(server)
679+ require.NoError(t, err)
680+ _, err = DecodeServer(mutated, testEnvelopeCeiling, testChunkCeiling)
681+ require.Error(t, err)
682+ })
683+ t.Run("bidi and control text refused", func(t *testing.T) {
684+ evil := testErrorDetail()
685+ evil.Text = "timeout\u202erorrE"
686+ _, err := EncodeServer(BrokerErrorMessage{Epoch: 7, Connection: connection, Error: evil}, testEnvelopeCeiling, testChunkCeiling)
687+ require.ErrorIs(t, err, ErrInvalidMessage)
688+ evil.Text = "bad\x01text"
689+ _, err = EncodeServer(BrokerErrorMessage{Epoch: 7, Connection: connection, Error: evil}, testEnvelopeCeiling, testChunkCeiling)
690+ require.ErrorIs(t, err, ErrInvalidMessage)
691+ _, err = EncodeServer(Shutdown{Epoch: 7, Connection: connection, Reason: ShutdownIdleExit, Text: "x\x1b"}, testEnvelopeCeiling, testChunkCeiling)
692+ require.ErrorIs(t, err, ErrInvalidMessage)
693+ })
694+ t.Run("env without equals refused", func(t *testing.T) {
695+ _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"NOEQUALS"}, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
696+ require.ErrorIs(t, err, ErrInvalidMessage)
697+ })
698+ t.Run("invalid policy refused", func(t *testing.T) {
699+ _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: ports.BrokerPolicy{}}, testEnvelopeCeiling, testChunkCeiling)
700+ require.ErrorIs(t, err, ErrInvalidMessage)
701+ })
702+ t.Run("snapshot revision zero refused", func(t *testing.T) {
703+ _, err := EncodeServer(SnapshotPart{Epoch: 7, Connection: connection, Generation: 1, Index: 0, Part: SnapshotEnd{}}, testEnvelopeCeiling, testChunkCeiling)
704+ require.ErrorIs(t, err, ErrInvalidMessage)
705+ })
706+ t.Run("missing snapshot part refused", func(t *testing.T) {
707+ _, err := EncodeServer(SnapshotPart{Epoch: 7, Connection: connection, Generation: 1, Revision: 1}, testEnvelopeCeiling, testChunkCeiling)
708+ require.ErrorIs(t, err, ErrInvalidMessage)
709+ })
710+ t.Run("local open with registration refused", func(t *testing.T) {
711+ _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Local: true, Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
712+ require.ErrorIs(t, err, ErrInvalidMessage)
713+ })
714+ t.Run("bad shutdown reason refused", func(t *testing.T) {
715+ _, err := EncodeServer(Shutdown{Epoch: 7, Connection: connection, Reason: ShutdownReason(9)}, testEnvelopeCeiling, testChunkCeiling)
716+ require.ErrorIs(t, err, ErrInvalidMessage)
717+ })
718+ t.Run("bad progress phase refused", func(t *testing.T) {
719+ _, err := EncodeServer(Progress{Epoch: 7, Connection: connection, Stream: 3, Phase: BrokerProgressPhase(9)}, testEnvelopeCeiling, testChunkCeiling)
720+ require.ErrorIs(t, err, ErrInvalidMessage)
721+ })
722+ t.Run("nil messages refused", func(t *testing.T) {
723+ var clientNil *Register
724+ _, err := EncodeClient(clientNil, testEnvelopeCeiling, testChunkCeiling)
725+ require.ErrorIs(t, err, ErrInvalidMessage)
726+ var serverNil *Registered
727+ _, err = EncodeServer(serverNil, testEnvelopeCeiling, testChunkCeiling)
728+ require.ErrorIs(t, err, ErrInvalidMessage)
729+ })
730+}
731+
732+// TestBrokerTruncatedAndTrailing proves truncated prefixes and trailing
733+// garbage are rejected on both directions.
734+func TestBrokerTruncatedAndTrailing(t *testing.T) {
735+ raw := mustEncodeClient(t, Subscribe{Epoch: 7, Connection: testConnectionID(1), Generation: 3})
736+ for size := range len(raw) {
737+ _, err := DecodeClient(raw[:size], testEnvelopeCeiling, testChunkCeiling)
738+ require.Error(t, err, "client prefix[:%d] accepted", size)
739+ }
740+ require.Error(t, func() error {
741+ _, err := DecodeClient(append(append([]byte(nil), raw...), 0xFF), testEnvelopeCeiling, testChunkCeiling)
742+ return err
743+ }())
744+
745+ serverRaw := mustEncodeServer(t, Registered{Epoch: 7, Connection: testConnectionID(1)})
746+ for size := range len(serverRaw) {
747+ _, err := DecodeServer(serverRaw[:size], testEnvelopeCeiling, testChunkCeiling)
748+ require.Error(t, err, "server prefix[:%d] accepted", size)
749+ }
750+ concatenated := append(append([]byte(nil), serverRaw...), serverRaw...)
751+ _, err := DecodeServer(concatenated, testEnvelopeCeiling, testChunkCeiling)
752+ require.Error(t, err)
753+}
754+
755+// TestBrokerWrongDirection proves wrong-direction decode is rejected:
756+// client bytes never decode as server output and vice versa.
757+func TestBrokerWrongDirection(t *testing.T) {
758+ clientRaw := mustEncodeClient(t, Register{})
759+ _, err := DecodeServer(clientRaw, testEnvelopeCeiling, testChunkCeiling)
760+ require.Error(t, err)
761+
762+ serverRaw := mustEncodeServer(t, Registered{Epoch: 7, Connection: testConnectionID(1)})
763+ _, err = DecodeClient(serverRaw, testEnvelopeCeiling, testChunkCeiling)
764+ require.Error(t, err)
765+
766+ // Go-typed misuse fails with ErrWrongDirection at encode time: unknown
767+ // concrete types presented to either directional codec fail closed.
768+ _, err = encodeClientEnvelope(unknownClientMessage{}, testChunkCeiling)
769+ require.ErrorIs(t, err, ErrWrongDirection)
770+ _, err = encodeServerEnvelope(unknownServerMessage{}, testChunkCeiling)
771+ require.ErrorIs(t, err, ErrWrongDirection)
772+
773+ // Nil-payload envelopes decode as wrong-direction.
774+ _, err = decodeClientEnvelope(&wire.BrokerClientEnvelope{}, testChunkCeiling)
775+ require.ErrorIs(t, err, ErrWrongDirection)
776+ _, err = decodeServerEnvelope(&wire.BrokerServerEnvelope{}, testChunkCeiling)
777+ require.ErrorIs(t, err, ErrWrongDirection)
778+}
779+
780+// TestBrokerCorruptInputSmoke feeds representative corruptions through both
781+// decoders as a smoke test: bit flips, truncation, unknown fields, and
782+// direction swaps must never panic and must never be accepted silently.
783+func TestBrokerCorruptInputSmoke(t *testing.T) {
784+ seeds := [][]byte{
785+ mustEncodeClient(t, Register{}),
786+ mustEncodeClient(t, OpenStream{Epoch: 7, Connection: testConnectionID(2), Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}),
787+ mustEncodeServer(t, SnapshotPart{Epoch: 7, Connection: testConnectionID(2), Generation: 1, Revision: 1, Index: 0, Part: SnapshotEnd{}}),
788+ mustEncodeServer(t, Shutdown{Epoch: 7, Connection: testConnectionID(2), Reason: ShutdownIdleExit}),
789+ }
790+ for _, seed := range seeds {
791+ mutated := append([]byte(nil), seed...)
792+ if len(mutated) > 2 {
793+ mutated[len(mutated)-1] ^= 0xFF
794+ mutated[len(mutated)-2] ^= 0x01
795+ }
796+ _, _ = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
797+ _, _ = DecodeServer(mutated, testEnvelopeCeiling, testChunkCeiling)
798+ unknown := protowire.AppendTag(append([]byte(nil), seed...), 199, protowire.VarintType)
799+ unknown = protowire.AppendVarint(unknown, 1)
800+ _, _ = DecodeClient(unknown, testEnvelopeCeiling, testChunkCeiling)
801+ _, _ = DecodeServer(unknown, testEnvelopeCeiling, testChunkCeiling)
802+ }
803+ require.NotEmpty(t, seeds)
804+}
805+
806+// fuzzClientSeeds and fuzzServerSeeds are the shared strict-scan seed corpus:
807+// valid brokered messages plus vectors that exercise ScanEnvelope rejection
808+// (unknown tag, wrong wire type, truncation, empty, and trailing bytes).
809+func fuzzClientSeeds(t testing.TB) [][]byte {
810+ t.Helper()
811+ seeds := [][]byte{}
812+ for _, message := range []ClientMessage{
813+ Register{},
814+ Subscribe{Epoch: 7, Connection: testConnectionID(0x11), Generation: 1},
815+ OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()},
816+ ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")},
817+ CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3},
818+ } {
819+ raw, err := EncodeClient(message, testEnvelopeCeiling, testChunkCeiling)
820+ require.NoError(t, err)
821+ seeds = append(seeds, raw)
822+ }
823+ return appendStrictScanSeeds(seeds)
824+}
825+
826+func fuzzServerSeeds(t testing.TB) [][]byte {
827+ t.Helper()
828+ seeds := [][]byte{}
829+ for _, message := range []ServerMessage{
830+ Registered{Epoch: 7, Connection: testConnectionID(0x11)},
831+ SnapshotPart{Epoch: 7, Connection: testConnectionID(0x11), Generation: 2, Revision: 5, Index: 0, Part: SnapshotEnd{}},
832+ SnapshotPart{Epoch: 7, Connection: testConnectionID(0x11), Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: testCatalogSession()}},
833+ BrokerErrorMessage{Epoch: 7, Connection: testConnectionID(0x11), Error: testErrorDetail()},
834+ Shutdown{Epoch: 7, Connection: testConnectionID(0x11), Reason: ShutdownTerminating},
835+ } {
836+ raw, err := EncodeServer(message, testEnvelopeCeiling, testChunkCeiling)
837+ require.NoError(t, err)
838+ seeds = append(seeds, raw)
839+ }
840+ return appendStrictScanSeeds(seeds)
841+}
842+
843+// appendStrictScanSeeds adds raw vectors that must be refused by the strict
844+// scanner rather than reaching generated unmarshal.
845+func appendStrictScanSeeds(seeds [][]byte) [][]byte {
846+ unknown := protowire.AppendTag(nil, 199, protowire.VarintType)
847+ unknown = protowire.AppendVarint(unknown, 1)
848+ wrongType := protowire.AppendTag(nil, 101, protowire.VarintType)
849+ wrongType = protowire.AppendVarint(wrongType, 1)
850+ return append(seeds, unknown, wrongType, []byte{0xFF}, []byte{0x80}, nil)
851+}
852+
853+// FuzzDecodeClient proves DecodeClient never panics on arbitrary input.
854+func FuzzDecodeClient(f *testing.F) {
855+ for _, seed := range fuzzClientSeeds(f) {
856+ f.Add(seed)
857+ }
858+ f.Fuzz(func(t *testing.T, payload []byte) {
859+ _, _ = DecodeClient(payload, testEnvelopeCeiling, testChunkCeiling)
860+ })
861+}
862+
863+// FuzzDecodeServer proves DecodeServer never panics on arbitrary input.
864+func FuzzDecodeServer(f *testing.F) {
865+ for _, seed := range fuzzServerSeeds(f) {
866+ f.Add(seed)
867+ }
868+ f.Fuzz(func(t *testing.T, payload []byte) {
869+ _, _ = DecodeServer(payload, testEnvelopeCeiling, testChunkCeiling)
870+ })
871+}
A internal/adapters/brokerwire/convert.go +476 -0
@@ -0,0 +1,476 @@
1+package brokerwire
2+
3+// Shared semantic converters (P3.1).
4+//
5+// Conversion mirrors the port, domain, and catalogue types losslessly and
6+// validates every narrowing numeric cast before it happens. Wire uint32
7+// taxonomy fields never truncate into a smaller semantic enum: values
8+// whose high bits would alias a legitimate code are refused with
9+// errConvertRange before any cast. Identity lengths are exact (16-byte
10+// connection/operation/incarnation/lifecycle values); text fields enforce
11+// UTF-8, control/bidi, and byte bounds.
12+
13+import (
14+ "math"
15+ "strings"
16+ "unicode"
17+ "unicode/utf8"
18+
19+ "github.com/bnema/vev/internal/domain"
20+ "github.com/bnema/vev/internal/ports"
21+ "github.com/bnema/vev/internal/protocol"
22+ "github.com/bnema/vev/internal/protocol/catalogue"
23+ "github.com/bnema/vev/internal/protocol/wire"
24+)
25+
26+// brokerEnum8 narrows one wire uint32 into an 8-bit semantic enum. Values
27+// whose high bits would otherwise truncate into a valid enum range are
28+// refused before any cast.
29+func brokerEnum8[T ~uint8](value uint32) (T, error) {
30+ if value > math.MaxUint8 {
31+ return 0, errConvertRange
32+ }
33+ return T(value), nil
34+}
35+
36+// brokerEnum16 narrows one wire uint32 into a 16-bit semantic value.
37+func brokerEnum16[T ~uint16](value uint32) (T, error) {
38+ if value > math.MaxUint16 {
39+ return 0, errConvertRange
40+ }
41+ return T(value), nil
42+}
43+
44+func scopeToWire(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID) *wire.BrokerScope {
45+ id := append([]byte(nil), connection[:]...)
46+ return &wire.BrokerScope{BrokerEpoch: uint64(epoch), ConnectionId: id}
47+}
48+
49+func scopeFromWire(message *wire.BrokerScope) (ports.BrokerEpoch, ports.BrokerConnectionID, error) {
50+ var connection ports.BrokerConnectionID
51+ if message == nil {
52+ return 0, connection, errConvertRange
53+ }
54+ if message.GetBrokerEpoch() == 0 {
55+ return 0, connection, errConvertRange
56+ }
57+ raw := message.GetConnectionId()
58+ if len(raw) != len(connection) {
59+ return 0, connection, errConvertRange
60+ }
61+ copy(connection[:], raw)
62+ if err := connection.Validate(); err != nil {
63+ return 0, ports.BrokerConnectionID{}, errConvertRange
64+ }
65+ return ports.BrokerEpoch(message.GetBrokerEpoch()), connection, nil
66+}
67+
68+func refToWire(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID, stream ports.BrokerStreamID) *wire.BrokerStreamRef {
69+ return &wire.BrokerStreamRef{Scope: scopeToWire(epoch, connection), StreamId: uint64(stream)}
70+}
71+
72+func refFromWire(message *wire.BrokerStreamRef) (ports.BrokerEpoch, ports.BrokerConnectionID, ports.BrokerStreamID, error) {
73+ var connection ports.BrokerConnectionID
74+ if message == nil {
75+ return 0, connection, 0, errConvertRange
76+ }
77+ epoch, conn, err := scopeFromWire(message.GetScope())
78+ if err != nil {
79+ return 0, connection, 0, err
80+ }
81+ stream := ports.BrokerStreamID(message.GetStreamId())
82+ if err := stream.Validate(); err != nil {
83+ return 0, connection, 0, errConvertRange
84+ }
85+ return epoch, conn, stream, nil
86+}
87+
88+func operationToWire(id ports.BrokerOperationID) []byte {
89+ return append([]byte(nil), id[:]...)
90+}
91+
92+func operationFromWire(raw []byte) (ports.BrokerOperationID, error) {
93+ var id ports.BrokerOperationID
94+ if len(raw) != len(id) {
95+ return id, errConvertRange
96+ }
97+ copy(id[:], raw)
98+ if err := id.Validate(); err != nil {
99+ return ports.BrokerOperationID{}, errConvertRange
100+ }
101+ return id, nil
102+}
103+
104+func registrationToWire(registration domain.RemoteRegistration) *wire.RemoteRegistration {
105+ incarnation := append([]byte(nil), registration.Incarnation[:]...)
106+ return &wire.RemoteRegistration{
107+ Endpoint: registration.Endpoint,
108+ Incarnation: incarnation,
109+ Generation: uint64(registration.Generation),
110+ }
111+}
112+
113+func registrationFromWire(message *wire.RemoteRegistration) (domain.RemoteRegistration, error) {
114+ var registration domain.RemoteRegistration
115+ if message == nil {
116+ return registration, errConvertRange
117+ }
118+ registration.Endpoint = message.GetEndpoint()
119+ if err := domain.ValidateRemoteHostTarget(registration.Endpoint); err != nil {
120+ return domain.RemoteRegistration{}, errConvertRange
121+ }
122+ raw := message.GetIncarnation()
123+ if len(raw) != len(registration.Incarnation) {
124+ return domain.RemoteRegistration{}, errConvertRange
125+ }
126+ copy(registration.Incarnation[:], raw)
127+ registration.Generation = domain.RemoteGeneration(message.GetGeneration())
128+ if err := registration.Validate(); err != nil {
129+ return domain.RemoteRegistration{}, errConvertRange
130+ }
131+ return registration, nil
132+}
133+
134+func exactTargetToWire(target protocol.ExactSessionTarget) *wire.ExactTarget {
135+ lifecycle := append([]byte(nil), target.LifecycleID[:]...)
136+ return &wire.ExactTarget{
137+ LifecycleId: &wire.LifecycleID{Value: lifecycle},
138+ SessionName: target.SessionName,
139+ }
140+}
141+
142+func exactTargetFromWire(message *wire.ExactTarget) (protocol.ExactSessionTarget, error) {
143+ var target protocol.ExactSessionTarget
144+ if message == nil {
145+ return target, errConvertRange
146+ }
147+ lifecycle := message.GetLifecycleId()
148+ if lifecycle == nil || len(lifecycle.GetValue()) != len(target.LifecycleID) {
149+ return protocol.ExactSessionTarget{}, errConvertRange
150+ }
151+ copy(target.LifecycleID[:], lifecycle.GetValue())
152+ target.SessionName = message.GetSessionName()
153+ if err := target.Validate(); err != nil {
154+ return protocol.ExactSessionTarget{}, errConvertRange
155+ }
156+ return target, nil
157+}
158+
159+// validateBrokerEnvEntry enforces the per-request environment contract:
160+// valid UTF-8, no control/bidi characters, bounded bytes. The broker never
161+// merges these into its own process environment.
162+func validateBrokerEnvEntry(entry string) error {
163+ if len(entry) > ports.BrokerMaxEnvEntryBytes {
164+ return ErrTooLarge
165+ }
166+ if !utf8.ValidString(entry) {
167+ return ErrInvalidMessage
168+ }
169+ for _, r := range entry {
170+ if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || r == '\u2028' || r == '\u2029' {
171+ return ErrInvalidMessage
172+ }
173+ }
174+ return nil
175+}
176+
177+func policyToWire(policy ports.BrokerPolicy) *wire.BrokerWirePolicy {
178+ return &wire.BrokerWirePolicy{
179+ ProtocolVersion: uint32(policy.ProtocolVersion),
180+ CatalogueVersion: uint32(policy.CatalogSchemaVersion),
181+ EnvironmentPolicy: uint32(policy.EnvironmentPolicy),
182+ Transport: policy.Transport,
183+ Trust: policy.Trust,
184+ Launch: policy.Launch,
185+ Isolation: policy.Isolation,
186+ }
187+}
188+
189+func policyFromWire(message *wire.BrokerWirePolicy) (ports.BrokerPolicy, error) {
190+ var policy ports.BrokerPolicy
191+ if message == nil {
192+ return policy, errConvertRange
193+ }
194+ protocolVersion, err := brokerEnum16[uint16](message.GetProtocolVersion())
195+ if err != nil {
196+ return ports.BrokerPolicy{}, err
197+ }
198+ catalogueVersion, err := brokerEnum16[uint16](message.GetCatalogueVersion())
199+ if err != nil {
200+ return ports.BrokerPolicy{}, err
201+ }
202+ env, err := brokerEnum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy())
203+ if err != nil {
204+ return ports.BrokerPolicy{}, err
205+ }
206+ policy = ports.BrokerPolicy{
207+ ProtocolVersion: protocolVersion,
208+ CatalogSchemaVersion: catalogueVersion,
209+ EnvironmentPolicy: env,
210+ Transport: message.GetTransport(),
211+ Trust: message.GetTrust(),
212+ Launch: message.GetLaunch(),
213+ Isolation: message.GetIsolation(),
214+ }
215+ if err := policy.Validate(); err != nil {
216+ return ports.BrokerPolicy{}, ErrInvalidMessage
217+ }
218+ return policy, nil
219+}
220+
221+// validateBrokerDisplayText enforces bounded, presentation-safe text:
222+// valid UTF-8, no control/bidi/line-separator characters, within maxBytes.
223+func validateBrokerDisplayText(value string, maxBytes int) error {
224+ if len(value) > maxBytes {
225+ return ErrTooLarge
226+ }
227+ if !utf8.ValidString(value) {
228+ return ErrInvalidMessage
229+ }
230+ for _, r := range value {
231+ if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || r == '\u2028' || r == '\u2029' {
232+ return ErrInvalidMessage
233+ }
234+ }
235+ return nil
236+}
237+
238+// validateBrokerEndpoint enforces the SSH target contract shared by
239+// AddHost/RemoveHost/OpenStream/snapshot hosts.
240+func validateBrokerEndpoint(endpoint string) error {
241+ if err := domain.ValidateRemoteHostTarget(endpoint); err != nil {
242+ return ErrInvalidMessage
243+ }
244+ return nil
245+}
246+
247+// validateBrokerOrigin enforces presentation-only display-origin text. It
248+// may be empty (the endpoint renders instead) but never carries control
249+// or bidi characters.
250+func validateBrokerOrigin(origin string) error {
251+ if origin == "" {
252+ return nil
253+ }
254+ if err := domain.ValidateRemoteDisplayOrigin(origin); err != nil {
255+ return ErrInvalidMessage
256+ }
257+ return nil
258+}
259+
260+func errorDetailToWire(detail ErrorDetail) *wire.BrokerErrorDetail {
261+ return &wire.BrokerErrorDetail{
262+ Code: uint32(detail.Code),
263+ Text: detail.Text,
264+ AdmissionCode: detail.AdmissionCode,
265+ FailureKind: uint32(detail.FailureKind),
266+ }
267+}
268+
269+func errorDetailFromWire(message *wire.BrokerErrorDetail) (ErrorDetail, error) {
270+ var detail ErrorDetail
271+ if message == nil {
272+ return detail, errConvertRange
273+ }
274+ code, err := brokerEnum8[ports.BrokerErrorCode](message.GetCode())
275+ if err != nil {
276+ return ErrorDetail{}, err
277+ }
278+ if err := code.Validate(); err != nil {
279+ return ErrorDetail{}, ErrInvalidMessage
280+ }
281+ detail.Code = code
282+ if err := validateBrokerDisplayText(message.GetText(), ports.BrokerMaxErrorBytes); err != nil {
283+ return ErrorDetail{}, err
284+ }
285+ detail.Text = message.GetText()
286+ admission := message.GetAdmissionCode()
287+ if admission > MaxBrokerAdmissionCode {
288+ return ErrorDetail{}, errConvertRange
289+ }
290+ detail.AdmissionCode = admission
291+ kind, err := brokerEnum8[domain.RemoteFailureKind](message.GetFailureKind())
292+ if err != nil {
293+ return ErrorDetail{}, err
294+ }
295+ if kind > MaxBrokerFailureKind {
296+ return ErrorDetail{}, ErrInvalidMessage
297+ }
298+ detail.FailureKind = kind
299+ failure := ports.BrokerError{Code: code, Text: detail.Text}
300+ if err := failure.Validate(); err != nil {
301+ return ErrorDetail{}, ErrInvalidMessage
302+ }
303+ return detail, nil
304+}
305+
306+func catalogTabToWire(tab catalogue.RemoteCatalogTab) *wire.BrokerCatalogTab {
307+ return &wire.BrokerCatalogTab{
308+ Id: tab.ID,
309+ Index: uint32(tab.Index),
310+ Name: tab.Name,
311+ Detail: tab.Detail,
312+ Attention: tab.Attention,
313+ }
314+}
315+
316+func catalogTabFromWire(message *wire.BrokerCatalogTab) (catalogue.RemoteCatalogTab, error) {
317+ var tab catalogue.RemoteCatalogTab
318+ if message == nil {
319+ return tab, errConvertRange
320+ }
321+ if len(message.GetId()) > catalogue.RemoteCatalogMaxIDBytes ||
322+ len(message.GetName()) > catalogue.RemoteCatalogMaxNameBytes ||
323+ len(message.GetDetail()) > catalogue.RemoteCatalogMaxDetailBytes {
324+ return catalogue.RemoteCatalogTab{}, ErrTooLarge
325+ }
326+ index := message.GetIndex()
327+ if index >= catalogue.RemoteCatalogMaxTabsPerSess {
328+ return catalogue.RemoteCatalogTab{}, errConvertRange
329+ }
330+ tab = catalogue.RemoteCatalogTab{
331+ ID: message.GetId(),
332+ Index: uint16(index),
333+ Name: message.GetName(),
334+ Detail: message.GetDetail(),
335+ Attention: message.GetAttention(),
336+ }
337+ return tab, nil
338+}
339+
340+func catalogSessionToWire(session catalogue.RemoteCatalogSession) *wire.BrokerCatalogSession {
341+ lifecycle := append([]byte(nil), session.LifecycleID[:]...)
342+ var state uint32
343+ switch session.State {
344+ case catalogue.RemoteCatalogSessionUp:
345+ state = 1
346+ case catalogue.RemoteCatalogSessionDown:
347+ state = 2
348+ case catalogue.RemoteCatalogSessionBroken:
349+ state = 3
350+ }
351+ tabs := make([]*wire.BrokerCatalogTab, 0, len(session.Tabs))
352+ for _, tab := range session.Tabs {
353+ tabs = append(tabs, catalogTabToWire(tab))
354+ }
355+ out := &wire.BrokerCatalogSession{
356+ LifecycleId: lifecycle,
357+ Name: session.Name,
358+ State: state,
359+ Ephemeral: session.Ephemeral,
360+ Attached: session.Attached,
361+ LastUsedSeq: session.LastUsedSeq,
362+ ActiveTabId: session.ActiveTabID,
363+ Reason: session.Reason,
364+ }
365+ if len(session.Tabs) > 0 || session.Tabs != nil {
366+ out.Tabs = tabs
367+ }
368+ return out
369+}
370+
371+func catalogSessionFromWire(message *wire.BrokerCatalogSession) (catalogue.RemoteCatalogSession, error) {
372+ var session catalogue.RemoteCatalogSession
373+ if message == nil {
374+ return session, errConvertRange
375+ }
376+ raw := message.GetLifecycleId()
377+ if len(raw) != len(session.LifecycleID) {
378+ return catalogue.RemoteCatalogSession{}, errConvertRange
379+ }
380+ copy(session.LifecycleID[:], raw)
381+ var state catalogue.RemoteCatalogSessionState
382+ switch message.GetState() {
383+ case 1:
384+ state = catalogue.RemoteCatalogSessionUp
385+ case 2:
386+ state = catalogue.RemoteCatalogSessionDown
387+ case 3:
388+ state = catalogue.RemoteCatalogSessionBroken
389+ default:
390+ return catalogue.RemoteCatalogSession{}, errConvertRange
391+ }
392+ session.State = state
393+ session.Name = message.GetName()
394+ session.Ephemeral = message.GetEphemeral()
395+ session.Attached = message.GetAttached()
396+ session.LastUsedSeq = message.GetLastUsedSeq()
397+ session.ActiveTabID = message.GetActiveTabId()
398+ session.Reason = message.GetReason()
399+ wireTabs := message.GetTabs()
400+ // Proto3 repeated fields carry no presence: an absent or empty tab list
401+ // both decode as nil, and both mean an empty-present list. The catalogue
402+ // requires a present tabs slice, so a nil field becomes the empty list
403+ // rather than a conversion failure.
404+ if len(wireTabs) > catalogue.RemoteCatalogMaxTabsPerSess {
405+ return catalogue.RemoteCatalogSession{}, ErrTooLarge
406+ }
407+ session.Tabs = make([]catalogue.RemoteCatalogTab, 0, len(wireTabs))
408+ for i, wireTab := range wireTabs {
409+ tab, err := catalogTabFromWire(wireTab)
410+ if err != nil {
411+ return catalogue.RemoteCatalogSession{}, err
412+ }
413+ if uint32(tab.Index) != uint32(i) {
414+ return catalogue.RemoteCatalogSession{}, ErrInvalidMessage
415+ }
416+ session.Tabs = append(session.Tabs, tab)
417+ }
418+ if err := validateCatalogSession(session); err != nil {
419+ return catalogue.RemoteCatalogSession{}, err
420+ }
421+ return session, nil
422+}
423+
424+// validateCatalogSession enforces the exact catalogue contract of one session
425+// so both encode and decode refuse a session that could not be published.
426+func validateCatalogSession(session catalogue.RemoteCatalogSession) error {
427+ catalog := catalogue.RemoteCatalog{
428+ ProtocolVersion: protocol.Version,
429+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
430+ Sessions: []catalogue.RemoteCatalogSession{session},
431+ }
432+ if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
433+ return ErrInvalidMessage
434+ }
435+ return nil
436+}
437+
438+// validateSnapshotHostBounds enforces the per-host snapshot contract:
439+// endpoint grammar, presentation origin, rank fit, closed availability and
440+// failure taxonomies, and time values that survive the wire exactly.
441+func validateSnapshotHostFields(host ports.RemoteHostSnapshot) error {
442+ if err := validateBrokerEndpoint(host.Endpoint); err != nil {
443+ return err
444+ }
445+ if err := validateBrokerOrigin(host.DisplayOrigin); err != nil {
446+ return err
447+ }
448+ if host.Rank < 0 || host.Rank > math.MaxUint32 {
449+ return errConvertRange
450+ }
451+ if host.Availability < domain.RemoteAvailabilityUnknown ||
452+ host.Availability > domain.RemoteAvailabilityInvalidResponse {
453+ return ErrInvalidMessage
454+ }
455+ if host.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
456+ return ErrInvalidMessage
457+ }
458+ return nil
459+}
460+
461+// checkEnvEntries enforces the per-request environment bound: at most
462+// BrokerMaxEnvEntries entries, each valid and bounded.
463+func checkEnvEntries(env []string) error {
464+ if len(env) > ports.BrokerMaxEnvEntries {
465+ return ErrTooLarge
466+ }
467+ for _, entry := range env {
468+ if err := validateBrokerEnvEntry(entry); err != nil {
469+ return err
470+ }
471+ if !strings.Contains(entry, "=") {
472+ return ErrInvalidMessage
473+ }
474+ }
475+ return nil
476+}
A internal/adapters/brokerwire/doc.go +17 -0
@@ -0,0 +1,17 @@
1+// Package brokerwire adapts typed broker messages to Protobuf envelopes.
2+//
3+// The broker is a separate conversation from the session protocol: client
4+// tags 101-110 and server tags 201-209 are disjoint from every session tag,
5+// and each direction is its own closed oneof union. Every connection starts
6+// with the bounded broker preamble (roles 3/4); magic, epoch, exact
7+// protocol version, and negotiated limits are validated before application
8+// messages.
9+//
10+// This package is stateful only where P3.1 requires it: connection state,
11+// multipart snapshot assembly, operation tracking, and stream lifecycle
12+// live in snapshot.go and state.go. EncodeClient/DecodeClient and
13+// EncodeServer/DecodeServer remain stateless, converting one complete
14+// serialized envelope at a time using strict wire.ScanEnvelope before
15+// generated unmarshal. There is no socket, framing pump, or P3.2 transport
16+// here.
17+package brokerwire
A internal/adapters/brokerwire/limits.go +103 -0
@@ -0,0 +1,103 @@
1+package brokerwire
2+
3+// Stateless bound refusals (P3.1).
4+//
5+// The broker preamble negotiates one immutable pair of ceilings per
6+// connection: maxReceiveEnvelopeBytes bounds every envelope this side
7+// accepts, and StreamChunkLimit bounds one opaque ClientStreamData or
8+// ServerStreamData frame. Envelope ceilings are fixed: ordinary envelopes
9+// never exceed the advertised floor, and the preamble advertisement itself
10+// rides inside BrokerPreambleLimit.
11+//
12+// The broker has no Output/image/picker bulk category: every application
13+// envelope is control-class and shares one ceiling. Stream chunk payloads
14+// are bounded separately so hostile lengths never allocate.
15+
16+import (
17+ "github.com/bnema/vev/internal/protocol/wire"
18+)
19+
20+const (
21+ // BrokerRoleClient is the broker preamble client role. It is disjoint
22+ // from the session preamble roles 1/2.
23+ BrokerRoleClient uint32 = 3
24+ // BrokerRoleServer is the broker preamble server role.
25+ BrokerRoleServer uint32 = 4
26+
27+ // BrokerPreambleLimit bounds the serialized broker preamble envelope.
28+ BrokerPreambleLimit = 4 << 10
29+
30+ // MinBrokerEnvelopeBytes is the advertised-envelope floor: a broker
31+ // peer must accept at least 1 MiB per envelope.
32+ MinBrokerEnvelopeBytes uint64 = 1 << 20
33+ // MaxBrokerEnvelopeBytes is the advertised-envelope ceiling: a broker
34+ // peer must never advertise above 16 MiB (wire.AbsoluteEnvelopeLimit).
35+ MaxBrokerEnvelopeBytes uint64 = wire.AbsoluteEnvelopeLimit
36+
37+ // MinStreamChunkBytes is the smallest legal stream chunk ceiling.
38+ MinStreamChunkBytes uint64 = 1
39+ // MaxStreamChunkBytes is the largest legal stream chunk ceiling:
40+ // 64 KiB per opaque stream frame.
41+ MaxStreamChunkBytes uint64 = 64 << 10
42+)
43+
44+// brokerCeilings are the immutable per-connection ceilings negotiated once
45+// by the broker preamble. StreamChunkLimit bounds one opaque stream data
46+// frame in either direction.
47+type brokerCeilings struct {
48+ maxReceiveEnvelopeBytes uint64
49+ streamChunkLimit uint64
50+}
51+
52+// defaultBrokerCeilings advertises the local broker receive policy: the
53+// full absolute envelope ceiling and the maximum stream chunk.
54+func defaultBrokerCeilings() brokerCeilings {
55+ return brokerCeilings{
56+ maxReceiveEnvelopeBytes: wire.AbsoluteEnvelopeLimit,
57+ streamChunkLimit: MaxStreamChunkBytes,
58+ }
59+}
60+
61+// effectiveBrokerCeilings takes the minima of both sides' advertisements.
62+func effectiveBrokerCeilings(local, remote brokerCeilings) brokerCeilings {
63+ return brokerCeilings{
64+ maxReceiveEnvelopeBytes: min(local.maxReceiveEnvelopeBytes, remote.maxReceiveEnvelopeBytes),
65+ streamChunkLimit: min(local.streamChunkLimit, remote.streamChunkLimit),
66+ }
67+}
68+
69+// checkBrokerCeilings refuses a zero, below-floor, or above-ceiling
70+// advertisement. Envelope bounds are the 1 MiB..16 MiB advertised window;
71+// chunk bounds are 1..64 KiB.
72+func checkBrokerCeilings(remote brokerCeilings) error {
73+ if remote.maxReceiveEnvelopeBytes < MinBrokerEnvelopeBytes ||
74+ remote.maxReceiveEnvelopeBytes > MaxBrokerEnvelopeBytes {
75+ return ErrPreambleRejected
76+ }
77+ if remote.streamChunkLimit < MinStreamChunkBytes ||
78+ remote.streamChunkLimit > MaxStreamChunkBytes {
79+ return ErrPreambleRejected
80+ }
81+ return nil
82+}
83+
84+// checkBrokerEnvelopeCeiling rejects one serialized broker envelope above
85+// the negotiated ceiling. All broker envelopes are control-class: the
86+// negotiated ceiling always applies in full.
87+func checkBrokerEnvelopeCeiling(payload []byte, ceiling uint64) error {
88+ if uint64(len(payload)) > ceiling {
89+ return wire.ErrScanLength
90+ }
91+ return nil
92+}
93+
94+// CheckPreambleSize refuses one serialized broker preamble above the 4 KiB
95+// bound before scanning or allocation. Callers apply it to an encoded
96+// preamble before sending and to a received payload before decode; the
97+// session contract applies the identical wire.PreambleLimit.
98+func CheckPreambleSize(payload []byte) error {
99+ if len(payload) > BrokerPreambleLimit {
100+ return ErrPreambleRejected
101+ }
102+ return nil
103+}
A internal/adapters/brokerwire/messages.go +404 -0
@@ -0,0 +1,404 @@
1+package brokerwire
2+
3+import (
4+ "errors"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/protocol/catalogue"
11+)
12+
13+var (
14+ // ErrWrongDirection reports a broker message presented to the wrong
15+ // directional codec. Direction is a closed oneof property: a client
16+ // envelope never decodes as server output and vice versa.
17+ ErrWrongDirection = errors.New("brokerwire: wrong message direction")
18+ // ErrInvalidMessage reports a broker message that fails semantic
19+ // validation: bad identity, text, taxonomy, bound, time, environment,
20+ // policy, chunk, or snapshot part.
21+ ErrInvalidMessage = errors.New("brokerwire: invalid message")
22+ // ErrTooLarge reports a stateless bound refusal established by limits.go:
23+ // an envelope, chunk, environment, policy token, error text, snapshot
24+ // part, or catalogue value above its ceiling.
25+ ErrTooLarge = errors.New("brokerwire: message exceeds bound")
26+ errConvertRange = errors.New("brokerwire: wire value out of semantic range")
27+)
28+
29+// ClientMessage is the closed set of client-to-broker messages. Every
30+// variant converts to exactly one BrokerClientEnvelope payload.
31+type ClientMessage interface{ brokerClientMessage() }
32+
33+// ServerMessage is the closed set of broker-to-client messages. Every
34+// variant converts to exactly one BrokerServerEnvelope payload.
35+type ServerMessage interface{ brokerServerMessage() }
36+
37+// Register opens one client connection to the broker.
38+type Register struct{}
39+
40+func (Register) brokerClientMessage() {}
41+
42+// Subscribe asks for snapshot and lifecycle publications for one scope.
43+type Subscribe struct {
44+ Epoch ports.BrokerEpoch
45+ Connection ports.BrokerConnectionID
46+ Generation SubscriptionGeneration
47+}
48+
49+func (Subscribe) brokerClientMessage() {}
50+
51+// Resync asks for a full snapshot publication after an epoch change or a
52+// gap in the client's subscription series.
53+type Resync struct {
54+ Epoch ports.BrokerEpoch
55+ Connection ports.BrokerConnectionID
56+ Generation SubscriptionGeneration
57+}
58+
59+func (Resync) brokerClientMessage() {}
60+
61+// Unsubscribe stops publications for one scope.
62+type Unsubscribe struct {
63+ Epoch ports.BrokerEpoch
64+ Connection ports.BrokerConnectionID
65+ Generation SubscriptionGeneration
66+}
67+
68+func (Unsubscribe) brokerClientMessage() {}
69+
70+// AddHost adds one configured host registration. Operation is the
71+// idempotency identity of this mutating operation.
72+type AddHost struct {
73+ Epoch ports.BrokerEpoch
74+ Connection ports.BrokerConnectionID
75+ Operation ports.BrokerOperationID
76+ Endpoint string
77+}
78+
79+func (AddHost) brokerClientMessage() {}
80+
81+// RemoveHost removes one configured host registration.
82+type RemoveHost struct {
83+ Epoch ports.BrokerEpoch
84+ Connection ports.BrokerConnectionID
85+ Operation ports.BrokerOperationID
86+ Endpoint string
87+}
88+
89+func (RemoveHost) brokerClientMessage() {}
90+
91+// Reconcile hands the broker one exact registration to probe.
92+type Reconcile struct {
93+ Epoch ports.BrokerEpoch
94+ Connection ports.BrokerConnectionID
95+ Registration domain.RemoteRegistration
96+}
97+
98+func (Reconcile) brokerClientMessage() {}
99+
100+// OpenStream asks the broker to open one independently cancellable logical
101+// stream to the owning daemon. Env is the per-request session environment
102+// and is never inherited from the broker process environment.
103+type OpenStream struct {
104+ Epoch ports.BrokerEpoch
105+ Connection ports.BrokerConnectionID
106+ Stream ports.BrokerStreamID
107+ Purpose ports.BrokerStreamPurpose
108+ Local bool
109+ Endpoint string
110+ Registration domain.RemoteRegistration
111+ Target protocol.ExactSessionTarget
112+ Env []string
113+ Policy ports.BrokerPolicy
114+}
115+
116+func (OpenStream) brokerClientMessage() {}
117+
118+// ClientStreamData carries one opaque client-to-daemon stream frame.
119+type ClientStreamData struct {
120+ Epoch ports.BrokerEpoch
121+ Connection ports.BrokerConnectionID
122+ Stream ports.BrokerStreamID
123+ Data []byte
124+}
125+
126+func (ClientStreamData) brokerClientMessage() {}
127+
128+// CloseStream closes one logical stream.
129+type CloseStream struct {
130+ Epoch ports.BrokerEpoch
131+ Connection ports.BrokerConnectionID
132+ Stream ports.BrokerStreamID
133+}
134+
135+func (CloseStream) brokerClientMessage() {}
136+
137+// Registered confirms one accepted client connection and its assigned scope.
138+type Registered struct {
139+ Epoch ports.BrokerEpoch
140+ Connection ports.BrokerConnectionID
141+}
142+
143+func (Registered) brokerServerMessage() {}
144+
145+// SnapshotPart is one fragment of a snapshot publication. Parts of one
146+// epoch generation and broker revision arrive in index order and carry
147+// exactly one nested payload.
148+type SnapshotPart struct {
149+ Epoch ports.BrokerEpoch
150+ Connection ports.BrokerConnectionID
151+ // Generation is the client's subscription series, echoed back so the
152+ // client can drop parts bound to a superseded subscription.
153+ Generation SubscriptionGeneration
154+ // Revision is the broker publication revision within the epoch.
155+ Revision ports.BrokerRevision
156+ Index uint32
157+ Part SnapshotPartPayload
158+}
159+
160+func (SnapshotPart) brokerServerMessage() {}
161+
162+// SnapshotPartPayload is the closed set of snapshot fragment payloads.
163+type SnapshotPartPayload interface{ snapshotPartPayload() }
164+
165+// SnapshotBegin opens a snapshot publication with its element counts.
166+type SnapshotBegin struct {
167+ HostCount uint32
168+ SessionCount uint32
169+ TombstoneCount uint32
170+}
171+
172+func (SnapshotBegin) snapshotPartPayload() {}
173+
174+// SnapshotHostPart is one host projection. Sessions travel as separate
175+// SnapshotSessionPart parts bound by host index; SessionCount advertises
176+// how many follow. The embedded Host must carry no inline sessions.
177+type SnapshotHostPart struct {
178+ HostIndex uint32
179+ Host ports.RemoteHostSnapshot
180+ SessionCount uint32
181+}
182+
183+func (SnapshotHostPart) snapshotPartPayload() {}
184+
185+// SnapshotSessionPart is one catalogue session bound to its host by index.
186+type SnapshotSessionPart struct {
187+ HostIndex uint32
188+ SessionIndex uint32
189+ Session catalogue.RemoteCatalogSession
190+}
191+
192+func (SnapshotSessionPart) snapshotPartPayload() {}
193+
194+// SnapshotTombstonePart fences one retired host registration so a stale
195+// probe cannot revive authority for that exact incarnation.
196+type SnapshotTombstonePart struct {
197+ TombstoneIndex uint32
198+ Registration domain.RemoteRegistration
199+ RetiredRevision ports.BrokerRevision
200+}
201+
202+func (SnapshotTombstonePart) snapshotPartPayload() {}
203+
204+// SnapshotEnd closes a snapshot publication.
205+type SnapshotEnd struct{}
206+
207+func (SnapshotEnd) snapshotPartPayload() {}
208+
209+// OperationResult is exactly one completion for one admitted mutating
210+// operation. Removed reports the host is gone; Error carries the typed
211+// failure when Outcome is failed or outcome-unknown. HasError=false means no
212+// detail at all: encode refuses a nonzero Error rather than dropping it.
213+type OperationResult struct {
214+ Epoch ports.BrokerEpoch
215+ Connection ports.BrokerConnectionID
216+ Operation ports.BrokerOperationID
217+ Outcome ports.BrokerMutationOutcome
218+ Removed bool
219+ Error ErrorDetail
220+ HasError bool
221+}
222+
223+func (OperationResult) brokerServerMessage() {}
224+
225+// StreamOpened confirms one logical stream is established.
226+type StreamOpened struct {
227+ Epoch ports.BrokerEpoch
228+ Connection ports.BrokerConnectionID
229+ Stream ports.BrokerStreamID
230+}
231+
232+func (StreamOpened) brokerServerMessage() {}
233+
234+// ServerStreamData carries one opaque daemon-to-client stream frame.
235+type ServerStreamData struct {
236+ Epoch ports.BrokerEpoch
237+ Connection ports.BrokerConnectionID
238+ Stream ports.BrokerStreamID
239+ Data []byte
240+}
241+
242+func (ServerStreamData) brokerServerMessage() {}
243+
244+// StreamClosed ends one logical stream. An absent error is an orderly
245+// close; a present error reports the typed failure that ended it.
246+// HasError=false means no detail at all: encode refuses a nonzero Error rather
247+// than dropping it.
248+type StreamClosed struct {
249+ Epoch ports.BrokerEpoch
250+ Connection ports.BrokerConnectionID
251+ Stream ports.BrokerStreamID
252+ Error ErrorDetail
253+ HasError bool
254+}
255+
256+func (StreamClosed) brokerServerMessage() {}
257+
258+// Progress reports one phase of a long-running broker operation.
259+type Progress struct {
260+ Epoch ports.BrokerEpoch
261+ Connection ports.BrokerConnectionID
262+ Stream ports.BrokerStreamID
263+ Phase BrokerProgressPhase
264+ Text string
265+}
266+
267+func (Progress) brokerServerMessage() {}
268+
269+// BrokerErrorMessage reports a broker-wide typed failure for one scope.
270+type BrokerErrorMessage struct {
271+ Epoch ports.BrokerEpoch
272+ Connection ports.BrokerConnectionID
273+ Error ErrorDetail
274+}
275+
276+func (BrokerErrorMessage) brokerServerMessage() {}
277+
278+// MaxBrokerAdmissionCode is the highest admission-refusal code carried on
279+// ErrorDetail. The taxonomy is closed: 0 is none, 1 limit, 2 closed, 3 stale
280+// connection or stream, 4 invalid request (mirroring
281+// ports.BrokerAdmissionError).
282+const MaxBrokerAdmissionCode uint32 = 4
283+
284+// MaxBrokerFailureKind bounds ErrorDetail.FailureKind to the domain
285+// transport-failure taxonomy. The zero value (RemoteFailureNone) is valid.
286+const MaxBrokerFailureKind = domain.RemoteFailureInvalidResponse
287+
288+// ErrorDetail is the typed broker failure carried to clients: a closed
289+// error code plus bounded display text, the admission refusal (if any),
290+// and the sanitized transport cause. The underlying diagnostic cause is
291+// local-only and never travels on this wire.
292+type ErrorDetail struct {
293+ Code ports.BrokerErrorCode
294+ Text string
295+ // AdmissionCode is the closed admission-refusal taxonomy: 0 is none,
296+ // 1 limit, 2 closed, 3 stale connection or stream, 4 invalid request.
297+ AdmissionCode uint32
298+ FailureKind domain.RemoteFailureKind
299+}
300+
301+// validate enforces the transport contract of one ErrorDetail: a closed
302+// error code, bounded presentation-safe text, an admission code within the
303+// closed refusal taxonomy, and a failure kind within the domain taxonomy.
304+func (d ErrorDetail) validate() error {
305+ if err := d.Code.Validate(); err != nil {
306+ return ErrInvalidMessage
307+ }
308+ if d.AdmissionCode > MaxBrokerAdmissionCode {
309+ return ErrInvalidMessage
310+ }
311+ if d.FailureKind > MaxBrokerFailureKind {
312+ return ErrInvalidMessage
313+ }
314+ return validateBrokerDisplayText(d.Text, ports.BrokerMaxErrorBytes)
315+}
316+
317+type ShutdownReason uint8
318+
319+const (
320+ // ShutdownUnspecified is the zero value: never sent on the wire.
321+ ShutdownUnspecified ShutdownReason = iota
322+ // ShutdownIdleExit reports the broker stopped for lack of work.
323+ ShutdownIdleExit
324+ // ShutdownTerminating reports the broker process is stopping.
325+ ShutdownTerminating
326+)
327+
328+func (r ShutdownReason) validate() error {
329+ switch r {
330+ case ShutdownIdleExit, ShutdownTerminating:
331+ return nil
332+ default:
333+ return errConvertRange
334+ }
335+}
336+
337+// Shutdown reports the broker process is stopping.
338+type Shutdown struct {
339+ Epoch ports.BrokerEpoch
340+ Connection ports.BrokerConnectionID
341+ Reason ShutdownReason
342+ Text string
343+}
344+
345+func (Shutdown) brokerServerMessage() {}
346+
347+// BrokerProgressPhase is the closed taxonomy for long-running operation
348+// progress reports carried on Progress.
349+type BrokerProgressPhase uint32
350+
351+const (
352+ // BrokerProgressStarted opens a long-running operation.
353+ BrokerProgressStarted BrokerProgressPhase = 1
354+ // BrokerProgressProbing reports host observation is in flight.
355+ BrokerProgressProbing BrokerProgressPhase = 2
356+ // BrokerProgressConnecting reports physical transport dialing.
357+ BrokerProgressConnecting BrokerProgressPhase = 3
358+ // BrokerProgressDone closes a long-running operation.
359+ BrokerProgressDone BrokerProgressPhase = 4
360+)
361+
362+func (p BrokerProgressPhase) validate() error {
363+ switch p {
364+ case BrokerProgressStarted, BrokerProgressProbing, BrokerProgressConnecting, BrokerProgressDone:
365+ return nil
366+ default:
367+ return errConvertRange
368+ }
369+}
370+
371+// SubscriptionGeneration identifies one client subscription series within a
372+// scope. The wire carries it as a bare uint64 on Subscribe/Resync/
373+// Unsubscribe and echoes it on every SnapshotPart; zero is valid.
374+type SubscriptionGeneration = uint64
375+
376+// brokerTimeToWire splits one instant into seconds and nanoseconds so
377+// time.Time survives the wire exactly. The zero time maps to the zero
378+// timestamp and back.
379+func brokerTimeToWire(t time.Time) (int64, int32) {
380+ if t.IsZero() {
381+ return 0, 0
382+ }
383+ return t.Unix(), int32(t.Nanosecond())
384+}
385+
386+// brokerTimeFromWire reassembles one timestamp. Out-of-range values fail:
387+// times must round-trip through time.Unix without overflow.
388+func brokerTimeFromWire(seconds int64, nanos int32) (time.Time, error) {
389+ if seconds == 0 && nanos == 0 {
390+ return time.Time{}, nil
391+ }
392+ if nanos < 0 || nanos > 999999999 {
393+ return time.Time{}, errConvertRange
394+ }
395+ // time.Unix panics only outside int64-derived ranges; seconds is
396+ // already int64, and the nanosecond field is bounded above, so this
397+ // cannot overflow on supported platforms beyond what time.Unix
398+ // itself rejects. Guard the documented extremes anyway.
399+ t := time.Unix(seconds, int64(nanos)).UTC()
400+ if t.Unix() != seconds {
401+ return time.Time{}, errConvertRange
402+ }
403+ return t, nil
404+}
A internal/adapters/brokerwire/preamble.go +212 -0
@@ -0,0 +1,212 @@
1+package brokerwire
2+
3+// Broker preamble (P3.1).
4+//
5+// The first client frame on a broker connection is always PreambleRequest;
6+// the first server frame is always PreambleResponse. The broker reuses the
7+// session preamble shapes with broker-specific roles: 3 names the broker
8+// client, 4 names the broker server. Magic must equal wire.PreambleMagic,
9+// epoch must equal wire.ProtocolEpoch, and version must exactly equal
10+// protocol.Version. Capability bits must be zero: the broker negotiates no
11+// optional capabilities in P3.1.
12+//
13+// The preamble negotiates immutable per-connection ceilings once: the
14+// advertised envelope ceiling (1 MiB..16 MiB) rides in
15+// max_receive_envelope_bytes, and the stream chunk ceiling (1..64 KiB)
16+// rides in output_data_limit. Exact version equality is mandatory.
17+//
18+// Refusals carry the precise PreambleRejectionCode taxonomy: 1 bad magic,
19+// 2 epoch mismatch, 3 version mismatch, 4 wrong role, 5 duplicate
20+// preamble, 7 limit refused. Code 6 is reserved for the connection
21+// dispatcher: out-of-order application frames are a framing-order failure
22+// that this stateless codec never observes. Nonzero capability bits have no
23+// dedicated code in P3.1 and are refused as code 7 (limit refused), since
24+// the broker negotiates no optional capabilities.
25+
26+import (
27+ "errors"
28+
29+ "github.com/bnema/vev/internal/protocol"
30+ "github.com/bnema/vev/internal/protocol/wire"
31+)
32+
33+// ErrPreambleRejected reports a broker preamble that fails validation:
34+// bad magic, epoch, version, role, capability bits, or limit
35+// advertisement. Callers map it to a precise rejection code with
36+// RejectionCodeFor.
37+var ErrPreambleRejected = errors.New("brokerwire: preamble rejected")
38+
39+// Preamble rejection codes. They mirror the session preamble refusal
40+// taxonomy carried on wire.PreambleRejectionCode.
41+const (
42+ // RejectionBadMagic reports a preamble magic mismatch.
43+ RejectionBadMagic uint32 = 1
44+ // RejectionEpochMismatch reports a protocol epoch mismatch.
45+ RejectionEpochMismatch uint32 = 2
46+ // RejectionVersionMismatch reports a session protocol version mismatch.
47+ RejectionVersionMismatch uint32 = 3
48+ // RejectionWrongRole reports a preamble role mismatch.
49+ RejectionWrongRole uint32 = 4
50+ // RejectionDuplicate reports a second preamble on one connection.
51+ RejectionDuplicate uint32 = 5
52+ // RejectionOutOfOrder is reserved for the connection dispatcher, which
53+ // owns framing-order detection. The stateless codec never emits it.
54+ RejectionOutOfOrder uint32 = 6
55+ // RejectionLimitRefused reports an unnegotiable limit advertisement. It
56+ // also covers nonzero capability bits: P3.1 negotiates no optional
57+ // capabilities, so any capability bit is an unnegotiable advertisement.
58+ RejectionLimitRefused uint32 = 7
59+)
60+
61+// BrokerPreambleRequest is the validated broker client preamble: the
62+// negotiated ceilings plus the raw wire message for stateless re-encode.
63+type BrokerPreambleRequest struct {
64+ Ceilings brokerCeilings
65+}
66+
67+// BrokerPreambleResponse is the validated broker server preamble.
68+type BrokerPreambleResponse struct {
69+ Ceilings brokerCeilings
70+ Accepted bool
71+ Code uint32
72+}
73+
74+// EncodePreambleRequest builds the local broker client preamble offer.
75+func EncodePreambleRequest(ceilings brokerCeilings) *wire.PreambleRequest {
76+ return &wire.PreambleRequest{
77+ Magic: wire.PreambleMagic,
78+ Epoch: wire.ProtocolEpoch,
79+ Version: uint32(protocol.Version),
80+ Role: &wire.PreambleRole{Role: BrokerRoleClient},
81+ MaxReceiveEnvelopeBytes: ceilings.maxReceiveEnvelopeBytes,
82+ OutputDataLimit: ceilings.streamChunkLimit,
83+ }
84+}
85+
86+// EncodePreambleResponse builds the broker server preamble answer:
87+// acceptance with the effective ceilings, or a typed refusal carrying the
88+// precise rejection code.
89+func EncodePreambleResponse(accepted bool, ceilings brokerCeilings, code uint32) *wire.PreambleResponse {
90+ response := &wire.PreambleResponse{
91+ Magic: wire.PreambleMagic,
92+ Epoch: wire.ProtocolEpoch,
93+ Version: uint32(protocol.Version),
94+ Role: &wire.PreambleRole{Role: BrokerRoleServer},
95+ MaxReceiveEnvelopeBytes: ceilings.maxReceiveEnvelopeBytes,
96+ OutputDataLimit: ceilings.streamChunkLimit,
97+ Accepted: accepted,
98+ }
99+ if !accepted {
100+ response.Rejection = &wire.PreambleRejectionCode{Code: code}
101+ }
102+ return response
103+}
104+
105+// DecodePreambleRequest validates one raw broker client preamble. Magic,
106+// epoch, version, role, and zero capability bits are checked before the
107+// limit advertisement; the refusal code is precise.
108+func DecodePreambleRequest(message *wire.PreambleRequest) (BrokerPreambleRequest, error) {
109+ if message == nil {
110+ return BrokerPreambleRequest{}, ErrPreambleRejected
111+ }
112+ if message.GetMagic() != wire.PreambleMagic {
113+ return BrokerPreambleRequest{}, ErrPreambleRejected
114+ }
115+ if message.GetEpoch() != wire.ProtocolEpoch {
116+ return BrokerPreambleRequest{}, ErrPreambleRejected
117+ }
118+ if message.GetVersion() != uint32(protocol.Version) {
119+ return BrokerPreambleRequest{}, ErrPreambleRejected
120+ }
121+ if message.GetRole().GetRole() != BrokerRoleClient {
122+ return BrokerPreambleRequest{}, ErrPreambleRejected
123+ }
124+ if message.GetCapabilityBits() != 0 {
125+ return BrokerPreambleRequest{}, ErrPreambleRejected
126+ }
127+ remote := brokerCeilings{
128+ maxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(),
129+ streamChunkLimit: message.GetOutputDataLimit(),
130+ }
131+ if err := checkBrokerCeilings(remote); err != nil {
132+ return BrokerPreambleRequest{}, err
133+ }
134+ return BrokerPreambleRequest{Ceilings: remote}, nil
135+}
136+
137+// DecodePreambleResponse validates one raw broker server preamble.
138+// Refusals fail with ErrPreambleRejected; acceptance additionally
139+// validates the server role and its limit advertisement.
140+func DecodePreambleResponse(message *wire.PreambleResponse) (BrokerPreambleResponse, error) {
141+ if message == nil {
142+ return BrokerPreambleResponse{}, ErrPreambleRejected
143+ }
144+ if message.GetMagic() != wire.PreambleMagic {
145+ return BrokerPreambleResponse{}, ErrPreambleRejected
146+ }
147+ if message.GetEpoch() != wire.ProtocolEpoch {
148+ return BrokerPreambleResponse{}, ErrPreambleRejected
149+ }
150+ if message.GetVersion() != uint32(protocol.Version) {
151+ return BrokerPreambleResponse{}, ErrPreambleRejected
152+ }
153+ if message.GetRole().GetRole() != BrokerRoleServer {
154+ return BrokerPreambleResponse{}, ErrPreambleRejected
155+ }
156+ if message.GetCapabilityBits() != 0 {
157+ return BrokerPreambleResponse{}, ErrPreambleRejected
158+ }
159+ if !message.GetAccepted() {
160+ return BrokerPreambleResponse{Code: message.GetRejection().GetCode()}, ErrPreambleRejected
161+ }
162+ remote := brokerCeilings{
163+ maxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(),
164+ streamChunkLimit: message.GetOutputDataLimit(),
165+ }
166+ if err := checkBrokerCeilings(remote); err != nil {
167+ return BrokerPreambleResponse{}, err
168+ }
169+ return BrokerPreambleResponse{Ceilings: remote, Accepted: true}, nil
170+}
171+
172+// RejectionCodeFor maps a preamble validation failure to its precise
173+// refusal code. Limit, magic, epoch, version, and role failures each carry
174+// their own code; nonzero capability bits are refused as
175+// RejectionLimitRefused because P3.1 negotiates no capabilities; unknown
176+// scan failures are limit refusals. Code 6 is never returned here: the
177+// dispatcher owns out-of-order framing.
178+func RejectionCodeFor(request *wire.PreambleRequest, err error) uint32 {
179+ if err == nil {
180+ return 0
181+ }
182+ if request != nil {
183+ if request.GetMagic() != wire.PreambleMagic {
184+ return RejectionBadMagic
185+ }
186+ if request.GetEpoch() != wire.ProtocolEpoch {
187+ return RejectionEpochMismatch
188+ }
189+ if request.GetVersion() != uint32(protocol.Version) {
190+ return RejectionVersionMismatch
191+ }
192+ if request.GetRole().GetRole() != BrokerRoleClient {
193+ return RejectionWrongRole
194+ }
195+ if request.GetCapabilityBits() != 0 {
196+ // No capability is negotiable in P3.1, so any bit is a refused
197+ // limit advertisement (code 7), documented explicitly.
198+ return RejectionLimitRefused
199+ }
200+ remote := brokerCeilings{
201+ maxReceiveEnvelopeBytes: request.GetMaxReceiveEnvelopeBytes(),
202+ streamChunkLimit: request.GetOutputDataLimit(),
203+ }
204+ if checkBrokerCeilings(remote) != nil {
205+ return RejectionLimitRefused
206+ }
207+ }
208+ if errors.Is(err, wire.ErrScanDuplicate) {
209+ return RejectionDuplicate
210+ }
211+ return RejectionLimitRefused
212+}
A internal/adapters/brokerwire/snapshot.go +634 -0
@@ -0,0 +1,634 @@
1+// Stateful multipart snapshot assembly (P3.1).
2+//
3+// SnapshotAssembler stages one multipart SnapshotPart publication and
4+// publishes it atomically only after full validation. It builds on the
5+// stateless codec: parts are already strict-scanned and semantically
6+// converted, so this file owns only transfer state: exact indexes, counts,
7+// order, staged bounds, revision fencing, generation fencing, and atomic
8+// publication.
9+//
10+// Transfer layout (indexes are exact positions):
11+//
12+// Begin(0) Host/Session... Tombstone... End(N-1)
13+//
14+// Begin carries the total host, session, and tombstone counts. Host parts
15+// arrive in host_index order; each Host advertises how many Session parts
16+// follow it immediately, with per-host session_index order. Tombstones
17+// arrive in tombstone_index order after all hosts and sessions. End closes
18+// the transfer. Total parts are 1 + hosts + sessions + tombstones + 1,
19+// bounded by MaxSnapshotParts (64 hosts, 64*256 sessions, 64 tombstones).
20+//
21+// Bounds: at most MaxSnapshotParts parts and MaxSnapshotStagedBytes staged
22+// estimate per transfer. The staged estimate is a deterministic function of
23+// endpoint, origin, session, tab, and tombstone text lengths plus fixed
24+// per-part overhead; it bounds hostile memory without measuring wire bytes.
25+//
26+// Publication is atomic: the committed snapshot is replaced only when End
27+// arrives with exact indexes and the assembled BrokerSnapshot passes
28+// ports.BrokerSnapshot.Validate plus per-host
29+// ports.ValidateDurableHostProjection. Any malformed index, count, order,
30+// or validation failure aborts staging and retains the old snapshot. A new
31+// Begin for the same generation with a newer revision coalesces by
32+// replacing staging; an older revision is rejected and retains both the
33+// committed snapshot and any staging in flight. Equal revisions are allowed
34+// so a resync may republish the committed revision.
35+//
36+// Generation fencing: the assembler belongs to exactly one nonzero
37+// subscription generation, adopted from the first Begin or pinned by
38+// WithGeneration. Parts with a stale generation are ignored without
39+// touching staging; parts with a future generation are rejected, whether or
40+// not a transfer is in flight. Scope fencing is exact: every part must carry
41+// the assembler's epoch and connection.
42+//
43+// There is no I/O, socket, pump, or P3.2 transport here. The owning
44+// Connection discards staging on generation advance, unsubscribe, or close.
45+package brokerwire
46+
47+import (
48+ "errors"
49+ "sync"
50+
51+ "github.com/bnema/vev/internal/ports"
52+ "github.com/bnema/vev/internal/protocol/catalogue"
53+)
54+
55+const (
56+ // MaxSnapshotParts bounds one multipart publication: 1 Begin + 64
57+ // hosts + 64*256 sessions + 64 tombstones + 1 End.
58+ MaxSnapshotParts = 1 + ports.BrokerMaxHosts + ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost + ports.BrokerMaxTombstones + 1
59+ // MaxSnapshotStagedBytes bounds the deterministic staged estimate of
60+ // one transfer (80 MiB).
61+ MaxSnapshotStagedBytes = uint64(80 << 20)
62+)
63+
64+var (
65+ // ErrSnapshotInvalid reports a malformed transfer: wrong index, count,
66+ // order, payload type, or assembled validation failure. Staging is
67+ // aborted and the committed snapshot is retained.
68+ ErrSnapshotInvalid = errors.New("brokerwire: invalid snapshot transfer")
69+ // ErrSnapshotStale reports an older revision: below the committed
70+ // revision or below staging. Nothing changes.
71+ ErrSnapshotStale = errors.New("brokerwire: stale snapshot revision")
72+ // ErrStaleGeneration reports a part bound to a superseded
73+ // subscription. It is ignored without touching staging.
74+ ErrStaleGeneration = errors.New("brokerwire: stale subscription generation")
75+ // ErrFutureGeneration reports a part bound to a generation past the
76+ // active one. It is rejected.
77+ ErrFutureGeneration = errors.New("brokerwire: future subscription generation")
78+ // ErrInvalidGeneration reports a zero subscription generation.
79+ ErrInvalidGeneration = errors.New("brokerwire: invalid subscription generation")
80+ // ErrScopeMismatch reports a part whose epoch or connection does not
81+ // exactly match the assigned scope. It is rejected.
82+ ErrScopeMismatch = errors.New("brokerwire: scope mismatch")
83+)
84+
85+// SnapshotOption tunes a SnapshotAssembler. Tests use a small staged cap
86+// to prove the bound without allocating 80 MiB; production uses defaults.
87+type SnapshotOption func(*SnapshotAssembler)
88+
89+// WithMaxStagedBytes overrides the staged estimate ceiling.
90+func WithMaxStagedBytes(n uint64) SnapshotOption {
91+ return func(a *SnapshotAssembler) {
92+ a.maxStagedBytes = n
93+ }
94+}
95+
96+// WithGeneration pins the subscription generation this assembler belongs to,
97+// so a part of any other generation is fenced from the first one. The zero
98+// default adopts the generation from the first Begin.
99+func WithGeneration(generation uint64) SnapshotOption {
100+ return func(a *SnapshotAssembler) {
101+ a.generation = generation
102+ }
103+}
104+
105+// SnapshotAssembler stages one multipart publication per generation and
106+// holds the last committed snapshot. It is safe for concurrent use.
107+type SnapshotAssembler struct {
108+ mu sync.Mutex
109+ epoch ports.BrokerEpoch
110+ connection ports.BrokerConnectionID
111+ generation SubscriptionGeneration
112+ committed ports.BrokerSnapshot
113+ hasCommitted bool
114+ staging *snapshotStaging
115+ maxStagedBytes uint64
116+}
117+
118+type snapshotStaging struct {
119+ generation SubscriptionGeneration
120+ revision ports.BrokerRevision
121+ hostCount uint32
122+ sessionTotal uint32
123+ tombstoneCount uint32
124+ totalParts uint32
125+ nextIndex uint32
126+ hosts []ports.RemoteHostSnapshot
127+ hostExpected []uint32
128+ hostSeen []uint32
129+ hostsSeen uint32
130+ sessionsSeen uint32
131+ tombstones []ports.BrokerHostTombstone
132+ tombstonesSeen uint32
133+ stagedBytes uint64
134+}
135+
136+// NewSnapshotAssembler binds an assembler to one exact scope assigned by
137+// Registered. Every part must carry that scope.
138+func NewSnapshotAssembler(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID, opts ...SnapshotOption) *SnapshotAssembler {
139+ a := &SnapshotAssembler{epoch: epoch, connection: connection, maxStagedBytes: MaxSnapshotStagedBytes}
140+ for _, opt := range opts {
141+ if opt != nil {
142+ opt(a)
143+ }
144+ }
145+ if a.maxStagedBytes == 0 {
146+ a.maxStagedBytes = MaxSnapshotStagedBytes
147+ }
148+ return a
149+}
150+
151+// Snapshot returns the last committed snapshot and whether one exists.
152+// The returned value is a defensive clone.
153+func (a *SnapshotAssembler) Snapshot() (ports.BrokerSnapshot, bool) {
154+ if a == nil {
155+ return ports.BrokerSnapshot{}, false
156+ }
157+ a.mu.Lock()
158+ defer a.mu.Unlock()
159+ if !a.hasCommitted {
160+ return ports.BrokerSnapshot{}, false
161+ }
162+ return a.committed.Clone(), true
163+}
164+
165+// AdoptCommitted seeds the assembler with a snapshot already committed for
166+// the same scope, so a subscription generation advance keeps publishing the
167+// last committed revision until a newer transfer commits. An older revision
168+// or a foreign epoch is ignored, and no transfer in flight is disturbed.
169+func (a *SnapshotAssembler) adoptCommitted(snapshot ports.BrokerSnapshot) {
170+ if a == nil || snapshot.Epoch != a.epoch || snapshot.Revision == 0 {
171+ return
172+ }
173+ a.mu.Lock()
174+ defer a.mu.Unlock()
175+ if a.hasCommitted && snapshot.Revision < a.committed.Revision {
176+ return
177+ }
178+ a.committed = snapshot.Clone()
179+ a.hasCommitted = true
180+}
181+
182+// StagingActive reports whether a transfer is in flight.
183+func (a *SnapshotAssembler) StagingActive() bool {
184+ if a == nil {
185+ return false
186+ }
187+ a.mu.Lock()
188+ defer a.mu.Unlock()
189+ return a.staging != nil
190+}
191+
192+// DiscardStaging abandons any transfer in flight and retains the committed
193+// snapshot.
194+func (a *SnapshotAssembler) DiscardStaging() {
195+ if a == nil {
196+ return
197+ }
198+ a.mu.Lock()
199+ defer a.mu.Unlock()
200+ a.staging = nil
201+}
202+
203+// Add stages one SnapshotPart. It returns completed=true with the newly
204+// committed clone only when End validates and publishes. Stale generations
205+// are ignored, future generations rejected, older revisions rejected, and
206+// malformed transfers aborted; all retain the committed snapshot.
207+func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot, error) {
208+ if a == nil {
209+ return false, ports.BrokerSnapshot{}, ErrSnapshotInvalid
210+ }
211+ a.mu.Lock()
212+ defer a.mu.Unlock()
213+ if part.Epoch != a.epoch || part.Connection != a.connection {
214+ return false, ports.BrokerSnapshot{}, ErrScopeMismatch
215+ }
216+ if part.Generation == 0 {
217+ return false, ports.BrokerSnapshot{}, ErrInvalidGeneration
218+ }
219+ // Generation fencing holds for the assembler's whole lifetime: the
220+ // first Begin adopts the generation, and every later part must carry
221+ // it even when no transfer is staged.
222+ if a.generation != 0 && part.Generation != a.generation {
223+ if part.Generation < a.generation {
224+ return false, ports.BrokerSnapshot{}, ErrStaleGeneration
225+ }
226+ return false, ports.BrokerSnapshot{}, ErrFutureGeneration
227+ }
228+ if part.Revision == 0 {
229+ return false, ports.BrokerSnapshot{}, ErrSnapshotInvalid
230+ }
231+ if a.staging == nil {
232+ begin, ok := normalizeBegin(part.Part)
233+ if !ok {
234+ return false, ports.BrokerSnapshot{}, ErrSnapshotInvalid
235+ }
236+ if part.Index != 0 {
237+ return false, ports.BrokerSnapshot{}, ErrSnapshotInvalid
238+ }
239+ if err := checkBeginCounts(begin); err != nil {
240+ return false, ports.BrokerSnapshot{}, err
241+ }
242+ if a.hasCommitted && part.Revision < a.committed.Revision {
243+ return false, ports.BrokerSnapshot{}, ErrSnapshotStale
244+ }
245+ total := 1 + uint64(begin.HostCount) + uint64(begin.SessionCount) + uint64(begin.TombstoneCount) + 1
246+ if total > uint64(MaxSnapshotParts) {
247+ return false, ports.BrokerSnapshot{}, ErrTooLarge
248+ }
249+ staging := &snapshotStaging{
250+ generation: part.Generation,
251+ revision: part.Revision,
252+ hostCount: begin.HostCount,
253+ sessionTotal: begin.SessionCount,
254+ tombstoneCount: begin.TombstoneCount,
255+ totalParts: uint32(total),
256+ nextIndex: 1,
257+ hosts: make([]ports.RemoteHostSnapshot, begin.HostCount),
258+ hostExpected: make([]uint32, begin.HostCount),
259+ hostSeen: make([]uint32, begin.HostCount),
260+ tombstones: make([]ports.BrokerHostTombstone, begin.TombstoneCount),
261+ }
262+ staged := estimatePartBytes(part)
263+ if staged > a.maxStagedBytes {
264+ return false, ports.BrokerSnapshot{}, ErrTooLarge
265+ }
266+ staging.stagedBytes = staged
267+ a.generation = part.Generation
268+ a.staging = staging
269+ return false, ports.BrokerSnapshot{}, nil
270+ }
271+ staging := a.staging
272+ if part.Generation != staging.generation {
273+ if part.Generation < staging.generation {
274+ return false, ports.BrokerSnapshot{}, ErrStaleGeneration
275+ }
276+ return false, ports.BrokerSnapshot{}, ErrFutureGeneration
277+ }
278+ if begin, ok := normalizeBegin(part.Part); ok {
279+ if part.Index != 0 {
280+ a.staging = nil
281+ return false, ports.BrokerSnapshot{}, ErrSnapshotInvalid
282+ }
283+ if err := checkBeginCounts(begin); err != nil {
284+ a.staging = nil
285+ return false, ports.BrokerSnapshot{}, err
286+ }
287+ total := 1 + uint64(begin.HostCount) + uint64(begin.SessionCount) + uint64(begin.TombstoneCount) + 1
288+ if total > uint64(MaxSnapshotParts) {
289+ a.staging = nil
290+ return false, ports.BrokerSnapshot{}, ErrTooLarge
291+ }
292+ switch {
293+ case part.Revision == staging.revision:
294+ // Restart of the same transfer: discard progress.
295+ case part.Revision > staging.revision:
296+ if a.hasCommitted && part.Revision < a.committed.Revision {
297+ return false, ports.BrokerSnapshot{}, ErrSnapshotStale
298+ }
299+ default:
300+ return false, ports.BrokerSnapshot{}, ErrSnapshotStale
301+ }
302+ fresh := &snapshotStaging{
303+ generation: part.Generation,
304+ revision: part.Revision,
305+ hostCount: begin.HostCount,
306+ sessionTotal: begin.SessionCount,
307+ tombstoneCount: begin.TombstoneCount,
308+ totalParts: uint32(total),
309+ nextIndex: 1,
310+ hosts: make([]ports.RemoteHostSnapshot, begin.HostCount),
311+ hostExpected: make([]uint32, begin.HostCount),
312+ hostSeen: make([]uint32, begin.HostCount),
313+ tombstones: make([]ports.BrokerHostTombstone, begin.TombstoneCount),
314+ }
315+ staged := estimatePartBytes(part)
316+ if staged > a.maxStagedBytes {
317+ a.staging = nil
318+ return false, ports.BrokerSnapshot{}, ErrTooLarge
319+ }
320+ fresh.stagedBytes = staged
321+ a.staging = fresh
322+ return false, ports.BrokerSnapshot{}, nil
323+ }
324+ if part.Revision != staging.revision {
325+ if part.Revision < staging.revision {
326+ return false, ports.BrokerSnapshot{}, ErrSnapshotStale
327+ }
328+ a.staging = nil
329+ return false, ports.BrokerSnapshot{}, ErrSnapshotInvalid
330+ }
331+ if part.Index != staging.nextIndex {
332+ a.staging = nil
333+ return false, ports.BrokerSnapshot{}, ErrSnapshotInvalid
334+ }
335+ if uint64(part.Index) >= uint64(staging.totalParts) {
336+ a.staging = nil
337+ return false, ports.BrokerSnapshot{}, ErrSnapshotInvalid
338+ }
339+ staged := staging.stagedBytes + estimatePartBytes(part)
340+ if staged > a.maxStagedBytes {
341+ a.staging = nil
342+ return false, ports.BrokerSnapshot{}, ErrTooLarge
343+ }
344+ switch payload := part.Part.(type) {
345+ case SnapshotHostPart, *SnapshotHostPart:
346+ hostPart := normalizeHostPart(payload)
347+ if err := a.stageHost(staging, hostPart); err != nil {
348+ a.staging = nil
349+ return false, ports.BrokerSnapshot{}, err
350+ }
351+ case SnapshotSessionPart, *SnapshotSessionPart:
352+ sessionPart := normalizeSessionPart(payload)
353+ if err := a.stageSession(staging, sessionPart); err != nil {
354+ a.staging = nil
355+ return false, ports.BrokerSnapshot{}, err
356+ }
357+ case SnapshotTombstonePart, *SnapshotTombstonePart:
358+ tombPart := normalizeTombstonePart(payload)
359+ if err := a.stageTombstone(staging, tombPart); err != nil {
360+ a.staging = nil
361+ return false, ports.BrokerSnapshot{}, err
362+ }
363+ case SnapshotEnd, *SnapshotEnd:
364+ snapshot, err := a.commitStaging(staging)
365+ if err != nil {
366+ a.staging = nil
367+ return false, ports.BrokerSnapshot{}, err
368+ }
369+ staging.stagedBytes = staged
370+ a.committed = snapshot
371+ a.hasCommitted = true
372+ a.staging = nil
373+ return true, snapshot.Clone(), nil
374+ default:
375+ a.staging = nil
376+ return false, ports.BrokerSnapshot{}, ErrSnapshotInvalid
377+ }
378+ staging.stagedBytes = staged
379+ staging.nextIndex++
380+ return false, ports.BrokerSnapshot{}, nil
381+}
382+
383+func normalizeBegin(payload SnapshotPartPayload) (SnapshotBegin, bool) {
384+ switch p := payload.(type) {
385+ case SnapshotBegin:
386+ return p, true
387+ case *SnapshotBegin:
388+ if p == nil {
389+ return SnapshotBegin{}, false
390+ }
391+ return *p, true
392+ default:
393+ return SnapshotBegin{}, false
394+ }
395+}
396+
397+func normalizeHostPart(payload SnapshotPartPayload) SnapshotHostPart {
398+ switch p := payload.(type) {
399+ case SnapshotHostPart:
400+ return p
401+ case *SnapshotHostPart:
402+ if p == nil {
403+ return SnapshotHostPart{HostIndex: ^uint32(0)}
404+ }
405+ return *p
406+ default:
407+ return SnapshotHostPart{HostIndex: ^uint32(0)}
408+ }
409+}
410+
411+func normalizeSessionPart(payload SnapshotPartPayload) SnapshotSessionPart {
412+ switch p := payload.(type) {
413+ case SnapshotSessionPart:
414+ return p
415+ case *SnapshotSessionPart:
416+ if p == nil {
417+ return SnapshotSessionPart{HostIndex: ^uint32(0)}
418+ }
419+ return *p
420+ default:
421+ return SnapshotSessionPart{HostIndex: ^uint32(0)}
422+ }
423+}
424+
425+func normalizeTombstonePart(payload SnapshotPartPayload) SnapshotTombstonePart {
426+ switch p := payload.(type) {
427+ case SnapshotTombstonePart:
428+ return p
429+ case *SnapshotTombstonePart:
430+ if p == nil {
431+ return SnapshotTombstonePart{TombstoneIndex: ^uint32(0)}
432+ }
433+ return *p
434+ default:
435+ return SnapshotTombstonePart{TombstoneIndex: ^uint32(0)}
436+ }
437+}
438+
439+func checkBeginCounts(begin SnapshotBegin) error {
440+ if begin.HostCount > uint32(ports.BrokerMaxHosts) ||
441+ begin.SessionCount > uint32(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost) ||
442+ begin.TombstoneCount > uint32(ports.BrokerMaxTombstones) {
443+ return ErrTooLarge
444+ }
445+ return nil
446+}
447+
448+func (a *SnapshotAssembler) stageHost(staging *snapshotStaging, part SnapshotHostPart) error {
449+ if staging.hostsSeen >= staging.hostCount {
450+ return ErrSnapshotInvalid
451+ }
452+ if part.HostIndex != staging.hostsSeen {
453+ return ErrSnapshotInvalid
454+ }
455+ if part.HostIndex >= staging.hostCount {
456+ return ErrSnapshotInvalid
457+ }
458+ if part.SessionCount > uint32(ports.BrokerMaxSessionsPerHost) {
459+ return ErrTooLarge
460+ }
461+ if len(part.Host.Sessions) != 0 {
462+ return ErrSnapshotInvalid
463+ }
464+ var seen uint64
465+ for _, n := range staging.hostExpected[:staging.hostsSeen] {
466+ seen += uint64(n)
467+ }
468+ if seen+uint64(part.SessionCount) > uint64(staging.sessionTotal) {
469+ return ErrSnapshotInvalid
470+ }
471+ // Remaining hosts must still be able to fill the advertised total:
472+ // even if every remaining host carried the per-host maximum, the
473+ // total must stay reachable. This is checked exactly at End; here we
474+ // only refuse an already-exceeded sum.
475+ staging.hosts[part.HostIndex] = part.Host.Clone()
476+ staging.hostExpected[part.HostIndex] = part.SessionCount
477+ staging.hostSeen[part.HostIndex] = 0
478+ staging.hostsSeen++
479+ return nil
480+}
481+
482+func (a *SnapshotAssembler) stageSession(staging *snapshotStaging, part SnapshotSessionPart) error {
483+ if staging.hostsSeen == 0 || staging.hostsSeen > staging.hostCount {
484+ return ErrSnapshotInvalid
485+ }
486+ current := staging.hostsSeen - 1
487+ if part.HostIndex != current {
488+ return ErrSnapshotInvalid
489+ }
490+ if current >= staging.hostCount {
491+ return ErrSnapshotInvalid
492+ }
493+ expected := staging.hostExpected[current]
494+ if staging.hostSeen[current] >= expected {
495+ return ErrSnapshotInvalid
496+ }
497+ if part.SessionIndex != staging.hostSeen[current] {
498+ return ErrSnapshotInvalid
499+ }
500+ if part.HostIndex >= uint32(ports.BrokerMaxHosts) || part.SessionIndex >= uint32(ports.BrokerMaxSessionsPerHost) {
501+ return ErrTooLarge
502+ }
503+ if staging.sessionsSeen >= staging.sessionTotal {
504+ return ErrSnapshotInvalid
505+ }
506+ session := part.Session
507+ if session.Tabs == nil {
508+ return ErrSnapshotInvalid
509+ }
510+ tabs := make([]catalogue.RemoteCatalogTab, len(session.Tabs))
511+ copy(tabs, session.Tabs)
512+ session.Tabs = tabs
513+ host := staging.hosts[current]
514+ host.Sessions = append(host.Sessions, session)
515+ staging.hosts[current] = host
516+ staging.hostSeen[current]++
517+ staging.sessionsSeen++
518+ return nil
519+}
520+
521+func (a *SnapshotAssembler) stageTombstone(staging *snapshotStaging, part SnapshotTombstonePart) error {
522+ if staging.hostsSeen != staging.hostCount || staging.sessionsSeen != staging.sessionTotal {
523+ return ErrSnapshotInvalid
524+ }
525+ if staging.tombstonesSeen >= staging.tombstoneCount {
526+ return ErrSnapshotInvalid
527+ }
528+ if part.TombstoneIndex != staging.tombstonesSeen {
529+ return ErrSnapshotInvalid
530+ }
531+ if part.RetiredRevision == 0 {
532+ return ErrSnapshotInvalid
533+ }
534+ staging.tombstones[part.TombstoneIndex] = ports.BrokerHostTombstone{
535+ Endpoint: part.Registration.Endpoint,
536+ Registration: part.Registration,
537+ RetiredRevision: part.RetiredRevision,
538+ }
539+ staging.tombstonesSeen++
540+ return nil
541+}
542+
543+func (a *SnapshotAssembler) commitStaging(staging *snapshotStaging) (ports.BrokerSnapshot, error) {
544+ if staging.hostsSeen != staging.hostCount || staging.sessionsSeen != staging.sessionTotal || staging.tombstonesSeen != staging.tombstoneCount {
545+ return ports.BrokerSnapshot{}, ErrSnapshotInvalid
546+ }
547+ if staging.nextIndex != staging.totalParts-1 {
548+ return ports.BrokerSnapshot{}, ErrSnapshotInvalid
549+ }
550+ var sum uint64
551+ for _, n := range staging.hostExpected {
552+ sum += uint64(n)
553+ }
554+ if sum != uint64(staging.sessionTotal) {
555+ return ports.BrokerSnapshot{}, ErrSnapshotInvalid
556+ }
557+ hosts := make([]ports.RemoteHostSnapshot, len(staging.hosts))
558+ for i, host := range staging.hosts {
559+ hosts[i] = host.Clone()
560+ if hosts[i].Sessions == nil {
561+ hosts[i].Sessions = []catalogue.RemoteCatalogSession{}
562+ }
563+ }
564+ removed := append([]ports.BrokerHostTombstone(nil), staging.tombstones...)
565+ snapshot := ports.BrokerSnapshot{Epoch: a.epoch, Revision: staging.revision, Hosts: hosts, Removed: removed}
566+ if err := snapshot.Validate(); err != nil {
567+ return ports.BrokerSnapshot{}, ErrSnapshotInvalid
568+ }
569+ for _, host := range hosts {
570+ if err := ports.ValidateDurableHostProjection(host); err != nil {
571+ return ports.BrokerSnapshot{}, ErrSnapshotInvalid
572+ }
573+ }
574+ return snapshot, nil
575+}
576+
577+func estimatePartBytes(part SnapshotPart) uint64 {
578+ const overhead = 32
579+ switch payload := part.Part.(type) {
580+ case SnapshotBegin, *SnapshotBegin:
581+ return overhead
582+ case SnapshotHostPart:
583+ return uint64(len(payload.Host.Endpoint)+len(payload.Host.DisplayOrigin)+len(payload.Host.Registration.Endpoint)+64) + overhead
584+ case *SnapshotHostPart:
585+ if payload == nil {
586+ return overhead
587+ }
588+ return uint64(len(payload.Host.Endpoint)+len(payload.Host.DisplayOrigin)+len(payload.Host.Registration.Endpoint)+64) + overhead
589+ case SnapshotSessionPart:
590+ return estimateCatalogSessionBytes(payload.Session) + overhead
591+ case *SnapshotSessionPart:
592+ if payload == nil {
593+ return overhead
594+ }
595+ return estimateCatalogSessionBytes(payload.Session) + overhead
596+ case SnapshotTombstonePart:
597+ return uint64(len(payload.Registration.Endpoint)+32) + overhead
598+ case *SnapshotTombstonePart:
599+ if payload == nil {
600+ return overhead
601+ }
602+ return uint64(len(payload.Registration.Endpoint)+32) + overhead
603+ case SnapshotEnd, *SnapshotEnd:
604+ return overhead
605+ default:
606+ return overhead
607+ }
608+}
609+
610+// estimateCatalogSessionBytes bounds one session's staged memory. It charges
611+// every tab at least estimateTabBytes (covering the tab struct, its string
612+// headers, and its slice slot) plus the variable-length text, and adds one
613+// slice-header charge when the tab list is present. This deliberately
614+// over-charges minimal tabs so a hostile max-tab publication cannot hide
615+// behind tiny strings and slip under MaxSnapshotStagedBytes.
616+const (
617+ estimateTabBytes = 64
618+ estimateTabSliceBytes = 24
619+ estimateSessionBase = 32
620+)
621+
622+func estimateCatalogSessionBytes(session catalogue.RemoteCatalogSession) uint64 {
623+ n := len(session.Name) + len(session.Reason) + len(session.ActiveTabID) + estimateSessionBase
624+ if session.Tabs != nil {
625+ n += estimateTabSliceBytes
626+ }
627+ for _, tab := range session.Tabs {
628+ n += len(tab.ID) + len(tab.Name) + len(tab.Detail) + estimateTabBytes
629+ }
630+ if n < 0 {
631+ return 0
632+ }
633+ return uint64(n)
634+}
A internal/adapters/brokerwire/snapshot_test.go +956 -0
@@ -0,0 +1,956 @@
1+package brokerwire
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol/catalogue"
16+)
17+
18+func testSnapshotScope() (ports.BrokerEpoch, ports.BrokerConnectionID) {
19+ return 7, testConnectionID(0x21)
20+}
21+
22+func testSnapshotAssembler(t *testing.T, opts ...SnapshotOption) *SnapshotAssembler {
23+ t.Helper()
24+ epoch, connection := testSnapshotScope()
25+ return NewSnapshotAssembler(epoch, connection, opts...)
26+}
27+
28+// testHostAt builds one catalogue-valid host projection for host index i.
29+func testHostAt(i int) ports.RemoteHostSnapshot {
30+ endpoint := fmt.Sprintf("user%d@host%d:22", i, i)
31+ return ports.RemoteHostSnapshot{
32+ Endpoint: endpoint,
33+ DisplayOrigin: endpoint,
34+ Registration: domain.RemoteRegistration{Endpoint: endpoint, Incarnation: [16]byte{0x10, byte(i + 1), 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xf0, 0x11}, Generation: domain.RemoteGeneration(i + 1)},
35+ Availability: domain.RemoteAvailabilityReachable,
36+ LastSuccess: time.Unix(1700000000+int64(i), 0).UTC(),
37+ InventoryKnown: true,
38+ Sessions: []catalogue.RemoteCatalogSession{},
39+ }
40+}
41+
42+// testSessionAt builds one catalogue-valid session for a host and index.
43+func testSessionAt(host, index int) catalogue.RemoteCatalogSession {
44+ return catalogue.RemoteCatalogSession{
45+ LifecycleID: domain.SessionLifecycleID{byte(host + 1), byte(index >> 8), byte(index), 0x77},
46+ Name: fmt.Sprintf("s%02d-%03d", host, index),
47+ State: catalogue.RemoteCatalogSessionDown,
48+ Tabs: []catalogue.RemoteCatalogTab{},
49+ }
50+}
51+
52+// testTombstoneAt builds one valid retired registration for index i.
53+func testTombstoneAt(i int) ports.BrokerHostTombstone {
54+ endpoint := fmt.Sprintf("retired%d@old%d:22", i, i)
55+ return ports.BrokerHostTombstone{
56+ Endpoint: endpoint,
57+ Registration: domain.RemoteRegistration{Endpoint: endpoint, Incarnation: [16]byte{0x90, byte(i + 1), 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e}, Generation: domain.RemoteGeneration(i + 1)},
58+ RetiredRevision: ports.BrokerRevision(i + 1),
59+ }
60+}
61+
62+func sampleSnapshotHosts() []ports.RemoteHostSnapshot {
63+ host0 := testHostAt(0)
64+ host0.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)}
65+ host1 := testHostAt(1)
66+ host1.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(1, 0)}
67+ return []ports.RemoteHostSnapshot{host0, host1}
68+}
69+
70+func sampleSnapshotTombstones() []ports.BrokerHostTombstone {
71+ return []ports.BrokerHostTombstone{testTombstoneAt(0)}
72+}
73+
74+func snapshotSessionTotal(hosts []ports.RemoteHostSnapshot) int {
75+ total := 0
76+ for _, host := range hosts {
77+ total += len(host.Sessions)
78+ }
79+ return total
80+}
81+
82+// snapshotPartsFor lays out one exact transfer: Begin(0), hosts with their
83+// sessions, tombstones, End. Indexes are exact sequential positions.
84+func snapshotPartsFor(gen uint64, rev ports.BrokerRevision, hosts []ports.RemoteHostSnapshot, tombstones []ports.BrokerHostTombstone) []SnapshotPart {
85+ epoch, connection := testSnapshotScope()
86+ next := uint32(0)
87+ part := func(payload SnapshotPartPayload) SnapshotPart {
88+ out := SnapshotPart{Epoch: epoch, Connection: connection, Generation: gen, Revision: rev, Index: next, Part: payload}
89+ next++
90+ return out
91+ }
92+ parts := []SnapshotPart{part(SnapshotBegin{
93+ HostCount: uint32(len(hosts)),
94+ SessionCount: uint32(snapshotSessionTotal(hosts)),
95+ TombstoneCount: uint32(len(tombstones)),
96+ })}
97+ for hostIndex, host := range hosts {
98+ // Host parts carry no inline sessions: the advertised count is
99+ // followed by that many session parts.
100+ projection := host.Clone()
101+ projection.Sessions = []catalogue.RemoteCatalogSession{}
102+ parts = append(parts, part(SnapshotHostPart{HostIndex: uint32(hostIndex), Host: projection, SessionCount: uint32(len(host.Sessions))}))
103+ for sessionIndex, session := range host.Sessions {
104+ parts = append(parts, part(SnapshotSessionPart{HostIndex: uint32(hostIndex), SessionIndex: uint32(sessionIndex), Session: session}))
105+ }
106+ }
107+ for tombstoneIndex, tombstone := range tombstones {
108+ parts = append(parts, part(SnapshotTombstonePart{
109+ TombstoneIndex: uint32(tombstoneIndex),
110+ Registration: tombstone.Registration,
111+ RetiredRevision: tombstone.RetiredRevision,
112+ }))
113+ }
114+ return append(parts, part(SnapshotEnd{}))
115+}
116+
117+func cloneSnapshotParts(parts []SnapshotPart) []SnapshotPart {
118+ return append([]SnapshotPart(nil), parts...)
119+}
120+
121+// stagePart stages one part and requires acceptance.
122+func stagePart(t *testing.T, a *SnapshotAssembler, part SnapshotPart) {
123+ t.Helper()
124+ _, _, err := a.Add(part)
125+ require.NoError(t, err)
126+}
127+
128+// runSnapshotParts stages parts in order and returns the first staging error.
129+func runSnapshotParts(a *SnapshotAssembler, parts []SnapshotPart) error {
130+ for _, part := range parts {
131+ if _, _, err := a.Add(part); err != nil {
132+ return err
133+ }
134+ }
135+ return nil
136+}
137+
138+func TestSnapshotAssemblerBoundsConstants(t *testing.T) {
139+ require.Equal(t, 16514, MaxSnapshotParts)
140+ require.Equal(t, 1+64+64*256+64+1, MaxSnapshotParts)
141+ require.Equal(t, uint64(80<<20), MaxSnapshotStagedBytes)
142+}
143+
144+// TestSnapshotAssemblerExactTransfer proves exact order, count, and index
145+// acceptance, and that committed order matches the staged order.
146+func TestSnapshotAssemblerExactTransfer(t *testing.T) {
147+ hosts := sampleSnapshotHosts()
148+ tombstones := sampleSnapshotTombstones()
149+ parts := snapshotPartsFor(5, 3, hosts, tombstones)
150+ require.Len(t, parts, 1+2+3+1+1)
151+
152+ a := testSnapshotAssembler(t)
153+ for i, part := range parts {
154+ completed, snapshot, err := a.Add(part)
155+ require.NoError(t, err, "part %d", i)
156+ if i == len(parts)-1 {
157+ require.True(t, completed)
158+ require.Equal(t, ports.BrokerRevision(3), snapshot.Revision)
159+ } else {
160+ require.False(t, completed, "part %d", i)
161+ _, committed := a.Snapshot()
162+ require.False(t, committed, "part %d must not publish", i)
163+ require.True(t, a.StagingActive())
164+ }
165+ }
166+ committed, ok := a.Snapshot()
167+ require.True(t, ok)
168+ require.False(t, a.StagingActive())
169+ epoch, connection := testSnapshotScope()
170+ require.Equal(t, epoch, committed.Epoch)
171+ require.Equal(t, ports.BrokerRevision(3), committed.Revision)
172+ require.Len(t, committed.Hosts, 2)
173+ require.Equal(t, "user0@host0:22", committed.Hosts[0].Endpoint)
174+ require.Equal(t, "user1@host1:22", committed.Hosts[1].Endpoint)
175+ require.Equal(t, 2, len(committed.Hosts[0].Sessions))
176+ require.Equal(t, "s00-000", committed.Hosts[0].Sessions[0].Name)
177+ require.Equal(t, "s00-001", committed.Hosts[0].Sessions[1].Name)
178+ require.Equal(t, "s01-000", committed.Hosts[1].Sessions[0].Name)
179+ require.Len(t, committed.Removed, 1)
180+ require.Equal(t, "retired0@old0:22", committed.Removed[0].Endpoint)
181+ _ = connection
182+}
183+
184+func TestSnapshotAssemblerEmptyTransfer(t *testing.T) {
185+ parts := snapshotPartsFor(1, 1, nil, nil)
186+ require.Len(t, parts, 2)
187+ a := testSnapshotAssembler(t)
188+ completed, _, err := a.Add(parts[0])
189+ require.NoError(t, err)
190+ require.False(t, completed)
191+ require.True(t, a.StagingActive())
192+ completed, snapshot, err := a.Add(parts[1])
193+ require.NoError(t, err)
194+ require.True(t, completed)
195+ require.Empty(t, snapshot.Hosts)
196+ require.Empty(t, snapshot.Removed)
197+}
198+
199+// TestSnapshotAssemblerTransferRejections proves malformed indexes, counts,
200+// order, payloads, and assembled validation failures abort staging and
201+// retain the committed snapshot.
202+func TestSnapshotAssemblerTransferRejections(t *testing.T) {
203+ base := func() []SnapshotPart {
204+ return snapshotPartsFor(5, 3, sampleSnapshotHosts(), sampleSnapshotTombstones())
205+ }
206+ mutate := func(parts []SnapshotPart, index int, apply func(*SnapshotPart)) []SnapshotPart {
207+ out := cloneSnapshotParts(parts)
208+ apply(&out[index])
209+ return out
210+ }
211+ cases := []struct {
212+ name string
213+ parts []SnapshotPart
214+ wantErr error
215+ }{
216+ {"begin index not zero", mutate(base(), 0, func(p *SnapshotPart) { p.Index = 1 }), ErrSnapshotInvalid},
217+ {"non-begin first", base()[1:], ErrSnapshotInvalid},
218+ {"missing session part", append(cloneSnapshotParts(base()[:3]), base()[4:]...), ErrSnapshotInvalid},
219+ {"duplicate host index", mutate(base(), 4, func(p *SnapshotPart) {
220+ p.Part = SnapshotHostPart{HostIndex: 0, Host: testHostAt(1), SessionCount: 1}
221+ }), ErrSnapshotInvalid},
222+ {"host index skips ahead", mutate(base(), 4, func(p *SnapshotPart) {
223+ p.Part = SnapshotHostPart{HostIndex: 5, Host: testHostAt(1), SessionCount: 1}
224+ }), ErrSnapshotInvalid},
225+ {"session host index mismatch", mutate(base(), 3, func(p *SnapshotPart) {
226+ p.Part = SnapshotSessionPart{HostIndex: 1, SessionIndex: 1, Session: testSessionAt(0, 1)}
227+ }), ErrSnapshotInvalid},
228+ {"session index gap", mutate(base(), 3, func(p *SnapshotPart) {
229+ p.Part = SnapshotSessionPart{HostIndex: 0, SessionIndex: 2, Session: testSessionAt(0, 1)}
230+ }), ErrSnapshotInvalid},
231+ {"session tabs absent", mutate(base(), 2, func(p *SnapshotPart) {
232+ session := testSessionAt(0, 0)
233+ session.Tabs = nil
234+ p.Part = SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: session}
235+ }), ErrSnapshotInvalid},
236+ {"host carries inline sessions", mutate(base(), 1, func(p *SnapshotPart) {
237+ host := testHostAt(0)
238+ host.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0)}
239+ p.Part = SnapshotHostPart{HostIndex: 0, Host: host, SessionCount: 1}
240+ }), ErrSnapshotInvalid},
241+ {"host session count over maximum", mutate(base(), 1, func(p *SnapshotPart) {
242+ p.Part = SnapshotHostPart{HostIndex: 0, Host: testHostAt(0), SessionCount: uint32(ports.BrokerMaxSessionsPerHost) + 1}
243+ }), ErrTooLarge},
244+ {"host session count exceeds advertised total", mutate(base(), 1, func(p *SnapshotPart) {
245+ p.Part = SnapshotHostPart{HostIndex: 0, Host: testHostAt(0), SessionCount: 3}
246+ }), ErrSnapshotInvalid},
247+ {"tombstone before all sessions", func() []SnapshotPart {
248+ parts := base()
249+ reordered := []SnapshotPart{parts[0], parts[1], parts[2], parts[3], parts[6], parts[4], parts[5], parts[7]}
250+ for i := range reordered {
251+ reordered[i].Index = uint32(i)
252+ }
253+ return reordered
254+ }(), ErrSnapshotInvalid},
255+ {"tombstone index out of order", mutate(base(), 6, func(p *SnapshotPart) {
256+ p.Part = SnapshotTombstonePart{TombstoneIndex: 1, Registration: testRegistration(), RetiredRevision: 1}
257+ }), ErrSnapshotInvalid},
258+ {"tombstone missing retired revision", mutate(base(), 6, func(p *SnapshotPart) {
259+ p.Part = SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 0}
260+ }), ErrSnapshotInvalid},
261+ {"end before completion", append(cloneSnapshotParts(base()[:5]), SnapshotPart{
262+ Epoch: 7, Connection: testConnectionID(0x21), Generation: 5, Revision: 3, Index: 5, Part: SnapshotEnd{},
263+ }), ErrSnapshotInvalid},
264+ {"begin session count mismatch", mutate(base(), 0, func(p *SnapshotPart) {
265+ p.Part = SnapshotBegin{HostCount: 2, SessionCount: 4, TombstoneCount: 1}
266+ }), ErrSnapshotInvalid},
267+ {"begin host count over maximum", mutate(base(), 0, func(p *SnapshotPart) {
268+ p.Part = SnapshotBegin{HostCount: uint32(ports.BrokerMaxHosts) + 1}
269+ }), ErrTooLarge},
270+ {"begin session count over maximum", mutate(base(), 0, func(p *SnapshotPart) {
271+ p.Part = SnapshotBegin{HostCount: 1, SessionCount: uint32(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost) + 1}
272+ }), ErrTooLarge},
273+ {"begin tombstone count over maximum", mutate(base(), 0, func(p *SnapshotPart) {
274+ p.Part = SnapshotBegin{HostCount: 1, TombstoneCount: uint32(ports.BrokerMaxTombstones) + 1}
275+ }), ErrTooLarge},
276+ {"commit duplicate host endpoint", func() []SnapshotPart {
277+ hosts := sampleSnapshotHosts()
278+ hosts[1].Endpoint = hosts[0].Endpoint
279+ hosts[1].Registration.Endpoint = hosts[0].Endpoint
280+ return snapshotPartsFor(5, 3, hosts, nil)
281+ }(), ErrSnapshotInvalid},
282+ {"commit host endpoint registration mismatch", func() []SnapshotPart {
283+ hosts := sampleSnapshotHosts()
284+ hosts[0].Endpoint = "other@elsewhere:22"
285+ return snapshotPartsFor(5, 3, hosts, nil)
286+ }(), ErrSnapshotInvalid},
287+ {"commit invalid projection", func() []SnapshotPart {
288+ hosts := sampleSnapshotHosts()
289+ hosts[0].Availability = 0
290+ return snapshotPartsFor(5, 3, hosts, nil)
291+ }(), ErrSnapshotInvalid},
292+ {"commit duplicate session name", func() []SnapshotPart {
293+ hosts := sampleSnapshotHosts()
294+ hosts[0].Sessions[1].Name = hosts[0].Sessions[0].Name
295+ return snapshotPartsFor(5, 3, hosts, nil)
296+ }(), ErrSnapshotInvalid},
297+ {"commit tombstone for live host", func() []SnapshotPart {
298+ hosts := sampleSnapshotHosts()
299+ tombstone := testTombstoneAt(0)
300+ tombstone.Endpoint = hosts[0].Endpoint
301+ tombstone.Registration.Endpoint = hosts[0].Endpoint
302+ return snapshotPartsFor(5, 3, hosts, []ports.BrokerHostTombstone{tombstone})
303+ }(), ErrSnapshotInvalid},
304+ }
305+ for _, tc := range cases {
306+ t.Run(tc.name, func(t *testing.T) {
307+ a := testSnapshotAssembler(t)
308+ err := runSnapshotParts(a, tc.parts)
309+ require.ErrorIs(t, err, tc.wantErr)
310+ require.False(t, a.StagingActive())
311+ _, committed := a.Snapshot()
312+ require.False(t, committed)
313+ })
314+ }
315+}
316+
317+// TestSnapshotAssemblerLatePartAfterCommit proves a part that follows a
318+// committed transfer is refused as malformed while the committed snapshot
319+// survives.
320+func TestSnapshotAssemblerLatePartAfterCommit(t *testing.T) {
321+ a := testSnapshotAssembler(t)
322+ parts := snapshotPartsFor(5, 3, sampleSnapshotHosts(), sampleSnapshotTombstones())
323+ require.NoError(t, runSnapshotParts(a, parts))
324+ late := parts[len(parts)-1]
325+ late.Index = uint32(len(parts))
326+ _, _, err := a.Add(late)
327+ require.ErrorIs(t, err, ErrSnapshotInvalid)
328+ require.False(t, a.StagingActive())
329+ committed, ok := a.Snapshot()
330+ require.True(t, ok)
331+ require.Equal(t, ports.BrokerRevision(3), committed.Revision)
332+}
333+
334+// TestSnapshotAssemblerBounds proves the 16,514-part and 80 MiB staged
335+// ceilings: maximum counts commit, one over is refused.
336+func TestSnapshotAssemblerBounds(t *testing.T) {
337+ t.Run("maximum counts commit", func(t *testing.T) {
338+ hosts := make([]ports.RemoteHostSnapshot, ports.BrokerMaxHosts)
339+ for i := range hosts {
340+ host := testHostAt(i)
341+ sessions := make([]catalogue.RemoteCatalogSession, ports.BrokerMaxSessionsPerHost)
342+ for j := range sessions {
343+ sessions[j] = testSessionAt(i, j)
344+ }
345+ host.Sessions = sessions
346+ hosts[i] = host
347+ }
348+ tombstones := make([]ports.BrokerHostTombstone, ports.BrokerMaxTombstones)
349+ for i := range tombstones {
350+ tombstones[i] = testTombstoneAt(i)
351+ }
352+ parts := snapshotPartsFor(9, 12, hosts, tombstones)
353+ require.Len(t, parts, MaxSnapshotParts)
354+ a := testSnapshotAssembler(t)
355+ completed := false
356+ var snapshot ports.BrokerSnapshot
357+ for _, part := range parts {
358+ published, committed, err := a.Add(part)
359+ require.NoError(t, err)
360+ if published {
361+ completed = true
362+ snapshot = committed
363+ }
364+ }
365+ require.True(t, completed)
366+ require.Len(t, snapshot.Hosts, ports.BrokerMaxHosts)
367+ require.Len(t, snapshot.Removed, ports.BrokerMaxTombstones)
368+ for _, host := range snapshot.Hosts {
369+ require.Len(t, host.Sessions, ports.BrokerMaxSessionsPerHost)
370+ }
371+ require.Equal(t, ports.BrokerRevision(12), snapshot.Revision)
372+ })
373+
374+ t.Run("one part over maximum refused", func(t *testing.T) {
375+ a := testSnapshotAssembler(t)
376+ _, _, err := a.Add(SnapshotPart{
377+ Epoch: 7, Connection: testConnectionID(0x21), Generation: 1, Revision: 1, Index: 0,
378+ Part: SnapshotBegin{HostCount: 1, SessionCount: uint32(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost) + 1},
379+ })
380+ require.ErrorIs(t, err, ErrTooLarge)
381+ require.False(t, a.StagingActive())
382+ })
383+
384+ t.Run("staged byte ceiling aborts and retains", func(t *testing.T) {
385+ a := testSnapshotAssembler(t, WithMaxStagedBytes(200))
386+ parts := snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)
387+ // Begin (32) + host part stay within the cap; the first session part
388+ // crosses it.
389+ require.NoError(t, runSnapshotParts(a, parts[:2]))
390+ require.True(t, a.StagingActive())
391+ _, _, err := a.Add(parts[2])
392+ require.ErrorIs(t, err, ErrTooLarge)
393+ require.False(t, a.StagingActive())
394+ _, committed := a.Snapshot()
395+ require.False(t, committed)
396+ })
397+
398+ t.Run("zero staged cap falls back to default", func(t *testing.T) {
399+ a := testSnapshotAssembler(t, WithMaxStagedBytes(0))
400+ require.Equal(t, MaxSnapshotStagedBytes, a.maxStagedBytes)
401+ })
402+}
403+
404+// TestSnapshotStagedEstimateTabCharge proves the staged-memory estimate
405+// charges at least 64 bytes of overhead per tab plus the tab-list slice
406+// header, so a minimal tab cannot hide behind tiny strings.
407+func TestSnapshotStagedEstimateTabCharge(t *testing.T) {
408+ base := testSessionAt(0, 0)
409+ base.Tabs = []catalogue.RemoteCatalogTab{}
410+ oneTab := base
411+ oneTab.Tabs = []catalogue.RemoteCatalogTab{{Name: "t"}}
412+ delta := estimateCatalogSessionBytes(oneTab) - estimateCatalogSessionBytes(base)
413+ require.GreaterOrEqual(t, delta, uint64(64), "each tab must charge at least 64 bytes")
414+
415+ maxTabs := make([]catalogue.RemoteCatalogTab, catalogue.RemoteCatalogMaxTabsPerSess)
416+ for i := range maxTabs {
417+ maxTabs[i] = catalogue.RemoteCatalogTab{Index: uint16(i), Name: "t"}
418+ }
419+ hostile := base
420+ hostile.Tabs = maxTabs
421+ require.GreaterOrEqual(t, estimateCatalogSessionBytes(hostile), uint64(catalogue.RemoteCatalogMaxTabsPerSess*64))
422+ // The slice header is charged when the tab list is present.
423+ present := base
424+ present.Tabs = []catalogue.RemoteCatalogTab{}
425+ absent := base
426+ absent.Tabs = nil
427+ require.Greater(t, estimateCatalogSessionBytes(present), estimateCatalogSessionBytes(absent))
428+}
429+
430+// TestSnapshotHostileMaxTabsBound proves the 80 MiB staged ceiling against
431+// hostile max tabs: the worst-case arithmetic crosses the ceiling, a max-tab
432+// session crosses a ceiling that fits exactly one session, and a ceiling
433+// that admits it still commits.
434+func TestSnapshotHostileMaxTabsBound(t *testing.T) {
435+ require.Equal(t, uint64(80<<20), MaxSnapshotStagedBytes)
436+
437+ maxTabs := make([]catalogue.RemoteCatalogTab, catalogue.RemoteCatalogMaxTabsPerSess)
438+ for i := range maxTabs {
439+ maxTabs[i] = catalogue.RemoteCatalogTab{Index: uint16(i), Name: "t"}
440+ }
441+ session := testSessionAt(0, 0)
442+ session.Tabs = maxTabs
443+ perSession := estimateCatalogSessionBytes(session)
444+ worst := perSession * uint64(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost)
445+ require.Greater(t, worst, MaxSnapshotStagedBytes, "hostile max tabs must cross the 80 MiB ceiling")
446+
447+ host := testHostAt(0)
448+ host.Sessions = []catalogue.RemoteCatalogSession{session}
449+ parts := snapshotPartsFor(9, 1, []ports.RemoteHostSnapshot{host}, nil)
450+
451+ tight := testSnapshotAssembler(t, WithMaxStagedBytes(perSession))
452+ require.NoError(t, runSnapshotParts(tight, parts[:2]))
453+ require.True(t, tight.StagingActive())
454+ _, _, err := tight.Add(parts[2])
455+ require.ErrorIs(t, err, ErrTooLarge)
456+ require.False(t, tight.StagingActive())
457+
458+ generous := testSnapshotAssembler(t, WithMaxStagedBytes(perSession*4))
459+ require.NoError(t, runSnapshotParts(generous, parts))
460+ committed, ok := generous.Snapshot()
461+ require.True(t, ok)
462+ require.Len(t, committed.Hosts, 1)
463+ require.Len(t, committed.Hosts[0].Sessions, 1)
464+ require.Len(t, committed.Hosts[0].Sessions[0].Tabs, catalogue.RemoteCatalogMaxTabsPerSess)
465+}
466+
467+// TestSnapshotAssemblerAtomicCommit proves publication happens only at End
468+// and that the returned snapshot is a defensive clone.
469+func TestSnapshotAssemblerAtomicCommit(t *testing.T) {
470+ a := testSnapshotAssembler(t)
471+ first := snapshotPartsFor(5, 1, sampleSnapshotHosts(), sampleSnapshotTombstones())
472+ require.NoError(t, runSnapshotParts(a, first))
473+ committed, ok := a.Snapshot()
474+ require.True(t, ok)
475+ require.Equal(t, ports.BrokerRevision(1), committed.Revision)
476+
477+ second := snapshotPartsFor(5, 2, sampleSnapshotHosts(), nil)
478+ for _, part := range second[:len(second)-1] {
479+ completed, _, err := a.Add(part)
480+ require.NoError(t, err)
481+ require.False(t, completed)
482+ }
483+ // Staging a newer revision does not disturb the committed snapshot.
484+ committed, ok = a.Snapshot()
485+ require.True(t, ok)
486+ require.Equal(t, ports.BrokerRevision(1), committed.Revision)
487+ require.Len(t, committed.Removed, 1)
488+
489+ completed, published, err := a.Add(second[len(second)-1])
490+ require.NoError(t, err)
491+ require.True(t, completed)
492+ require.Equal(t, ports.BrokerRevision(2), published.Revision)
493+ require.Empty(t, published.Removed)
494+
495+ // The committed snapshot is immutable to the caller: mutating a returned
496+ // value never reaches the assembler.
497+ published.Hosts[0].Endpoint = "mutated@nowhere:22"
498+ published.Hosts[0].Sessions[0].Tabs = []catalogue.RemoteCatalogTab{{ID: "injected", Index: 0, Name: "injected"}}
499+ published.Hosts[0].Sessions[0].Name = "mutated"
500+ again, ok := a.Snapshot()
501+ require.True(t, ok)
502+ require.Equal(t, "user0@host0:22", again.Hosts[0].Endpoint)
503+ require.Equal(t, "s00-000", again.Hosts[0].Sessions[0].Name)
504+ require.Empty(t, again.Hosts[0].Sessions[0].Tabs)
505+
506+ // An aborted transfer retains the committed snapshot.
507+ require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)[:2]))
508+ _, _, err = a.Add(snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)[4])
509+ require.ErrorIs(t, err, ErrSnapshotInvalid)
510+ again, ok = a.Snapshot()
511+ require.True(t, ok)
512+ require.Equal(t, ports.BrokerRevision(2), again.Revision)
513+}
514+
515+// TestSnapshotAssemblerInterruptedRetention proves an interrupted transfer is
516+// discarded while the committed snapshot survives, and that a restarted
517+// transfer starts from index 0.
518+func TestSnapshotAssemblerInterruptedRetention(t *testing.T) {
519+ a := testSnapshotAssembler(t)
520+ parts := snapshotPartsFor(5, 1, sampleSnapshotHosts(), sampleSnapshotTombstones())
521+ require.NoError(t, runSnapshotParts(a, parts))
522+ committed, ok := a.Snapshot()
523+ require.True(t, ok)
524+ require.Equal(t, ports.BrokerRevision(1), committed.Revision)
525+
526+ restart := snapshotPartsFor(5, 2, sampleSnapshotHosts(), sampleSnapshotTombstones())
527+ require.NoError(t, runSnapshotParts(a, restart[:3]))
528+ require.True(t, a.StagingActive())
529+ a.DiscardStaging()
530+ require.False(t, a.StagingActive())
531+ committed, ok = a.Snapshot()
532+ require.True(t, ok)
533+ require.Equal(t, ports.BrokerRevision(1), committed.Revision)
534+
535+ // A restarted transfer must begin again at index 0, and re-sent parts are
536+ // accepted from the start.
537+ stagePart(t, a, restart[0])
538+ require.True(t, a.StagingActive())
539+ _, _, err := a.Add(restart[2])
540+ require.ErrorIs(t, err, ErrSnapshotInvalid)
541+ require.False(t, a.StagingActive())
542+
543+ stagePart(t, a, restart[0])
544+ for _, part := range restart[1:] {
545+ _, _, err := a.Add(part)
546+ require.NoError(t, err)
547+ }
548+ committed, ok = a.Snapshot()
549+ require.True(t, ok)
550+ require.Equal(t, ports.BrokerRevision(2), committed.Revision)
551+}
552+
553+// TestSnapshotAssemblerRevisionFencing proves older revisions are refused,
554+// equal revisions are allowed, and a newer revision coalesces by replacing
555+// staging.
556+func TestSnapshotAssemblerRevisionFencing(t *testing.T) {
557+ a := testSnapshotAssembler(t)
558+ hosts := sampleSnapshotHosts()
559+ require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 5, hosts, nil)))
560+
561+ t.Run("older revision refused", func(t *testing.T) {
562+ _, _, err := a.Add(snapshotPartsFor(5, 4, hosts, nil)[0])
563+ require.ErrorIs(t, err, ErrSnapshotStale)
564+ require.False(t, a.StagingActive())
565+ committed, ok := a.Snapshot()
566+ require.True(t, ok)
567+ require.Equal(t, ports.BrokerRevision(5), committed.Revision)
568+ })
569+
570+ t.Run("equal revision republishes", func(t *testing.T) {
571+ require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 5, hosts, sampleSnapshotTombstones())))
572+ committed, ok := a.Snapshot()
573+ require.True(t, ok)
574+ require.Equal(t, ports.BrokerRevision(5), committed.Revision)
575+ require.Len(t, committed.Removed, 1)
576+ })
577+
578+ t.Run("newer revision coalesces", func(t *testing.T) {
579+ newer := snapshotPartsFor(5, 6, hosts, nil)
580+ stagePart(t, a, newer[0])
581+ require.True(t, a.StagingActive())
582+ // A part of the superseded staging revision is refused and staging is
583+ // retained intact.
584+ _, _, err := a.Add(snapshotPartsFor(5, 5, hosts, nil)[1])
585+ require.ErrorIs(t, err, ErrSnapshotStale)
586+ require.True(t, a.StagingActive())
587+ // The fresh transfer restarts at index 1.
588+ for _, part := range newer[1:] {
589+ _, _, err := a.Add(part)
590+ require.NoError(t, err)
591+ }
592+ committed, ok := a.Snapshot()
593+ require.True(t, ok)
594+ require.Equal(t, ports.BrokerRevision(6), committed.Revision)
595+ })
596+
597+ t.Run("revision below committed refused while staging", func(t *testing.T) {
598+ stagePart(t, a, snapshotPartsFor(5, 6, hosts, nil)[0])
599+ require.True(t, a.StagingActive())
600+ _, _, err := a.Add(snapshotPartsFor(5, 5, hosts, nil)[0])
601+ require.ErrorIs(t, err, ErrSnapshotStale)
602+ require.True(t, a.StagingActive())
603+ })
604+}
605+
606+// TestSnapshotAssemblerCoalescing proves a Begin of the same generation
607+// replaces staging: equal revisions restart progress, newer revisions
608+// coalesce.
609+func TestSnapshotAssemblerCoalescing(t *testing.T) {
610+ hosts := sampleSnapshotHosts()
611+ base := snapshotPartsFor(5, 3, hosts, nil)
612+
613+ a := testSnapshotAssembler(t)
614+ stagePart(t, a, base[0])
615+ stagePart(t, a, base[1])
616+ require.True(t, a.StagingActive())
617+ // Same revision restarts the transfer: progress is discarded.
618+ stagePart(t, a, base[0])
619+ require.True(t, a.StagingActive())
620+ for _, part := range base[1:] {
621+ _, _, err := a.Add(part)
622+ require.NoError(t, err)
623+ }
624+ committed, ok := a.Snapshot()
625+ require.True(t, ok)
626+ require.Equal(t, ports.BrokerRevision(3), committed.Revision)
627+
628+ // A newer revision coalesces and also resets progress.
629+ newer := snapshotPartsFor(5, 4, hosts, nil)
630+ require.NoError(t, runSnapshotParts(a, newer[:3]))
631+ require.True(t, a.StagingActive())
632+ stagePart(t, a, newer[0])
633+ require.True(t, a.StagingActive())
634+ for _, part := range newer[1:] {
635+ _, _, err := a.Add(part)
636+ require.NoError(t, err)
637+ }
638+ committed, ok = a.Snapshot()
639+ require.True(t, ok)
640+ require.Equal(t, ports.BrokerRevision(4), committed.Revision)
641+}
642+
643+// TestSnapshotAssemblerGenerationFencing proves the assembler adopts exactly
644+// one generation, ignores stale parts, rejects future parts, and refuses a
645+// zero generation.
646+func TestSnapshotAssemblerGenerationFencing(t *testing.T) {
647+ hosts := sampleSnapshotHosts()
648+ base := snapshotPartsFor(5, 3, hosts, nil)
649+
650+ t.Run("zero generation refused", func(t *testing.T) {
651+ a := testSnapshotAssembler(t)
652+ part := base[0]
653+ part.Generation = 0
654+ _, _, err := a.Add(part)
655+ require.ErrorIs(t, err, ErrInvalidGeneration)
656+ require.False(t, a.StagingActive())
657+ })
658+
659+ t.Run("stale ignored and future rejected while staging", func(t *testing.T) {
660+ a := testSnapshotAssembler(t)
661+ stagePart(t, a, base[0])
662+ stale := base[1]
663+ stale.Generation = 4
664+ _, _, err := a.Add(stale)
665+ require.ErrorIs(t, err, ErrStaleGeneration)
666+ require.True(t, a.StagingActive())
667+ future := base[1]
668+ future.Generation = 6
669+ _, _, err = a.Add(future)
670+ require.ErrorIs(t, err, ErrFutureGeneration)
671+ require.True(t, a.StagingActive())
672+ for _, part := range base[1:] {
673+ _, _, err := a.Add(part)
674+ require.NoError(t, err)
675+ }
676+ committed, ok := a.Snapshot()
677+ require.True(t, ok)
678+ require.Equal(t, ports.BrokerRevision(3), committed.Revision)
679+ })
680+
681+ t.Run("generation is adopted for the assembler lifetime", func(t *testing.T) {
682+ a := testSnapshotAssembler(t)
683+ require.NoError(t, runSnapshotParts(a, base))
684+ stale := base[1]
685+ stale.Generation = 4
686+ _, _, err := a.Add(stale)
687+ require.ErrorIs(t, err, ErrStaleGeneration)
688+ future := base[0]
689+ future.Generation = 6
690+ _, _, err = a.Add(future)
691+ require.ErrorIs(t, err, ErrFutureGeneration)
692+ committed, ok := a.Snapshot()
693+ require.True(t, ok)
694+ require.Equal(t, ports.BrokerRevision(3), committed.Revision)
695+ // The same generation may republish a newer revision.
696+ require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 4, hosts, nil)))
697+ committed, ok = a.Snapshot()
698+ require.True(t, ok)
699+ require.Equal(t, ports.BrokerRevision(4), committed.Revision)
700+ })
701+}
702+
703+// TestSnapshotAssemblerScopeFencing proves every part must carry the exact
704+// assigned epoch and connection, whether or not a transfer is in flight.
705+func TestSnapshotAssemblerScopeFencing(t *testing.T) {
706+ base := snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)
707+ cases := []struct {
708+ name string
709+ apply func(*SnapshotPart)
710+ }{
711+ {"epoch", func(p *SnapshotPart) { p.Epoch = 8 }},
712+ {"epoch zero", func(p *SnapshotPart) { p.Epoch = 0 }},
713+ {"connection", func(p *SnapshotPart) { p.Connection = testConnectionID(0x99) }},
714+ {"connection zero", func(p *SnapshotPart) { p.Connection = ports.BrokerConnectionID{} }},
715+ }
716+ for _, tc := range cases {
717+ t.Run(tc.name, func(t *testing.T) {
718+ a := testSnapshotAssembler(t)
719+ part := base[0]
720+ tc.apply(&part)
721+ _, _, err := a.Add(part)
722+ require.ErrorIs(t, err, ErrScopeMismatch)
723+ require.False(t, a.StagingActive())
724+
725+ // With staging in flight the mismatched part is still refused and
726+ // staging is retained.
727+ stagePart(t, a, base[0])
728+ part = base[1]
729+ tc.apply(&part)
730+ _, _, err = a.Add(part)
731+ require.ErrorIs(t, err, ErrScopeMismatch)
732+ require.True(t, a.StagingActive())
733+ })
734+ }
735+}
736+
737+// TestSnapshotAssemblerZeroRevision proves a zero revision is refused.
738+func TestSnapshotAssemblerZeroRevision(t *testing.T) {
739+ a := testSnapshotAssembler(t)
740+ part := snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)[0]
741+ part.Revision = 0
742+ _, _, err := a.Add(part)
743+ require.ErrorIs(t, err, ErrSnapshotInvalid)
744+ require.False(t, a.StagingActive())
745+}
746+
747+// TestSnapshotAssemblerNilAndEmptySafety proves the nil-receiver and
748+// no-transfer paths.
749+func TestSnapshotAssemblerNilAndEmptySafety(t *testing.T) {
750+ var a *SnapshotAssembler
751+ _, ok := a.Snapshot()
752+ require.False(t, ok)
753+ require.False(t, a.StagingActive())
754+ a.DiscardStaging()
755+ _, _, err := a.Add(snapshotPartsFor(5, 3, nil, nil)[0])
756+ require.ErrorIs(t, err, ErrSnapshotInvalid)
757+
758+ fresh := testSnapshotAssembler(t)
759+ _, ok = fresh.Snapshot()
760+ require.False(t, ok)
761+ extra := SnapshotPart{Epoch: 7, Connection: testConnectionID(0x21), Generation: 5, Revision: 3, Index: 1, Part: SnapshotEnd{}}
762+ _, _, err = fresh.Add(extra)
763+ require.ErrorIs(t, err, ErrSnapshotInvalid)
764+ _, _, err = fresh.Add(SnapshotPart{Epoch: 7, Connection: testConnectionID(0x21), Generation: 5, Revision: 3, Index: 0, Part: nil})
765+ require.ErrorIs(t, err, ErrSnapshotInvalid)
766+}
767+
768+// TestSnapshotAssemblerPinnedGeneration proves WithGeneration fences every
769+// part from the first one.
770+func TestSnapshotAssemblerPinnedGeneration(t *testing.T) {
771+ a := testSnapshotAssembler(t, WithGeneration(5))
772+ stale := snapshotPartsFor(4, 1, sampleSnapshotHosts(), nil)[0]
773+ _, _, err := a.Add(stale)
774+ require.ErrorIs(t, err, ErrStaleGeneration)
775+ future := snapshotPartsFor(6, 1, sampleSnapshotHosts(), nil)[0]
776+ _, _, err = a.Add(future)
777+ require.ErrorIs(t, err, ErrFutureGeneration)
778+ require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 1, sampleSnapshotHosts(), nil)))
779+ committed, ok := a.Snapshot()
780+ require.True(t, ok)
781+ require.Equal(t, ports.BrokerRevision(1), committed.Revision)
782+}
783+
784+// TestSnapshotAssemblerAdoptCommitted proves a committed snapshot can be
785+// handed to a fresh assembler of the same scope, so a generation advance
786+// keeps publishing it until a newer transfer commits.
787+func TestSnapshotAssemblerAdoptCommitted(t *testing.T) {
788+ source := testSnapshotAssembler(t)
789+ require.NoError(t, runSnapshotParts(source, snapshotPartsFor(5, 3, sampleSnapshotHosts(), sampleSnapshotTombstones())))
790+ committed, ok := source.Snapshot()
791+ require.True(t, ok)
792+
793+ target := testSnapshotAssembler(t, WithGeneration(6))
794+ require.False(t, target.StagingActive())
795+ _, ok = target.Snapshot()
796+ require.False(t, ok)
797+ target.adoptCommitted(committed)
798+ adopted, ok := target.Snapshot()
799+ require.True(t, ok)
800+ require.Equal(t, ports.BrokerRevision(3), adopted.Revision)
801+ require.Len(t, adopted.Removed, 1)
802+
803+ // An older revision and a foreign epoch are ignored.
804+ target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch, Revision: 2, Hosts: committed.Hosts})
805+ target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch + 1, Revision: 9, Hosts: committed.Hosts})
806+ adopted, ok = target.Snapshot()
807+ require.True(t, ok)
808+ require.Equal(t, ports.BrokerRevision(3), adopted.Revision)
809+
810+ // A newer transfer commits over the adopted snapshot.
811+ require.NoError(t, runSnapshotParts(target, snapshotPartsFor(6, 4, sampleSnapshotHosts(), nil)))
812+ adopted, ok = target.Snapshot()
813+ require.True(t, ok)
814+ require.Equal(t, ports.BrokerRevision(4), adopted.Revision)
815+ require.Empty(t, adopted.Removed)
816+
817+ // The adopted value is independent of the source.
818+ adopted.Hosts[0].Endpoint = "mutated@nowhere:22"
819+ again, ok := source.Snapshot()
820+ require.True(t, ok)
821+ require.Equal(t, "user0@host0:22", again.Hosts[0].Endpoint)
822+}
823+
824+// TestSnapshotAssemblerConcurrentReaders is a deterministic race test: one
825+// writer publishes sequential revisions while readers observe the committed
826+// snapshot and staging state.
827+func TestSnapshotAssemblerConcurrentReaders(t *testing.T) {
828+ a := testSnapshotAssembler(t)
829+ const revisions = 40
830+ const readers = 4
831+ var wg sync.WaitGroup
832+ done := make(chan struct{})
833+ for i := 0; i < readers; i++ {
834+ wg.Add(1)
835+ go func() {
836+ defer wg.Done()
837+ for {
838+ select {
839+ case <-done:
840+ return
841+ default:
842+ }
843+ if snapshot, ok := a.Snapshot(); ok {
844+ assert.NotZero(t, snapshot.Revision)
845+ assert.NotZero(t, snapshot.Epoch)
846+ }
847+ _ = a.StagingActive()
848+ }
849+ }()
850+ }
851+ for revision := 1; revision <= revisions; revision++ {
852+ parts := snapshotPartsFor(5, ports.BrokerRevision(revision), sampleSnapshotHosts(), nil)
853+ for _, part := range parts {
854+ _, _, err := a.Add(part)
855+ require.NoError(t, err)
856+ }
857+ }
858+ close(done)
859+ wg.Wait()
860+ snapshot, ok := a.Snapshot()
861+ require.True(t, ok)
862+ require.Equal(t, ports.BrokerRevision(revisions), snapshot.Revision)
863+ require.False(t, a.StagingActive())
864+}
865+
866+// assemblerFrames concatenates 4-byte big-endian length-prefixed frames, so one
867+// fuzz input can drive a whole part sequence.
868+func assemblerFrames(frames ...[]byte) []byte {
869+ out := make([]byte, 0)
870+ for _, frame := range frames {
871+ var header [4]byte
872+ binary.BigEndian.PutUint32(header[:], uint32(len(frame)))
873+ out = append(out, header[:]...)
874+ out = append(out, frame...)
875+ }
876+ return out
877+}
878+
879+// fuzzSnapshotAssemblerSeeds is the starting corpus: one complete valid
880+// transfer, a valid staged prefix, empty input, and corrupt variants (a flipped
881+// byte in one frame and a frame whose length prefix overruns the payload).
882+func fuzzSnapshotAssemblerSeeds(tb testing.TB) [][]byte {
883+ tb.Helper()
884+ encode := func(part SnapshotPart) []byte {
885+ raw, err := EncodeServer(part, testEnvelopeCeiling, testChunkCeiling)
886+ require.NoError(tb, err)
887+ return raw
888+ }
889+ parts := snapshotPartsFor(4, 2, sampleSnapshotHosts(), sampleSnapshotTombstones())
890+ frames := make([][]byte, 0, len(parts))
891+ for _, part := range parts {
892+ frames = append(frames, encode(part))
893+ }
894+ corrupt := append([]byte(nil), frames[1]...)
895+ corrupt[len(corrupt)-1] ^= 0xFF
896+ truncated := append([]byte(nil), frames[2][:len(frames[2])-1]...)
897+ return [][]byte{
898+ assemblerFrames(frames...),
899+ assemblerFrames(frames[:len(frames)-1]...),
900+ assemblerFrames(frames[0], corrupt),
901+ assemblerFrames(frames[0], truncated),
902+ nil,
903+ }
904+}
905+
906+// FuzzSnapshotAssembler proves arbitrary part sequences never panic the
907+// assembler, never publish an invalid snapshot, and never retain an invalid
908+// committed snapshot. Input is a sequence of 4-byte big-endian length-prefixed
909+// frames, each a serialized SnapshotPart envelope; frames that fail strict
910+// scanning or semantic conversion are dropped, and every accepted part is
911+// staged in order.
912+func FuzzSnapshotAssembler(f *testing.F) {
913+ for _, seed := range fuzzSnapshotAssemblerSeeds(f) {
914+ f.Add(seed)
915+ }
916+ f.Fuzz(func(t *testing.T, payload []byte) {
917+ assembler := testSnapshotAssembler(t, WithMaxStagedBytes(1<<20))
918+ for len(payload) >= 4 {
919+ size := int(binary.BigEndian.Uint32(payload[:4]))
920+ payload = payload[4:]
921+ if size <= 0 || size > len(payload) {
922+ return
923+ }
924+ frame := payload[:size]
925+ payload = payload[size:]
926+ message, err := DecodeServer(frame, testEnvelopeCeiling, testChunkCeiling)
927+ if err != nil {
928+ continue
929+ }
930+ part, ok := message.(SnapshotPart)
931+ if !ok {
932+ continue
933+ }
934+ completed, snapshot, err := assembler.Add(part)
935+ if err != nil {
936+ continue
937+ }
938+ if completed {
939+ requireValidBrokerSnapshot(t, snapshot)
940+ }
941+ }
942+ if snapshot, ok := assembler.Snapshot(); ok {
943+ requireValidBrokerSnapshot(t, snapshot)
944+ }
945+ })
946+}
947+
948+// requireValidBrokerSnapshot asserts one published snapshot is exactly what the
949+// assembler promises: a validated snapshot with durable host projections.
950+func requireValidBrokerSnapshot(t *testing.T, snapshot ports.BrokerSnapshot) {
951+ t.Helper()
952+ require.NoError(t, snapshot.Validate())
953+ for _, host := range snapshot.Hosts {
954+ require.NoError(t, ports.ValidateDurableHostProjection(host))
955+ }
956+}
A internal/adapters/brokerwire/state.go +913 -0
@@ -0,0 +1,913 @@
1+// Per-connection broker state (P3.1).
2+//
3+// Connection walks one exact lifecycle: New is entered when the listener
4+// assigns the connection scope, one locally validated preamble moves it to
5+// AwaitRegister, the first Register moves it to Ready, and shutdown runs
6+// Closing then Closed. Every later frame must carry the scope assigned at
7+// accept: a mismatched epoch or connection is rejected before it can touch
8+// connection state.
9+//
10+// The connection owns one subscription generation series, one snapshot
11+// assembler per active generation, one bounded operation tracker, and one
12+// bounded stream tracker. Subscription generations advance strictly: a
13+// resubscribe must carry a newer generation, while a resync or unsubscribe
14+// must carry the active one. A generation advance discards in-flight
15+// snapshot staging; the committed snapshot survives until a newer transfer
16+// commits. Operation and stream state is scoped to exactly this connection
17+// and epoch.
18+//
19+// There is no I/O, socket, pump, or P3.2 transport here.
20+package brokerwire
21+
22+import (
23+ "errors"
24+ "fmt"
25+ "sync"
26+
27+ "github.com/bnema/vev/internal/ports"
28+)
29+
30+// Per-connection bounds.
31+const (
32+ // MaxPendingOperations bounds admitted, unfinished mutating operations
33+ // per connection.
34+ MaxPendingOperations = 64
35+ // MaxTrackedOperations bounds the completed-operation dedup set.
36+ MaxTrackedOperations = 4096
37+ // operationIDBytes is the encoded size of one operation identity.
38+ operationIDBytes = 16
39+ // MaxOperationDedupBytes bounds the completed-operation dedup memory:
40+ // MaxTrackedOperations identities of 16 bytes each (64 KiB).
41+ MaxOperationDedupBytes = MaxTrackedOperations * operationIDBytes
42+ // MaxStreams bounds concurrently opening or open logical streams per
43+ // connection.
44+ MaxStreams = 128
45+ // MaxRetiredStreams bounds retired stream records retained per
46+ // connection so a late frame can be classified and discarded instead of
47+ // mistaken for a future stream.
48+ MaxRetiredStreams = 128
49+)
50+
51+var (
52+ // ErrInvalidScope reports an incomplete broker scope: a zero epoch or a
53+ // zero connection identity.
54+ ErrInvalidScope = errors.New("brokerwire: invalid broker scope")
55+ // ErrConnectionState reports an operation that is not legal in the
56+ // connection's current lifecycle state.
57+ ErrConnectionState = errors.New("brokerwire: invalid connection state")
58+ // ErrConnectionClosed reports work presented to a closing or closed
59+ // connection.
60+ ErrConnectionClosed = errors.New("brokerwire: connection is closed")
61+ // ErrDuplicateRegister reports a second Register on one connection.
62+ ErrDuplicateRegister = errors.New("brokerwire: duplicate register")
63+ // ErrNotSubscribed reports a snapshot part or resync without an active
64+ // subscription.
65+ ErrNotSubscribed = errors.New("brokerwire: connection has no active subscription")
66+ // ErrInvalidOperation reports a zero operation identity.
67+ ErrInvalidOperation = errors.New("brokerwire: invalid broker operation")
68+ // ErrInvalidOutcome reports an outcome outside the closed mutation
69+ // taxonomy.
70+ ErrInvalidOutcome = errors.New("brokerwire: invalid broker mutation outcome")
71+ // ErrOperationPending reports a duplicate admission of an in-flight
72+ // operation.
73+ ErrOperationPending = errors.New("brokerwire: operation is already pending")
74+ // ErrOperationCompleted reports an operation that already has a recorded
75+ // outcome. It is deduped and never replayed.
76+ ErrOperationCompleted = errors.New("brokerwire: operation already has an outcome")
77+ // ErrTooManyPendingOperations reports a full pending set.
78+ ErrTooManyPendingOperations = errors.New("brokerwire: too many pending operations")
79+ // ErrUnknownOperation reports a completion for an operation that was
80+ // never admitted, including one whose dedup record was evicted.
81+ ErrUnknownOperation = errors.New("brokerwire: unknown operation")
82+ // ErrInvalidStream reports a zero stream identity.
83+ ErrInvalidStream = errors.New("brokerwire: invalid broker stream")
84+ // ErrStreamIDReused reports a stream identity that is not strictly above
85+ // every allocated identity: stream IDs strictly increase and are never
86+ // reused.
87+ ErrStreamIDReused = errors.New("brokerwire: stream ID is not fresh")
88+ // ErrFutureStream reports a frame for a stream identity that was never
89+ // allocated.
90+ ErrFutureStream = errors.New("brokerwire: stream was never opened")
91+ // ErrTooManyStreams reports the per-connection concurrent stream
92+ // ceiling.
93+ ErrTooManyStreams = errors.New("brokerwire: too many open streams")
94+ // ErrStreamState reports an event that is illegal in the stream's
95+ // current state.
96+ ErrStreamState = errors.New("brokerwire: invalid stream state")
97+)
98+
99+// Scope is one exact broker connection scope: the broker epoch plus the
100+// connection identity assigned at accept. Every frame carries it, and a
101+// mismatched scope is rejected rather than applied.
102+type Scope struct {
103+ Epoch ports.BrokerEpoch
104+ Connection ports.BrokerConnectionID
105+}
106+
107+// Validate rejects an incomplete scope.
108+func (s Scope) Validate() error {
109+ if s.Epoch == 0 {
110+ return fmt.Errorf("%w: missing epoch", ErrInvalidScope)
111+ }
112+ if err := s.Connection.Validate(); err != nil {
113+ return fmt.Errorf("%w: %v", ErrInvalidScope, err)
114+ }
115+ return nil
116+}
117+
118+// ConnState is the closed broker connection lifecycle.
119+type ConnState uint8
120+
121+const (
122+ // ConnNew is the accepted connection state: only a preamble is legal.
123+ ConnNew ConnState = iota
124+ // ConnAwaitRegister waits for exactly one Register after the preamble.
125+ ConnAwaitRegister
126+ // ConnReady accepts subscription, snapshot, operation, and stream work.
127+ ConnReady
128+ // ConnClosing has stopped accepting new work and discarded in-flight
129+ // snapshot staging.
130+ ConnClosing
131+ // ConnClosed has released all connection state.
132+ ConnClosed
133+)
134+
135+func (s ConnState) String() string {
136+ switch s {
137+ case ConnNew:
138+ return "new"
139+ case ConnAwaitRegister:
140+ return "await_register"
141+ case ConnReady:
142+ return "ready"
143+ case ConnClosing:
144+ return "closing"
145+ case ConnClosed:
146+ return "closed"
147+ default:
148+ return "unknown"
149+ }
150+}
151+
152+// Connection is the per-connection broker state machine: handshake
153+// progression, one subscription generation series, one snapshot assembler
154+// per active generation, and the bounded operation and stream trackers for
155+// this exact scope. It is safe for concurrent use.
156+type Connection struct {
157+ mu sync.Mutex
158+ scope Scope
159+ state ConnState
160+ operations *OperationTracker
161+ streams *StreamTracker
162+
163+ subscribed bool
164+ generation SubscriptionGeneration
165+ highest SubscriptionGeneration
166+ assembler *SnapshotAssembler
167+}
168+
169+// NewConnection binds one connection to the exact scope assigned at accept.
170+func NewConnection(scope Scope) (*Connection, error) {
171+ if err := scope.Validate(); err != nil {
172+ return nil, err
173+ }
174+ operations, err := newOperationTracker(scope)
175+ if err != nil {
176+ return nil, err
177+ }
178+ streams, err := newStreamTracker(scope)
179+ if err != nil {
180+ return nil, err
181+ }
182+ return &Connection{scope: scope, state: ConnNew, operations: operations, streams: streams}, nil
183+}
184+
185+// State returns the current lifecycle state.
186+func (c *Connection) State() ConnState {
187+ if c == nil {
188+ return ConnClosed
189+ }
190+ c.mu.Lock()
191+ defer c.mu.Unlock()
192+ return c.state
193+}
194+
195+// Scope returns the exact scope assigned at accept.
196+func (c *Connection) Scope() Scope {
197+ if c == nil {
198+ return Scope{}
199+ }
200+ c.mu.Lock()
201+ defer c.mu.Unlock()
202+ return c.scope
203+}
204+
205+// Ready reports whether the handshake completed.
206+func (c *Connection) Ready() bool { return c.State() == ConnReady }
207+
208+// SignalPreamble records one locally validated preamble. It is legal exactly
209+// once, in New, and moves the connection to AwaitRegister.
210+func (c *Connection) SignalPreamble() error {
211+ if c == nil {
212+ return ErrConnectionClosed
213+ }
214+ c.mu.Lock()
215+ defer c.mu.Unlock()
216+ if c.state != ConnNew {
217+ return c.stateErrorLocked()
218+ }
219+ c.state = ConnAwaitRegister
220+ return nil
221+}
222+
223+// Register accepts the first Register and moves to Ready. A second Register,
224+// or one before the preamble, is refused.
225+func (c *Connection) Register() error {
226+ if c == nil {
227+ return ErrConnectionClosed
228+ }
229+ c.mu.Lock()
230+ defer c.mu.Unlock()
231+ switch c.state {
232+ case ConnAwaitRegister:
233+ c.state = ConnReady
234+ return nil
235+ case ConnReady:
236+ return ErrDuplicateRegister
237+ default:
238+ return c.stateErrorLocked()
239+ }
240+}
241+
242+// BeginClose stops accepting new work and discards in-flight snapshot
243+// staging. The committed snapshot survives until the connection is finally
244+// closed. It is idempotent.
245+func (c *Connection) BeginClose() error {
246+ if c == nil {
247+ return nil
248+ }
249+ c.mu.Lock()
250+ defer c.mu.Unlock()
251+ switch c.state {
252+ case ConnClosing, ConnClosed:
253+ return nil
254+ }
255+ c.state = ConnClosing
256+ c.subscribed = false
257+ c.generation = 0
258+ if c.assembler != nil {
259+ c.assembler.DiscardStaging()
260+ }
261+ return nil
262+}
263+
264+// FinishClose completes shutdown and releases operation and stream state.
265+// Only a closing connection may close; a second call is a no-op.
266+func (c *Connection) FinishClose() error {
267+ if c == nil {
268+ return nil
269+ }
270+ c.mu.Lock()
271+ defer c.mu.Unlock()
272+ switch c.state {
273+ case ConnClosed:
274+ return nil
275+ case ConnClosing:
276+ c.state = ConnClosed
277+ c.assembler = nil
278+ c.operations.reset()
279+ c.streams.reset()
280+ return nil
281+ default:
282+ return ErrConnectionState
283+ }
284+}
285+
286+// Subscribe starts one subscription generation series. The generation must be
287+// nonzero and strictly above every generation this connection has used, so a
288+// superseded series can never be revived. A new generation discards any
289+// in-flight staging and keeps publishing the committed snapshot until a newer
290+// transfer commits.
291+func (c *Connection) Subscribe(generation uint64) error {
292+ if c == nil {
293+ return ErrConnectionClosed
294+ }
295+ c.mu.Lock()
296+ defer c.mu.Unlock()
297+ if err := c.requireReadyLocked(); err != nil {
298+ return err
299+ }
300+ if generation == 0 {
301+ return ErrInvalidGeneration
302+ }
303+ if generation <= c.highest {
304+ return ErrStaleGeneration
305+ }
306+ var committed ports.BrokerSnapshot
307+ var hasCommitted bool
308+ if c.assembler != nil {
309+ committed, hasCommitted = c.assembler.Snapshot()
310+ }
311+ next := NewSnapshotAssembler(c.scope.Epoch, c.scope.Connection, WithGeneration(generation))
312+ if hasCommitted {
313+ next.adoptCommitted(committed)
314+ }
315+ c.subscribed = true
316+ c.generation = generation
317+ c.highest = generation
318+ c.assembler = next
319+ return nil
320+}
321+
322+// Resync restarts the active generation's publication: it must carry the
323+// active generation exactly, and it discards any in-flight staging so a full
324+// transfer can restart at index 0.
325+func (c *Connection) Resync(generation uint64) error {
326+ if c == nil {
327+ return ErrConnectionClosed
328+ }
329+ c.mu.Lock()
330+ defer c.mu.Unlock()
331+ if err := c.requireReadyLocked(); err != nil {
332+ return err
333+ }
334+ if !c.subscribed {
335+ return ErrNotSubscribed
336+ }
337+ if generation != c.generation {
338+ return generationFence(generation, c.generation)
339+ }
340+ c.assembler.DiscardStaging()
341+ return nil
342+}
343+
344+// Unsubscribe ends the active generation. It must carry the active generation
345+// exactly; the generation remains consumed and is never reused. The committed
346+// snapshot survives until the connection is finally closed.
347+func (c *Connection) Unsubscribe(generation uint64) error {
348+ if c == nil {
349+ return ErrConnectionClosed
350+ }
351+ c.mu.Lock()
352+ defer c.mu.Unlock()
353+ if err := c.requireReadyLocked(); err != nil {
354+ return err
355+ }
356+ if !c.subscribed {
357+ return ErrNotSubscribed
358+ }
359+ if generation != c.generation {
360+ return generationFence(generation, c.generation)
361+ }
362+ c.subscribed = false
363+ c.generation = 0
364+ if c.assembler != nil {
365+ c.assembler.DiscardStaging()
366+ }
367+ return nil
368+}
369+
370+// AddSnapshotPart stages one part of the active generation's publication. The
371+// committed snapshot changes only when End validates. The ready check, the
372+// subscription check, and the assembler mutation share one connection lock, so
373+// a concurrent close can never commit a part after it discarded staging.
374+func (c *Connection) AddSnapshotPart(part SnapshotPart) (bool, ports.BrokerSnapshot, error) {
375+ if c == nil {
376+ return false, ports.BrokerSnapshot{}, ErrConnectionClosed
377+ }
378+ c.mu.Lock()
379+ defer c.mu.Unlock()
380+ if err := c.requireReadyLocked(); err != nil {
381+ return false, ports.BrokerSnapshot{}, err
382+ }
383+ if !c.subscribed || c.assembler == nil {
384+ return false, ports.BrokerSnapshot{}, ErrNotSubscribed
385+ }
386+ return c.assembler.Add(part)
387+}
388+
389+// Snapshot returns the last committed snapshot and whether one exists.
390+func (c *Connection) Snapshot() (ports.BrokerSnapshot, bool) {
391+ if c == nil {
392+ return ports.BrokerSnapshot{}, false
393+ }
394+ c.mu.Lock()
395+ assembler := c.assembler
396+ c.mu.Unlock()
397+ if assembler == nil {
398+ return ports.BrokerSnapshot{}, false
399+ }
400+ return assembler.Snapshot()
401+}
402+
403+// StagingActive reports whether a snapshot transfer is in flight for the
404+// active subscription generation.
405+func (c *Connection) StagingActive() bool {
406+ if c == nil {
407+ return false
408+ }
409+ c.mu.Lock()
410+ assembler := c.assembler
411+ c.mu.Unlock()
412+ if assembler == nil {
413+ return false
414+ }
415+ return assembler.StagingActive()
416+}
417+
418+// AdmitOperation records one in-flight mutating operation for this scope.
419+// The ready check and the tracker mutation share one connection lock, so a
420+// concurrent close can never admit work after shutdown.
421+func (c *Connection) AdmitOperation(operation ports.BrokerOperationID) error {
422+ if c == nil {
423+ return ErrConnectionClosed
424+ }
425+ c.mu.Lock()
426+ defer c.mu.Unlock()
427+ if err := c.requireReadyLocked(); err != nil {
428+ return err
429+ }
430+ return c.operations.Admit(c.scope, operation)
431+}
432+
433+// CompleteOperation records exactly one outcome for an in-flight operation.
434+// The ready check and the tracker mutation share one connection lock.
435+func (c *Connection) CompleteOperation(operation ports.BrokerOperationID, outcome ports.BrokerMutationOutcome) error {
436+ if c == nil {
437+ return ErrConnectionClosed
438+ }
439+ c.mu.Lock()
440+ defer c.mu.Unlock()
441+ if err := c.requireReadyLocked(); err != nil {
442+ return err
443+ }
444+ return c.operations.Complete(c.scope, operation, outcome)
445+}
446+
447+// OpenStream allocates one fresh logical stream in this scope. The ready
448+// check and the tracker mutation share one connection lock.
449+func (c *Connection) OpenStream(stream ports.BrokerStreamID) error {
450+ if c == nil {
451+ return ErrConnectionClosed
452+ }
453+ c.mu.Lock()
454+ defer c.mu.Unlock()
455+ if err := c.requireReadyLocked(); err != nil {
456+ return err
457+ }
458+ return c.streams.Open(c.scope, stream)
459+}
460+
461+// StreamOpened confirms one logical stream the broker established. The
462+// ready check and the tracker mutation share one connection lock.
463+func (c *Connection) StreamOpened(stream ports.BrokerStreamID) error {
464+ if c == nil {
465+ return ErrConnectionClosed
466+ }
467+ c.mu.Lock()
468+ defer c.mu.Unlock()
469+ if err := c.requireReadyLocked(); err != nil {
470+ return err
471+ }
472+ return c.streams.Opened(c.scope, stream)
473+}
474+
475+// StreamData delivers one opaque daemon frame to an open stream. The ready
476+// check and the tracker mutation share one connection lock.
477+func (c *Connection) StreamData(stream ports.BrokerStreamID) (StreamDisposition, error) {
478+ if c == nil {
479+ return 0, ErrConnectionClosed
480+ }
481+ c.mu.Lock()
482+ defer c.mu.Unlock()
483+ if err := c.requireReadyLocked(); err != nil {
484+ return 0, err
485+ }
486+ return c.streams.Data(c.scope, stream)
487+}
488+
489+// StreamProgress reports one phase of an in-flight stream operation. The
490+// ready check and the tracker mutation share one connection lock.
491+func (c *Connection) StreamProgress(stream ports.BrokerStreamID) (StreamDisposition, error) {
492+ if c == nil {
493+ return 0, ErrConnectionClosed
494+ }
495+ c.mu.Lock()
496+ defer c.mu.Unlock()
497+ if err := c.requireReadyLocked(); err != nil {
498+ return 0, err
499+ }
500+ return c.streams.Progress(c.scope, stream)
501+}
502+
503+// CloseStream ends one logical stream. The ready check and the tracker
504+// mutation share one connection lock.
505+func (c *Connection) CloseStream(stream ports.BrokerStreamID) (StreamDisposition, error) {
506+ if c == nil {
507+ return 0, ErrConnectionClosed
508+ }
509+ c.mu.Lock()
510+ defer c.mu.Unlock()
511+ if err := c.requireReadyLocked(); err != nil {
512+ return 0, err
513+ }
514+ return c.streams.Close(c.scope, stream)
515+}
516+
517+func (c *Connection) requireReadyLocked() error {
518+ switch c.state {
519+ case ConnReady:
520+ return nil
521+ case ConnClosing, ConnClosed:
522+ return ErrConnectionClosed
523+ default:
524+ return ErrConnectionState
525+ }
526+}
527+
528+func (c *Connection) stateErrorLocked() error {
529+ if c.state == ConnClosing || c.state == ConnClosed {
530+ return ErrConnectionClosed
531+ }
532+ return ErrConnectionState
533+}
534+
535+// generationFence classifies a generation that is not the active one.
536+func generationFence(got, active uint64) error {
537+ if got < active {
538+ return ErrStaleGeneration
539+ }
540+ return ErrFutureGeneration
541+}
542+
543+// OperationTracker bounds the mutating operations admitted on one
544+// connection scope. At most MaxPendingOperations operations are in flight at
545+// once. Completed operations keep only their identity and outcome, bounded
546+// to MaxTrackedOperations identities (MaxOperationDedupBytes), so a repeated
547+// operation is deduped rather than re-executed inside that window.
548+// Outcome-unknown is recorded like any other outcome: the client refreshes
549+// authoritative state and never blindly replays a non-idempotent operation.
550+// It is safe for concurrent use.
551+type OperationTracker struct {
552+ mu sync.Mutex
553+ scope Scope
554+ pending map[ports.BrokerOperationID]struct{}
555+ results map[ports.BrokerOperationID]ports.BrokerMutationOutcome
556+ order []ports.BrokerOperationID
557+}
558+
559+// newOperationTracker binds a tracker to one exact connection scope. It is
560+// unexported on purpose: the trackers are connection-owned state, and every
561+// production mutation goes through the Connection methods that hold the
562+// connection lock across the ready check and the tracker mutation.
563+func newOperationTracker(scope Scope) (*OperationTracker, error) {
564+ if err := scope.Validate(); err != nil {
565+ return nil, err
566+ }
567+ return &OperationTracker{
568+ scope: scope,
569+ pending: make(map[ports.BrokerOperationID]struct{}, MaxPendingOperations),
570+ results: make(map[ports.BrokerOperationID]ports.BrokerMutationOutcome, MaxTrackedOperations),
571+ }, nil
572+}
573+
574+// Admit registers one mutating operation as in flight. A mismatched scope, a
575+// zero identity, a duplicate in-flight operation, an operation that already
576+// has a recorded outcome, and a full pending set are all refused.
577+func (t *OperationTracker) Admit(scope Scope, operation ports.BrokerOperationID) error {
578+ if t == nil {
579+ return ErrConnectionClosed
580+ }
581+ t.mu.Lock()
582+ defer t.mu.Unlock()
583+ if scope != t.scope {
584+ return ErrScopeMismatch
585+ }
586+ if operation.IsZero() {
587+ return ErrInvalidOperation
588+ }
589+ if _, done := t.results[operation]; done {
590+ return ErrOperationCompleted
591+ }
592+ if _, pending := t.pending[operation]; pending {
593+ return ErrOperationPending
594+ }
595+ if len(t.pending) >= MaxPendingOperations {
596+ return ErrTooManyPendingOperations
597+ }
598+ t.pending[operation] = struct{}{}
599+ return nil
600+}
601+
602+// Complete records exactly one outcome for an in-flight operation. The
603+// identity moves into the bounded dedup set, evicting the oldest record when
604+// full, so a repeat of a recently completed operation is deduped instead of
605+// replayed.
606+func (t *OperationTracker) Complete(scope Scope, operation ports.BrokerOperationID, outcome ports.BrokerMutationOutcome) error {
607+ if t == nil {
608+ return ErrConnectionClosed
609+ }
610+ t.mu.Lock()
611+ defer t.mu.Unlock()
612+ if scope != t.scope {
613+ return ErrScopeMismatch
614+ }
615+ if err := outcome.Validate(); err != nil {
616+ return ErrInvalidOutcome
617+ }
618+ if _, pending := t.pending[operation]; !pending {
619+ if _, done := t.results[operation]; done {
620+ return ErrOperationCompleted
621+ }
622+ return ErrUnknownOperation
623+ }
624+ delete(t.pending, operation)
625+ t.results[operation] = outcome
626+ t.order = append(t.order, operation)
627+ for len(t.order) > MaxTrackedOperations {
628+ evicted := t.order[0]
629+ t.order = t.order[1:]
630+ delete(t.results, evicted)
631+ }
632+ return nil
633+}
634+
635+// Pending returns the number of in-flight operations.
636+func (t *OperationTracker) Pending() int {
637+ if t == nil {
638+ return 0
639+ }
640+ t.mu.Lock()
641+ defer t.mu.Unlock()
642+ return len(t.pending)
643+}
644+
645+// Tracked returns the number of dedup records retained.
646+func (t *OperationTracker) Tracked() int {
647+ if t == nil {
648+ return 0
649+ }
650+ t.mu.Lock()
651+ defer t.mu.Unlock()
652+ return len(t.results)
653+}
654+
655+// DedupBytes returns the dedup memory in bytes: one fixed-size identity per
656+// retained record. It never exceeds MaxOperationDedupBytes.
657+func (t *OperationTracker) DedupBytes() int {
658+ return t.Tracked() * operationIDBytes
659+}
660+
661+// Outcome returns the recorded outcome for one operation.
662+func (t *OperationTracker) Outcome(operation ports.BrokerOperationID) (ports.BrokerMutationOutcome, bool) {
663+ if t == nil {
664+ return 0, false
665+ }
666+ t.mu.Lock()
667+ defer t.mu.Unlock()
668+ outcome, ok := t.results[operation]
669+ return outcome, ok
670+}
671+
672+func (t *OperationTracker) reset() {
673+ if t == nil {
674+ return
675+ }
676+ t.mu.Lock()
677+ defer t.mu.Unlock()
678+ t.pending = make(map[ports.BrokerOperationID]struct{}, MaxPendingOperations)
679+ t.results = make(map[ports.BrokerOperationID]ports.BrokerMutationOutcome, MaxTrackedOperations)
680+ t.order = nil
681+}
682+
683+// StreamState is the closed lifecycle of one logical stream.
684+type StreamState uint8
685+
686+const (
687+ // StreamOpening is an allocated stream the broker has not established.
688+ StreamOpening StreamState = iota + 1
689+ // StreamOpen carries data and progress.
690+ StreamOpen
691+ // StreamRetired is terminal: no further frame is applied.
692+ StreamRetired
693+)
694+
695+func (s StreamState) String() string {
696+ switch s {
697+ case StreamOpening:
698+ return "opening"
699+ case StreamOpen:
700+ return "open"
701+ case StreamRetired:
702+ return "retired"
703+ default:
704+ return "unknown"
705+ }
706+}
707+
708+// StreamDisposition classifies one stream frame: applied, or a late frame for
709+// a retired stream that is discarded without effect.
710+type StreamDisposition uint8
711+
712+const (
713+ // StreamAccepted reports the frame was applied.
714+ StreamAccepted StreamDisposition = iota + 1
715+ // StreamDiscarded reports a late frame for a retired stream. It is
716+ // ignored and never revives, reopens, or closes anything.
717+ StreamDiscarded
718+)
719+
720+// StreamTracker bounds the logical streams of one connection scope. Stream
721+// identities strictly increase and are never reused. At most MaxStreams
722+// streams are opening or open at once; at most MaxRetiredStreams retired
723+// records are retained, so an older late frame is still classified as
724+// retired rather than mistaken for a future stream. It is safe for
725+// concurrent use.
726+type StreamTracker struct {
727+ mu sync.Mutex
728+ scope Scope
729+ states map[ports.BrokerStreamID]StreamState
730+ retired []ports.BrokerStreamID
731+ highest ports.BrokerStreamID
732+ active int
733+}
734+
735+// newStreamTracker binds a tracker to one exact connection scope. It is
736+// unexported on purpose: the trackers are connection-owned state, and every
737+// production mutation goes through the Connection methods that hold the
738+// connection lock across the ready check and the tracker mutation.
739+func newStreamTracker(scope Scope) (*StreamTracker, error) {
740+ if err := scope.Validate(); err != nil {
741+ return nil, err
742+ }
743+ return &StreamTracker{scope: scope, states: make(map[ports.BrokerStreamID]StreamState, MaxStreams)}, nil
744+}
745+
746+// Open allocates one fresh logical stream in Opening state. The identity must
747+// be strictly above every allocated identity and must not already be in use.
748+func (t *StreamTracker) Open(scope Scope, stream ports.BrokerStreamID) error {
749+ if t == nil {
750+ return ErrConnectionClosed
751+ }
752+ t.mu.Lock()
753+ defer t.mu.Unlock()
754+ if scope != t.scope {
755+ return ErrScopeMismatch
756+ }
757+ if err := stream.Validate(); err != nil {
758+ return ErrInvalidStream
759+ }
760+ if stream <= t.highest {
761+ return ErrStreamIDReused
762+ }
763+ if t.active >= MaxStreams {
764+ return ErrTooManyStreams
765+ }
766+ t.states[stream] = StreamOpening
767+ t.highest = stream
768+ t.active++
769+ return nil
770+}
771+
772+// Opened moves one opening stream to Open. Every other state is illegal:
773+// opening is only ever confirmed once.
774+func (t *StreamTracker) Opened(scope Scope, stream ports.BrokerStreamID) error {
775+ if t == nil {
776+ return ErrConnectionClosed
777+ }
778+ t.mu.Lock()
779+ defer t.mu.Unlock()
780+ state, err := t.lookupLocked(scope, stream)
781+ if err != nil {
782+ return err
783+ }
784+ if state != StreamOpening {
785+ return ErrStreamState
786+ }
787+ t.states[stream] = StreamOpen
788+ return nil
789+}
790+
791+// Data delivers one opaque daemon frame. Only an open stream accepts data; a
792+// retired stream discards it, and a stream that was never allocated is
793+// refused.
794+func (t *StreamTracker) Data(scope Scope, stream ports.BrokerStreamID) (StreamDisposition, error) {
795+ if t == nil {
796+ return 0, ErrConnectionClosed
797+ }
798+ t.mu.Lock()
799+ defer t.mu.Unlock()
800+ state, err := t.lookupLocked(scope, stream)
801+ if err != nil {
802+ return 0, err
803+ }
804+ switch state {
805+ case StreamOpen:
806+ return StreamAccepted, nil
807+ case StreamRetired:
808+ return StreamDiscarded, nil
809+ default:
810+ return 0, ErrStreamState
811+ }
812+}
813+
814+// Progress reports one phase of an in-flight stream operation. It is legal
815+// while opening and while open; a retired stream discards it.
816+func (t *StreamTracker) Progress(scope Scope, stream ports.BrokerStreamID) (StreamDisposition, error) {
817+ if t == nil {
818+ return 0, ErrConnectionClosed
819+ }
820+ t.mu.Lock()
821+ defer t.mu.Unlock()
822+ state, err := t.lookupLocked(scope, stream)
823+ if err != nil {
824+ return 0, err
825+ }
826+ if state == StreamRetired {
827+ return StreamDiscarded, nil
828+ }
829+ return StreamAccepted, nil
830+}
831+
832+// Close moves one opening or open stream to its terminal retired state. A
833+// late close for a retired stream is discarded.
834+func (t *StreamTracker) Close(scope Scope, stream ports.BrokerStreamID) (StreamDisposition, error) {
835+ if t == nil {
836+ return 0, ErrConnectionClosed
837+ }
838+ t.mu.Lock()
839+ defer t.mu.Unlock()
840+ state, err := t.lookupLocked(scope, stream)
841+ if err != nil {
842+ return 0, err
843+ }
844+ if state == StreamRetired {
845+ return StreamDiscarded, nil
846+ }
847+ t.retireLocked(stream)
848+ return StreamAccepted, nil
849+}
850+
851+// Active returns the number of opening or open streams.
852+func (t *StreamTracker) Active() int {
853+ if t == nil {
854+ return 0
855+ }
856+ t.mu.Lock()
857+ defer t.mu.Unlock()
858+ return t.active
859+}
860+
861+// State returns the tracked state of one stream.
862+func (t *StreamTracker) State(stream ports.BrokerStreamID) (StreamState, bool) {
863+ if t == nil {
864+ return 0, false
865+ }
866+ t.mu.Lock()
867+ defer t.mu.Unlock()
868+ state, ok := t.states[stream]
869+ return state, ok
870+}
871+
872+// lookupLocked fences the frame scope and classifies the stream: a tracked
873+// opening or open state, a retired stream that was allocated earlier (a
874+// tracked retired record or one evicted past the retention bound), or a
875+// future identity that was never allocated.
876+func (t *StreamTracker) lookupLocked(scope Scope, stream ports.BrokerStreamID) (StreamState, error) {
877+ if scope != t.scope {
878+ return 0, ErrScopeMismatch
879+ }
880+ if err := stream.Validate(); err != nil {
881+ return 0, ErrInvalidStream
882+ }
883+ if state, ok := t.states[stream]; ok {
884+ return state, nil
885+ }
886+ if stream > t.highest {
887+ return 0, ErrFutureStream
888+ }
889+ return StreamRetired, nil
890+}
891+
892+func (t *StreamTracker) retireLocked(stream ports.BrokerStreamID) {
893+ t.states[stream] = StreamRetired
894+ t.retired = append(t.retired, stream)
895+ t.active--
896+ for len(t.retired) > MaxRetiredStreams {
897+ evicted := t.retired[0]
898+ t.retired = t.retired[1:]
899+ delete(t.states, evicted)
900+ }
901+}
902+
903+func (t *StreamTracker) reset() {
904+ if t == nil {
905+ return
906+ }
907+ t.mu.Lock()
908+ defer t.mu.Unlock()
909+ t.states = make(map[ports.BrokerStreamID]StreamState, MaxStreams)
910+ t.retired = nil
911+ t.highest = 0
912+ t.active = 0
913+}
A internal/adapters/brokerwire/state_test.go +1238 -0
@@ -0,0 +1,1238 @@
1+package brokerwire
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func testBrokerScope() Scope {
14+ epoch, connection := testSnapshotScope()
15+ return Scope{Epoch: epoch, Connection: connection}
16+}
17+
18+func testForeignScope() Scope {
19+ return Scope{Epoch: 8, Connection: testConnectionID(0x32)}
20+}
21+
22+// connectionOperations and connectionStreams expose the trackers bound to a
23+// connection to in-package tests. Production code never touches them: the
24+// Connection lifecycle methods hold the connection lock across the ready check
25+// and the tracker mutation, so no caller can bypass the connection state
26+// machine. The trackers are assigned once by NewConnection and never replaced,
27+// so these reads need no lock.
28+func connectionOperations(c *Connection) *OperationTracker { return c.operations }
29+
30+func connectionStreams(c *Connection) *StreamTracker { return c.streams }
31+
32+// operationIDAt builds one unique non-zero operation identity.
33+func operationIDAt(i int) ports.BrokerOperationID {
34+ var id ports.BrokerOperationID
35+ id[0] = byte(i >> 8)
36+ id[1] = byte(i)
37+ id[2] = 0x5a
38+ id[15] = 0x01
39+ return id
40+}
41+
42+func newTestConnection(t *testing.T) *Connection {
43+ t.Helper()
44+ conn, err := NewConnection(testBrokerScope())
45+ require.NoError(t, err)
46+ require.Equal(t, ConnNew, conn.State())
47+ return conn
48+}
49+
50+func awaitRegisterConnection(t *testing.T) *Connection {
51+ t.Helper()
52+ conn := newTestConnection(t)
53+ require.NoError(t, conn.SignalPreamble())
54+ require.Equal(t, ConnAwaitRegister, conn.State())
55+ return conn
56+}
57+
58+func readyTestConnection(t *testing.T) *Connection {
59+ t.Helper()
60+ conn := awaitRegisterConnection(t)
61+ require.NoError(t, conn.Register())
62+ require.Equal(t, ConnReady, conn.State())
63+ require.True(t, conn.Ready())
64+ return conn
65+}
66+
67+func closingTestConnection(t *testing.T) *Connection {
68+ t.Helper()
69+ conn := readyTestConnection(t)
70+ require.NoError(t, conn.BeginClose())
71+ require.Equal(t, ConnClosing, conn.State())
72+ return conn
73+}
74+
75+func closedTestConnection(t *testing.T) *Connection {
76+ t.Helper()
77+ conn := closingTestConnection(t)
78+ require.NoError(t, conn.FinishClose())
79+ require.Equal(t, ConnClosed, conn.State())
80+ return conn
81+}
82+
83+func TestNewConnectionScope(t *testing.T) {
84+ cases := []struct {
85+ name string
86+ scope Scope
87+ wantErr bool
88+ }{
89+ {"valid", testBrokerScope(), false},
90+ {"zero epoch", Scope{Connection: testConnectionID(0x31)}, true},
91+ {"zero connection", Scope{Epoch: 7}, true},
92+ {"zero scope", Scope{}, true},
93+ }
94+ for _, tc := range cases {
95+ t.Run(tc.name, func(t *testing.T) {
96+ conn, err := NewConnection(tc.scope)
97+ if tc.wantErr {
98+ require.ErrorIs(t, err, ErrInvalidScope)
99+ require.Nil(t, conn)
100+ return
101+ }
102+ require.NoError(t, err)
103+ require.Equal(t, tc.scope, conn.Scope())
104+ require.Equal(t, ConnNew, conn.State())
105+ require.False(t, conn.Ready())
106+ require.Zero(t, connectionOperations(conn).Pending())
107+ require.Zero(t, connectionStreams(conn).Active())
108+ })
109+ }
110+}
111+
112+func TestConnStateString(t *testing.T) {
113+ require.Equal(t, "new", ConnNew.String())
114+ require.Equal(t, "await_register", ConnAwaitRegister.String())
115+ require.Equal(t, "ready", ConnReady.String())
116+ require.Equal(t, "closing", ConnClosing.String())
117+ require.Equal(t, "closed", ConnClosed.String())
118+ require.Equal(t, "unknown", ConnState(200).String())
119+}
120+
121+// TestConnectionLifecycle is the handshake and shutdown table: New ->
122+// AwaitRegister after the preamble signal -> Ready after exactly one
123+// Register -> Closing -> Closed.
124+func TestConnectionLifecycle(t *testing.T) {
125+ op := operationIDAt(1)
126+ part := snapshotPartsFor(1, 1, sampleSnapshotHosts(), nil)[0]
127+ cases := []struct {
128+ name string
129+ setup func(*testing.T) *Connection
130+ run func(*Connection) error
131+ wantErr error
132+ wantState ConnState
133+ }{
134+ {"preamble in new", newTestConnection, func(c *Connection) error { return c.SignalPreamble() }, nil, ConnAwaitRegister},
135+ {"register before preamble", newTestConnection, func(c *Connection) error { return c.Register() }, ErrConnectionState, ConnNew},
136+ {"subscribe before preamble", newTestConnection, func(c *Connection) error { return c.Subscribe(1) }, ErrConnectionState, ConnNew},
137+ {"operation before preamble", newTestConnection, func(c *Connection) error { return c.AdmitOperation(op) }, ErrConnectionState, ConnNew},
138+ {"stream before preamble", newTestConnection, func(c *Connection) error { return c.OpenStream(1) }, ErrConnectionState, ConnNew},
139+ {"snapshot part before preamble", newTestConnection, func(c *Connection) error {
140+ _, _, err := c.AddSnapshotPart(part)
141+ return err
142+ }, ErrConnectionState, ConnNew},
143+ {"preamble twice", awaitRegisterConnection, func(c *Connection) error { return c.SignalPreamble() }, ErrConnectionState, ConnAwaitRegister},
144+ {"subscribe before register", awaitRegisterConnection, func(c *Connection) error { return c.Subscribe(1) }, ErrConnectionState, ConnAwaitRegister},
145+ {"register after preamble", awaitRegisterConnection, func(c *Connection) error { return c.Register() }, nil, ConnReady},
146+ {"register twice", readyTestConnection, func(c *Connection) error { return c.Register() }, ErrDuplicateRegister, ConnReady},
147+ {"preamble when ready", readyTestConnection, func(c *Connection) error { return c.SignalPreamble() }, ErrConnectionState, ConnReady},
148+ {"finish close before closing", readyTestConnection, func(c *Connection) error { return c.FinishClose() }, ErrConnectionState, ConnReady},
149+ {"finish close in new", newTestConnection, func(c *Connection) error { return c.FinishClose() }, ErrConnectionState, ConnNew},
150+ {"begin close when ready", readyTestConnection, func(c *Connection) error { return c.BeginClose() }, nil, ConnClosing},
151+ {"begin close idempotent", closingTestConnection, func(c *Connection) error { return c.BeginClose() }, nil, ConnClosing},
152+ {"finish close after begin", closingTestConnection, func(c *Connection) error { return c.FinishClose() }, nil, ConnClosed},
153+ {"finish close idempotent", closedTestConnection, func(c *Connection) error { return c.FinishClose() }, nil, ConnClosed},
154+ {"subscribe while closing", closingTestConnection, func(c *Connection) error { return c.Subscribe(1) }, ErrConnectionClosed, ConnClosing},
155+ {"operation while closing", closingTestConnection, func(c *Connection) error { return c.AdmitOperation(op) }, ErrConnectionClosed, ConnClosing},
156+ {"stream while closing", closingTestConnection, func(c *Connection) error { return c.OpenStream(1) }, ErrConnectionClosed, ConnClosing},
157+ {"part while closing", closingTestConnection, func(c *Connection) error {
158+ _, _, err := c.AddSnapshotPart(part)
159+ return err
160+ }, ErrConnectionClosed, ConnClosing},
161+ {"data while closing", closingTestConnection, func(c *Connection) error {
162+ _, err := c.StreamData(1)
163+ return err
164+ }, ErrConnectionClosed, ConnClosing},
165+ {"operation while closed", closedTestConnection, func(c *Connection) error { return c.AdmitOperation(op) }, ErrConnectionClosed, ConnClosed},
166+ {"preamble while closed", closedTestConnection, func(c *Connection) error { return c.SignalPreamble() }, ErrConnectionClosed, ConnClosed},
167+ {"begin close while closed", closedTestConnection, func(c *Connection) error { return c.BeginClose() }, nil, ConnClosed},
168+ }
169+ for _, tc := range cases {
170+ t.Run(tc.name, func(t *testing.T) {
171+ conn := tc.setup(t)
172+ err := tc.run(conn)
173+ if tc.wantErr == nil {
174+ require.NoError(t, err)
175+ } else {
176+ require.ErrorIs(t, err, tc.wantErr)
177+ }
178+ require.Equal(t, tc.wantState, conn.State())
179+ })
180+ }
181+}
182+
183+// TestConnectionLifecycleSequence walks the exact accepted lifecycle and
184+// proves close is idempotent in both steps.
185+func TestConnectionLifecycleSequence(t *testing.T) {
186+ conn := newTestConnection(t)
187+ require.Equal(t, ConnNew, conn.State())
188+ require.NoError(t, conn.SignalPreamble())
189+ require.Equal(t, ConnAwaitRegister, conn.State())
190+ require.NoError(t, conn.Register())
191+ require.Equal(t, ConnReady, conn.State())
192+ require.NoError(t, conn.BeginClose())
193+ require.Equal(t, ConnClosing, conn.State())
194+ require.NoError(t, conn.BeginClose())
195+ require.Equal(t, ConnClosing, conn.State())
196+ require.NoError(t, conn.FinishClose())
197+ require.Equal(t, ConnClosed, conn.State())
198+ require.NoError(t, conn.FinishClose())
199+ require.Equal(t, ConnClosed, conn.State())
200+ require.False(t, conn.Ready())
201+}
202+
203+// TestConnectionSubscriptionGenerations is the subscription generation table:
204+// resubscribe advances strictly, resync and unsubscribe must carry the active
205+// generation exactly.
206+func TestConnectionSubscriptionGenerations(t *testing.T) {
207+ type step struct {
208+ name string
209+ run func(*Connection) error
210+ want error
211+ check func(*testing.T, *Connection)
212+ }
213+ cases := []struct {
214+ name string
215+ steps []step
216+ }{
217+ {
218+ name: "zero generation refused",
219+ steps: []step{
220+ {name: "subscribe zero", run: func(c *Connection) error { return c.Subscribe(0) }, want: ErrInvalidGeneration},
221+ {name: "no active subscription", run: func(c *Connection) error { return c.Unsubscribe(0) }, want: ErrNotSubscribed},
222+ },
223+ },
224+ {
225+ name: "resubscribe advances strictly",
226+ steps: []step{
227+ {name: "subscribe one", run: func(c *Connection) error { return c.Subscribe(1) }},
228+ {name: "subscribe one again", run: func(c *Connection) error { return c.Subscribe(1) }, want: ErrStaleGeneration},
229+ {name: "subscribe two", run: func(c *Connection) error { return c.Subscribe(2) }},
230+ {name: "subscribe zero", run: func(c *Connection) error { return c.Subscribe(0) }, want: ErrInvalidGeneration},
231+ },
232+ },
233+ {
234+ name: "resync must match the active generation",
235+ steps: []step{
236+ {name: "resync before subscribe", run: func(c *Connection) error { return c.Resync(1) }, want: ErrNotSubscribed},
237+ {name: "subscribe", run: func(c *Connection) error { return c.Subscribe(3) }},
238+ {name: "resync active", run: func(c *Connection) error { return c.Resync(3) }},
239+ {name: "resync stale", run: func(c *Connection) error { return c.Resync(2) }, want: ErrStaleGeneration},
240+ {name: "resync future", run: func(c *Connection) error { return c.Resync(4) }, want: ErrFutureGeneration},
241+ },
242+ },
243+ {
244+ name: "unsubscribe must match and generations are never reused",
245+ steps: []step{
246+ {name: "subscribe", run: func(c *Connection) error { return c.Subscribe(2) }},
247+ {name: "unsubscribe stale", run: func(c *Connection) error { return c.Unsubscribe(1) }, want: ErrStaleGeneration},
248+ {name: "unsubscribe future", run: func(c *Connection) error { return c.Unsubscribe(3) }, want: ErrFutureGeneration},
249+ {name: "unsubscribe active", run: func(c *Connection) error { return c.Unsubscribe(2) }},
250+ {name: "unsubscribe twice", run: func(c *Connection) error { return c.Unsubscribe(2) }, want: ErrNotSubscribed},
251+ {name: "resubscribe consumed generation", run: func(c *Connection) error { return c.Subscribe(2) }, want: ErrStaleGeneration},
252+ {name: "resubscribe newer generation", run: func(c *Connection) error { return c.Subscribe(3) }},
253+ },
254+ },
255+ }
256+ for _, tc := range cases {
257+ t.Run(tc.name, func(t *testing.T) {
258+ conn := readyTestConnection(t)
259+ for _, s := range tc.steps {
260+ err := s.run(conn)
261+ if s.want == nil {
262+ require.NoError(t, err, s.name)
263+ } else {
264+ require.ErrorIs(t, err, s.want, s.name)
265+ }
266+ if s.check != nil {
267+ s.check(t, conn)
268+ }
269+ }
270+ })
271+ }
272+}
273+
274+// stageConnectionPart stages one snapshot part through the connection and
275+// requires acceptance.
276+func stageConnectionPart(t *testing.T, conn *Connection, part SnapshotPart) {
277+ t.Helper()
278+ _, _, err := conn.AddSnapshotPart(part)
279+ require.NoError(t, err)
280+}
281+
282+// TestConnectionSubscriptionStagingRetention proves a generation advance
283+// discards in-flight staging, keeps the committed snapshot, and that close
284+// retains the commit until the connection is finally closed.
285+func TestConnectionSubscriptionStagingRetention(t *testing.T) {
286+ conn := readyTestConnection(t)
287+ require.NoError(t, conn.Subscribe(1))
288+ for _, part := range snapshotPartsFor(1, 4, sampleSnapshotHosts(), sampleSnapshotTombstones()) {
289+ stageConnectionPart(t, conn, part)
290+ }
291+ committed, ok := conn.Snapshot()
292+ require.True(t, ok)
293+ require.Equal(t, ports.BrokerRevision(4), committed.Revision)
294+
295+ // In-flight staging of the active generation is discarded on advance.
296+ inFlight := snapshotPartsFor(1, 6, sampleSnapshotHosts(), nil)
297+ stageConnectionPart(t, conn, inFlight[0])
298+ require.True(t, conn.StagingActive())
299+ require.NoError(t, conn.Subscribe(2))
300+ require.False(t, conn.StagingActive())
301+ committed, ok = conn.Snapshot()
302+ require.True(t, ok, "generation advance keeps the committed snapshot")
303+ require.Equal(t, ports.BrokerRevision(4), committed.Revision)
304+
305+ // A part of the superseded generation is refused and never applied, a
306+ // part of a future generation is refused, and the fresh generation must
307+ // start at index 0.
308+ stale := inFlight[1]
309+ _, _, err := conn.AddSnapshotPart(stale)
310+ require.ErrorIs(t, err, ErrStaleGeneration)
311+ future := inFlight[1]
312+ future.Generation = 3
313+ _, _, err = conn.AddSnapshotPart(future)
314+ require.ErrorIs(t, err, ErrFutureGeneration)
315+ next := snapshotPartsFor(2, 5, sampleSnapshotHosts(), nil)
316+ _, _, err = conn.AddSnapshotPart(next[1])
317+ require.ErrorIs(t, err, ErrSnapshotInvalid)
318+ require.False(t, conn.StagingActive())
319+ for _, part := range next {
320+ stageConnectionPart(t, conn, part)
321+ }
322+ committed, ok = conn.Snapshot()
323+ require.True(t, ok)
324+ require.Equal(t, ports.BrokerRevision(5), committed.Revision)
325+
326+ require.NoError(t, conn.BeginClose())
327+ _, ok = conn.Snapshot()
328+ require.True(t, ok, "closing keeps the committed snapshot")
329+ require.NoError(t, conn.FinishClose())
330+ _, ok = conn.Snapshot()
331+ require.False(t, ok, "closed releases the snapshot")
332+ require.False(t, conn.StagingActive())
333+}
334+
335+// TestConnectionSnapshotParts proves snapshot staging requires Ready plus an
336+// active subscription, and that every part is fenced by scope and generation.
337+func TestConnectionSnapshotParts(t *testing.T) {
338+ t.Run("part without subscription", func(t *testing.T) {
339+ conn := readyTestConnection(t)
340+ _, _, err := conn.AddSnapshotPart(snapshotPartsFor(1, 1, nil, nil)[0])
341+ require.ErrorIs(t, err, ErrNotSubscribed)
342+ })
343+
344+ t.Run("unsubscribe stops staging", func(t *testing.T) {
345+ conn := readyTestConnection(t)
346+ require.NoError(t, conn.Subscribe(1))
347+ require.NoError(t, conn.Unsubscribe(1))
348+ _, _, err := conn.AddSnapshotPart(snapshotPartsFor(1, 1, nil, nil)[0])
349+ require.ErrorIs(t, err, ErrNotSubscribed)
350+ })
351+
352+ t.Run("future generation rejected", func(t *testing.T) {
353+ conn := readyTestConnection(t)
354+ require.NoError(t, conn.Subscribe(2))
355+ part := snapshotPartsFor(1, 1, sampleSnapshotHosts(), nil)[0]
356+ part.Generation = 3
357+ _, _, err := conn.AddSnapshotPart(part)
358+ require.ErrorIs(t, err, ErrFutureGeneration)
359+ part.Generation = 1
360+ _, _, err = conn.AddSnapshotPart(part)
361+ require.ErrorIs(t, err, ErrStaleGeneration)
362+ })
363+
364+ t.Run("foreign scope rejected", func(t *testing.T) {
365+ conn := readyTestConnection(t)
366+ require.NoError(t, conn.Subscribe(2))
367+ part := snapshotPartsFor(2, 1, sampleSnapshotHosts(), nil)[0]
368+ part.Epoch = 8
369+ _, _, err := conn.AddSnapshotPart(part)
370+ require.ErrorIs(t, err, ErrScopeMismatch)
371+ part = snapshotPartsFor(2, 1, sampleSnapshotHosts(), nil)[0]
372+ part.Connection = testConnectionID(0x77)
373+ _, _, err = conn.AddSnapshotPart(part)
374+ require.ErrorIs(t, err, ErrScopeMismatch)
375+ })
376+
377+ t.Run("complete transfer through the connection", func(t *testing.T) {
378+ conn := readyTestConnection(t)
379+ require.NoError(t, conn.Subscribe(9))
380+ parts := snapshotPartsFor(9, 2, sampleSnapshotHosts(), sampleSnapshotTombstones())
381+ var published bool
382+ for _, part := range parts {
383+ completed, _, err := conn.AddSnapshotPart(part)
384+ require.NoError(t, err)
385+ if completed {
386+ published = true
387+ }
388+ }
389+ require.True(t, published)
390+ committed, ok := conn.Snapshot()
391+ require.True(t, ok)
392+ require.Equal(t, ports.BrokerRevision(2), committed.Revision)
393+ })
394+}
395+
396+// TestConnectionExactScopeRegistration proves the connection keeps exactly
397+// the scope assigned at accept: every frame must carry it, refused frames
398+// change nothing, and the scope survives the whole lifecycle.
399+func TestConnectionExactScopeRegistration(t *testing.T) {
400+ conn := readyTestConnection(t)
401+ assigned := conn.Scope()
402+ require.Equal(t, testBrokerScope(), assigned)
403+ other := Scope{Epoch: assigned.Epoch + 1, Connection: assigned.Connection}
404+
405+ require.ErrorIs(t, connectionOperations(conn).Admit(other, operationIDAt(1)), ErrScopeMismatch)
406+ require.ErrorIs(t, connectionOperations(conn).Complete(other, operationIDAt(1), ports.BrokerOutcomeOK), ErrScopeMismatch)
407+ require.ErrorIs(t, connectionStreams(conn).Open(other, 1), ErrScopeMismatch)
408+ _, err := connectionStreams(conn).Data(other, 1)
409+ require.ErrorIs(t, err, ErrScopeMismatch)
410+ _, err = connectionStreams(conn).Close(other, 1)
411+ require.ErrorIs(t, err, ErrScopeMismatch)
412+
413+ part := snapshotPartsFor(1, 1, sampleSnapshotHosts(), nil)[0]
414+ part.Connection = other.Connection
415+ part.Epoch = other.Epoch
416+ require.NoError(t, conn.Subscribe(1))
417+ _, _, err = conn.AddSnapshotPart(part)
418+ require.ErrorIs(t, err, ErrScopeMismatch)
419+
420+ // Refused frames leave no state behind, and the accepted scope still works.
421+ require.Equal(t, assigned, conn.Scope())
422+ require.Zero(t, connectionOperations(conn).Pending())
423+ require.Zero(t, connectionStreams(conn).Active())
424+ require.False(t, conn.StagingActive())
425+ require.NoError(t, conn.AdmitOperation(operationIDAt(2)))
426+ require.Equal(t, 1, connectionOperations(conn).Pending())
427+
428+ require.NoError(t, conn.BeginClose())
429+ require.NoError(t, conn.FinishClose())
430+ require.Equal(t, assigned, conn.Scope())
431+}
432+
433+// TestOperationTrackerErrors is the operation admission taxonomy table.
434+func TestOperationTrackerErrors(t *testing.T) {
435+ scope := testBrokerScope()
436+ op := operationIDAt(3)
437+ cases := []struct {
438+ name string
439+ setup func(*testing.T, *OperationTracker)
440+ run func(*OperationTracker) error
441+ want error
442+ }{
443+ {"admit accepted", nil, func(tr *OperationTracker) error { return tr.Admit(scope, op) }, nil},
444+ {"admit zero identity", nil, func(tr *OperationTracker) error { return tr.Admit(scope, ports.BrokerOperationID{}) }, ErrInvalidOperation},
445+ {"admit foreign scope", nil, func(tr *OperationTracker) error { return tr.Admit(testForeignScope(), op) }, ErrScopeMismatch},
446+ {"admit duplicate pending", func(t *testing.T, tr *OperationTracker) {
447+ require.NoError(t, tr.Admit(scope, op))
448+ }, func(tr *OperationTracker) error { return tr.Admit(scope, op) }, ErrOperationPending},
449+ {"admit completed", func(t *testing.T, tr *OperationTracker) {
450+ require.NoError(t, tr.Admit(scope, op))
451+ require.NoError(t, tr.Complete(scope, op, ports.BrokerOutcomeOK))
452+ }, func(tr *OperationTracker) error { return tr.Admit(scope, op) }, ErrOperationCompleted},
453+ {"complete unknown", nil, func(tr *OperationTracker) error {
454+ return tr.Complete(scope, op, ports.BrokerOutcomeOK)
455+ }, ErrUnknownOperation},
456+ {"complete invalid outcome", func(t *testing.T, tr *OperationTracker) {
457+ require.NoError(t, tr.Admit(scope, op))
458+ }, func(tr *OperationTracker) error { return tr.Complete(scope, op, 0) }, ErrInvalidOutcome},
459+ {"complete foreign scope", func(t *testing.T, tr *OperationTracker) {
460+ require.NoError(t, tr.Admit(scope, op))
461+ }, func(tr *OperationTracker) error {
462+ return tr.Complete(testForeignScope(), op, ports.BrokerOutcomeOK)
463+ }, ErrScopeMismatch},
464+ {"complete twice", func(t *testing.T, tr *OperationTracker) {
465+ require.NoError(t, tr.Admit(scope, op))
466+ require.NoError(t, tr.Complete(scope, op, ports.BrokerOutcomeFailed))
467+ }, func(tr *OperationTracker) error { return tr.Complete(scope, op, ports.BrokerOutcomeOK) }, ErrOperationCompleted},
468+ }
469+ for _, tc := range cases {
470+ t.Run(tc.name, func(t *testing.T) {
471+ tr, err := newOperationTracker(scope)
472+ require.NoError(t, err)
473+ if tc.setup != nil {
474+ tc.setup(t, tr)
475+ }
476+ err = tc.run(tr)
477+ if tc.want == nil {
478+ require.NoError(t, err)
479+ } else {
480+ require.ErrorIs(t, err, tc.want)
481+ }
482+ })
483+ }
484+}
485+
486+func TestNewOperationTrackerValidatesScope(t *testing.T) {
487+ tr, err := newOperationTracker(Scope{})
488+ require.ErrorIs(t, err, ErrInvalidScope)
489+ require.Nil(t, tr)
490+}
491+
492+// TestOperationTrackerPendingBound proves at most 64 operations are in flight.
493+func TestOperationTrackerPendingBound(t *testing.T) {
494+ scope := testBrokerScope()
495+ tr, err := newOperationTracker(scope)
496+ require.NoError(t, err)
497+ for i := 0; i < MaxPendingOperations; i++ {
498+ require.NoError(t, tr.Admit(scope, operationIDAt(i)))
499+ }
500+ require.Equal(t, MaxPendingOperations, tr.Pending())
501+ require.ErrorIs(t, tr.Admit(scope, operationIDAt(MaxPendingOperations)), ErrTooManyPendingOperations)
502+ require.Equal(t, MaxPendingOperations, tr.Pending())
503+
504+ // Completing one frees exactly one slot.
505+ require.NoError(t, tr.Complete(scope, operationIDAt(0), ports.BrokerOutcomeOK))
506+ require.Equal(t, MaxPendingOperations-1, tr.Pending())
507+ require.NoError(t, tr.Admit(scope, operationIDAt(MaxPendingOperations)))
508+ require.Equal(t, MaxPendingOperations, tr.Pending())
509+ require.ErrorIs(t, tr.Admit(scope, operationIDAt(MaxPendingOperations+1)), ErrTooManyPendingOperations)
510+}
511+
512+// TestOperationTrackerDedupBound proves the dedup set is bounded to 4096
513+// identities and 64 KiB, evicting the oldest record first.
514+func TestOperationTrackerDedupBound(t *testing.T) {
515+ scope := testBrokerScope()
516+ tr, err := newOperationTracker(scope)
517+ require.NoError(t, err)
518+ require.Equal(t, 4096, MaxTrackedOperations)
519+ require.Equal(t, 65536, MaxOperationDedupBytes)
520+
521+ total := MaxTrackedOperations + 128
522+ for i := 0; i < total; i++ {
523+ op := operationIDAt(i)
524+ require.NoError(t, tr.Admit(scope, op))
525+ require.NoError(t, tr.Complete(scope, op, ports.BrokerOutcomeOK))
526+ require.LessOrEqual(t, tr.DedupBytes(), MaxOperationDedupBytes)
527+ }
528+ require.Equal(t, MaxTrackedOperations, tr.Tracked())
529+ require.Equal(t, MaxOperationDedupBytes, tr.DedupBytes())
530+ require.Zero(t, tr.Pending())
531+
532+ // The oldest records were evicted: they are no longer deduped, while the
533+ // newest records still are.
534+ evicted := operationIDAt(0)
535+ _, ok := tr.Outcome(evicted)
536+ require.False(t, ok)
537+ require.NoError(t, tr.Admit(scope, evicted))
538+ require.ErrorIs(t, tr.Admit(scope, operationIDAt(total-1)), ErrOperationCompleted)
539+}
540+
541+// TestOperationTrackerOutcomeUnknownNoReplay proves outcome-unknown is
542+// recorded like any outcome and a completed identity is never replayed.
543+func TestOperationTrackerOutcomeUnknownNoReplay(t *testing.T) {
544+ scope := testBrokerScope()
545+ tr, err := newOperationTracker(scope)
546+ require.NoError(t, err)
547+ cases := []ports.BrokerMutationOutcome{ports.BrokerOutcomeOK, ports.BrokerOutcomeFailed, ports.BrokerOutcomeUnknown}
548+ for i, outcome := range cases {
549+ op := operationIDAt(100 + i)
550+ require.NoError(t, tr.Admit(scope, op))
551+ require.NoError(t, tr.Complete(scope, op, outcome))
552+ recorded, ok := tr.Outcome(op)
553+ require.True(t, ok)
554+ require.Equal(t, outcome, recorded)
555+ require.ErrorIs(t, tr.Admit(scope, op), ErrOperationCompleted, "never replays %s", outcome)
556+ require.ErrorIs(t, tr.Complete(scope, op, ports.BrokerOutcomeFailed), ErrOperationCompleted)
557+ }
558+ // An unknown outcome still frees its pending slot.
559+ require.Zero(t, tr.Pending())
560+}
561+
562+// streamAction is one stream lifecycle event under test.
563+type streamAction func(*StreamTracker, Scope, ports.BrokerStreamID) (StreamDisposition, error)
564+
565+func actionOpen(tr *StreamTracker, scope Scope, stream ports.BrokerStreamID) (StreamDisposition, error) {
566+ if err := tr.Open(scope, stream); err != nil {
567+ return 0, err
568+ }
569+ return StreamAccepted, nil
570+}
571+
572+func actionOpened(tr *StreamTracker, scope Scope, stream ports.BrokerStreamID) (StreamDisposition, error) {
573+ if err := tr.Opened(scope, stream); err != nil {
574+ return 0, err
575+ }
576+ return StreamAccepted, nil
577+}
578+
579+func actionData(tr *StreamTracker, scope Scope, stream ports.BrokerStreamID) (StreamDisposition, error) {
580+ return tr.Data(scope, stream)
581+}
582+
583+func actionProgress(tr *StreamTracker, scope Scope, stream ports.BrokerStreamID) (StreamDisposition, error) {
584+ return tr.Progress(scope, stream)
585+}
586+
587+func actionClose(tr *StreamTracker, scope Scope, stream ports.BrokerStreamID) (StreamDisposition, error) {
588+ return tr.Close(scope, stream)
589+}
590+
591+func TestStreamStateStrings(t *testing.T) {
592+ require.Equal(t, "opening", StreamOpening.String())
593+ require.Equal(t, "open", StreamOpen.String())
594+ require.Equal(t, "retired", StreamRetired.String())
595+ require.Equal(t, "unknown", StreamState(0).String())
596+}
597+
598+func TestNewStreamTrackerValidatesScope(t *testing.T) {
599+ tr, err := newStreamTracker(Scope{})
600+ require.ErrorIs(t, err, ErrInvalidScope)
601+ require.Nil(t, tr)
602+}
603+
604+// TestStreamTrackerLifecycle is the stream state table: opening -> open ->
605+// retired, with legal progress/data/close and late retired discard.
606+func TestStreamTrackerLifecycle(t *testing.T) {
607+ cases := []struct {
608+ name string
609+ stream ports.BrokerStreamID
610+ steps []streamStep
611+ wantState StreamState
612+ wantFound bool
613+ }{
614+ {
615+ name: "open then data then close",
616+ stream: 1,
617+ steps: []streamStep{
618+ {name: "open", action: actionOpen, want: StreamAccepted},
619+ {name: "data while opening", action: actionData, wantErr: ErrStreamState},
620+ {name: "progress while opening", action: actionProgress, want: StreamAccepted},
621+ {name: "opened", action: actionOpened, want: StreamAccepted},
622+ {name: "opened twice", action: actionOpened, wantErr: ErrStreamState},
623+ {name: "data when open", action: actionData, want: StreamAccepted},
624+ {name: "progress when open", action: actionProgress, want: StreamAccepted},
625+ {name: "close", action: actionClose, want: StreamAccepted},
626+ {name: "late data", action: actionData, want: StreamDiscarded},
627+ {name: "late progress", action: actionProgress, want: StreamDiscarded},
628+ {name: "late close", action: actionClose, want: StreamDiscarded},
629+ {name: "opened after close", action: actionOpened, wantErr: ErrStreamState},
630+ },
631+ wantState: StreamRetired,
632+ wantFound: true,
633+ },
634+ {
635+ name: "close while opening is terminal",
636+ stream: 4,
637+ steps: []streamStep{
638+ {name: "open", action: actionOpen, want: StreamAccepted},
639+ {name: "close", action: actionClose, want: StreamAccepted},
640+ {name: "data after close", action: actionData, want: StreamDiscarded},
641+ {name: "opened after close", action: actionOpened, wantErr: ErrStreamState},
642+ },
643+ wantState: StreamRetired,
644+ wantFound: true,
645+ },
646+ }
647+ for _, tc := range cases {
648+ t.Run(tc.name, func(t *testing.T) {
649+ scope := testBrokerScope()
650+ tr, err := newStreamTracker(scope)
651+ require.NoError(t, err)
652+ for _, s := range tc.steps {
653+ disposition, err := s.action(tr, scope, tc.stream)
654+ if s.wantErr != nil {
655+ require.ErrorIs(t, err, s.wantErr, s.name)
656+ require.Zero(t, disposition, s.name)
657+ continue
658+ }
659+ require.NoError(t, err, s.name)
660+ require.Equal(t, s.want, disposition, s.name)
661+ }
662+ state, ok := tr.State(tc.stream)
663+ require.Equal(t, tc.wantFound, ok)
664+ require.Equal(t, tc.wantState, state)
665+ require.Zero(t, tr.Active())
666+ })
667+ }
668+}
669+
670+type streamStep struct {
671+ name string
672+ action streamAction
673+ want StreamDisposition
674+ wantErr error
675+}
676+
677+// TestStreamTrackerOpenRules proves stream identities strictly increase, are
678+// never reused, and that at most MaxStreams streams are concurrent.
679+func TestStreamTrackerOpenRules(t *testing.T) {
680+ scope := testBrokerScope()
681+
682+ t.Run("zero identity refused", func(t *testing.T) {
683+ tr, err := newStreamTracker(scope)
684+ require.NoError(t, err)
685+ require.ErrorIs(t, tr.Open(scope, 0), ErrInvalidStream)
686+ })
687+
688+ t.Run("strictly increasing and never reused", func(t *testing.T) {
689+ tr, err := newStreamTracker(scope)
690+ require.NoError(t, err)
691+ require.NoError(t, tr.Open(scope, 5))
692+ require.ErrorIs(t, tr.Open(scope, 5), ErrStreamIDReused)
693+ require.ErrorIs(t, tr.Open(scope, 1), ErrStreamIDReused)
694+ require.NoError(t, tr.Opened(scope, 5))
695+ require.NoError(t, tr.Open(scope, 6))
696+ if disposition, err := tr.Close(scope, 5); err != nil || disposition != StreamAccepted {
697+ t.Fatalf("close: %v %v", disposition, err)
698+ }
699+ require.ErrorIs(t, tr.Open(scope, 5), ErrStreamIDReused)
700+ require.ErrorIs(t, tr.Open(scope, 6), ErrStreamIDReused)
701+ require.NoError(t, tr.Open(scope, 7))
702+ })
703+
704+ t.Run("concurrent ceiling", func(t *testing.T) {
705+ tr, err := newStreamTracker(scope)
706+ require.NoError(t, err)
707+ for i := 1; i <= MaxStreams; i++ {
708+ require.NoError(t, tr.Open(scope, ports.BrokerStreamID(i)))
709+ }
710+ require.Equal(t, MaxStreams, tr.Active())
711+ require.ErrorIs(t, tr.Open(scope, ports.BrokerStreamID(MaxStreams+1)), ErrTooManyStreams)
712+ // Retiring one stream frees exactly one slot for a fresh higher ID.
713+ disposition, err := tr.Close(scope, 1)
714+ require.NoError(t, err)
715+ require.Equal(t, StreamAccepted, disposition)
716+ require.Equal(t, MaxStreams-1, tr.Active())
717+ require.NoError(t, tr.Open(scope, ports.BrokerStreamID(MaxStreams+2)))
718+ require.ErrorIs(t, tr.Open(scope, ports.BrokerStreamID(MaxStreams+3)), ErrTooManyStreams)
719+ })
720+
721+ t.Run("scope fence", func(t *testing.T) {
722+ tr, err := newStreamTracker(scope)
723+ require.NoError(t, err)
724+ require.ErrorIs(t, tr.Open(testForeignScope(), 1), ErrScopeMismatch)
725+ require.ErrorIs(t, tr.Opened(testForeignScope(), 1), ErrScopeMismatch)
726+ _, err = tr.Data(testForeignScope(), 1)
727+ require.ErrorIs(t, err, ErrScopeMismatch)
728+ _, err = tr.Progress(testForeignScope(), 1)
729+ require.ErrorIs(t, err, ErrScopeMismatch)
730+ _, err = tr.Close(testForeignScope(), 1)
731+ require.ErrorIs(t, err, ErrScopeMismatch)
732+ require.Zero(t, tr.Active())
733+ })
734+}
735+
736+// TestStreamTrackerFutureReject proves a stream identity that was never
737+// allocated is refused rather than treated as retired, and that a retired
738+// record evicted after the retention bound is still discarded.
739+func TestStreamTrackerFutureReject(t *testing.T) {
740+ scope := testBrokerScope()
741+ tr, err := newStreamTracker(scope)
742+ require.NoError(t, err)
743+ require.NoError(t, tr.Open(scope, 2))
744+ require.NoError(t, tr.Opened(scope, 2))
745+ for _, action := range []streamAction{actionOpened, actionData, actionProgress, actionClose} {
746+ disposition, err := action(tr, scope, 3)
747+ require.ErrorIs(t, err, ErrFutureStream)
748+ require.Zero(t, disposition)
749+ }
750+ disposition, err := tr.Close(scope, 2)
751+ require.NoError(t, err)
752+ require.Equal(t, StreamAccepted, disposition)
753+
754+ // Retain more retired records than the bound so the oldest is evicted.
755+ for i := 0; i < MaxRetiredStreams+8; i++ {
756+ stream := ports.BrokerStreamID(10 + i)
757+ require.NoError(t, tr.Open(scope, stream))
758+ require.NoError(t, tr.Opened(scope, stream))
759+ disposition, err := tr.Close(scope, stream)
760+ require.NoError(t, err)
761+ require.Equal(t, StreamAccepted, disposition)
762+ }
763+ evicted := ports.BrokerStreamID(2)
764+ _, tracked := tr.State(evicted)
765+ require.False(t, tracked, "oldest retired record is evicted")
766+ disposition, err = tr.Data(scope, evicted)
767+ require.NoError(t, err)
768+ require.Equal(t, StreamDiscarded, disposition)
769+ disposition, err = tr.Close(scope, evicted)
770+ require.NoError(t, err)
771+ require.Equal(t, StreamDiscarded, disposition)
772+ _, err = tr.Data(scope, ports.BrokerStreamID(10+MaxRetiredStreams+8))
773+ require.ErrorIs(t, err, ErrFutureStream)
774+}
775+
776+// TestStreamTrackerNilSafety proves the nil-receiver paths.
777+func TestStreamTrackerNilSafety(t *testing.T) {
778+ var tr *StreamTracker
779+ scope := testBrokerScope()
780+ require.ErrorIs(t, tr.Open(scope, 1), ErrConnectionClosed)
781+ require.ErrorIs(t, tr.Opened(scope, 1), ErrConnectionClosed)
782+ _, err := tr.Data(scope, 1)
783+ require.ErrorIs(t, err, ErrConnectionClosed)
784+ _, err = tr.Progress(scope, 1)
785+ require.ErrorIs(t, err, ErrConnectionClosed)
786+ _, err = tr.Close(scope, 1)
787+ require.ErrorIs(t, err, ErrConnectionClosed)
788+ require.Zero(t, tr.Active())
789+ _, ok := tr.State(1)
790+ require.False(t, ok)
791+}
792+
793+// TestConnectionStreamsAndOperations proves the connection-bound trackers
794+// share the connection scope while every mutation still travels through the
795+// Connection lifecycle methods.
796+func TestConnectionStreamsAndOperations(t *testing.T) {
797+ conn := readyTestConnection(t)
798+ scope := conn.Scope()
799+ op := operationIDAt(7)
800+ require.NoError(t, conn.AdmitOperation(op))
801+ require.Equal(t, 1, connectionOperations(conn).Pending())
802+ require.NoError(t, conn.CompleteOperation(op, ports.BrokerOutcomeUnknown))
803+ require.Zero(t, connectionOperations(conn).Pending())
804+ recorded, ok := connectionOperations(conn).Outcome(op)
805+ require.True(t, ok)
806+ require.Equal(t, ports.BrokerOutcomeUnknown, recorded)
807+
808+ require.NoError(t, conn.OpenStream(1))
809+ require.NoError(t, conn.StreamOpened(1))
810+ disposition, err := conn.StreamProgress(1)
811+ require.NoError(t, err)
812+ require.Equal(t, StreamAccepted, disposition)
813+ disposition, err = conn.StreamData(1)
814+ require.NoError(t, err)
815+ require.Equal(t, StreamAccepted, disposition)
816+ disposition, err = conn.CloseStream(1)
817+ require.NoError(t, err)
818+ require.Equal(t, StreamAccepted, disposition)
819+ disposition, err = conn.StreamData(1)
820+ require.NoError(t, err)
821+ require.Equal(t, StreamDiscarded, disposition)
822+ require.Zero(t, connectionStreams(conn).Active())
823+ require.Equal(t, scope, testBrokerScope())
824+
825+ // FinishClose releases both trackers.
826+ require.NoError(t, conn.BeginClose())
827+ require.NoError(t, conn.FinishClose())
828+ require.Zero(t, connectionOperations(conn).Tracked())
829+ require.Zero(t, connectionStreams(conn).Active())
830+}
831+
832+// TestOperationTrackerConcurrentAdmitComplete is a deterministic race test:
833+// exactly MaxPendingOperations distinct operations are admitted concurrently
834+// and completed concurrently.
835+func TestOperationTrackerConcurrentAdmitComplete(t *testing.T) {
836+ scope := testBrokerScope()
837+ tr, err := newOperationTracker(scope)
838+ require.NoError(t, err)
839+ var wg sync.WaitGroup
840+ errs := make(chan error, 2*MaxPendingOperations)
841+ for i := 0; i < MaxPendingOperations; i++ {
842+ op := operationIDAt(i)
843+ wg.Add(1)
844+ go func(op ports.BrokerOperationID) {
845+ defer wg.Done()
846+ if err := tr.Admit(scope, op); err != nil {
847+ errs <- err
848+ }
849+ }(op)
850+ }
851+ wg.Wait()
852+ require.Equal(t, MaxPendingOperations, tr.Pending())
853+ for i := 0; i < MaxPendingOperations; i++ {
854+ op := operationIDAt(i)
855+ wg.Add(1)
856+ go func(op ports.BrokerOperationID) {
857+ defer wg.Done()
858+ if err := tr.Complete(scope, op, ports.BrokerOutcomeOK); err != nil {
859+ errs <- err
860+ }
861+ }(op)
862+ }
863+ wg.Wait()
864+ close(errs)
865+ for err := range errs {
866+ require.NoError(t, err)
867+ }
868+ require.Zero(t, tr.Pending())
869+ require.Equal(t, MaxPendingOperations, tr.Tracked())
870+ require.Equal(t, MaxPendingOperations*operationIDBytes, tr.DedupBytes())
871+}
872+
873+// TestStreamTrackerConcurrentFrames is a deterministic race test: one stream
874+// per goroutine progresses and retires concurrently.
875+func TestStreamTrackerConcurrentFrames(t *testing.T) {
876+ scope := testBrokerScope()
877+ tr, err := newStreamTracker(scope)
878+ require.NoError(t, err)
879+ for i := 1; i <= MaxStreams; i++ {
880+ require.NoError(t, tr.Open(scope, ports.BrokerStreamID(i)))
881+ }
882+ var wg sync.WaitGroup
883+ errs := make(chan error, MaxStreams)
884+ for i := 1; i <= MaxStreams; i++ {
885+ stream := ports.BrokerStreamID(i)
886+ wg.Add(1)
887+ go func(stream ports.BrokerStreamID) {
888+ defer wg.Done()
889+ if _, err := tr.Progress(scope, stream); err != nil {
890+ errs <- err
891+ }
892+ if err := tr.Opened(scope, stream); err != nil {
893+ errs <- err
894+ }
895+ if disposition, err := tr.Data(scope, stream); err != nil || disposition != StreamAccepted {
896+ errs <- err
897+ }
898+ if disposition, err := tr.Close(scope, stream); err != nil || disposition != StreamAccepted {
899+ errs <- err
900+ }
901+ }(stream)
902+ }
903+ wg.Wait()
904+ close(errs)
905+ for err := range errs {
906+ require.NoError(t, err)
907+ }
908+ require.Zero(t, tr.Active())
909+}
910+
911+// TestConnectionConcurrentWork is a deterministic race test: the connection
912+// publishes sequential revisions while readers observe it and operations
913+// complete concurrently.
914+func TestConnectionConcurrentWork(t *testing.T) {
915+ conn := readyTestConnection(t)
916+ require.NoError(t, conn.Subscribe(1))
917+ hosts := sampleSnapshotHosts()
918+ const revisions = 20
919+ const operations = 32
920+ for i := 0; i < operations; i++ {
921+ require.NoError(t, conn.AdmitOperation(operationIDAt(i)))
922+ }
923+
924+ var wg sync.WaitGroup
925+ done := make(chan struct{})
926+ errs := make(chan error, operations)
927+ for i := 0; i < 4; i++ {
928+ wg.Add(1)
929+ go func() {
930+ defer wg.Done()
931+ for {
932+ select {
933+ case <-done:
934+ return
935+ default:
936+ }
937+ assert.Equal(t, ConnReady, conn.State())
938+ assert.Equal(t, testBrokerScope(), conn.Scope())
939+ if snapshot, ok := conn.Snapshot(); ok {
940+ assert.NotZero(t, snapshot.Revision)
941+ }
942+ }
943+ }()
944+ }
945+ for i := 0; i < operations; i++ {
946+ op := operationIDAt(i)
947+ wg.Add(1)
948+ go func(op ports.BrokerOperationID) {
949+ defer wg.Done()
950+ if err := conn.CompleteOperation(op, ports.BrokerOutcomeOK); err != nil {
951+ errs <- err
952+ }
953+ }(op)
954+ }
955+ for revision := 1; revision <= revisions; revision++ {
956+ for _, part := range snapshotPartsFor(1, ports.BrokerRevision(revision), hosts, nil) {
957+ _, _, err := conn.AddSnapshotPart(part)
958+ require.NoError(t, err)
959+ }
960+ }
961+ close(done)
962+ wg.Wait()
963+ close(errs)
964+ for err := range errs {
965+ require.NoError(t, err)
966+ }
967+ require.Zero(t, connectionOperations(conn).Pending())
968+ require.Equal(t, operations, connectionOperations(conn).Tracked())
969+ snapshot, ok := conn.Snapshot()
970+ require.True(t, ok)
971+ require.Equal(t, ports.BrokerRevision(revisions), snapshot.Revision)
972+}
973+
974+// TestConnectionCloseRaceNoPostCloseAdmission proves the connection lock
975+// covers both the ready check and the tracker mutation: whenever a close and
976+// an admit/open race, the close wins the state transition and leaves no
977+// admitted operation or opened stream behind, and every attempt after close
978+// is refused.
979+func TestConnectionCloseRaceNoPostCloseAdmission(t *testing.T) {
980+ t.Run("admit", func(t *testing.T) {
981+ for i := 0; i < 500; i++ {
982+ conn := readyTestConnection(t)
983+ op := operationIDAt(1)
984+ var wg sync.WaitGroup
985+ start := make(chan struct{})
986+ var admitErr error
987+ wg.Add(2)
988+ go func() { defer wg.Done(); <-start; admitErr = conn.AdmitOperation(op) }()
989+ go func() { defer wg.Done(); <-start; _ = conn.BeginClose(); _ = conn.FinishClose() }()
990+ close(start)
991+ wg.Wait()
992+ require.Equal(t, ConnClosed, conn.State(), "iteration %d", i)
993+ require.Zero(t, connectionOperations(conn).Pending(), "iteration %d: no admitted operation survives close", i)
994+ if admitErr != nil {
995+ require.ErrorIs(t, admitErr, ErrConnectionClosed, "iteration %d", i)
996+ }
997+ // The closed connection admits nothing, and a refused attempt
998+ // mutates nothing.
999+ require.ErrorIs(t, conn.AdmitOperation(op), ErrConnectionClosed)
1000+ require.Zero(t, connectionOperations(conn).Pending())
1001+ }
1002+ })
1003+
1004+ t.Run("open", func(t *testing.T) {
1005+ for i := 0; i < 500; i++ {
1006+ conn := readyTestConnection(t)
1007+ var wg sync.WaitGroup
1008+ start := make(chan struct{})
1009+ var openErr error
1010+ wg.Add(2)
1011+ go func() { defer wg.Done(); <-start; openErr = conn.OpenStream(1) }()
1012+ go func() { defer wg.Done(); <-start; _ = conn.BeginClose(); _ = conn.FinishClose() }()
1013+ close(start)
1014+ wg.Wait()
1015+ require.Equal(t, ConnClosed, conn.State(), "iteration %d", i)
1016+ require.Zero(t, connectionStreams(conn).Active(), "iteration %d: no opened stream survives close", i)
1017+ if openErr != nil {
1018+ require.ErrorIs(t, openErr, ErrConnectionClosed, "iteration %d", i)
1019+ }
1020+ require.ErrorIs(t, conn.OpenStream(1), ErrConnectionClosed)
1021+ require.Zero(t, connectionStreams(conn).Active())
1022+ }
1023+ })
1024+
1025+ t.Run("burst", func(t *testing.T) {
1026+ conn := readyTestConnection(t)
1027+ const attempts = MaxPendingOperations
1028+ var wg sync.WaitGroup
1029+ start := make(chan struct{})
1030+ errs := make([]error, attempts)
1031+ wg.Add(attempts + 1)
1032+ for i := 0; i < attempts; i++ {
1033+ go func(i int) {
1034+ defer wg.Done()
1035+ <-start
1036+ errs[i] = conn.AdmitOperation(operationIDAt(i))
1037+ }(i)
1038+ }
1039+ go func() {
1040+ defer wg.Done()
1041+ <-start
1042+ _ = conn.BeginClose()
1043+ _ = conn.FinishClose()
1044+ }()
1045+ close(start)
1046+ wg.Wait()
1047+ require.Equal(t, ConnClosed, conn.State())
1048+ require.Zero(t, connectionOperations(conn).Pending(), "no admitted operation survives the racing close")
1049+ for i, err := range errs {
1050+ if err != nil {
1051+ require.ErrorIs(t, err, ErrConnectionClosed, "attempt %d", i)
1052+ }
1053+ }
1054+ require.ErrorIs(t, conn.AdmitOperation(operationIDAt(attempts+1)), ErrConnectionClosed)
1055+ })
1056+}
1057+
1058+// TestConnectionCloseRaceNoPostCloseSnapshotCommit proves the connection lock
1059+// covers both the ready check and the assembler mutation: whenever a close and
1060+// an Add race, no snapshot can commit after BeginClose returned, no part can be
1061+// accepted after the close fence, and an Add that wins the race holds the lock
1062+// across its commit, so its revision is already visible when BeginClose
1063+// returns.
1064+func TestConnectionCloseRaceNoPostCloseSnapshotCommit(t *testing.T) {
1065+ const revision = 6
1066+ const attempts = 500
1067+
1068+ t.Run("staged end", func(t *testing.T) {
1069+ for i := 0; i < attempts; i++ {
1070+ conn := readyTestConnection(t)
1071+ require.NoError(t, conn.Subscribe(2))
1072+ parts := snapshotPartsFor(2, revision, sampleSnapshotHosts(), nil)
1073+ end := parts[len(parts)-1]
1074+ for _, part := range parts[:len(parts)-1] {
1075+ stageConnectionPart(t, conn, part)
1076+ }
1077+ require.True(t, conn.StagingActive())
1078+ _, committedBefore := conn.Snapshot()
1079+ require.False(t, committedBefore, "iteration %d: nothing publishes before End", i)
1080+
1081+ var completed bool
1082+ var addedRevision ports.BrokerRevision
1083+ committedAtClose, closeRevision, errs := raceConnectionClose(t, conn, func() error {
1084+ done, published, err := conn.AddSnapshotPart(end)
1085+ if done {
1086+ completed = true
1087+ addedRevision = published.Revision
1088+ }
1089+ return err
1090+ })
1091+ addErr := errs[0]
1092+
1093+ if completed {
1094+ // A committed End is already visible when BeginClose returned;
1095+ // it never lands in the closing window.
1096+ require.NoError(t, addErr, "iteration %d", i)
1097+ require.True(t, committedAtClose, "iteration %d: commit landed after close", i)
1098+ require.Equal(t, ports.BrokerRevision(revision), addedRevision, "iteration %d", i)
1099+ require.Equal(t, addedRevision, closeRevision, "iteration %d: commit changed after close", i)
1100+ } else {
1101+ require.ErrorIs(t, addErr, ErrConnectionClosed, "iteration %d", i)
1102+ require.False(t, committedAtClose, "iteration %d: refused part must not publish", i)
1103+ }
1104+ requireClosedConnectionRejects(t, conn, i, end)
1105+ }
1106+ })
1107+
1108+ t.Run("empty transfer", func(t *testing.T) {
1109+ // An empty transfer's Begin stages and its End publishes. Racing the
1110+ // close against both parts proves no commit can land after BeginClose
1111+ // returned, whatever order the two Adds run in: an End that runs before
1112+ // its Begin is refused as an invalid transfer, and a part that runs
1113+ // after the close is refused as closed.
1114+ for i := 0; i < attempts; i++ {
1115+ conn := readyTestConnection(t)
1116+ require.NoError(t, conn.Subscribe(2))
1117+ parts := snapshotPartsFor(2, revision, nil, nil)
1118+ begin, end := parts[0], parts[1]
1119+
1120+ var completed bool
1121+ var addedRevision ports.BrokerRevision
1122+ committedAtClose, _, errs := raceConnectionClose(t, conn,
1123+ func() error {
1124+ _, _, err := conn.AddSnapshotPart(begin)
1125+ return err
1126+ },
1127+ func() error {
1128+ done, published, err := conn.AddSnapshotPart(end)
1129+ if done {
1130+ completed = true
1131+ addedRevision = published.Revision
1132+ }
1133+ return err
1134+ },
1135+ )
1136+ beginErr, endErr := errs[0], errs[1]
1137+
1138+ if beginErr != nil {
1139+ require.ErrorIs(t, beginErr, ErrConnectionClosed, "iteration %d", i)
1140+ }
1141+ if completed {
1142+ require.NoError(t, endErr, "iteration %d", i)
1143+ require.True(t, committedAtClose, "iteration %d: commit landed after close", i)
1144+ require.Equal(t, ports.BrokerRevision(revision), addedRevision, "iteration %d", i)
1145+ } else {
1146+ require.Error(t, endErr, "iteration %d", i)
1147+ require.False(t, committedAtClose, "iteration %d: refused transfer must not publish", i)
1148+ }
1149+ requireClosedConnectionRejects(t, conn, i, end)
1150+ }
1151+ })
1152+}
1153+
1154+// raceConnectionClose runs every supplied Add concurrently with one full close
1155+// and reports whether a snapshot was committed at the moment BeginClose
1156+// returned, that revision, and each Add's error in argument order.
1157+func raceConnectionClose(t *testing.T, conn *Connection, adds ...func() error) (bool, ports.BrokerRevision, []error) {
1158+ t.Helper()
1159+ errs := make([]error, len(adds))
1160+ var committedAtClose bool
1161+ var closeRevision ports.BrokerRevision
1162+ var wg sync.WaitGroup
1163+ start := make(chan struct{})
1164+ wg.Add(len(adds) + 1)
1165+ for i, add := range adds {
1166+ go func(i int, add func() error) {
1167+ defer wg.Done()
1168+ <-start
1169+ errs[i] = add()
1170+ }(i, add)
1171+ }
1172+ go func() {
1173+ defer wg.Done()
1174+ <-start
1175+ _ = conn.BeginClose()
1176+ published, ok := conn.Snapshot()
1177+ committedAtClose = ok
1178+ closeRevision = published.Revision
1179+ _ = conn.FinishClose()
1180+ }()
1181+ close(start)
1182+ wg.Wait()
1183+ return committedAtClose, closeRevision, errs
1184+}
1185+
1186+// requireClosedConnectionRejects proves a closed connection retains no snapshot
1187+// and accepts no further part.
1188+func requireClosedConnectionRejects(t *testing.T, conn *Connection, iteration int, part SnapshotPart) {
1189+ t.Helper()
1190+ require.Equal(t, ConnClosed, conn.State(), "iteration %d", iteration)
1191+ require.False(t, conn.StagingActive(), "iteration %d", iteration)
1192+ _, ok := conn.Snapshot()
1193+ require.False(t, ok, "iteration %d: closed releases the snapshot", iteration)
1194+ _, _, err := conn.AddSnapshotPart(part)
1195+ require.ErrorIs(t, err, ErrConnectionClosed, "iteration %d", iteration)
1196+}
1197+
1198+// TestConnectionNilSafety proves the nil-receiver paths fail closed.
1199+func TestConnectionNilSafety(t *testing.T) {
1200+ var conn *Connection
1201+ require.Equal(t, ConnClosed, conn.State())
1202+ require.False(t, conn.Ready())
1203+ require.Equal(t, Scope{}, conn.Scope())
1204+ require.ErrorIs(t, conn.SignalPreamble(), ErrConnectionClosed)
1205+ require.ErrorIs(t, conn.Register(), ErrConnectionClosed)
1206+ require.ErrorIs(t, conn.Subscribe(1), ErrConnectionClosed)
1207+ require.ErrorIs(t, conn.Resync(1), ErrConnectionClosed)
1208+ require.ErrorIs(t, conn.Unsubscribe(1), ErrConnectionClosed)
1209+ require.ErrorIs(t, conn.AdmitOperation(operationIDAt(1)), ErrConnectionClosed)
1210+ require.ErrorIs(t, conn.CompleteOperation(operationIDAt(1), ports.BrokerOutcomeOK), ErrConnectionClosed)
1211+ require.ErrorIs(t, conn.OpenStream(1), ErrConnectionClosed)
1212+ _, err := conn.StreamData(1)
1213+ require.ErrorIs(t, err, ErrConnectionClosed)
1214+ _, err = conn.StreamProgress(1)
1215+ require.ErrorIs(t, err, ErrConnectionClosed)
1216+ _, err = conn.CloseStream(1)
1217+ require.ErrorIs(t, err, ErrConnectionClosed)
1218+ _, _, err = conn.AddSnapshotPart(snapshotPartsFor(1, 1, nil, nil)[0])
1219+ require.ErrorIs(t, err, ErrConnectionClosed)
1220+ _, ok := conn.Snapshot()
1221+ require.False(t, ok)
1222+ require.NoError(t, conn.BeginClose())
1223+ require.NoError(t, conn.FinishClose())
1224+}
1225+
1226+// TestOperationTrackerNilSafety and TestStreamTrackerNilSafety above cover the
1227+// tracker-receiver paths; this proves the nil tracker pointer paths.
1228+func TestNilOperationTracker(t *testing.T) {
1229+ var tr *OperationTracker
1230+ scope := testBrokerScope()
1231+ require.ErrorIs(t, tr.Admit(scope, operationIDAt(1)), ErrConnectionClosed)
1232+ require.ErrorIs(t, tr.Complete(scope, operationIDAt(1), ports.BrokerOutcomeOK), ErrConnectionClosed)
1233+ require.Zero(t, tr.Pending())
1234+ require.Zero(t, tr.Tracked())
1235+ require.Zero(t, tr.DedupBytes())
1236+ _, ok := tr.Outcome(operationIDAt(1))
1237+ require.False(t, ok)
1238+}
M internal/protocol/session.go +1 -1
@@ -13,7 +13,7 @@ import (
1313 )
1414
1515 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 52
16+const Version uint16 = 53
1717
1818 // HandshakeTimeout bounds every transport handshake from connect through the
1919 // first committed publication. It excludes the preceding client-local
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(52), Version)
13+ require.Equal(t, uint16(53), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
A internal/protocol/wire/broker.pb.go +2904 -0 generated file: collapsed
A internal/protocol/wire/broker_test.go +439 -0
@@ -0,0 +1,439 @@
1+package wire
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "testing"
7+
8+ "google.golang.org/protobuf/encoding/protowire"
9+ "google.golang.org/protobuf/proto"
10+ "google.golang.org/protobuf/reflect/protoreflect"
11+)
12+
13+// The broker conversation is separate from the session conversation: its
14+// tags are disjoint from every session tag, both directions are closed
15+// oneof unions, and the strict scanner treats them exactly like the
16+// session envelopes.
17+
18+// brokerClientTags is the frozen client tag inventory.
19+var brokerClientTags = map[string]protoreflect.FieldNumber{
20+ "register": 101, "subscribe": 102, "resync": 103, "unsubscribe": 104,
21+ "add_host": 105, "remove_host": 106, "reconcile": 107, "open_stream": 108,
22+ "client_stream_data": 109, "close_stream": 110,
23+}
24+
25+// brokerServerTags is the frozen server tag inventory.
26+var brokerServerTags = map[string]protoreflect.FieldNumber{
27+ "registered": 201, "snapshot_part": 202, "operation_result": 203,
28+ "stream_opened": 204, "server_stream_data": 205, "stream_closed": 206,
29+ "progress": 207, "broker_error_message": 208, "shutdown": 209,
30+}
31+
32+func oneofTags(t *testing.T, message proto.Message) map[string]protoreflect.FieldNumber {
33+ t.Helper()
34+ descriptor := message.ProtoReflect().Descriptor()
35+ if descriptor.Oneofs().Len() != 1 {
36+ t.Fatalf("%v has %d oneofs, want exactly the payload oneof", descriptor.FullName(), descriptor.Oneofs().Len())
37+ }
38+ oneof := descriptor.Oneofs().Get(0)
39+ if string(oneof.Name()) != "payload" {
40+ t.Fatalf("%v oneof %q is not the payload oneof", descriptor.FullName(), oneof.Name())
41+ }
42+ tags := make(map[string]protoreflect.FieldNumber, oneof.Fields().Len())
43+ for i := range oneof.Fields().Len() {
44+ field := oneof.Fields().Get(i)
45+ tags[string(field.Name())] = field.Number()
46+ }
47+ return tags
48+}
49+
50+// TestBrokerEnvelopeInventory proves both broker envelopes carry exactly
51+// the frozen tag inventory and share no tag with the session envelopes or
52+// with each other.
53+func TestBrokerEnvelopeInventory(t *testing.T) {
54+ client := oneofTags(t, &BrokerClientEnvelope{})
55+ server := oneofTags(t, &BrokerServerEnvelope{})
56+
57+ if len(client) != len(brokerClientTags) {
58+ t.Fatalf("broker client envelope has %d variants, want %d (%v)", len(client), len(brokerClientTags), client)
59+ }
60+ for name, number := range brokerClientTags {
61+ if got, ok := client[name]; !ok || got != number {
62+ t.Fatalf("broker client variant %q = %d (present %t), want %d", name, got, ok, number)
63+ }
64+ }
65+ if len(server) != len(brokerServerTags) {
66+ t.Fatalf("broker server envelope has %d variants, want %d (%v)", len(server), len(brokerServerTags), server)
67+ }
68+ for name, number := range brokerServerTags {
69+ if got, ok := server[name]; !ok || got != number {
70+ t.Fatalf("broker server variant %q = %d (present %t), want %d", name, got, ok, number)
71+ }
72+ }
73+
74+ sessionTags := map[protoreflect.FieldNumber]string{}
75+ for _, envelope := range []proto.Message{&ClientEnvelope{}, &ServerEnvelope{}} {
76+ for name, number := range oneofTags(t, envelope) {
77+ sessionTags[number] = name
78+ }
79+ }
80+ for name, number := range brokerClientTags {
81+ if session, taken := sessionTags[number]; taken {
82+ t.Fatalf("broker client tag %d (%s) collides with session variant %s", number, name, session)
83+ }
84+ }
85+ clientByTag := map[protoreflect.FieldNumber]string{}
86+ for name, number := range brokerClientTags {
87+ clientByTag[number] = name
88+ }
89+ for name, number := range brokerServerTags {
90+ if session, taken := sessionTags[number]; taken {
91+ t.Fatalf("broker server tag %d (%s) collides with session variant %s", number, name, session)
92+ }
93+ if peer, taken := clientByTag[number]; taken {
94+ t.Fatalf("broker server tag %d (%s) collides with broker client variant %s", number, name, peer)
95+ }
96+ }
97+
98+ for _, envelope := range []proto.Message{&BrokerClientEnvelope{}, &BrokerServerEnvelope{}} {
99+ if !isEnvelopeDescriptor(envelope.ProtoReflect().Descriptor()) {
100+ t.Fatalf("%v is not recognized as a directional envelope", envelope.ProtoReflect().Descriptor().FullName())
101+ }
102+ }
103+ // Payload messages are never envelopes: only the two directions require
104+ // exactly one variant.
105+ for _, payload := range []proto.Message{&Subscribe{}, &SnapshotPart{}, &Registered{}} {
106+ if isEnvelopeDescriptor(payload.ProtoReflect().Descriptor()) {
107+ t.Fatalf("payload %v is wrongly recognized as a directional envelope", payload.ProtoReflect().Descriptor().FullName())
108+ }
109+ }
110+}
111+
112+func brokerScope() *BrokerScope {
113+ return &BrokerScope{BrokerEpoch: 7, ConnectionId: bytes.Repeat([]byte{0x11}, 16)}
114+}
115+
116+func brokerRef() *BrokerStreamRef {
117+ return &BrokerStreamRef{Scope: brokerScope(), StreamId: 3}
118+}
119+
120+func brokerRegistration() *RemoteRegistration {
121+ return &RemoteRegistration{Endpoint: "dev@host:22", Incarnation: bytes.Repeat([]byte{0x22}, 16), Generation: 9}
122+}
123+
124+func brokerPolicy() *BrokerWirePolicy {
125+ return &BrokerWirePolicy{
126+ ProtocolVersion: 53, CatalogueVersion: 3, EnvironmentPolicy: 1,
127+ Transport: "quic", Trust: "pinned", Launch: "agent", Isolation: "user",
128+ }
129+}
130+
131+func brokerCatalogTab() *BrokerCatalogTab {
132+ return &BrokerCatalogTab{Id: "tab-1", Index: 0, Name: "work", Detail: "1 pane", Attention: true}
133+}
134+
135+func brokerCatalogSession() *BrokerCatalogSession {
136+ return &BrokerCatalogSession{
137+ LifecycleId: bytes.Repeat([]byte{0x33}, 16), Name: "work", State: 1,
138+ Ephemeral: true, Tabs: []*BrokerCatalogTab{brokerCatalogTab()}, Attached: true,
139+ LastUsedSeq: 4, ActiveTabId: "tab-1", Reason: "refreshing",
140+ }
141+}
142+
143+// brokerClientSamples returns one populated envelope per client variant.
144+// Every sample's final serialized field is deliberately multi-byte so a
145+// one-byte truncation always lands inside a field.
146+func brokerClientSamples() map[string]*BrokerClientEnvelope {
147+ return map[string]*BrokerClientEnvelope{
148+ "register": {Payload: &BrokerClientEnvelope_Register{Register: &Register{}}},
149+ "subscribe": {Payload: &BrokerClientEnvelope_Subscribe{Subscribe: &Subscribe{Scope: brokerScope(), Generation: 1 << 40}}},
150+ "resync": {Payload: &BrokerClientEnvelope_Resync{Resync: &Resync{Scope: brokerScope(), Generation: 1 << 41}}},
151+ "unsubscribe": {Payload: &BrokerClientEnvelope_Unsubscribe{Unsubscribe: &Unsubscribe{Scope: brokerScope(), Generation: 1 << 42}}},
152+ "add_host": {Payload: &BrokerClientEnvelope_AddHost{AddHost: &AddHost{
153+ Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x44}, 16), Endpoint: "dev@host:22",
154+ }}},
155+ "remove_host": {Payload: &BrokerClientEnvelope_RemoveHost{RemoveHost: &RemoveHost{
156+ Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x45}, 16), Endpoint: "dev@host:23",
157+ }}},
158+ "reconcile": {Payload: &BrokerClientEnvelope_Reconcile{Reconcile: &Reconcile{
159+ Scope: brokerScope(), Registration: brokerRegistration(),
160+ }}},
161+ "open_stream": {Payload: &BrokerClientEnvelope_OpenStream{OpenStream: &OpenStream{
162+ Ref: brokerRef(), Purpose: 1, Local: false, Endpoint: "dev@host:22",
163+ Registration: brokerRegistration(),
164+ Target: &ExactTarget{LifecycleId: &LifecycleID{Value: bytes.Repeat([]byte{0x55}, 16)}, SessionName: "work"},
165+ Env: []string{"TERM=xterm-256color", "LANG=C.UTF-8"},
166+ Policy: brokerPolicy(),
167+ }}},
168+ "client_stream_data": {Payload: &BrokerClientEnvelope_ClientStreamData{ClientStreamData: &ClientStreamData{
169+ Ref: brokerRef(), Data: bytes.Repeat([]byte{0x66}, 24),
170+ }}},
171+ "close_stream": {Payload: &BrokerClientEnvelope_CloseStream{CloseStream: &CloseStream{Ref: brokerRef()}}},
172+ }
173+}
174+
175+// brokerServerSamples returns one populated envelope per server variant.
176+func brokerServerSamples() map[string]*BrokerServerEnvelope {
177+ return map[string]*BrokerServerEnvelope{
178+ "registered": {Payload: &BrokerServerEnvelope_Registered{Registered: &Registered{Scope: brokerScope()}}},
179+ "snapshot_part": {Payload: &BrokerServerEnvelope_SnapshotPart{SnapshotPart: &SnapshotPart{
180+ Scope: brokerScope(), Generation: 2, Revision: 5, Index: 9,
181+ Part: &SnapshotPart_Host{Host: &SnapshotHost{
182+ HostIndex: 1, Endpoint: "dev@host:22", DisplayOrigin: "dev@host",
183+ Rank: 2, Registration: brokerRegistration(), Availability: 1,
184+ Checking: true,
185+ LastAttempt: &BrokerTimestamp{Seconds: 1700000000, Nanos: 1},
186+ LastSuccess: &BrokerTimestamp{Seconds: 1700000001, Nanos: 2},
187+ NextDue: &BrokerTimestamp{Seconds: 1700000002, Nanos: 3},
188+ ConsecutiveFailures: 4, FailureEpisode: 6, FailureKind: 1,
189+ InventoryKnown: true, SessionCount: 1,
190+ }},
191+ }}},
192+ "operation_result": {Payload: &BrokerServerEnvelope_OperationResult{OperationResult: &OperationResult{
193+ Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x77}, 16), Outcome: 2, Removed: true,
194+ Error: &BrokerErrorDetail{Code: 2, Text: "incompatible", AdmissionCode: 1, FailureKind: 3},
195+ }}},
196+ "stream_opened": {Payload: &BrokerServerEnvelope_StreamOpened{StreamOpened: &StreamOpened{Ref: brokerRef()}}},
197+ "server_stream_data": {Payload: &BrokerServerEnvelope_ServerStreamData{ServerStreamData: &ServerStreamData{
198+ Ref: brokerRef(), Data: bytes.Repeat([]byte{0x88}, 24),
199+ }}},
200+ "stream_closed": {Payload: &BrokerServerEnvelope_StreamClosed{StreamClosed: &StreamClosed{
201+ Ref: brokerRef(), Error: &BrokerErrorDetail{Code: 6, Text: "attachment_lost"},
202+ }}},
203+ "progress": {Payload: &BrokerServerEnvelope_Progress{Progress: &Progress{
204+ Ref: brokerRef(), Phase: 1, Text: "probing",
205+ }}},
206+ "broker_error_message": {Payload: &BrokerServerEnvelope_BrokerErrorMessage{BrokerErrorMessage: &BrokerErrorMessage{
207+ Scope: brokerScope(), Error: &BrokerErrorDetail{Code: 1, Text: "unavailable"},
208+ }}},
209+ "shutdown": {Payload: &BrokerServerEnvelope_Shutdown{Shutdown: &Shutdown{
210+ Scope: brokerScope(), Reason: 1, Text: "idle exit",
211+ }}},
212+ }
213+}
214+
215+func mustMarshalBroker(t *testing.T, message proto.Message) []byte {
216+ t.Helper()
217+ raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(message)
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ return raw
222+}
223+
224+// TestBrokerEnvelopeRoundTrip proves every variant survives strict scan,
225+// generated unmarshal, and equality, including nested snapshot payloads.
226+func TestBrokerEnvelopeRoundTrip(t *testing.T) {
227+ for name, sample := range brokerClientSamples() {
228+ t.Run("client/"+name, func(t *testing.T) {
229+ raw := mustMarshalBroker(t, sample)
230+ decoded := &BrokerClientEnvelope{}
231+ if err := validatedUnmarshal(decoded, raw); err != nil {
232+ t.Fatalf("validatedUnmarshal() = %v", err)
233+ }
234+ if !proto.Equal(sample, decoded) {
235+ t.Fatalf("roundtrip mismatch:\n got %v\nwant %v", decoded, sample)
236+ }
237+ })
238+ }
239+ for name, sample := range brokerServerSamples() {
240+ t.Run("server/"+name, func(t *testing.T) {
241+ raw := mustMarshalBroker(t, sample)
242+ decoded := &BrokerServerEnvelope{}
243+ if err := validatedUnmarshal(decoded, raw); err != nil {
244+ t.Fatalf("validatedUnmarshal() = %v", err)
245+ }
246+ if !proto.Equal(sample, decoded) {
247+ t.Fatalf("roundtrip mismatch:\n got %v\nwant %v", decoded, sample)
248+ }
249+ })
250+ }
251+
252+ // The snapshot nested unions round-trip their non-host parts too, so
253+ // every part tag is exercised.
254+ for name, part := range map[string]*SnapshotPart{
255+ "begin": {Part: &SnapshotPart_Begin{Begin: &SnapshotBegin{HostCount: 1, SessionCount: 2, TombstoneCount: 3}}},
256+ "session": {Part: &SnapshotPart_Session{Session: &SnapshotSession{HostIndex: 1, SessionIndex: 2, Session: brokerCatalogSession()}}},
257+ "tombstone": {Part: &SnapshotPart_Tombstone{Tombstone: &SnapshotTombstone{TombstoneIndex: 3, Registration: brokerRegistration(), RetiredRevision: 4}}},
258+ "end": {Part: &SnapshotPart_End{End: &SnapshotEnd{}}},
259+ } {
260+ t.Run("snapshot/"+name, func(t *testing.T) {
261+ raw := mustMarshalBroker(t, &BrokerServerEnvelope{Payload: &BrokerServerEnvelope_SnapshotPart{
262+ SnapshotPart: &SnapshotPart{
263+ Scope: brokerScope(), Generation: 1, Revision: 2, Index: 3, Part: part.Part,
264+ },
265+ }})
266+ decoded := &BrokerServerEnvelope{}
267+ if err := validatedUnmarshal(decoded, raw); err != nil {
268+ t.Fatalf("validatedUnmarshal() = %v", err)
269+ }
270+ got := decoded.GetSnapshotPart()
271+ if got == nil || got.GetIndex() != 3 || got.Part == nil {
272+ t.Fatalf("snapshot part did not round-trip: %v", decoded)
273+ }
274+ })
275+ }
276+}
277+
278+// TestBrokerEnvelopeRequiredVariant proves both broker envelopes demand
279+// exactly one payload variant, like the session envelopes.
280+func TestBrokerEnvelopeRequiredVariant(t *testing.T) {
281+ for _, message := range []proto.Message{&BrokerClientEnvelope{}, &BrokerServerEnvelope{}} {
282+ if err := ScanEnvelope(message, mustMarshalBroker(t, message)); !errors.Is(err, ErrScanEmpty) {
283+ t.Fatalf("ScanEnvelope(%T empty) = %v, want ErrScanEmpty", message, err)
284+ }
285+ }
286+}
287+
288+// TestBrokerEnvelopeWrongDirection proves a payload serialized in one
289+// direction is rejected when scanned as the other direction.
290+func TestBrokerEnvelopeWrongDirection(t *testing.T) {
291+ client := mustMarshalBroker(t, brokerClientSamples()["subscribe"])
292+ if err := ScanEnvelope(&BrokerServerEnvelope{}, client); !errors.Is(err, ErrScanUnknown) {
293+ t.Fatalf("client payload as server envelope = %v, want ErrScanUnknown", err)
294+ }
295+ server := mustMarshalBroker(t, brokerServerSamples()["progress"])
296+ if err := ScanEnvelope(&BrokerClientEnvelope{}, server); !errors.Is(err, ErrScanUnknown) {
297+ t.Fatalf("server payload as client envelope = %v, want ErrScanUnknown", err)
298+ }
299+ // A session payload is unknown on the broker conversation too.
300+ session := mustMarshalBroker(t, &ClientEnvelope{Payload: &ClientEnvelope_Ping{Ping: &Ping{}}})
301+ if err := ScanEnvelope(&BrokerClientEnvelope{}, session); !errors.Is(err, ErrScanUnknown) {
302+ t.Fatalf("session payload as broker envelope = %v, want ErrScanUnknown", err)
303+ }
304+}
305+
306+// TestBrokerEnvelopeDuplicateAlternatives proves a second oneof occurrence
307+// is rejected whether it repeats the same variant or switches alternatives.
308+func TestBrokerEnvelopeDuplicateAlternatives(t *testing.T) {
309+ register := mustMarshalBroker(t, brokerClientSamples()["register"])
310+ subscribe := mustMarshalBroker(t, brokerClientSamples()["subscribe"])
311+ concatenated := append(append([]byte(nil), register...), subscribe...)
312+ if err := ScanEnvelope(&BrokerClientEnvelope{}, concatenated); !errors.Is(err, ErrScanDuplicate) {
313+ t.Fatalf("alternate alternatives = %v, want ErrScanDuplicate", err)
314+ }
315+ repeated := append(append([]byte(nil), subscribe...), subscribe...)
316+ if err := ScanEnvelope(&BrokerClientEnvelope{}, repeated); !errors.Is(err, ErrScanDuplicate) {
317+ t.Fatalf("duplicate variant = %v, want ErrScanDuplicate", err)
318+ }
319+
320+ // The nested snapshot part union is exclusive as well.
321+ host := mustMarshalBroker(t, &SnapshotPart{Part: &SnapshotPart_Host{Host: &SnapshotHost{Endpoint: "dev@host:22"}}})
322+ end := mustMarshalBroker(t, &SnapshotPart{Part: &SnapshotPart_End{End: &SnapshotEnd{}}})
323+ if err := ScanEnvelope(&SnapshotPart{}, append(append([]byte(nil), host...), end...)); !errors.Is(err, ErrScanDuplicate) {
324+ t.Fatalf("duplicate snapshot part = %v, want ErrScanDuplicate", err)
325+ }
326+}
327+
328+// TestBrokerEnvelopeRejectsUnknownAndWrongTypes proves unknown and
329+// mistyped fields are rejected recursively, at the envelope level and
330+// several levels into a nested snapshot payload.
331+func TestBrokerEnvelopeRejectsUnknownAndWrongTypes(t *testing.T) {
332+ // Unknown field on the envelope itself.
333+ raw := mustMarshalBroker(t, brokerClientSamples()["close_stream"])
334+ raw = protowire.AppendTag(raw, 199, protowire.VarintType)
335+ raw = protowire.AppendVarint(raw, 1)
336+ if err := ScanEnvelope(&BrokerClientEnvelope{}, raw); !errors.Is(err, ErrScanUnknown) {
337+ t.Fatalf("unknown envelope field = %v, want ErrScanUnknown", err)
338+ }
339+
340+ // Unknown field two levels down: BrokerClientEnvelope → OpenStream → BrokerStreamRef.
341+ ref := protowire.AppendTag(nil, 1, protowire.BytesType) // BrokerStreamRef.scope
342+ ref = protowire.AppendBytes(ref, mustMarshalBroker(t, brokerScope()))
343+ ref = protowire.AppendTag(ref, 2, protowire.VarintType) // BrokerStreamRef.stream_id
344+ ref = protowire.AppendVarint(ref, 3)
345+ ref = protowire.AppendTag(ref, 9, protowire.VarintType) // unknown in BrokerStreamRef
346+ ref = protowire.AppendVarint(ref, 1)
347+ open := protowire.AppendTag(nil, 1, protowire.BytesType) // OpenStream.ref
348+ open = protowire.AppendBytes(open, ref)
349+ outer := protowire.AppendTag(nil, 108, protowire.BytesType) // BrokerClientEnvelope.open_stream
350+ outer = protowire.AppendBytes(outer, open)
351+ if err := ScanEnvelope(&BrokerClientEnvelope{}, outer); !errors.Is(err, ErrScanUnknown) {
352+ t.Fatalf("nested unknown field = %v, want ErrScanUnknown", err)
353+ }
354+
355+ // Unknown field inside a nested snapshot part message.
356+ part := protowire.AppendTag(nil, 11, protowire.BytesType) // SnapshotPart.host
357+ host := protowire.AppendTag(nil, 2, protowire.BytesType) // SnapshotHost.endpoint
358+ host = protowire.AppendBytes(host, []byte("dev@host:22"))
359+ host = protowire.AppendTag(host, 99, protowire.VarintType)
360+ host = protowire.AppendVarint(host, 1)
361+ part = protowire.AppendBytes(part, host)
362+ raw = protowire.AppendTag(nil, 202, protowire.BytesType) // BrokerServerEnvelope.snapshot_part
363+ raw = protowire.AppendBytes(raw, part)
364+ if err := ScanEnvelope(&BrokerServerEnvelope{}, raw); !errors.Is(err, ErrScanUnknown) {
365+ t.Fatalf("unknown nested snapshot field = %v, want ErrScanUnknown", err)
366+ }
367+
368+ // Wrong wire type: BrokerClientEnvelope.close_stream (bytes) as a varint.
369+ wrongType := protowire.AppendTag(nil, 110, protowire.VarintType)
370+ wrongType = protowire.AppendVarint(wrongType, 1)
371+ if err := ScanEnvelope(&BrokerClientEnvelope{}, wrongType); !errors.Is(err, ErrScanWireType) {
372+ t.Fatalf("wrong wire type = %v, want ErrScanWireType", err)
373+ }
374+
375+ // A raw varint wider than the declared uint32 is rejected before
376+ // generated unmarshal can truncate its high bits.
377+ open = protowire.AppendTag(nil, 2, protowire.VarintType) // OpenStream.purpose
378+ open = protowire.AppendVarint(open, 1<<33)
379+ outer = protowire.AppendTag(nil, 108, protowire.BytesType)
380+ outer = protowire.AppendBytes(outer, open)
381+ if err := ScanEnvelope(&BrokerClientEnvelope{}, outer); !errors.Is(err, ErrScanVarintRange) {
382+ t.Fatalf("uint32 overflow = %v, want ErrScanVarintRange", err)
383+ }
384+}
385+
386+// TestBrokerEnvelopeTruncation proves truncated tags, varints, lengths, and
387+// every strict prefix of a valid envelope are rejected.
388+func TestBrokerEnvelopeTruncation(t *testing.T) {
389+ if err := ScanEnvelope(&BrokerClientEnvelope{}, []byte{0xFF}); !errors.Is(err, ErrScanTruncated) {
390+ t.Fatalf("truncated tag = %v, want ErrScanTruncated", err)
391+ }
392+ overlong := protowire.AppendTag(nil, 101, protowire.BytesType)
393+ overlong = protowire.AppendBytes(overlong, make([]byte, 8))
394+ overlong = overlong[:len(overlong)-4]
395+ if err := ScanEnvelope(&BrokerClientEnvelope{}, overlong); !errors.Is(err, ErrScanTruncated) {
396+ t.Fatalf("overlong length = %v, want ErrScanTruncated", err)
397+ }
398+ partialVarint := protowire.AppendTag(nil, 102, protowire.BytesType) // BrokerClientEnvelope.subscribe
399+ subscribe := protowire.AppendTag(nil, 2, protowire.VarintType) // Subscribe.generation
400+ partialVarint = protowire.AppendBytes(partialVarint, append(subscribe, 0x80))
401+ if err := ScanEnvelope(&BrokerClientEnvelope{}, partialVarint); !errors.Is(err, ErrScanTruncated) {
402+ t.Fatalf("truncated varint = %v, want ErrScanTruncated", err)
403+ }
404+
405+ // Every strict prefix of a valid envelope must be refused: a top-level
406+ // envelope requires its complete variant.
407+ for name, sample := range brokerClientSamples() {
408+ raw := mustMarshalBroker(t, sample)
409+ for size := range len(raw) {
410+ if err := ScanEnvelope(&BrokerClientEnvelope{}, raw[:size]); err == nil {
411+ t.Fatalf("client prefix %s[:%d] accepted", name, size)
412+ }
413+ }
414+ }
415+ for name, sample := range brokerServerSamples() {
416+ raw := mustMarshalBroker(t, sample)
417+ for size := range len(raw) {
418+ if err := ScanEnvelope(&BrokerServerEnvelope{}, raw[:size]); err == nil {
419+ t.Fatalf("server prefix %s[:%d] accepted", name, size)
420+ }
421+ }
422+ }
423+}
424+
425+// TestBrokerEnvelopeTrailingGarbage proves trailing bytes and a second
426+// concatenated envelope are rejected.
427+func TestBrokerEnvelopeTrailingGarbage(t *testing.T) {
428+ raw := mustMarshalBroker(t, brokerServerSamples()["stream_opened"])
429+ for _, tail := range [][]byte{
430+ {0x00},
431+ protowire.AppendTag(nil, 209, protowire.VarintType),
432+ mustMarshalBroker(t, brokerServerSamples()["shutdown"]),
433+ } {
434+ payload := append(append([]byte(nil), raw...), tail...)
435+ if err := ScanEnvelope(&BrokerServerEnvelope{}, payload); err == nil {
436+ t.Fatalf("trailing %x accepted", tail)
437+ }
438+ }
439+}
M internal/protocol/wire/envelope_inventory_test.go +2 -2
@@ -15,8 +15,8 @@ import (
1515 // shares a payload type except route navigation failure, and the preamble
1616 // constants agree with the schema authority.
1717 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 52 {
19- t.Fatalf("protocol.Version = %d, want 52", protocol.Version)
18+ if protocol.Version != 53 {
19+ t.Fatalf("protocol.Version = %d, want 53", protocol.Version)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
M internal/protocol/wire/scan.go +10 -3
@@ -185,10 +185,17 @@ func (s *envelopeScanner) scanMessage(descriptor protoreflect.MessageDescriptor,
185185 }
186186
187187 // isEnvelopeDescriptor reports whether the descriptor is a top-level
188-// directional envelope requiring exactly one variant.
188+// directional envelope requiring exactly one variant. Session and broker
189+// envelopes are separate conversations with disjoint tag spaces; both
190+// directions of each require exactly one variant.
189191 func isEnvelopeDescriptor(descriptor protoreflect.MessageDescriptor) bool {
190- name := string(descriptor.FullName())
191- return name == "vev.wire.v1.ClientEnvelope" || name == "vev.wire.v1.ServerEnvelope"
192+ switch string(descriptor.FullName()) {
193+ case "vev.wire.v1.ClientEnvelope", "vev.wire.v1.ServerEnvelope",
194+ "vev.wire.v1.BrokerClientEnvelope", "vev.wire.v1.BrokerServerEnvelope":
195+ return true
196+ default:
197+ return false
198+ }
192199 }
193200
194201 // checkVarintRange rejects a raw varint that cannot be represented by the
A internal/protocol/wire/schema/broker.proto +320 -0
@@ -0,0 +1,320 @@
1+// Broker wire schema: the client/server broker conversations.
2+//
3+// The broker is an independently versioned conversation from the session
4+// protocol: client tags 101-110 and server tags 201-209 are disjoint from
5+// every session tag, and each direction is its own closed oneof union.
6+// Scope (broker epoch plus client connection) is carried explicitly on
7+// every payload so stream IDs and events never cross a scope. Semantic
8+// validation stays outside this package; conversion mirrors the port
9+// types losslessly.
10+syntax = "proto3";
11+
12+package vev.wire.v1;
13+
14+import "common.proto";
15+
16+option go_package = "github.com/bnema/vev/internal/protocol/wire";
17+
18+// BrokerScope identifies one broker process incarnation and one client
19+// connection within it (ports.BrokerEpoch / ports.BrokerConnectionID).
20+// Streams and events are scoped to one scope; an epoch change forces a
21+// full resynchronization.
22+message BrokerScope {
23+ uint64 broker_epoch = 1;
24+ bytes connection_id = 2; // exactly 16 bytes
25+}
26+
27+// BrokerStreamRef identifies one logical stream within one scope
28+// (ports.BrokerStreamID). It never crosses connections or epochs.
29+message BrokerStreamRef {
30+ BrokerScope scope = 1;
31+ uint64 stream_id = 2;
32+}
33+
34+// BrokerWirePolicy is the exact connection policy two endpoints must share
35+// before pooling one physical transport (ports.BrokerPolicy). Tokens are
36+// opaque, authoritative identities: never display labels, never secrets.
37+message BrokerWirePolicy {
38+ uint32 protocol_version = 1;
39+ uint32 catalogue_version = 2;
40+ // Closed taxonomy: 0 = client-owned, 1 = daemon-owned.
41+ uint32 environment_policy = 3;
42+ string transport = 4;
43+ string trust = 5;
44+ string launch = 6;
45+ string isolation = 7;
46+}
47+
48+// BrokerErrorDetail is the typed broker failure carried to clients
49+// (ports.BrokerError plus bounded display text). The underlying cause is
50+// local-only diagnostic authority and never travels on this wire.
51+message BrokerErrorDetail {
52+ // Closed taxonomy: 1 = unavailable, 2 = incompatible, 3 = timeout,
53+ // 4 = cancelled, 5 = outcome_unknown, 6 = attachment_lost,
54+ // 7 = stale_epoch, 8 = conflicting_policy, 9 = explicit_exit,
55+ // 10 = fatal_terminal.
56+ uint32 code = 1;
57+ string text = 2;
58+ // Closed admission-refusal taxonomy; 0 = none. Codes are assigned by the
59+ // broker admission slice.
60+ uint32 admission_code = 3;
61+ // Closed cause taxonomy (domain.RemoteFailureKind): 0 = none, 1 =
62+ // transport, through the last declared failure kind.
63+ uint32 failure_kind = 4;
64+}
65+
66+// BrokerTimestamp is one instant split into seconds and nanoseconds so
67+// time.Time survives the wire exactly.
68+message BrokerTimestamp {
69+ int64 seconds = 1;
70+ int32 nanos = 2;
71+}
72+
73+// BrokerClientEnvelope is the closed client-to-server broker union. Exactly
74+// one variant must be set per envelope; empty, duplicate, and
75+// wrong-direction payloads are rejected before generated unmarshal.
76+message BrokerClientEnvelope {
77+ oneof payload {
78+ Register register = 101;
79+ Subscribe subscribe = 102;
80+ Resync resync = 103;
81+ Unsubscribe unsubscribe = 104;
82+ AddHost add_host = 105;
83+ RemoveHost remove_host = 106;
84+ Reconcile reconcile = 107;
85+ OpenStream open_stream = 108;
86+ ClientStreamData client_stream_data = 109;
87+ CloseStream close_stream = 110;
88+ }
89+}
90+
91+// BrokerServerEnvelope is the closed server-to-client broker union. Exactly
92+// one variant must be set per envelope.
93+message BrokerServerEnvelope {
94+ oneof payload {
95+ Registered registered = 201;
96+ SnapshotPart snapshot_part = 202;
97+ OperationResult operation_result = 203;
98+ StreamOpened stream_opened = 204;
99+ ServerStreamData server_stream_data = 205;
100+ StreamClosed stream_closed = 206;
101+ Progress progress = 207;
102+ BrokerErrorMessage broker_error_message = 208;
103+ Shutdown shutdown = 209;
104+ }
105+}
106+
107+// Register opens one client connection to the broker.
108+message Register {}
109+
110+// Subscribe asks for snapshot and lifecycle publications for one scope.
111+message Subscribe {
112+ BrokerScope scope = 1;
113+ uint64 generation = 2;
114+}
115+
116+// Resync asks for a full snapshot publication after an epoch change or a
117+// gap in the client's generation series.
118+message Resync {
119+ BrokerScope scope = 1;
120+ uint64 generation = 2;
121+}
122+
123+// Unsubscribe stops publications for one scope.
124+message Unsubscribe {
125+ BrokerScope scope = 1;
126+ uint64 generation = 2;
127+}
128+
129+// AddHost adds one configured host registration. operation_id is the
130+// idempotency identity of this mutating operation.
131+message AddHost {
132+ BrokerScope scope = 1;
133+ bytes operation_id = 2; // exactly 16 bytes
134+ string endpoint = 3;
135+}
136+
137+// RemoveHost removes one configured host registration.
138+message RemoveHost {
139+ BrokerScope scope = 1;
140+ bytes operation_id = 2; // exactly 16 bytes
141+ string endpoint = 3;
142+}
143+
144+// Reconcile hands the broker one exact registration to probe.
145+message Reconcile {
146+ BrokerScope scope = 1;
147+ RemoteRegistration registration = 2;
148+}
149+
150+// OpenStream asks the broker to open one independently cancellable logical
151+// stream to the owning daemon. env is the per-request session environment
152+// and is never inherited from the broker process environment.
153+message OpenStream {
154+ BrokerStreamRef ref = 1;
155+ // Closed taxonomy: 1 = attachment, 2 = control, 3 = observation.
156+ uint32 purpose = 2;
157+ bool local = 3;
158+ string endpoint = 4;
159+ RemoteRegistration registration = 5;
160+ ExactTarget target = 6;
161+ repeated string env = 7;
162+ BrokerWirePolicy policy = 8;
163+}
164+
165+// ClientStreamData carries one opaque client-to-daemon stream frame.
166+message ClientStreamData {
167+ BrokerStreamRef ref = 1;
168+ bytes data = 2;
169+}
170+
171+// CloseStream closes one logical stream.
172+message CloseStream {
173+ BrokerStreamRef ref = 1;
174+}
175+
176+// Registered confirms one accepted client connection and its assigned scope.
177+message Registered {
178+ BrokerScope scope = 1;
179+}
180+
181+// SnapshotPart is one fragment of a snapshot publication. Parts of one
182+// generation and revision arrive in index order and carry exactly one
183+// nested payload.
184+message SnapshotPart {
185+ BrokerScope scope = 1;
186+ uint64 generation = 2;
187+ uint64 revision = 3;
188+ uint32 index = 4;
189+ oneof part {
190+ SnapshotBegin begin = 10;
191+ SnapshotHost host = 11;
192+ SnapshotSession session = 12;
193+ SnapshotTombstone tombstone = 13;
194+ SnapshotEnd end = 14;
195+ }
196+}
197+
198+// SnapshotBegin opens a snapshot publication with its element counts.
199+message SnapshotBegin {
200+ uint32 host_count = 1;
201+ uint32 session_count = 2;
202+ uint32 tombstone_count = 3;
203+}
204+
205+// SnapshotHost is one host projection (ports.RemoteHostSnapshot). The
206+// endpoint and display origin stay separate: the latter is
207+// presentation-only. Sessions travel as separate SnapshotSession parts.
208+message SnapshotHost {
209+ uint32 host_index = 1;
210+ string endpoint = 2;
211+ string display_origin = 3;
212+ uint32 rank = 4;
213+ RemoteRegistration registration = 5;
214+ // Closed availability taxonomy; 0 is the zero value.
215+ uint32 availability = 6;
216+ bool checking = 7;
217+ BrokerTimestamp last_attempt = 8;
218+ BrokerTimestamp last_success = 9;
219+ BrokerTimestamp next_due = 10;
220+ uint64 consecutive_failures = 11;
221+ uint64 failure_episode = 12;
222+ // Closed cause taxonomy (domain.RemoteFailureKind); 0 = none.
223+ uint32 failure_kind = 13;
224+ bool inventory_known = 14;
225+ uint32 session_count = 15;
226+}
227+
228+// SnapshotSession is one catalogue session bound to its host by index.
229+message SnapshotSession {
230+ uint32 host_index = 1;
231+ uint32 session_index = 2;
232+ BrokerCatalogSession session = 3;
233+}
234+
235+// SnapshotTombstone fences one retired host registration so a stale probe
236+// cannot revive authority for that exact incarnation.
237+message SnapshotTombstone {
238+ uint32 tombstone_index = 1;
239+ RemoteRegistration registration = 2;
240+ uint64 retired_revision = 3;
241+}
242+
243+// SnapshotEnd closes a snapshot publication.
244+message SnapshotEnd {}
245+
246+// OperationResult is exactly one completion for one admitted mutating
247+// operation.
248+message OperationResult {
249+ BrokerScope scope = 1;
250+ bytes operation_id = 2; // exactly 16 bytes
251+ // Closed taxonomy: 1 = ok, 2 = failed, 3 = outcome_unknown.
252+ uint32 outcome = 3;
253+ bool removed = 4;
254+ BrokerErrorDetail error = 5;
255+}
256+
257+// StreamOpened confirms one logical stream is established.
258+message StreamOpened {
259+ BrokerStreamRef ref = 1;
260+}
261+
262+// ServerStreamData carries one opaque daemon-to-client stream frame.
263+message ServerStreamData {
264+ BrokerStreamRef ref = 1;
265+ bytes data = 2;
266+}
267+
268+// StreamClosed ends one logical stream. An unset error is an orderly close;
269+// a set error reports the typed failure that ended it.
270+message StreamClosed {
271+ BrokerStreamRef ref = 1;
272+ BrokerErrorDetail error = 2;
273+}
274+
275+// Progress reports one phase of a long-running broker operation.
276+message Progress {
277+ BrokerStreamRef ref = 1;
278+ uint32 phase = 2;
279+ string text = 3;
280+}
281+
282+// BrokerErrorMessage reports a broker-wide typed failure for one scope.
283+message BrokerErrorMessage {
284+ BrokerScope scope = 1;
285+ BrokerErrorDetail error = 2;
286+}
287+
288+// Shutdown reports the broker process is stopping.
289+message Shutdown {
290+ BrokerScope scope = 1;
291+ // Closed reason taxonomy; 0 is the zero value.
292+ uint32 reason = 2;
293+ string text = 3;
294+}
295+
296+// BrokerCatalogSession mirrors catalogue.RemoteCatalogSession exactly: the
297+// lifecycle identity is a fixed 16-byte value, the state is a closed
298+// taxonomy, and the tab list keeps its presence separately from its
299+// length.
300+message BrokerCatalogSession {
301+ bytes lifecycle_id = 1; // exactly 16 bytes
302+ string name = 2;
303+ // Closed taxonomy: 1 = up, 2 = down, 3 = broken.
304+ uint32 state = 3;
305+ bool ephemeral = 4;
306+ repeated BrokerCatalogTab tabs = 5;
307+ bool attached = 6;
308+ uint64 last_used_seq = 7;
309+ string active_tab_id = 8;
310+ string reason = 9;
311+}
312+
313+// BrokerCatalogTab mirrors catalogue.RemoteCatalogTab exactly.
314+message BrokerCatalogTab {
315+ string id = 1;
316+ uint32 index = 2;
317+ string name = 3;
318+ string detail = 4;
319+ bool attention = 5;
320+}

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close