feat(protocol): add broker IPC wire contract
- commit
- 0471f4ffabec0494b28757c652e9bef92e66b8da
- author
- brice · · 2026-09-16 14:59 UTC
- parent
- e8cd8f6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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, and | 22 | 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) plus | 34 | 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 per | 86 | (`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 control | 110 | ## 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 = 52 | 16 | + | const Version uint16 = 53 |
| 17 | 17 | ||||
| 18 | // HandshakeTimeout bounds every transport handshake from connect through the | 18 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 19 | // first committed publication. It excludes the preceding client-local | 19 | // 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 preamble | 15 | // 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-level | 187 | // 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 the | 201 | // 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 | + | } | |||