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
21 (PreambleRequest first, PreambleResponse acceptance, exact version,21 (PreambleRequest first, PreambleResponse acceptance, exact version,
22 negotiated ceilings, one absolute deadline), direction checks, and22 negotiated ceilings, one absolute deadline), direction checks, and
23 decode-failure classification.23 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.
24 - `internal/adapters/quic`: one-stream QUIC carriage (TLS 1.3, epoch ALPN,33 - `internal/adapters/quic`: one-stream QUIC carriage (TLS 1.3, epoch ALPN,
25 exact SHA-256 pin, single bidirectional stream, bounded admission) plus34 exact SHA-256 pin, single bidirectional stream, bounded admission) plus
26 the short-lived SSH bootstrap (ephemeral certificate, 32-byte token,35 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
29 - `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.38 - `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.
30 - `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.39 - `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.
31 - `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.40 - `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.
33 - `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.42 - `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.
34 - `pkg`: reusable packages that never import `internal`.43 - `pkg`: reusable packages that never import `internal`.
35 44
@@ -101,7 +110,29 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
101 `go tool buf generate`, and handle the new variant in `sessionwire`.110 `go tool buf generate`, and handle the new variant in `sessionwire`.
102 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.111 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
103 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.112 - 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.
105 136
106 ## Broker pool core (Plan 001 P2.2, not activated)137 ## Broker pool core (Plan 001 P2.2, not activated)
107 138
M docs/protocol.md +21 -0
@@ -86,6 +86,27 @@ Command requests additionally carry a 10-second result deadline
86 (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per86 (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per
87 connection and correlated by `RequestID`.87 connection and correlated by `RequestID`.
88 88
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+
89 ## Output, ACK, and flow control110 ## Output, ACK, and flow control
90 111
91 An `Output` state is `(Epoch, Base, New, Echo, ViewRevision, Size, Full,112 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 (
13 )13 )
14 14
15 // Version is the negotiated vev session protocol version.15 // Version is the negotiated vev session protocol version.
16-const Version uint16 = 5216+const Version uint16 = 53
17 17
18 // HandshakeTimeout bounds every transport handshake from connect through the18 // HandshakeTimeout bounds every transport handshake from connect through the
19 // first committed publication. It excludes the preceding client-local19 // first committed publication. It excludes the preceding client-local
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(52), Version)13+ require.Equal(t, uint16(53), Version)
14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
15 }15 }
16 16
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 (
15 // shares a payload type except route navigation failure, and the preamble15 // shares a payload type except route navigation failure, and the preamble
16 // constants agree with the schema authority.16 // constants agree with the schema authority.
17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 52 {18+ if protocol.Version != 53 {
19- t.Fatalf("protocol.Version = %d, want 52", protocol.Version)19+ t.Fatalf("protocol.Version = %d, want 53", protocol.Version)
20 }20 }
21 if ProtocolEpoch != 1 {21 if ProtocolEpoch != 1 {
22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)22 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,
185 }185 }
186 186
187 // isEnvelopeDescriptor reports whether the descriptor is a top-level187 // 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.
189 func isEnvelopeDescriptor(descriptor protoreflect.MessageDescriptor) bool {191 func isEnvelopeDescriptor(descriptor protoreflect.MessageDescriptor) bool {
190- name := string(descriptor.FullName())192+ switch string(descriptor.FullName()) {
191- return name == "vev.wire.v1.ClientEnvelope" || name == "vev.wire.v1.ServerEnvelope"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+ }
192 }199 }
193 200
194 // checkVarintRange rejects a raw varint that cannot be represented by the201 // 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