feat(broker): add private local IPC
- commit
- 36c187dc3ce968a932c1ad7527daa6a114248e53
- author
- brice · · 2026-09-16 20:16 UTC
- parent
- 7bfefe2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
26 files · +6346 -37
M docs/architecture.md +57 -0
| @@ -30,6 +30,17 @@ vev uses a hexagonal core with typed session messages at the client and daemon b | |||||
| 30 | byte-length bound), multipart snapshot assembly, per-connection operation | 30 | byte-length bound), multipart snapshot assembly, per-connection operation | ||
| 31 | and stream trackers, and the bounded admission lock. It is not activated | 31 | and stream trackers, and the bounded admission lock. It is not activated | ||
| 32 | by production composition until P3.2. | 32 | by production composition until P3.2. | ||
| 33 | + | - `internal/adapters/brokeripc`: the Plan 001 P3.3 private local broker | |||
| 34 | + | endpoint. It implements `ports.BrokerListener` (accepting same-user clients | |||
| 35 | + | and running one brokerwire session per accepted connection) and | |||
| 36 | + | `ports.BrokerService` (the client adapter over the same conversation) on one | |||
| 37 | + | per-user AF_UNIX endpoint, reusing the P3.2 private IPC carriage | |||
| 38 | + | (`ipc.ListenMux`/`ipc.DialMuxContext`: owner-only directory, 0600 socket, | |||
| 39 | + | same-user `SO_PEERCRED` admission, race-safe stale-socket recovery, | |||
| 40 | + | foreign-path refusal) and the shared `streamframe` framing. It owns the | |||
| 41 | + | bounded per-connection queues, the snapshot publisher, the per-stream typed | |||
| 42 | + | bridge over `sessionwire`, and deterministic disconnect cleanup. Like P3.1 and | |||
| 43 | + | P3.2 it is not activated by production composition. | |||
| 33 | - `internal/adapters/quic`: one-stream QUIC carriage (TLS 1.3, epoch ALPN, | 44 | - `internal/adapters/quic`: one-stream QUIC carriage (TLS 1.3, epoch ALPN, | ||
| 34 | exact SHA-256 pin, single bidirectional stream, bounded admission) plus | 45 | exact SHA-256 pin, single bidirectional stream, bounded admission) plus | ||
| 35 | the short-lived SSH bootstrap (ephemeral certificate, 32-byte token, | 46 | the short-lived SSH bootstrap (ephemeral certificate, 32-byte token, | ||
| @@ -181,3 +192,49 @@ underlying Close error. Timer ownership is single-goroutine; Stop followed by a | |||||
| 181 | nonblocking drain on false precedes Reset, supporting buffered timer adapters. | 192 | nonblocking drain on false precedes Reset, supporting buffered timer adapters. | ||
| 182 | Policy identities have independent field-labelled validation and a named bound. | 193 | Policy identities have independent field-labelled validation and a named bound. | ||
| 183 | These are P2.2 port contracts, not a wire migration or production activation. | 194 | These are P2.2 port contracts, not a wire migration or production activation. | ||
| 195 | + | ||||
| 196 | + | ## Broker local IPC (Plan 001 P3.3, not activated) | |||
| 197 | + | ||||
| 198 | + | `internal/adapters/brokeripc` carries the P3.1 broker conversation over one | |||
| 199 | + | per-user Unix endpoint. The carriage is the P3.2 private AF_UNIX carriage in | |||
| 200 | + | `internal/adapters/ipc`: an owner-only (0700, `pkg/safedir.EnsurePrivate`) | |||
| 201 | + | parent directory, a bound socket tightened to 0600, race-safe recovery of a | |||
| 202 | + | stale socket whose owner died, refusal (never removal) of a path that is not a | |||
| 203 | + | socket, and same-user kernel peer credentials (`SO_PEERCRED` on Linux) on both | |||
| 204 | + | accept and dial, failing closed on a platform or build without that check. The | |||
| 205 | + | endpoint name (`broker.sock`) is deliberately distinct from the daemon's | |||
| 206 | + | `daemon.sock`. | |||
| 207 | + | ||||
| 208 | + | The listener owns its accept loop: one client slot is acquired before each | |||
| 209 | + | accept, each accepted carriage completes the broker preamble and broker | |||
| 210 | + | admission in its own goroutine, and admitted sessions are published through a | |||
| 211 | + | bounded FIFO to `Accept`. A client that stalls during the preamble therefore | |||
| 212 | + | occupies one slot and one goroutine, never the loop. Each session runs the | |||
| 213 | + | brokerwire connection state machine (one Register, one subscription generation | |||
| 214 | + | series, bounded operation and stream trackers), one snapshot publisher that | |||
| 215 | + | writes the P3.1 multipart layout and coalesces through the core's capacity-one | |||
| 216 | + | subscription, and one bridge per logical stream. Streams carry the typed | |||
| 217 | + | session protocol: `sessionwire` runs over a bounded private byte pipe on each | |||
| 218 | + | side, and two relay goroutines move typed messages between the client's session | |||
| 219 | + | connection and the admitted broker core connection, so no session byte is | |||
| 220 | + | reinterpreted. | |||
| 221 | + | ||||
| 222 | + | Every frame must carry the epoch and connection identity the listener assigned | |||
| 223 | + | at accept; a mismatched frame is fenced, and a stale stream open is answered | |||
| 224 | + | with a `StreamClosed` carrying the stale admission code. A malformed, | |||
| 225 | + | oversize, or wrong-direction envelope settles exactly the connection that sent | |||
| 226 | + | it. Operational bounds are explicit: concurrent clients per listener, per-stream | |||
| 227 | + | inbound chunks and bytes, pending mutating operations, and the brokerwire | |||
| 228 | + | per-connection stream and dedup bounds. Disconnect cleanup is deterministic: | |||
| 229 | + | the session cancels its context, stops publishing, closes the outer carriage | |||
| 230 | + | (so a stream writer parked on a non-reading peer is interrupted), settles every | |||
| 231 | + | bridged stream (releasing its core connection and carriage), closes the | |||
| 232 | + | brokerwire state, joins every worker, closes the admitted core service, and | |||
| 233 | + | releases its client slot. Mutating operations report `outcome unknown` when a | |||
| 234 | + | reply is lost after the request was sent, and the client never replays them | |||
| 235 | + | blindly. | |||
| 236 | + | ||||
| 237 | + | P3.3 introduces no production activation: nothing in `internal/app` or `main` | |||
| 238 | + | constructs this adapter yet. P3.4 composes the broker use case, its stores, and | |||
| 239 | + | this listener behind a hidden entry point, and P7 performs the coordinated | |||
| 240 | + | cutover. | |||
M docs/protocol.md +5 -1
| @@ -113,7 +113,11 @@ capability negotiation in P3.1: nonzero capability bits are refused as | |||||
| 113 | code 7 (`limit refused`), and code 6 (`out of order`) is reserved for the | 113 | code 7 (`limit refused`), and code 6 (`out of order`) is reserved for the | ||
| 114 | connection dispatcher, which owns framing-order detection and never surfaces | 114 | connection dispatcher, which owns framing-order detection and never surfaces | ||
| 115 | it from the stateless codec. Brokerwire is not activated by production | 115 | it from the stateless codec. Brokerwire is not activated by production | ||
| 116 | - | composition until P3.2. | 116 | + | composition until P3.2. The P3.3 private local endpoint |
| 117 | + | (`internal/adapters/brokeripc`) carries this conversation over one per-user | |||
| 118 | + | AF_UNIX socket: `broker.sock` in the per-user runtime directory, with | |||
| 119 | + | same-user peer-credential admission, bounded per-connection queues, and | |||
| 120 | + | production composition deferred to P3.4 and the P7 cutover. | |||
| 117 | 121 | ||||
| 118 | ## Output, ACK, and flow control | 122 | ## Output, ACK, and flow control | ||
| 119 | 123 | ||||
A internal/adapters/brokeripc/carriage.go +52 -0
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/adapters/streamframe" | |||
| 5 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // carriage adapts one bounded streamPipe to the wire.Transport contract the | |||
| 9 | + | // sessionwire typed connection frames over: Send writes one complete envelope | |||
| 10 | + | // (4-byte big-endian length then payload) into the pipe, Recv reads one | |||
| 11 | + | // complete envelope back out, and Close settles the pipe. Framing, the bounded | |||
| 12 | + | // egress queue, and close-unblocks-IO behavior all come from the shared | |||
| 13 | + | // streamframe implementation; this adapter owns no frame format of its own. | |||
| 14 | + | type carriage struct { | |||
| 15 | + | framer *streamframe.Framer | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | var _ wire.BoundedTransport = (*carriage)(nil) | |||
| 19 | + | ||||
| 20 | + | // newCarriage builds one envelope carriage over a stream pipe. The pipe carries | |||
| 21 | + | // opaque chunks and sessionwire applies the negotiated per-category ceilings | |||
| 22 | + | // above it, so the carriage ceiling is the protocol absolute limit. | |||
| 23 | + | func newCarriage(pipe *streamPipe) *carriage { | |||
| 24 | + | return &carriage{ | |||
| 25 | + | framer: streamframe.NewFramer(pipe, pipe, func() error { pipe.closeWith(nil); return nil }, streamframe.WithMaxEnvelope(wire.AbsoluteEnvelopeLimit)), | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // Send queues one envelope for the sole writer and waits for its pipe write. | |||
| 30 | + | func (c *carriage) Send(envelope wire.Envelope) error { return c.framer.Send(envelope.Payload) } | |||
| 31 | + | ||||
| 32 | + | // Recv reads one complete envelope. | |||
| 33 | + | func (c *carriage) Recv() (wire.Envelope, error) { | |||
| 34 | + | payload, err := c.framer.Recv() | |||
| 35 | + | if err != nil { | |||
| 36 | + | return wire.Envelope{}, err | |||
| 37 | + | } | |||
| 38 | + | return wire.Envelope{Payload: payload}, nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // RecvBounded reads one complete envelope bounded to limit before allocation. | |||
| 42 | + | func (c *carriage) RecvBounded(limit uint64) (wire.Envelope, error) { | |||
| 43 | + | payload, err := c.framer.RecvBounded(limit) | |||
| 44 | + | if err != nil { | |||
| 45 | + | return wire.Envelope{}, err | |||
| 46 | + | } | |||
| 47 | + | return wire.Envelope{Payload: payload}, nil | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Close settles the pipe and joins the carriage writer. It is idempotent and | |||
| 51 | + | // concurrent-safe. | |||
| 52 | + | func (c *carriage) Close() error { return c.framer.Close() } | |||
A internal/adapters/brokeripc/client.go +836 -0
| @@ -0,0 +1,836 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/rand" | |||
| 6 | + | "errors" | |||
| 7 | + | "sync" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 10 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 11 | + | "github.com/bnema/vev/internal/adapters/streamframe" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Broker IPC client (P3.3). | |||
| 17 | + | // | |||
| 18 | + | // Dial connects to one per-user broker endpoint through the P3.2 private IPC | |||
| 19 | + | // carriage (same-user kernel peer credentials on dial), completes the broker | |||
| 20 | + | // preamble, sends exactly one Register, adopts the scope the listener assigned | |||
| 21 | + | // in the Registered answer, and then runs one reader for the connection. The | |||
| 22 | + | // returned value implements ports.BrokerService, so a client process reaches | |||
| 23 | + | // local and remote daemons through the same façade it would use in-process. | |||
| 24 | + | // | |||
| 25 | + | // Identity is the connection's, not the caller's: OpenStream fills in an absent | |||
| 26 | + | // epoch, connection, and stream identity from this connection and refuses a | |||
| 27 | + | // caller-supplied identity that belongs elsewhere as stale. Stream identities | |||
| 28 | + | // are strictly increasing and never reused. Mutating operations carry a fresh | |||
| 29 | + | // operation identity, are bounded while pending, and report outcome-unknown | |||
| 30 | + | // rather than a silent retry when a reply is lost after the request was sent. | |||
| 31 | + | ||||
| 32 | + | // Dial connects to the broker endpoint at path and returns a ports.BrokerService | |||
| 33 | + | // bound to exactly one accepted connection. | |||
| 34 | + | func Dial(ctx context.Context, path string, cfg Config) (ports.BrokerService, error) { | |||
| 35 | + | return dial(ctx, path, cfg, ipc.SameUserPeerVerifier()) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // dial is Dial with an injected peer verifier, so a test can observe a | |||
| 39 | + | // deterministic same-user refusal without root. | |||
| 40 | + | func dial(ctx context.Context, path string, cfg Config, verify ipc.PeerVerifier) (ports.BrokerService, error) { | |||
| 41 | + | cfg = cfg.withDefaults() | |||
| 42 | + | if err := cfg.validate(); err != nil { | |||
| 43 | + | return nil, err | |||
| 44 | + | } | |||
| 45 | + | if path == "" { | |||
| 46 | + | return nil, ErrConfig | |||
| 47 | + | } | |||
| 48 | + | transport, err := ipc.DialMuxContextWithPeerVerifier(ctx, path, verify) | |||
| 49 | + | if err != nil { | |||
| 50 | + | return nil, err | |||
| 51 | + | } | |||
| 52 | + | handshake, cancel := context.WithTimeout(ctx, cfg.HandshakeTimeout) | |||
| 53 | + | defer cancel() | |||
| 54 | + | ceilings, err := runClientPreamble(handshake, transport, brokerwire.DefaultCeilings()) | |||
| 55 | + | if err != nil { | |||
| 56 | + | _ = transport.Close() | |||
| 57 | + | return nil, err | |||
| 58 | + | } | |||
| 59 | + | scope, err := register(handshake, transport, ceilings) | |||
| 60 | + | if err != nil { | |||
| 61 | + | _ = transport.Close() | |||
| 62 | + | return nil, err | |||
| 63 | + | } | |||
| 64 | + | conn, err := brokerwire.NewConnection(scope) | |||
| 65 | + | if err != nil { | |||
| 66 | + | _ = transport.Close() | |||
| 67 | + | return nil, err | |||
| 68 | + | } | |||
| 69 | + | if err := conn.SignalPreamble(); err != nil { | |||
| 70 | + | _ = transport.Close() | |||
| 71 | + | return nil, err | |||
| 72 | + | } | |||
| 73 | + | if err := conn.Register(); err != nil { | |||
| 74 | + | _ = transport.Close() | |||
| 75 | + | return nil, err | |||
| 76 | + | } | |||
| 77 | + | ctx, cancelSession := context.WithCancel(context.Background()) | |||
| 78 | + | c := &client{ | |||
| 79 | + | transport: transport, | |||
| 80 | + | ceilings: ceilings, | |||
| 81 | + | scope: scope, | |||
| 82 | + | conn: conn, | |||
| 83 | + | cfg: cfg, | |||
| 84 | + | ctx: ctx, | |||
| 85 | + | cancel: cancelSession, | |||
| 86 | + | pending: make(map[ports.BrokerOperationID]chan operationResult), | |||
| 87 | + | streams: make(map[ports.BrokerStreamID]*clientStream), | |||
| 88 | + | done: make(chan struct{}), | |||
| 89 | + | } | |||
| 90 | + | go c.run() | |||
| 91 | + | return c, nil | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // register completes the broker registration exchange: one Register follows the | |||
| 95 | + | // preamble, and the first server frame must be the Registered answer carrying | |||
| 96 | + | // the scope assigned at accept. Any other first frame is a protocol violation. | |||
| 97 | + | func register(ctx context.Context, transport wire.BoundedTransport, ceilings brokerwire.Ceilings) (brokerwire.Scope, error) { | |||
| 98 | + | payload, err := brokerwire.EncodeClient(brokerwire.Register{}, ceilings.MaxReceiveEnvelopeBytes, ceilings.StreamChunkLimit) | |||
| 99 | + | if err != nil { | |||
| 100 | + | return brokerwire.Scope{}, err | |||
| 101 | + | } | |||
| 102 | + | if err := transport.Send(wire.Envelope{Payload: payload}); err != nil { | |||
| 103 | + | return brokerwire.Scope{}, transportFailure(err) | |||
| 104 | + | } | |||
| 105 | + | envelope, err := transport.RecvBounded(ceilings.MaxReceiveEnvelopeBytes) | |||
| 106 | + | if err != nil { | |||
| 107 | + | return brokerwire.Scope{}, transportFailure(err) | |||
| 108 | + | } | |||
| 109 | + | message, err := brokerwire.DecodeServer(envelope.Payload, ceilings.MaxReceiveEnvelopeBytes, ceilings.StreamChunkLimit) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return brokerwire.Scope{}, errors.Join(ErrMalformedFrame, err) | |||
| 112 | + | } | |||
| 113 | + | registered, ok := message.(brokerwire.Registered) | |||
| 114 | + | if !ok { | |||
| 115 | + | return brokerwire.Scope{}, errors.Join(ErrProtocol, errors.New("brokeripc: broker did not answer Register with Registered")) | |||
| 116 | + | } | |||
| 117 | + | scope := brokerwire.Scope{Epoch: registered.Epoch, Connection: registered.Connection} | |||
| 118 | + | if err := scope.Validate(); err != nil { | |||
| 119 | + | return brokerwire.Scope{}, errors.Join(ErrProtocol, err) | |||
| 120 | + | } | |||
| 121 | + | if err := ctx.Err(); err != nil { | |||
| 122 | + | return brokerwire.Scope{}, err | |||
| 123 | + | } | |||
| 124 | + | return scope, nil | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // operationResult is one reply to a mutating operation. | |||
| 128 | + | type operationResult struct { | |||
| 129 | + | outcome ports.BrokerMutationOutcome | |||
| 130 | + | removed bool | |||
| 131 | + | detail brokerwire.ErrorDetail | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // client implements ports.BrokerService over one broker IPC connection. | |||
| 135 | + | type client struct { | |||
| 136 | + | transport wire.BoundedTransport | |||
| 137 | + | ceilings brokerwire.Ceilings | |||
| 138 | + | scope brokerwire.Scope | |||
| 139 | + | conn *brokerwire.Connection | |||
| 140 | + | cfg Config | |||
| 141 | + | ||||
| 142 | + | ctx context.Context | |||
| 143 | + | cancel context.CancelFunc | |||
| 144 | + | ||||
| 145 | + | mu sync.Mutex | |||
| 146 | + | closed bool | |||
| 147 | + | err error | |||
| 148 | + | sub *clientSubscription | |||
| 149 | + | generation brokerwire.SubscriptionGeneration | |||
| 150 | + | highest ports.BrokerStreamID | |||
| 151 | + | nextStream ports.BrokerStreamID | |||
| 152 | + | snapshot ports.BrokerSnapshot | |||
| 153 | + | pending map[ports.BrokerOperationID]chan operationResult | |||
| 154 | + | streams map[ports.BrokerStreamID]*clientStream | |||
| 155 | + | closeOnce sync.Once | |||
| 156 | + | done chan struct{} | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | var _ ports.BrokerService = (*client)(nil) | |||
| 160 | + | ||||
| 161 | + | // run is the connection's single reader. | |||
| 162 | + | func (c *client) run() { | |||
| 163 | + | for { | |||
| 164 | + | envelope, err := c.transport.RecvBounded(c.ceilings.MaxReceiveEnvelopeBytes) | |||
| 165 | + | if err != nil { | |||
| 166 | + | c.closeWith(transportFailure(err)) | |||
| 167 | + | return | |||
| 168 | + | } | |||
| 169 | + | message, err := brokerwire.DecodeServer(envelope.Payload, c.ceilings.MaxReceiveEnvelopeBytes, c.ceilings.StreamChunkLimit) | |||
| 170 | + | if err != nil { | |||
| 171 | + | c.closeWith(errors.Join(ErrMalformedFrame, err)) | |||
| 172 | + | return | |||
| 173 | + | } | |||
| 174 | + | if err := c.dispatch(message); err != nil { | |||
| 175 | + | c.closeWith(err) | |||
| 176 | + | return | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // dispatch applies one server frame. A returned error is a protocol violation | |||
| 182 | + | // that settles the connection. | |||
| 183 | + | func (c *client) dispatch(message brokerwire.ServerMessage) error { | |||
| 184 | + | switch m := message.(type) { | |||
| 185 | + | case brokerwire.SnapshotPart: | |||
| 186 | + | if !c.scopeMatches(m.Epoch, m.Connection) { | |||
| 187 | + | return errors.Join(ErrScopeMismatch, ErrProtocol) | |||
| 188 | + | } | |||
| 189 | + | committed, snapshot, err := c.conn.AddSnapshotPart(m) | |||
| 190 | + | if err != nil { | |||
| 191 | + | if errors.Is(err, brokerwire.ErrStaleGeneration) || errors.Is(err, brokerwire.ErrFutureGeneration) || errors.Is(err, brokerwire.ErrSnapshotStale) { | |||
| 192 | + | return nil | |||
| 193 | + | } | |||
| 194 | + | return errors.Join(ErrMalformedFrame, err) | |||
| 195 | + | } | |||
| 196 | + | if committed { | |||
| 197 | + | c.publish(snapshot) | |||
| 198 | + | } | |||
| 199 | + | return nil | |||
| 200 | + | case brokerwire.OperationResult: | |||
| 201 | + | if !c.scopeMatches(m.Epoch, m.Connection) { | |||
| 202 | + | return errors.Join(ErrScopeMismatch, ErrProtocol) | |||
| 203 | + | } | |||
| 204 | + | // The operation's waiter records the tracker completion exactly once; | |||
| 205 | + | // a result with no waiter (an abandoned or replayed operation) is | |||
| 206 | + | // dropped here. | |||
| 207 | + | c.completeOperation(m.Operation, operationResult{outcome: m.Outcome, removed: m.Removed, detail: m.Error}) | |||
| 208 | + | return nil | |||
| 209 | + | case brokerwire.StreamOpened: | |||
| 210 | + | if !c.scopeMatches(m.Epoch, m.Connection) { | |||
| 211 | + | return errors.Join(ErrScopeMismatch, ErrProtocol) | |||
| 212 | + | } | |||
| 213 | + | if err := c.conn.StreamOpened(m.Stream); err != nil && !errors.Is(err, brokerwire.ErrStreamState) { | |||
| 214 | + | return errors.Join(ErrProtocol, err) | |||
| 215 | + | } | |||
| 216 | + | if st := c.lookupStream(m.Stream); st != nil { | |||
| 217 | + | select { | |||
| 218 | + | case st.opened <- nil: | |||
| 219 | + | default: | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | return nil | |||
| 223 | + | case brokerwire.ServerStreamData: | |||
| 224 | + | if !c.scopeMatches(m.Epoch, m.Connection) { | |||
| 225 | + | return errors.Join(ErrScopeMismatch, ErrProtocol) | |||
| 226 | + | } | |||
| 227 | + | disposition, err := c.conn.StreamData(m.Stream) | |||
| 228 | + | if err != nil { | |||
| 229 | + | return errors.Join(ErrProtocol, err) | |||
| 230 | + | } | |||
| 231 | + | if disposition == brokerwire.StreamDiscarded { | |||
| 232 | + | return nil | |||
| 233 | + | } | |||
| 234 | + | st := c.lookupStream(m.Stream) | |||
| 235 | + | if st == nil { | |||
| 236 | + | // The stream was retired locally between the tracker check and the | |||
| 237 | + | // lookup; its data is discarded, exactly like a retired stream. | |||
| 238 | + | return nil | |||
| 239 | + | } | |||
| 240 | + | if err := st.deliver(m.Data); err != nil { | |||
| 241 | + | if errors.Is(err, ErrStreamBackpressure) { | |||
| 242 | + | st.fail(ErrStreamBackpressure) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | return nil | |||
| 246 | + | case brokerwire.StreamClosed: | |||
| 247 | + | if !c.scopeMatches(m.Epoch, m.Connection) { | |||
| 248 | + | return errors.Join(ErrScopeMismatch, ErrProtocol) | |||
| 249 | + | } | |||
| 250 | + | _, _ = c.conn.CloseStream(m.Stream) | |||
| 251 | + | if st := c.lookupStream(m.Stream); st != nil { | |||
| 252 | + | var cause error | |||
| 253 | + | if m.HasError { | |||
| 254 | + | cause = failureFromDetail(m.Error) | |||
| 255 | + | } | |||
| 256 | + | st.terminate(cause) | |||
| 257 | + | } | |||
| 258 | + | return nil | |||
| 259 | + | case brokerwire.Progress: | |||
| 260 | + | if !c.scopeMatches(m.Epoch, m.Connection) { | |||
| 261 | + | return errors.Join(ErrScopeMismatch, ErrProtocol) | |||
| 262 | + | } | |||
| 263 | + | _, _ = c.conn.StreamProgress(m.Stream) | |||
| 264 | + | return nil | |||
| 265 | + | case brokerwire.BrokerErrorMessage: | |||
| 266 | + | if !c.scopeMatches(m.Epoch, m.Connection) { | |||
| 267 | + | return errors.Join(ErrScopeMismatch, ErrProtocol) | |||
| 268 | + | } | |||
| 269 | + | cause := failureFromDetail(m.Error) | |||
| 270 | + | if cause == nil { | |||
| 271 | + | cause = ports.BrokerError{Code: ports.BrokerErrorUnavailable} | |||
| 272 | + | } | |||
| 273 | + | return cause | |||
| 274 | + | case brokerwire.Shutdown: | |||
| 275 | + | return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: m.Text} | |||
| 276 | + | default: | |||
| 277 | + | return errors.Join(ErrProtocol, errors.New("brokeripc: unexpected server message")) | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // scopeMatches reports whether one frame carries this connection's accepted | |||
| 282 | + | // scope. | |||
| 283 | + | func (c *client) scopeMatches(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID) bool { | |||
| 284 | + | return epoch == c.scope.Epoch && connection == c.scope.Connection | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // publish installs one committed snapshot and wakes the active subscription. | |||
| 288 | + | func (c *client) publish(snapshot ports.BrokerSnapshot) { | |||
| 289 | + | c.mu.Lock() | |||
| 290 | + | c.snapshot = snapshot | |||
| 291 | + | sub := c.sub | |||
| 292 | + | c.mu.Unlock() | |||
| 293 | + | if sub != nil { | |||
| 294 | + | sub.notify() | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // completeOperation hands one result to the operation's waiter, if any. | |||
| 299 | + | func (c *client) completeOperation(operation ports.BrokerOperationID, result operationResult) { | |||
| 300 | + | c.mu.Lock() | |||
| 301 | + | ch := c.pending[operation] | |||
| 302 | + | delete(c.pending, operation) | |||
| 303 | + | c.mu.Unlock() | |||
| 304 | + | if ch == nil { | |||
| 305 | + | return | |||
| 306 | + | } | |||
| 307 | + | select { | |||
| 308 | + | case ch <- result: | |||
| 309 | + | default: | |||
| 310 | + | } | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | // ConnectionID returns the identity assigned to this connection at accept. | |||
| 314 | + | func (c *client) ConnectionID() ports.BrokerConnectionID { return c.scope.Connection } | |||
| 315 | + | ||||
| 316 | + | // Snapshot returns the newest fully committed publication. | |||
| 317 | + | func (c *client) Snapshot() ports.BrokerSnapshot { | |||
| 318 | + | c.mu.Lock() | |||
| 319 | + | defer c.mu.Unlock() | |||
| 320 | + | return c.snapshot.Clone() | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | // Subscribe asks the broker for publications of the current scope. The call is | |||
| 324 | + | // a bounded, non-blocking hint: it enqueues one Subscribe frame carrying a fresh | |||
| 325 | + | // generation and returns immediately. Each notification means a newer committed | |||
| 326 | + | // snapshot is available through Snapshot. | |||
| 327 | + | // | |||
| 328 | + | // Adopting the generation and enqueueing its frame are one step on this | |||
| 329 | + | // connection: the local generation series is consumed as soon as the frame is | |||
| 330 | + | // admitted, so a send that then fails has already superseded the previous | |||
| 331 | + | // subscription. That previous subscription is therefore retired rather than | |||
| 332 | + | // restored, and the connection is left with no current subscription: the caller | |||
| 333 | + | // sees the send failure and subscribes again with a fresh generation. | |||
| 334 | + | func (c *client) Subscribe() (ports.BrokerSubscription, error) { | |||
| 335 | + | c.mu.Lock() | |||
| 336 | + | if c.closed { | |||
| 337 | + | err := c.terminalErrorLocked() | |||
| 338 | + | c.mu.Unlock() | |||
| 339 | + | return nil, err | |||
| 340 | + | } | |||
| 341 | + | generation := c.generation + 1 | |||
| 342 | + | if err := c.conn.Subscribe(generation); err != nil { | |||
| 343 | + | c.mu.Unlock() | |||
| 344 | + | return nil, admissionError(err) | |||
| 345 | + | } | |||
| 346 | + | c.generation = generation | |||
| 347 | + | sub := newClientSubscription() | |||
| 348 | + | previous := c.sub | |||
| 349 | + | c.sub = sub | |||
| 350 | + | c.mu.Unlock() | |||
| 351 | + | if err := c.sendAsync(brokerwire.Subscribe{ | |||
| 352 | + | Epoch: c.scope.Epoch, Connection: c.scope.Connection, Generation: generation, | |||
| 353 | + | }); err != nil { | |||
| 354 | + | // The generation was already consumed by the connection's subscription | |||
| 355 | + | // tracker, so the previous subscription's generation is no longer | |||
| 356 | + | // current even though this frame never left. Restoring it would present | |||
| 357 | + | // a superseded generation as the live subscription; retire both and | |||
| 358 | + | // leave the connection with no current subscription instead. | |||
| 359 | + | c.mu.Lock() | |||
| 360 | + | if c.sub == sub { | |||
| 361 | + | c.sub = nil | |||
| 362 | + | } | |||
| 363 | + | c.mu.Unlock() | |||
| 364 | + | sub.Close() | |||
| 365 | + | previous.Close() | |||
| 366 | + | return nil, err | |||
| 367 | + | } | |||
| 368 | + | if previous != nil { | |||
| 369 | + | previous.Close() | |||
| 370 | + | } | |||
| 371 | + | return sub, nil | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | // OpenStream opens one independently cancellable logical stream. An absent | |||
| 375 | + | // epoch, connection, or stream identity is filled from this connection; a | |||
| 376 | + | // caller-supplied identity that belongs elsewhere, or a stream identity that is | |||
| 377 | + | // not strictly increasing, is refused as stale. | |||
| 378 | + | func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 379 | + | scoped, err := c.scopeRequest(request) | |||
| 380 | + | if err != nil { | |||
| 381 | + | return nil, err | |||
| 382 | + | } | |||
| 383 | + | c.mu.Lock() | |||
| 384 | + | if c.closed { | |||
| 385 | + | err := c.terminalErrorLocked() | |||
| 386 | + | c.mu.Unlock() | |||
| 387 | + | return nil, err | |||
| 388 | + | } | |||
| 389 | + | // Admit the identity on the connection's own tracker before any frame | |||
| 390 | + | // travels, so a racing StreamOpened can never precede local admission. | |||
| 391 | + | if err := c.conn.OpenStream(scoped.Stream); err != nil { | |||
| 392 | + | c.mu.Unlock() | |||
| 393 | + | return nil, admissionError(err) | |||
| 394 | + | } | |||
| 395 | + | stream, err := newClientStream(c, scoped.Stream) | |||
| 396 | + | if err != nil { | |||
| 397 | + | _, _ = c.conn.CloseStream(scoped.Stream) | |||
| 398 | + | c.mu.Unlock() | |||
| 399 | + | return nil, err | |||
| 400 | + | } | |||
| 401 | + | c.streams[scoped.Stream] = stream | |||
| 402 | + | c.mu.Unlock() | |||
| 403 | + | ||||
| 404 | + | // OpenStream treats every send failure alike: a stream whose request | |||
| 405 | + | // definitely never left is retired exactly like one that may have been | |||
| 406 | + | // dispatched, because the local identity is already admitted either way. | |||
| 407 | + | if _, err := c.sendContext(ctx, brokerwire.OpenStream{ | |||
| 408 | + | Epoch: scoped.Epoch, Connection: scoped.Connection, Stream: scoped.Stream, | |||
| 409 | + | Purpose: scoped.Purpose, Local: scoped.Local, | |||
| 410 | + | Endpoint: scoped.Endpoint, Registration: scoped.Registration, | |||
| 411 | + | Target: scoped.Target, Env: scoped.Env, Policy: scoped.Policy, | |||
| 412 | + | }); err != nil { | |||
| 413 | + | c.retireStream(scoped.Stream, err) | |||
| 414 | + | return nil, err | |||
| 415 | + | } | |||
| 416 | + | select { | |||
| 417 | + | case err := <-stream.opened: | |||
| 418 | + | if err != nil { | |||
| 419 | + | return nil, err | |||
| 420 | + | } | |||
| 421 | + | return stream, nil | |||
| 422 | + | case <-ctx.Done(): | |||
| 423 | + | // The request was sent, so the broker may still establish the stream. | |||
| 424 | + | // Retire it locally and tell the broker; the caller sees cancellation. | |||
| 425 | + | c.retireStream(scoped.Stream, ctx.Err()) | |||
| 426 | + | return nil, ctx.Err() | |||
| 427 | + | case <-c.done: | |||
| 428 | + | return nil, c.terminalError() | |||
| 429 | + | } | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | // CloseStream retires one scope-checked logical stream. | |||
| 433 | + | func (c *client) CloseStream(connection ports.BrokerConnectionID, stream ports.BrokerStreamID) error { | |||
| 434 | + | if !connection.IsZero() && connection != c.scope.Connection { | |||
| 435 | + | return errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale) | |||
| 436 | + | } | |||
| 437 | + | c.mu.Lock() | |||
| 438 | + | if c.closed { | |||
| 439 | + | err := c.terminalErrorLocked() | |||
| 440 | + | c.mu.Unlock() | |||
| 441 | + | return err | |||
| 442 | + | } | |||
| 443 | + | _, err := c.conn.CloseStream(stream) | |||
| 444 | + | c.mu.Unlock() | |||
| 445 | + | if err != nil { | |||
| 446 | + | return admissionError(err) | |||
| 447 | + | } | |||
| 448 | + | c.requestStreamClose(stream) | |||
| 449 | + | if st := c.lookupStream(stream); st != nil { | |||
| 450 | + | st.terminate(nil) | |||
| 451 | + | } | |||
| 452 | + | return nil | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | // AddHost requests one host registration. A lost reply is reported as | |||
| 456 | + | // outcome-unknown, never as an invitation to replay blindly. | |||
| 457 | + | func (c *client) AddHost(ctx context.Context, target string) error { | |||
| 458 | + | result, err := c.mutate(ctx, func(operation ports.BrokerOperationID) brokerwire.ClientMessage { | |||
| 459 | + | return brokerwire.AddHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: target} | |||
| 460 | + | }) | |||
| 461 | + | if err != nil { | |||
| 462 | + | return err | |||
| 463 | + | } | |||
| 464 | + | return mutationFailure(result) | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | // RemoveHost requests one host removal and reports whether the host was present. | |||
| 468 | + | func (c *client) RemoveHost(ctx context.Context, target string) (bool, error) { | |||
| 469 | + | result, err := c.mutate(ctx, func(operation ports.BrokerOperationID) brokerwire.ClientMessage { | |||
| 470 | + | return brokerwire.RemoveHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: target} | |||
| 471 | + | }) | |||
| 472 | + | if err != nil { | |||
| 473 | + | return false, err | |||
| 474 | + | } | |||
| 475 | + | if err := mutationFailure(result); err != nil { | |||
| 476 | + | return false, err | |||
| 477 | + | } | |||
| 478 | + | return result.removed, nil | |||
| 479 | + | } | |||
| 480 | + | ||||
| 481 | + | // RequestReconcile asks the broker to re-observe one endpoint. The port carries | |||
| 482 | + | // an endpoint while the wire carries an exact registration, so the hint is sent | |||
| 483 | + | // only for an endpoint this connection already holds a registration for; an | |||
| 484 | + | // unknown endpoint is a no-op. It never blocks and never fails the caller. | |||
| 485 | + | func (c *client) RequestReconcile(endpoint string) { | |||
| 486 | + | c.mu.Lock() | |||
| 487 | + | host, ok := c.snapshot.Find(endpoint) | |||
| 488 | + | closed := c.closed | |||
| 489 | + | c.mu.Unlock() | |||
| 490 | + | if closed || !ok { | |||
| 491 | + | return | |||
| 492 | + | } | |||
| 493 | + | _ = c.sendAsync(brokerwire.Reconcile{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Registration: host.Registration}) | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | // Close ends the connection, releasing its streams and subscription. It is | |||
| 497 | + | // idempotent and concurrent-safe. | |||
| 498 | + | func (c *client) Close() error { | |||
| 499 | + | if c == nil { | |||
| 500 | + | return nil | |||
| 501 | + | } | |||
| 502 | + | c.closeWith(nil) | |||
| 503 | + | return nil | |||
| 504 | + | } | |||
| 505 | + | ||||
| 506 | + | // scopeRequest fills an absent identity from this connection and refuses one | |||
| 507 | + | // that belongs elsewhere, then validates the resulting request. | |||
| 508 | + | func (c *client) scopeRequest(request ports.BrokerOpenStreamRequest) (ports.BrokerOpenStreamRequest, error) { | |||
| 509 | + | scoped := request | |||
| 510 | + | if scoped.Epoch == 0 { | |||
| 511 | + | scoped.Epoch = c.scope.Epoch | |||
| 512 | + | } else if scoped.Epoch != c.scope.Epoch { | |||
| 513 | + | return ports.BrokerOpenStreamRequest{}, errors.Join(ErrScopeMismatch, ports.BrokerError{Code: ports.BrokerErrorStaleEpoch}) | |||
| 514 | + | } | |||
| 515 | + | if scoped.Connection.IsZero() { | |||
| 516 | + | scoped.Connection = c.scope.Connection | |||
| 517 | + | } else if scoped.Connection != c.scope.Connection { | |||
| 518 | + | return ports.BrokerOpenStreamRequest{}, errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale) | |||
| 519 | + | } | |||
| 520 | + | c.mu.Lock() | |||
| 521 | + | switch { | |||
| 522 | + | case scoped.Stream == 0: | |||
| 523 | + | if c.nextStream == ^ports.BrokerStreamID(0) { | |||
| 524 | + | c.mu.Unlock() | |||
| 525 | + | return ports.BrokerOpenStreamRequest{}, ports.BrokerAdmissionLimit | |||
| 526 | + | } | |||
| 527 | + | c.nextStream++ | |||
| 528 | + | scoped.Stream = c.nextStream | |||
| 529 | + | c.highest = scoped.Stream | |||
| 530 | + | case scoped.Stream <= c.highest: | |||
| 531 | + | c.mu.Unlock() | |||
| 532 | + | return ports.BrokerOpenStreamRequest{}, errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale) | |||
| 533 | + | default: | |||
| 534 | + | c.highest = scoped.Stream | |||
| 535 | + | } | |||
| 536 | + | c.mu.Unlock() | |||
| 537 | + | if err := scoped.Validate(); err != nil { | |||
| 538 | + | return ports.BrokerOpenStreamRequest{}, errors.Join(ports.BrokerAdmissionInvalid, err) | |||
| 539 | + | } | |||
| 540 | + | return scoped, nil | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | // mutate sends one mutating operation and waits for its bounded completion. The | |||
| 544 | + | // operation identity is admitted on the connection's tracker (bounded by the | |||
| 545 | + | // same per-connection bound the wire enforces) and completed exactly once, so an | |||
| 546 | + | // abandoned or timed-out operation releases its pending slot and a late or | |||
| 547 | + | // replayed result is deduped rather than applied twice. | |||
| 548 | + | // | |||
| 549 | + | // A request that definitely never left the process fails as an ordinary definite | |||
| 550 | + | // error and records a failed outcome; once the wire attempt was launched, every | |||
| 551 | + | // failure is reported as outcome-unknown, so a caller refreshes authoritative | |||
| 552 | + | // state instead of replaying a non-idempotent mutation blindly. | |||
| 553 | + | func (c *client) mutate(ctx context.Context, build func(ports.BrokerOperationID) brokerwire.ClientMessage) (operationResult, error) { | |||
| 554 | + | operation, err := newOperationID() | |||
| 555 | + | if err != nil { | |||
| 556 | + | return operationResult{}, err | |||
| 557 | + | } | |||
| 558 | + | reply := make(chan operationResult, 1) | |||
| 559 | + | c.mu.Lock() | |||
| 560 | + | if c.closed { | |||
| 561 | + | err := c.terminalErrorLocked() | |||
| 562 | + | c.mu.Unlock() | |||
| 563 | + | return operationResult{}, err | |||
| 564 | + | } | |||
| 565 | + | if len(c.pending) >= c.cfg.MaxPendingOperations { | |||
| 566 | + | c.mu.Unlock() | |||
| 567 | + | return operationResult{}, ports.BrokerAdmissionLimit | |||
| 568 | + | } | |||
| 569 | + | if err := c.conn.AdmitOperation(operation); err != nil { | |||
| 570 | + | c.mu.Unlock() | |||
| 571 | + | return operationResult{}, admissionError(err) | |||
| 572 | + | } | |||
| 573 | + | c.pending[operation] = reply | |||
| 574 | + | c.mu.Unlock() | |||
| 575 | + | // No result observed is exactly the outcome-unknown case; a request that | |||
| 576 | + | // was never launched is recorded as failed instead. | |||
| 577 | + | outcome := ports.BrokerOutcomeUnknown | |||
| 578 | + | defer func() { | |||
| 579 | + | c.mu.Lock() | |||
| 580 | + | delete(c.pending, operation) | |||
| 581 | + | c.mu.Unlock() | |||
| 582 | + | _ = c.conn.CompleteOperation(operation, outcome) | |||
| 583 | + | }() | |||
| 584 | + | attempted, err := c.sendContext(ctx, build(operation)) | |||
| 585 | + | if err != nil { | |||
| 586 | + | if !attempted { | |||
| 587 | + | // The request definitely did not travel, so a definite failure is | |||
| 588 | + | // the honest report and the operation is recorded as failed. | |||
| 589 | + | outcome = ports.BrokerOutcomeFailed | |||
| 590 | + | return operationResult{}, err | |||
| 591 | + | } | |||
| 592 | + | // The wire attempt was launched: the broker may have committed the | |||
| 593 | + | // operation even though this side observed no result. | |||
| 594 | + | return operationResult{outcome: ports.BrokerOutcomeUnknown, detail: errorDetail(err)}, nil | |||
| 595 | + | } | |||
| 596 | + | select { | |||
| 597 | + | case result := <-reply: | |||
| 598 | + | outcome = result.outcome | |||
| 599 | + | return result, nil | |||
| 600 | + | case <-ctx.Done(): | |||
| 601 | + | // The operation was already sent: the broker may have committed it. | |||
| 602 | + | return operationResult{outcome: ports.BrokerOutcomeUnknown, detail: errorDetail(ctx.Err())}, nil | |||
| 603 | + | case <-c.done: | |||
| 604 | + | return operationResult{outcome: ports.BrokerOutcomeUnknown, detail: errorDetail(c.terminalError())}, nil | |||
| 605 | + | } | |||
| 606 | + | } | |||
| 607 | + | ||||
| 608 | + | // send encodes and writes one client frame synchronously. | |||
| 609 | + | func (c *client) send(message brokerwire.ClientMessage) error { | |||
| 610 | + | payload, err := brokerwire.EncodeClient(message, c.ceilings.MaxReceiveEnvelopeBytes, c.ceilings.StreamChunkLimit) | |||
| 611 | + | if err != nil { | |||
| 612 | + | return err | |||
| 613 | + | } | |||
| 614 | + | if err := c.transport.Send(wire.Envelope{Payload: payload}); err != nil { | |||
| 615 | + | return transportFailure(err) | |||
| 616 | + | } | |||
| 617 | + | return nil | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | // sendAsync admits one client frame for ordered background transmission. It | |||
| 621 | + | // reports backpressure as a retryable unavailability rather than blocking a | |||
| 622 | + | // caller that has no context to spend. | |||
| 623 | + | func (c *client) sendAsync(message brokerwire.ClientMessage) error { | |||
| 624 | + | payload, err := brokerwire.EncodeClient(message, c.ceilings.MaxReceiveEnvelopeBytes, c.ceilings.StreamChunkLimit) | |||
| 625 | + | if err != nil { | |||
| 626 | + | return err | |||
| 627 | + | } | |||
| 628 | + | if async, ok := c.transport.(wire.AsyncTransport); ok { | |||
| 629 | + | if err := async.SendAsync(wire.Envelope{Payload: payload}); err != nil { | |||
| 630 | + | if errors.Is(err, streamframe.ErrBackpressure) { | |||
| 631 | + | return ports.BrokerError{Code: ports.BrokerErrorUnavailable} | |||
| 632 | + | } | |||
| 633 | + | return transportFailure(err) | |||
| 634 | + | } | |||
| 635 | + | return nil | |||
| 636 | + | } | |||
| 637 | + | if err := c.transport.Send(wire.Envelope{Payload: payload}); err != nil { | |||
| 638 | + | return transportFailure(err) | |||
| 639 | + | } | |||
| 640 | + | return nil | |||
| 641 | + | } | |||
| 642 | + | ||||
| 643 | + | // sendContext writes one client frame, honoring cancellation while the carriage | |||
| 644 | + | // egress queue is full. It reports whether the wire attempt was launched, which | |||
| 645 | + | // is exactly the dispatch uncertainty a caller needs: an encoding failure, an | |||
| 646 | + | // already-cancelled call, or a connection already terminal before the attempt | |||
| 647 | + | // begins means the frame definitely did not travel, while every failure | |||
| 648 | + | // observed after the attempt is launched means it may have been dispatched and | |||
| 649 | + | // the caller must not replay blindly. The wire attempt is raced against the | |||
| 650 | + | // caller's context and the connection's terminal outcome, so a stalled send | |||
| 651 | + | // never pins the caller: the racing goroutine observes the closed carriage and | |||
| 652 | + | // returns. | |||
| 653 | + | func (c *client) sendContext(ctx context.Context, message brokerwire.ClientMessage) (attempted bool, err error) { | |||
| 654 | + | payload, err := brokerwire.EncodeClient(message, c.ceilings.MaxReceiveEnvelopeBytes, c.ceilings.StreamChunkLimit) | |||
| 655 | + | if err != nil { | |||
| 656 | + | return false, err | |||
| 657 | + | } | |||
| 658 | + | // Synchronous preflight. Checking before the goroutine starts is what makes | |||
| 659 | + | // the not-dispatched claim true: a context that is already done or a | |||
| 660 | + | // connection that is already terminal can no longer race a launched Send. | |||
| 661 | + | if err := ctx.Err(); err != nil { | |||
| 662 | + | return false, err | |||
| 663 | + | } | |||
| 664 | + | select { | |||
| 665 | + | case <-c.done: | |||
| 666 | + | return false, c.terminalError() | |||
| 667 | + | default: | |||
| 668 | + | } | |||
| 669 | + | done := make(chan error, 1) | |||
| 670 | + | go func() { done <- c.transport.Send(wire.Envelope{Payload: payload}) }() | |||
| 671 | + | select { | |||
| 672 | + | case err := <-done: | |||
| 673 | + | if err != nil { | |||
| 674 | + | return true, transportFailure(err) | |||
| 675 | + | } | |||
| 676 | + | return true, nil | |||
| 677 | + | case <-ctx.Done(): | |||
| 678 | + | return true, ctx.Err() | |||
| 679 | + | case <-c.done: | |||
| 680 | + | return true, c.terminalError() | |||
| 681 | + | } | |||
| 682 | + | } | |||
| 683 | + | ||||
| 684 | + | // requestStreamClose tells the broker one stream is retired. It is best-effort: | |||
| 685 | + | // the stream is already settled locally, so a full queue or a dead carriage | |||
| 686 | + | // needs no further action. | |||
| 687 | + | func (c *client) requestStreamClose(stream ports.BrokerStreamID) { | |||
| 688 | + | _ = c.sendAsync(brokerwire.CloseStream{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Stream: stream}) | |||
| 689 | + | } | |||
| 690 | + | ||||
| 691 | + | // retireStream settles one local stream and tells the broker to retire it. | |||
| 692 | + | func (c *client) retireStream(stream ports.BrokerStreamID, cause error) { | |||
| 693 | + | st := c.lookupStream(stream) | |||
| 694 | + | if st != nil { | |||
| 695 | + | st.terminate(cause) | |||
| 696 | + | } | |||
| 697 | + | c.requestStreamClose(stream) | |||
| 698 | + | } | |||
| 699 | + | ||||
| 700 | + | // lookupStream returns the local stream for one identity. | |||
| 701 | + | func (c *client) lookupStream(stream ports.BrokerStreamID) *clientStream { | |||
| 702 | + | c.mu.Lock() | |||
| 703 | + | defer c.mu.Unlock() | |||
| 704 | + | return c.streams[stream] | |||
| 705 | + | } | |||
| 706 | + | ||||
| 707 | + | // unregisterStream drops one local stream record. | |||
| 708 | + | func (c *client) unregisterStream(stream ports.BrokerStreamID) { | |||
| 709 | + | c.mu.Lock() | |||
| 710 | + | delete(c.streams, stream) | |||
| 711 | + | c.mu.Unlock() | |||
| 712 | + | } | |||
| 713 | + | ||||
| 714 | + | // terminalErrorLocked returns the terminal cause with the connection lock held. | |||
| 715 | + | func (c *client) terminalErrorLocked() error { | |||
| 716 | + | if c.err != nil { | |||
| 717 | + | return c.err | |||
| 718 | + | } | |||
| 719 | + | return ErrConnectionClosed | |||
| 720 | + | } | |||
| 721 | + | ||||
| 722 | + | // terminalError returns the terminal cause. | |||
| 723 | + | func (c *client) terminalError() error { | |||
| 724 | + | c.mu.Lock() | |||
| 725 | + | defer c.mu.Unlock() | |||
| 726 | + | return c.terminalErrorLocked() | |||
| 727 | + | } | |||
| 728 | + | ||||
| 729 | + | // closeWith tears the connection down exactly once: cancel, close the carriage | |||
| 730 | + | // (unblocking a blocked read and any frame send), settle every local stream and | |||
| 731 | + | // the active subscription, and wake every waiter. A nil cause is an orderly | |||
| 732 | + | // local close. | |||
| 733 | + | func (c *client) closeWith(cause error) { | |||
| 734 | + | if c == nil { | |||
| 735 | + | return | |||
| 736 | + | } | |||
| 737 | + | c.closeOnce.Do(func() { | |||
| 738 | + | c.cancel() | |||
| 739 | + | _ = c.transport.Close() | |||
| 740 | + | c.mu.Lock() | |||
| 741 | + | c.closed = true | |||
| 742 | + | if cause != nil && c.err == nil { | |||
| 743 | + | c.err = cause | |||
| 744 | + | } | |||
| 745 | + | terminal := c.terminalErrorLocked() | |||
| 746 | + | sub := c.sub | |||
| 747 | + | c.sub = nil | |||
| 748 | + | streams := make([]*clientStream, 0, len(c.streams)) | |||
| 749 | + | for _, st := range c.streams { | |||
| 750 | + | streams = append(streams, st) | |||
| 751 | + | } | |||
| 752 | + | c.streams = make(map[ports.BrokerStreamID]*clientStream) | |||
| 753 | + | c.mu.Unlock() | |||
| 754 | + | for _, st := range streams { | |||
| 755 | + | st.terminate(terminal) | |||
| 756 | + | } | |||
| 757 | + | if sub != nil { | |||
| 758 | + | sub.Close() | |||
| 759 | + | } | |||
| 760 | + | close(c.done) | |||
| 761 | + | }) | |||
| 762 | + | } | |||
| 763 | + | ||||
| 764 | + | // mutationFailure converts one operation outcome into its typed caller error. | |||
| 765 | + | func mutationFailure(result operationResult) error { | |||
| 766 | + | switch result.outcome { | |||
| 767 | + | case ports.BrokerOutcomeOK: | |||
| 768 | + | return nil | |||
| 769 | + | case ports.BrokerOutcomeFailed: | |||
| 770 | + | if err := failureFromDetail(result.detail); err != nil { | |||
| 771 | + | return err | |||
| 772 | + | } | |||
| 773 | + | return ports.BrokerError{Code: ports.BrokerErrorUnavailable} | |||
| 774 | + | default: | |||
| 775 | + | return ports.BrokerError{Code: ports.BrokerErrorOutcomeUnknown} | |||
| 776 | + | } | |||
| 777 | + | } | |||
| 778 | + | ||||
| 779 | + | // newOperationID samples one fresh non-zero operation identity. | |||
| 780 | + | func newOperationID() (ports.BrokerOperationID, error) { | |||
| 781 | + | var id ports.BrokerOperationID | |||
| 782 | + | if _, err := rand.Read(id[:]); err != nil { | |||
| 783 | + | return ports.BrokerOperationID{}, err | |||
| 784 | + | } | |||
| 785 | + | if id.IsZero() { | |||
| 786 | + | id[15] = 1 | |||
| 787 | + | } | |||
| 788 | + | return id, nil | |||
| 789 | + | } | |||
| 790 | + | ||||
| 791 | + | // clientSubscription is one local capacity-one change notification. Each | |||
| 792 | + | // subscriber owns its own channel and re-reads the newest snapshot on wake. | |||
| 793 | + | type clientSubscription struct { | |||
| 794 | + | changed chan struct{} | |||
| 795 | + | once sync.Once | |||
| 796 | + | mu sync.Mutex | |||
| 797 | + | closed bool | |||
| 798 | + | } | |||
| 799 | + | ||||
| 800 | + | func newClientSubscription() *clientSubscription { | |||
| 801 | + | return &clientSubscription{changed: make(chan struct{}, 1)} | |||
| 802 | + | } | |||
| 803 | + | ||||
| 804 | + | // Changed is signalled once per committed publication. | |||
| 805 | + | func (s *clientSubscription) Changed() <-chan struct{} { return s.changed } | |||
| 806 | + | ||||
| 807 | + | // Close ends the subscription exactly once. The channel is closed under the same | |||
| 808 | + | // lock notify sends under, so a racing publication can never send on a closed | |||
| 809 | + | // channel. | |||
| 810 | + | func (s *clientSubscription) Close() { | |||
| 811 | + | if s == nil { | |||
| 812 | + | return | |||
| 813 | + | } | |||
| 814 | + | s.once.Do(func() { | |||
| 815 | + | s.mu.Lock() | |||
| 816 | + | s.closed = true | |||
| 817 | + | close(s.changed) | |||
| 818 | + | s.mu.Unlock() | |||
| 819 | + | }) | |||
| 820 | + | } | |||
| 821 | + | ||||
| 822 | + | // notify coalesces one publication wake. | |||
| 823 | + | func (s *clientSubscription) notify() { | |||
| 824 | + | if s == nil { | |||
| 825 | + | return | |||
| 826 | + | } | |||
| 827 | + | s.mu.Lock() | |||
| 828 | + | defer s.mu.Unlock() | |||
| 829 | + | if s.closed { | |||
| 830 | + | return | |||
| 831 | + | } | |||
| 832 | + | select { | |||
| 833 | + | case s.changed <- struct{}{}: | |||
| 834 | + | default: | |||
| 835 | + | } | |||
| 836 | + | } | |||
A internal/adapters/brokeripc/client_mutation_test.go +525 -0
| @@ -0,0 +1,525 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // Send-uncertainty taxonomy for mutating operations (GO-003). These tests drive | |||
| 19 | + | // one client adapter over a channel-controlled carriage, so the exact instant | |||
| 20 | + | // after a wire attempt is launched is observable without sleeping. | |||
| 21 | + | // | |||
| 22 | + | // The contract under test: only a request that definitely never left the process | |||
| 23 | + | // fails as an ordinary, definite error; once the attempt was launched, a caller | |||
| 24 | + | // cancellation, a carriage failure, or a connection teardown reports | |||
| 25 | + | // outcome-unknown, so no caller replays a non-idempotent mutation blindly. | |||
| 26 | + | ||||
| 27 | + | // errTestCarriage is the deterministic carriage failure the gated carriage | |||
| 28 | + | // reports for one released attempt. | |||
| 29 | + | var errTestCarriage = errors.New("brokeripc test: carriage write failed") | |||
| 30 | + | ||||
| 31 | + | // gatedTransport is a channel-controlled client carriage. Every Send records its | |||
| 32 | + | // envelope, announces the entered attempt, and then blocks until the test | |||
| 33 | + | // releases it with an explicit result or abandons it. Close deliberately leaves | |||
| 34 | + | // an in-flight Send blocked, so an attempt held inside the carriage observes | |||
| 35 | + | // only the client's own terminal outcome until the test itself decides. | |||
| 36 | + | type gatedTransport struct { | |||
| 37 | + | entered chan wire.Envelope | |||
| 38 | + | release chan error | |||
| 39 | + | abandon chan struct{} | |||
| 40 | + | closed chan struct{} | |||
| 41 | + | ||||
| 42 | + | closeOnce sync.Once | |||
| 43 | + | abandonOnce sync.Once | |||
| 44 | + | ||||
| 45 | + | mu sync.Mutex | |||
| 46 | + | sends int | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func newGatedTransport() *gatedTransport { | |||
| 50 | + | return &gatedTransport{ | |||
| 51 | + | entered: make(chan wire.Envelope, 8), | |||
| 52 | + | release: make(chan error, 8), | |||
| 53 | + | abandon: make(chan struct{}), | |||
| 54 | + | closed: make(chan struct{}), | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Send enters one wire attempt and holds it until the test releases it. | |||
| 59 | + | func (t *gatedTransport) Send(envelope wire.Envelope) error { | |||
| 60 | + | t.mu.Lock() | |||
| 61 | + | t.sends++ | |||
| 62 | + | t.mu.Unlock() | |||
| 63 | + | t.entered <- envelope | |||
| 64 | + | select { | |||
| 65 | + | case err := <-t.release: | |||
| 66 | + | return err | |||
| 67 | + | case <-t.abandon: | |||
| 68 | + | return io.EOF | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (t *gatedTransport) RecvBounded(uint64) (wire.Envelope, error) { | |||
| 73 | + | <-t.closed | |||
| 74 | + | return wire.Envelope{}, io.EOF | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (t *gatedTransport) Recv() (wire.Envelope, error) { return t.RecvBounded(0) } | |||
| 78 | + | ||||
| 79 | + | // Close ends the carriage without disturbing an in-flight attempt. | |||
| 80 | + | func (t *gatedTransport) Close() error { | |||
| 81 | + | t.closeOnce.Do(func() { close(t.closed) }) | |||
| 82 | + | return nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // releaseSend hands one blocked attempt its deterministic result. | |||
| 86 | + | func (t *gatedTransport) releaseSend(err error) { t.release <- err } | |||
| 87 | + | ||||
| 88 | + | // abandonAttempts releases every attempt still blocked in Send, so a test never | |||
| 89 | + | // leaks a send goroutine. | |||
| 90 | + | func (t *gatedTransport) abandonAttempts() { | |||
| 91 | + | t.abandonOnce.Do(func() { close(t.abandon) }) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (t *gatedTransport) sendCount() int { | |||
| 95 | + | t.mu.Lock() | |||
| 96 | + | defer t.mu.Unlock() | |||
| 97 | + | return t.sends | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // newGatedClient builds one ready client adapter over a gated carriage. It never | |||
| 101 | + | // starts the reader goroutine: the reply path is driven by the test, so every | |||
| 102 | + | // observation is deterministic. | |||
| 103 | + | func newGatedClient(t *testing.T, cfg Config) (*client, *gatedTransport) { | |||
| 104 | + | t.Helper() | |||
| 105 | + | transport := newGatedTransport() | |||
| 106 | + | t.Cleanup(transport.abandonAttempts) | |||
| 107 | + | scope := brokerwire.Scope{ | |||
| 108 | + | Epoch: ports.BrokerEpoch(0x71), | |||
| 109 | + | Connection: ports.BrokerConnectionID{0x71, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01}, | |||
| 110 | + | } | |||
| 111 | + | conn, err := brokerwire.NewConnection(scope) | |||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | require.NoError(t, conn.SignalPreamble()) | |||
| 114 | + | require.NoError(t, conn.Register()) | |||
| 115 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 116 | + | c := &client{ | |||
| 117 | + | transport: transport, | |||
| 118 | + | ceilings: brokerwire.DefaultCeilings(), | |||
| 119 | + | scope: scope, | |||
| 120 | + | conn: conn, | |||
| 121 | + | cfg: cfg.withDefaults(), | |||
| 122 | + | ctx: ctx, | |||
| 123 | + | cancel: cancel, | |||
| 124 | + | pending: make(map[ports.BrokerOperationID]chan operationResult), | |||
| 125 | + | streams: make(map[ports.BrokerStreamID]*clientStream), | |||
| 126 | + | done: make(chan struct{}), | |||
| 127 | + | } | |||
| 128 | + | t.Cleanup(func() { _ = c.Close() }) | |||
| 129 | + | return c, transport | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // mutationOutcome is one AddHost or RemoveHost result. | |||
| 133 | + | type mutationOutcome struct { | |||
| 134 | + | removed bool | |||
| 135 | + | err error | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // mutationCall adapts one mutating operation to the shared table shape. | |||
| 139 | + | type mutationCall struct { | |||
| 140 | + | name string | |||
| 141 | + | endpoint string | |||
| 142 | + | wantRemoved bool | |||
| 143 | + | call func(ctx context.Context, c *client, endpoint string) (bool, error) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | var mutationCalls = []mutationCall{ | |||
| 147 | + | { | |||
| 148 | + | name: "AddHost", | |||
| 149 | + | endpoint: "new@host:22", | |||
| 150 | + | call: func(ctx context.Context, c *client, endpoint string) (bool, error) { | |||
| 151 | + | return false, c.AddHost(ctx, endpoint) | |||
| 152 | + | }, | |||
| 153 | + | }, | |||
| 154 | + | { | |||
| 155 | + | name: "RemoveHost", | |||
| 156 | + | endpoint: "known", | |||
| 157 | + | wantRemoved: true, | |||
| 158 | + | call: func(ctx context.Context, c *client, endpoint string) (bool, error) { | |||
| 159 | + | return c.RemoveHost(ctx, endpoint) | |||
| 160 | + | }, | |||
| 161 | + | }, | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // awaitResult waits for one mutation call to return. | |||
| 165 | + | func awaitResult(t *testing.T, results <-chan mutationOutcome) mutationOutcome { | |||
| 166 | + | t.Helper() | |||
| 167 | + | select { | |||
| 168 | + | case got := <-results: | |||
| 169 | + | return got | |||
| 170 | + | case <-time.After(5 * time.Second): | |||
| 171 | + | t.Fatal("the mutating operation did not return") | |||
| 172 | + | return mutationOutcome{} | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // awaitSend waits for one wire attempt to enter the carriage and decodes it. | |||
| 177 | + | func awaitSend(t *testing.T, g *gatedTransport) brokerwire.ClientMessage { | |||
| 178 | + | t.Helper() | |||
| 179 | + | select { | |||
| 180 | + | case envelope := <-g.entered: | |||
| 181 | + | message, err := brokerwire.DecodeClient(envelope.Payload, brokerwire.MaxBrokerEnvelopeBytes, brokerwire.MaxStreamChunkBytes) | |||
| 182 | + | require.NoError(t, err, "the captured attempt must be a decodable client envelope") | |||
| 183 | + | return message | |||
| 184 | + | case <-time.After(5 * time.Second): | |||
| 185 | + | t.Fatal("no wire attempt was entered") | |||
| 186 | + | return nil | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // operationOf extracts the operation identity of one captured request. | |||
| 191 | + | func operationOf(t *testing.T, message brokerwire.ClientMessage) ports.BrokerOperationID { | |||
| 192 | + | t.Helper() | |||
| 193 | + | switch m := message.(type) { | |||
| 194 | + | case brokerwire.AddHost: | |||
| 195 | + | return m.Operation | |||
| 196 | + | case brokerwire.RemoveHost: | |||
| 197 | + | return m.Operation | |||
| 198 | + | default: | |||
| 199 | + | t.Fatalf("unexpected client message %T", message) | |||
| 200 | + | return ports.BrokerOperationID{} | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // endpointOf extracts the target of one captured request. | |||
| 205 | + | func endpointOf(t *testing.T, message brokerwire.ClientMessage) string { | |||
| 206 | + | t.Helper() | |||
| 207 | + | switch m := message.(type) { | |||
| 208 | + | case brokerwire.AddHost: | |||
| 209 | + | return m.Endpoint | |||
| 210 | + | case brokerwire.RemoveHost: | |||
| 211 | + | return m.Endpoint | |||
| 212 | + | default: | |||
| 213 | + | t.Fatalf("unexpected client message %T", message) | |||
| 214 | + | return "" | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // pendingOperations snapshots the client's in-flight waiters. | |||
| 219 | + | func pendingOperations(c *client) []ports.BrokerOperationID { | |||
| 220 | + | c.mu.Lock() | |||
| 221 | + | defer c.mu.Unlock() | |||
| 222 | + | out := make([]ports.BrokerOperationID, 0, len(c.pending)) | |||
| 223 | + | for operation := range c.pending { | |||
| 224 | + | out = append(out, operation) | |||
| 225 | + | } | |||
| 226 | + | return out | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // deliverResult plays one broker reply for a captured request. | |||
| 230 | + | func deliverResult(t *testing.T, c *client, operation ports.BrokerOperationID, outcome ports.BrokerMutationOutcome, removed bool) { | |||
| 231 | + | t.Helper() | |||
| 232 | + | require.NoError(t, c.dispatch(brokerwire.OperationResult{ | |||
| 233 | + | Epoch: c.scope.Epoch, Connection: c.scope.Connection, | |||
| 234 | + | Operation: operation, Outcome: outcome, Removed: removed, | |||
| 235 | + | })) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | // TestMutationSendUncertaintyTaxonomy pins the decision table for both mutating | |||
| 239 | + | // operations: a request that definitely never left is a definite error with zero | |||
| 240 | + | // sends; any failure observed after the attempt was launched is outcome-unknown | |||
| 241 | + | // with exactly one send. | |||
| 242 | + | func TestMutationSendUncertaintyTaxonomy(t *testing.T) { | |||
| 243 | + | scenarios := []struct { | |||
| 244 | + | name string | |||
| 245 | + | // before, when set, runs before the call starts. | |||
| 246 | + | before func(cancel context.CancelFunc) | |||
| 247 | + | // drive runs while the call is in flight. | |||
| 248 | + | drive func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) | |||
| 249 | + | wantSends int | |||
| 250 | + | wantUnknown bool | |||
| 251 | + | }{ | |||
| 252 | + | { | |||
| 253 | + | name: "pre-cancelled caller never dispatches and fails definitely", | |||
| 254 | + | before: func(cancel context.CancelFunc) { cancel() }, | |||
| 255 | + | wantSends: 0, | |||
| 256 | + | }, | |||
| 257 | + | { | |||
| 258 | + | name: "caller cancels while the attempt is blocked in Send", | |||
| 259 | + | drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) { | |||
| 260 | + | _ = awaitSend(t, g) | |||
| 261 | + | cancel() | |||
| 262 | + | }, | |||
| 263 | + | wantSends: 1, | |||
| 264 | + | wantUnknown: true, | |||
| 265 | + | }, | |||
| 266 | + | { | |||
| 267 | + | name: "connection terminates while the attempt is blocked in Send", | |||
| 268 | + | drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) { | |||
| 269 | + | _ = awaitSend(t, g) | |||
| 270 | + | require.NoError(t, c.Close()) | |||
| 271 | + | }, | |||
| 272 | + | wantSends: 1, | |||
| 273 | + | wantUnknown: true, | |||
| 274 | + | }, | |||
| 275 | + | { | |||
| 276 | + | name: "Send fails after the request was captured", | |||
| 277 | + | drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) { | |||
| 278 | + | _ = awaitSend(t, g) | |||
| 279 | + | g.releaseSend(errTestCarriage) | |||
| 280 | + | }, | |||
| 281 | + | wantSends: 1, | |||
| 282 | + | wantUnknown: true, | |||
| 283 | + | }, | |||
| 284 | + | } | |||
| 285 | + | for _, call := range mutationCalls { | |||
| 286 | + | for _, tt := range scenarios { | |||
| 287 | + | t.Run(call.name+"/"+tt.name, func(t *testing.T) { | |||
| 288 | + | c, g := newGatedClient(t, Config{MaxPendingOperations: 1}) | |||
| 289 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 290 | + | defer cancel() | |||
| 291 | + | if tt.before != nil { | |||
| 292 | + | tt.before(cancel) | |||
| 293 | + | } | |||
| 294 | + | results := make(chan mutationOutcome, 1) | |||
| 295 | + | go func() { | |||
| 296 | + | removed, err := call.call(ctx, c, call.endpoint) | |||
| 297 | + | results <- mutationOutcome{removed: removed, err: err} | |||
| 298 | + | }() | |||
| 299 | + | if tt.drive != nil { | |||
| 300 | + | tt.drive(t, c, g, cancel) | |||
| 301 | + | } | |||
| 302 | + | got := awaitResult(t, results) | |||
| 303 | + | require.False(t, got.removed, "no reply means the target is never reported removed") | |||
| 304 | + | if tt.wantUnknown { | |||
| 305 | + | var failure ports.BrokerError | |||
| 306 | + | require.ErrorAs(t, got.err, &failure) | |||
| 307 | + | require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code, | |||
| 308 | + | "a launched attempt must never look like a definite failure") | |||
| 309 | + | } else { | |||
| 310 | + | require.ErrorIs(t, got.err, context.Canceled) | |||
| 311 | + | var failure ports.BrokerError | |||
| 312 | + | require.False(t, errors.As(got.err, &failure), | |||
| 313 | + | "a request that never left must not be reported as a broker outcome") | |||
| 314 | + | } | |||
| 315 | + | require.Equal(t, tt.wantSends, g.sendCount(), "the request must travel at most once") | |||
| 316 | + | require.Empty(t, pendingOperations(c), "every mutation releases its pending slot") | |||
| 317 | + | }) | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | // TestMutationDefiniteFailureRecordsAndReleases proves a mutation whose request | |||
| 323 | + | // definitely never left is recorded as failed rather than outcome-unknown, so | |||
| 324 | + | // its pending slot is released for the next operation and nothing is resent. | |||
| 325 | + | func TestMutationDefiniteFailureRecordsAndReleases(t *testing.T) { | |||
| 326 | + | c, g := newGatedClient(t, Config{MaxPendingOperations: 1}) | |||
| 327 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 328 | + | cancel() | |||
| 329 | + | ||||
| 330 | + | // Driving mutate directly makes the operation identity observable even | |||
| 331 | + | // though the request is never encoded for the wire. | |||
| 332 | + | var operation ports.BrokerOperationID | |||
| 333 | + | result, err := c.mutate(ctx, func(id ports.BrokerOperationID) brokerwire.ClientMessage { | |||
| 334 | + | operation = id | |||
| 335 | + | return brokerwire.AddHost{ | |||
| 336 | + | Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: id, Endpoint: "pre@host:22", | |||
| 337 | + | } | |||
| 338 | + | }) | |||
| 339 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 340 | + | require.Equal(t, operationResult{}, result) | |||
| 341 | + | require.NotZero(t, operation) | |||
| 342 | + | require.Zero(t, g.sendCount(), "a pre-cancelled caller never enters the carriage") | |||
| 343 | + | require.Empty(t, pendingOperations(c), "the definite failure releases its pending slot") | |||
| 344 | + | ||||
| 345 | + | // The identity is recorded with an outcome, not left in flight: admitting it | |||
| 346 | + | // again is deduped, which is only true once the tracker was completed. | |||
| 347 | + | require.ErrorIs(t, c.conn.AdmitOperation(operation), brokerwire.ErrOperationCompleted) | |||
| 348 | + | ||||
| 349 | + | // The bound of one proves the slot is genuinely free: a fresh mutation is | |||
| 350 | + | // admitted and completes normally. | |||
| 351 | + | fresh, cancelFresh := context.WithCancel(context.Background()) | |||
| 352 | + | defer cancelFresh() | |||
| 353 | + | results := make(chan mutationOutcome, 1) | |||
| 354 | + | go func() { | |||
| 355 | + | removed, callErr := c.RemoveHost(fresh, "known") | |||
| 356 | + | results <- mutationOutcome{removed: removed, err: callErr} | |||
| 357 | + | }() | |||
| 358 | + | request := awaitSend(t, g) | |||
| 359 | + | g.releaseSend(nil) | |||
| 360 | + | deliverResult(t, c, operationOf(t, request), ports.BrokerOutcomeOK, true) | |||
| 361 | + | got := awaitResult(t, results) | |||
| 362 | + | require.NoError(t, got.err) | |||
| 363 | + | require.True(t, got.removed) | |||
| 364 | + | require.Equal(t, 1, g.sendCount()) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | // TestMutationUnknownOutcomeReleasesWithoutResend proves one outcome-unknown | |||
| 368 | + | // mutation releases its pending slot exactly once, never resends the request, | |||
| 369 | + | // and ignores a late result for the same identity. | |||
| 370 | + | func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) { | |||
| 371 | + | for _, call := range mutationCalls { | |||
| 372 | + | t.Run(call.name, func(t *testing.T) { | |||
| 373 | + | c, g := newGatedClient(t, Config{MaxPendingOperations: 1}) | |||
| 374 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 375 | + | defer cancel() | |||
| 376 | + | ||||
| 377 | + | results := make(chan mutationOutcome, 1) | |||
| 378 | + | go func() { | |||
| 379 | + | removed, err := call.call(ctx, c, call.endpoint) | |||
| 380 | + | results <- mutationOutcome{removed: removed, err: err} | |||
| 381 | + | }() | |||
| 382 | + | request := awaitSend(t, g) | |||
| 383 | + | operation := operationOf(t, request) | |||
| 384 | + | require.Equal(t, call.endpoint, endpointOf(t, request), "the captured request is the caller's own") | |||
| 385 | + | cancel() | |||
| 386 | + | got := awaitResult(t, results) | |||
| 387 | + | var failure ports.BrokerError | |||
| 388 | + | require.ErrorAs(t, got.err, &failure) | |||
| 389 | + | require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code) | |||
| 390 | + | require.False(t, got.removed) | |||
| 391 | + | require.Equal(t, 1, g.sendCount(), "an uncertain attempt is never resent") | |||
| 392 | + | // The cancelled attempt is still held inside the carriage. Hand it its | |||
| 393 | + | // result now, so the next mutation's attempt is the only one waiting | |||
| 394 | + | // on the shared release channel. | |||
| 395 | + | g.releaseSend(errTestCarriage) | |||
| 396 | + | ||||
| 397 | + | // The identity was completed once, so its pending slot is released | |||
| 398 | + | // rather than left in flight. | |||
| 399 | + | require.ErrorIs(t, c.conn.AdmitOperation(operation), brokerwire.ErrOperationCompleted) | |||
| 400 | + | require.Empty(t, pendingOperations(c)) | |||
| 401 | + | ||||
| 402 | + | // A late result for the completed identity is dropped, and it never | |||
| 403 | + | // provokes another wire attempt. | |||
| 404 | + | deliverResult(t, c, operation, ports.BrokerOutcomeOK, true) | |||
| 405 | + | require.Empty(t, pendingOperations(c)) | |||
| 406 | + | require.Equal(t, 1, g.sendCount()) | |||
| 407 | + | ||||
| 408 | + | // The connection stays usable: the next mutation is admitted under | |||
| 409 | + | // the same bound of one and completes normally. | |||
| 410 | + | fresh, cancelFresh := context.WithCancel(context.Background()) | |||
| 411 | + | defer cancelFresh() | |||
| 412 | + | next := make(chan mutationOutcome, 1) | |||
| 413 | + | go func() { | |||
| 414 | + | removed, err := call.call(fresh, c, call.endpoint) | |||
| 415 | + | next <- mutationOutcome{removed: removed, err: err} | |||
| 416 | + | }() | |||
| 417 | + | second := awaitSend(t, g) | |||
| 418 | + | g.releaseSend(nil) | |||
| 419 | + | deliverResult(t, c, operationOf(t, second), ports.BrokerOutcomeOK, call.wantRemoved) | |||
| 420 | + | done := awaitResult(t, next) | |||
| 421 | + | require.NoError(t, done.err) | |||
| 422 | + | require.Equal(t, call.wantRemoved, done.removed) | |||
| 423 | + | require.Equal(t, 2, g.sendCount()) | |||
| 424 | + | }) | |||
| 425 | + | } | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | // TestMutationDeliveredReplyIsDefinite proves an ordinary delivered reply stays | |||
| 429 | + | // the definite outcome for both operations, and a repeated result for the same | |||
| 430 | + | // identity changes nothing. | |||
| 431 | + | func TestMutationDeliveredReplyIsDefinite(t *testing.T) { | |||
| 432 | + | for _, call := range mutationCalls { | |||
| 433 | + | t.Run(call.name, func(t *testing.T) { | |||
| 434 | + | c, g := newGatedClient(t, Config{}) | |||
| 435 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 436 | + | defer cancel() | |||
| 437 | + | ||||
| 438 | + | results := make(chan mutationOutcome, 1) | |||
| 439 | + | go func() { | |||
| 440 | + | removed, err := call.call(ctx, c, call.endpoint) | |||
| 441 | + | results <- mutationOutcome{removed: removed, err: err} | |||
| 442 | + | }() | |||
| 443 | + | request := awaitSend(t, g) | |||
| 444 | + | operation := operationOf(t, request) | |||
| 445 | + | require.Equal(t, call.endpoint, endpointOf(t, request)) | |||
| 446 | + | g.releaseSend(nil) | |||
| 447 | + | deliverResult(t, c, operation, ports.BrokerOutcomeOK, call.wantRemoved) | |||
| 448 | + | got := awaitResult(t, results) | |||
| 449 | + | require.NoError(t, got.err) | |||
| 450 | + | require.Equal(t, call.wantRemoved, got.removed) | |||
| 451 | + | require.Equal(t, 1, g.sendCount()) | |||
| 452 | + | require.Empty(t, pendingOperations(c)) | |||
| 453 | + | ||||
| 454 | + | // A replayed result for an already settled identity is dropped. | |||
| 455 | + | deliverResult(t, c, operation, ports.BrokerOutcomeUnknown, true) | |||
| 456 | + | require.Empty(t, pendingOperations(c)) | |||
| 457 | + | require.Equal(t, 1, g.sendCount()) | |||
| 458 | + | }) | |||
| 459 | + | } | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | // TestSendContextPreflightClassification pins the private helper's taxonomy | |||
| 463 | + | // directly: an unencodable frame, an already-cancelled caller, and an | |||
| 464 | + | // already-terminal connection are definitely not dispatched; only a launched | |||
| 465 | + | // attempt is uncertain. | |||
| 466 | + | func TestSendContextPreflightClassification(t *testing.T) { | |||
| 467 | + | t.Run("unencodable frame is definite", func(t *testing.T) { | |||
| 468 | + | c, g := newGatedClient(t, Config{}) | |||
| 469 | + | attempted, err := c.sendContext(context.Background(), nil) | |||
| 470 | + | require.False(t, attempted) | |||
| 471 | + | require.ErrorIs(t, err, brokerwire.ErrInvalidMessage) | |||
| 472 | + | require.Zero(t, g.sendCount()) | |||
| 473 | + | }) | |||
| 474 | + | ||||
| 475 | + | t.Run("already-cancelled caller is definite", func(t *testing.T) { | |||
| 476 | + | c, g := newGatedClient(t, Config{}) | |||
| 477 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 478 | + | cancel() | |||
| 479 | + | attempted, err := c.sendContext(ctx, brokerwire.AddHost{ | |||
| 480 | + | Epoch: c.scope.Epoch, Connection: c.scope.Connection, | |||
| 481 | + | Operation: ports.BrokerOperationID{0x01}, Endpoint: "pre@host:22", | |||
| 482 | + | }) | |||
| 483 | + | require.False(t, attempted) | |||
| 484 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 485 | + | require.Zero(t, g.sendCount()) | |||
| 486 | + | }) | |||
| 487 | + | ||||
| 488 | + | t.Run("already-terminal connection is definite", func(t *testing.T) { | |||
| 489 | + | c, g := newGatedClient(t, Config{}) | |||
| 490 | + | require.NoError(t, c.Close()) | |||
| 491 | + | attempted, err := c.sendContext(context.Background(), brokerwire.AddHost{ | |||
| 492 | + | Epoch: c.scope.Epoch, Connection: c.scope.Connection, | |||
| 493 | + | Operation: ports.BrokerOperationID{0x01}, Endpoint: "pre@host:22", | |||
| 494 | + | }) | |||
| 495 | + | require.False(t, attempted) | |||
| 496 | + | require.ErrorIs(t, err, ErrConnectionClosed) | |||
| 497 | + | require.Zero(t, g.sendCount()) | |||
| 498 | + | }) | |||
| 499 | + | ||||
| 500 | + | t.Run("launched attempt is uncertain", func(t *testing.T) { | |||
| 501 | + | c, g := newGatedClient(t, Config{}) | |||
| 502 | + | type sendResult struct { | |||
| 503 | + | attempted bool | |||
| 504 | + | err error | |||
| 505 | + | } | |||
| 506 | + | done := make(chan sendResult, 1) | |||
| 507 | + | go func() { | |||
| 508 | + | attempted, err := c.sendContext(context.Background(), brokerwire.RemoveHost{ | |||
| 509 | + | Epoch: c.scope.Epoch, Connection: c.scope.Connection, | |||
| 510 | + | Operation: ports.BrokerOperationID{0x02}, Endpoint: "known", | |||
| 511 | + | }) | |||
| 512 | + | done <- sendResult{attempted: attempted, err: err} | |||
| 513 | + | }() | |||
| 514 | + | _ = awaitSend(t, g) | |||
| 515 | + | g.releaseSend(errTestCarriage) | |||
| 516 | + | select { | |||
| 517 | + | case got := <-done: | |||
| 518 | + | require.True(t, got.attempted, "a launched attempt is dispatch-uncertain") | |||
| 519 | + | require.ErrorIs(t, got.err, errTestCarriage) | |||
| 520 | + | case <-time.After(5 * time.Second): | |||
| 521 | + | t.Fatal("sendContext did not return") | |||
| 522 | + | } | |||
| 523 | + | require.Equal(t, 1, g.sendCount()) | |||
| 524 | + | }) | |||
| 525 | + | } | |||
A internal/adapters/brokeripc/config.go +81 -0
| @@ -0,0 +1,81 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 7 | + | "github.com/bnema/vev/internal/protocol" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Bounds. Every limit below is mandatory: no listener, session, or stream | |||
| 11 | + | // grows an unbounded queue, and every default is small enough that a stalled | |||
| 12 | + | // peer is refused rather than absorbed. | |||
| 13 | + | const ( | |||
| 14 | + | // DefaultMaxClients bounds concurrent accepted client connections on one | |||
| 15 | + | // listener. A listener acquires one slot before accepting, so the bound is | |||
| 16 | + | // enforced before a socket is admitted rather than after. | |||
| 17 | + | DefaultMaxClients = 64 | |||
| 18 | + | // DefaultStreamInboundChunks bounds one logical stream's inbound chunk | |||
| 19 | + | // queue: frames already accepted from the wire and not yet consumed by the | |||
| 20 | + | // local session carriage. | |||
| 21 | + | DefaultStreamInboundChunks = 64 | |||
| 22 | + | // DefaultStreamInboundBytes bounds one logical stream's inbound chunk queue | |||
| 23 | + | // in bytes (4 MiB). | |||
| 24 | + | DefaultStreamInboundBytes = 4 << 20 | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // Config bounds one endpoint. The zero value is valid and selects every | |||
| 28 | + | // default; a negative or zero explicit bound is replaced by its default. | |||
| 29 | + | type Config struct { | |||
| 30 | + | // MaxClients bounds concurrent accepted client connections. | |||
| 31 | + | MaxClients int | |||
| 32 | + | // StreamInboundChunks bounds one logical stream's inbound chunk queue. | |||
| 33 | + | StreamInboundChunks int | |||
| 34 | + | // StreamInboundBytes bounds one logical stream's inbound chunk bytes. | |||
| 35 | + | StreamInboundBytes uint64 | |||
| 36 | + | // HandshakeTimeout bounds one accepted connection's broker preamble plus | |||
| 37 | + | // admission. The client dialer also bounds its own preamble exchange with | |||
| 38 | + | // it. It is deliberately separate from the session handshake budget that | |||
| 39 | + | // runs inside a logical stream. | |||
| 40 | + | HandshakeTimeout time.Duration | |||
| 41 | + | // MaxPendingOperations bounds mutating operations this side is waiting for | |||
| 42 | + | // on one connection. It never exceeds the brokerwire per-connection bound. | |||
| 43 | + | MaxPendingOperations int | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // withDefaults fills every unset bound with its package default. | |||
| 47 | + | func (c Config) withDefaults() Config { | |||
| 48 | + | if c.MaxClients <= 0 { | |||
| 49 | + | c.MaxClients = DefaultMaxClients | |||
| 50 | + | } | |||
| 51 | + | if c.StreamInboundChunks <= 0 { | |||
| 52 | + | c.StreamInboundChunks = DefaultStreamInboundChunks | |||
| 53 | + | } | |||
| 54 | + | if c.StreamInboundBytes == 0 { | |||
| 55 | + | c.StreamInboundBytes = DefaultStreamInboundBytes | |||
| 56 | + | } | |||
| 57 | + | if c.HandshakeTimeout <= 0 { | |||
| 58 | + | c.HandshakeTimeout = protocol.HandshakeTimeout | |||
| 59 | + | } | |||
| 60 | + | if c.MaxPendingOperations <= 0 { | |||
| 61 | + | c.MaxPendingOperations = brokerwire.MaxPendingOperations | |||
| 62 | + | } | |||
| 63 | + | return c | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // validate refuses a configuration whose explicit bounds cannot be honored. | |||
| 67 | + | // The zero value is always valid because withDefaults has already been applied. | |||
| 68 | + | func (c Config) validate() error { | |||
| 69 | + | if c.MaxClients <= 0 || c.StreamInboundChunks <= 0 || c.StreamInboundBytes == 0 || c.HandshakeTimeout <= 0 { | |||
| 70 | + | return ErrConfig | |||
| 71 | + | } | |||
| 72 | + | if c.MaxPendingOperations <= 0 || c.MaxPendingOperations > brokerwire.MaxPendingOperations { | |||
| 73 | + | return ErrConfig | |||
| 74 | + | } | |||
| 75 | + | // A stream inbound byte bound below one maximum chunk can never accept a | |||
| 76 | + | // full stream frame, which would starve a legal peer. | |||
| 77 | + | if c.StreamInboundBytes < brokerwire.MaxStreamChunkBytes { | |||
| 78 | + | return ErrConfig | |||
| 79 | + | } | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
A internal/adapters/brokeripc/doc.go +46 -0
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | // Package brokeripc is the Plan 001 P3.3 private local broker IPC adapter. | |||
| 2 | + | // | |||
| 3 | + | // It exposes the client and server halves of one per-user Unix endpoint: a | |||
| 4 | + | // client adapter that implements ports.BrokerService over the brokerwire | |||
| 5 | + | // conversation, and a listener adapter that implements ports.BrokerListener by | |||
| 6 | + | // accepting same-user client connections and running one brokerwire session per | |||
| 7 | + | // accepted connection. The wire contract is exactly P3.1's brokerwire preamble | |||
| 8 | + | // and directional codec over the shared streamframe carriage; this package adds | |||
| 9 | + | // no message, tag, or preamble of its own. | |||
| 10 | + | // | |||
| 11 | + | // The carriage itself is the P3.2 private AF_UNIX carriage in | |||
| 12 | + | // internal/adapters/ipc: the endpoint lives in an owner-only (0700) directory | |||
| 13 | + | // created or validated by pkg/safedir.EnsurePrivate, the bound socket is | |||
| 14 | + | // tightened to 0600, a stale socket left by a dead owner is recovered | |||
| 15 | + | // race-safely, a live owner is reported instead of evicted, and a path that is | |||
| 16 | + | // not a socket is refused without being removed. Both accept and dial verify | |||
| 17 | + | // the connected peer's kernel credentials (SO_PEERCRED on Linux) and fail | |||
| 18 | + | // closed on a platform or build without that check, so no admission decision | |||
| 19 | + | // rests on filesystem permissions or an X11-style "the directory is private" | |||
| 20 | + | // assumption. | |||
| 21 | + | // | |||
| 22 | + | // One endpoint is one brokerwire conversation. The server assigns the | |||
| 23 | + | // connection scope at accept, answers the preamble with the negotiated | |||
| 24 | + | // ceilings, and waits for exactly one Register before admitting subscription, | |||
| 25 | + | // operation, or stream work; every later frame must carry the assigned epoch | |||
| 26 | + | // and connection identity or it is refused as stale without touching session | |||
| 27 | + | // state. Bounds are explicit: accepted clients are capped per listener, stream | |||
| 28 | + | // inbound queues are bounded per stream, pending operations are capped per | |||
| 29 | + | // connection, and the brokerwire per-connection trackers bound streams and | |||
| 30 | + | // completed-operation dedup. Every accepted connection owns its own reader, | |||
| 31 | + | // publisher, and stream relays, so a slow or stalled client blocks only its own | |||
| 32 | + | // connection. | |||
| 33 | + | // | |||
| 34 | + | // A malformed or oversize outer envelope, a wrong-direction payload, and a | |||
| 35 | + | // frame for an identity that was never allocated are protocol violations and | |||
| 36 | + | // settle that connection alone. Disconnect cleanup is deterministic: the | |||
| 37 | + | // session cancels its context, terminates every bridged stream against the | |||
| 38 | + | // admitted broker service, closes the carriage, and joins every worker before | |||
| 39 | + | // Close returns. | |||
| 40 | + | // | |||
| 41 | + | // Like the brokerwire codec (P3.1) and the daemonmux carriage (P3.2), this | |||
| 42 | + | // adapter is not activated by production composition. Nothing in internal/app | |||
| 43 | + | // or main constructs it yet; P3.4 composes the broker use case, its stores, and | |||
| 44 | + | // this listener behind a hidden entry point, and P7 performs the coordinated | |||
| 45 | + | // production cutover. | |||
| 46 | + | package brokeripc | |||
A internal/adapters/brokeripc/emit.go +119 -0
| @@ -0,0 +1,119 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Snapshot publication (P3.3). | |||
| 12 | + | // | |||
| 13 | + | // A broker publication travels as the exact multipart transfer the P3.1 | |||
| 14 | + | // assembler accepts: Begin(index 0, element counts), then every host part in | |||
| 15 | + | // host-index order immediately followed by that host's session parts in | |||
| 16 | + | // session-index order, then the tombstones, then End at the final index. Host | |||
| 17 | + | // parts carry no inline sessions; sessions travel as their own parts. Both | |||
| 18 | + | // sides agree on one deterministic layout, so the receiving assembler commits | |||
| 19 | + | // only a transfer whose indexes, counts, and order are exact. | |||
| 20 | + | // | |||
| 21 | + | // The emitter refuses a transfer the peer's assembler would refuse: more parts | |||
| 22 | + | // than MaxSnapshotParts, or a total encoded payload above the staged ceiling. | |||
| 23 | + | // The staged ceiling check is deliberately conservative - the encoded total is | |||
| 24 | + | // an upper bound of the deterministic staged estimate - so a publication this | |||
| 25 | + | // side accepts is never one the peer silently aborts. | |||
| 26 | + | ||||
| 27 | + | // snapshotParts splits one published snapshot into its ordered multipart | |||
| 28 | + | // transfer for one connection scope and subscription generation. | |||
| 29 | + | func snapshotParts(snapshot ports.BrokerSnapshot, epoch ports.BrokerEpoch, connection ports.BrokerConnectionID, generation brokerwire.SubscriptionGeneration) ([]brokerwire.SnapshotPart, error) { | |||
| 30 | + | if epoch == 0 || connection.IsZero() { | |||
| 31 | + | return nil, ErrScopeMismatch | |||
| 32 | + | } | |||
| 33 | + | if snapshot.Revision == 0 { | |||
| 34 | + | return nil, errors.New("brokeripc: snapshot publication has no revision") | |||
| 35 | + | } | |||
| 36 | + | if len(snapshot.Hosts) > ports.BrokerMaxHosts || len(snapshot.Removed) > ports.BrokerMaxTombstones { | |||
| 37 | + | return nil, errors.New("brokeripc: snapshot publication exceeds host bound") | |||
| 38 | + | } | |||
| 39 | + | sessions := 0 | |||
| 40 | + | for _, host := range snapshot.Hosts { | |||
| 41 | + | if len(host.Sessions) > ports.BrokerMaxSessionsPerHost { | |||
| 42 | + | return nil, errors.New("brokeripc: snapshot publication exceeds session bound") | |||
| 43 | + | } | |||
| 44 | + | sessions += len(host.Sessions) | |||
| 45 | + | } | |||
| 46 | + | total := 1 + len(snapshot.Hosts) + sessions + len(snapshot.Removed) + 1 | |||
| 47 | + | if total > brokerwire.MaxSnapshotParts { | |||
| 48 | + | return nil, errors.New("brokeripc: snapshot publication exceeds part bound") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | parts := make([]brokerwire.SnapshotPart, 0, total) | |||
| 52 | + | base := func(part brokerwire.SnapshotPartPayload) brokerwire.SnapshotPart { | |||
| 53 | + | return brokerwire.SnapshotPart{ | |||
| 54 | + | Epoch: epoch, Connection: connection, Generation: generation, | |||
| 55 | + | Revision: snapshot.Revision, Index: uint32(len(parts)), Part: part, | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | parts = append(parts, base(brokerwire.SnapshotBegin{ | |||
| 59 | + | HostCount: uint32(len(snapshot.Hosts)), | |||
| 60 | + | SessionCount: uint32(sessions), | |||
| 61 | + | TombstoneCount: uint32(len(snapshot.Removed)), | |||
| 62 | + | })) | |||
| 63 | + | for hostIndex, host := range snapshot.Hosts { | |||
| 64 | + | projection := host.Clone() | |||
| 65 | + | sessionCount := len(projection.Sessions) | |||
| 66 | + | projection.Sessions = nil | |||
| 67 | + | parts = append(parts, base(brokerwire.SnapshotHostPart{ | |||
| 68 | + | HostIndex: uint32(hostIndex), | |||
| 69 | + | Host: projection, | |||
| 70 | + | SessionCount: uint32(sessionCount), | |||
| 71 | + | })) | |||
| 72 | + | for sessionIndex, session := range host.Sessions { | |||
| 73 | + | parts = append(parts, base(brokerwire.SnapshotSessionPart{ | |||
| 74 | + | HostIndex: uint32(hostIndex), | |||
| 75 | + | SessionIndex: uint32(sessionIndex), | |||
| 76 | + | Session: cloneCatalogSession(session), | |||
| 77 | + | })) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | for tombstoneIndex, tombstone := range snapshot.Removed { | |||
| 81 | + | parts = append(parts, base(brokerwire.SnapshotTombstonePart{ | |||
| 82 | + | TombstoneIndex: uint32(tombstoneIndex), | |||
| 83 | + | Registration: tombstone.Registration, | |||
| 84 | + | RetiredRevision: tombstone.RetiredRevision, | |||
| 85 | + | })) | |||
| 86 | + | } | |||
| 87 | + | parts = append(parts, base(brokerwire.SnapshotEnd{})) | |||
| 88 | + | return parts, nil | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // cloneCatalogSession copies one session and guarantees its tabs slice is | |||
| 92 | + | // non-nil: the assembler refuses a session part whose tabs are absent, so an | |||
| 93 | + | // empty inventory publishes an empty list rather than a missing one. | |||
| 94 | + | func cloneCatalogSession(session catalogue.RemoteCatalogSession) catalogue.RemoteCatalogSession { | |||
| 95 | + | out := session | |||
| 96 | + | if session.Tabs == nil { | |||
| 97 | + | out.Tabs = []catalogue.RemoteCatalogTab{} | |||
| 98 | + | return out | |||
| 99 | + | } | |||
| 100 | + | tabs := make([]catalogue.RemoteCatalogTab, len(session.Tabs)) | |||
| 101 | + | copy(tabs, session.Tabs) | |||
| 102 | + | out.Tabs = tabs | |||
| 103 | + | return out | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // snapshotTransferBytes sums the encoded size of one prepared transfer so the | |||
| 107 | + | // publisher can refuse a publication above the staged ceiling before the peer | |||
| 108 | + | // aborts it. | |||
| 109 | + | func snapshotTransferBytes(parts []brokerwire.SnapshotPart, maxEnvelopeBytes, maxChunkBytes uint64) (uint64, error) { | |||
| 110 | + | var total uint64 | |||
| 111 | + | for _, part := range parts { | |||
| 112 | + | payload, err := brokerwire.EncodeServer(part, maxEnvelopeBytes, maxChunkBytes) | |||
| 113 | + | if err != nil { | |||
| 114 | + | return 0, err | |||
| 115 | + | } | |||
| 116 | + | total += uint64(len(payload)) | |||
| 117 | + | } | |||
| 118 | + | return total, nil | |||
| 119 | + | } | |||
A internal/adapters/brokeripc/errors.go +141 -0
| @@ -0,0 +1,141 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Adapter sentinels. They are typed so a caller can classify a listener, | |||
| 12 | + | // session, framing, or backpressure outcome without matching on message text. | |||
| 13 | + | // Semantic broker refusals stay ports.BrokerError, and admission refusals stay | |||
| 14 | + | // ports.BrokerAdmissionError; this adapter wraps them rather than redefining | |||
| 15 | + | // them. | |||
| 16 | + | var ( | |||
| 17 | + | // ErrConfig reports an invalid listener or dialer configuration. | |||
| 18 | + | ErrConfig = errors.New("brokeripc: invalid configuration") | |||
| 19 | + | // ErrListenerClosed reports Accept on a closed listener. | |||
| 20 | + | ErrListenerClosed = errors.New("brokeripc: listener is closed") | |||
| 21 | + | // ErrSessionClosed reports session work presented after the session reached | |||
| 22 | + | // its terminal outcome. | |||
| 23 | + | ErrSessionClosed = errors.New("brokeripc: session is closed") | |||
| 24 | + | // ErrMalformedFrame reports an outer envelope this side refuses to decode: | |||
| 25 | + | // an oversize length prefix, a truncated or trailing payload, an unknown or | |||
| 26 | + | // repeated field, or a wrong-direction payload. A malformed frame is a | |||
| 27 | + | // protocol violation and settles exactly the connection that sent it. | |||
| 28 | + | ErrMalformedFrame = errors.New("brokeripc: malformed broker frame") | |||
| 29 | + | // ErrProtocol reports a well-formed frame that violates connection | |||
| 30 | + | // ordering: a duplicate Register, a frame for an unallocated stream, or | |||
| 31 | + | // stream data before the stream was established. | |||
| 32 | + | ErrProtocol = errors.New("brokeripc: broker protocol violation") | |||
| 33 | + | // ErrStreamBackpressure reports a logical stream whose local consumer is | |||
| 34 | + | // not draining its bounded inbound queue. The affected stream is settled | |||
| 35 | + | // alone; the connection and every sibling stream stay up. | |||
| 36 | + | ErrStreamBackpressure = errors.New("brokeripc: stream consumer is not keeping up") | |||
| 37 | + | // ErrStreamGone reports a stream the peer retired while this side was still | |||
| 38 | + | // starting or using it. | |||
| 39 | + | ErrStreamGone = errors.New("brokeripc: logical stream is gone") | |||
| 40 | + | // ErrConnectionClosed reports a client adapter whose connection reached its | |||
| 41 | + | // terminal outcome. | |||
| 42 | + | ErrConnectionClosed = errors.New("brokeripc: broker connection is closed") | |||
| 43 | + | // ErrScopeMismatch reports a frame carrying an epoch or connection identity | |||
| 44 | + | // that is not the one assigned at accept. It is refused as stale and never | |||
| 45 | + | // applied. | |||
| 46 | + | ErrScopeMismatch = errors.New("brokeripc: frame scope does not match the accepted connection") | |||
| 47 | + | ) | |||
| 48 | + | ||||
| 49 | + | // transportFailure classifies one carriage error that ended a connection. A | |||
| 50 | + | // closed carriage is an orderly disconnect; anything else is a transport | |||
| 51 | + | // failure. | |||
| 52 | + | func transportFailure(err error) error { | |||
| 53 | + | if err == nil { | |||
| 54 | + | return ErrConnectionClosed | |||
| 55 | + | } | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // cancelOrCause prefers the context outcome when a wait was cancelled, so a | |||
| 60 | + | // caller sees cancellation rather than a synthetic transport error. | |||
| 61 | + | func cancelOrCause(ctx context.Context, cause error) error { | |||
| 62 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 63 | + | return ctxErr | |||
| 64 | + | } | |||
| 65 | + | return cause | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // admissionError maps one brokerwire tracker refusal onto the closed ports | |||
| 69 | + | // admission taxonomy. An unrecognized refusal is invalid-request, never a | |||
| 70 | + | // retryable limit. | |||
| 71 | + | func admissionError(err error) error { | |||
| 72 | + | switch { | |||
| 73 | + | case err == nil: | |||
| 74 | + | return nil | |||
| 75 | + | case errors.Is(err, brokerwire.ErrTooManyStreams), errors.Is(err, brokerwire.ErrTooManyPendingOperations): | |||
| 76 | + | return ports.BrokerAdmissionLimit | |||
| 77 | + | case errors.Is(err, brokerwire.ErrConnectionClosed): | |||
| 78 | + | return ports.BrokerAdmissionClosed | |||
| 79 | + | case errors.Is(err, brokerwire.ErrStreamIDReused), errors.Is(err, brokerwire.ErrConnectionState), | |||
| 80 | + | errors.Is(err, brokerwire.ErrNotSubscribed), errors.Is(err, brokerwire.ErrDuplicateRegister), | |||
| 81 | + | errors.Is(err, brokerwire.ErrOperationCompleted), errors.Is(err, brokerwire.ErrOperationPending): | |||
| 82 | + | return ports.BrokerAdmissionStale | |||
| 83 | + | default: | |||
| 84 | + | return ports.BrokerAdmissionInvalid | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // errorDetail converts one broker failure into the bounded, presentation-safe | |||
| 89 | + | // wire detail. The diagnostic cause stays local: only the typed code, bounded | |||
| 90 | + | // display text, closed admission code, and sanitized failure kind travel. | |||
| 91 | + | func errorDetail(err error) brokerwire.ErrorDetail { | |||
| 92 | + | if err == nil { | |||
| 93 | + | return brokerwire.ErrorDetail{} | |||
| 94 | + | } | |||
| 95 | + | var failure ports.BrokerError | |||
| 96 | + | if errors.As(err, &failure) { | |||
| 97 | + | return brokerwire.ErrorDetail{Code: failure.Code, Text: failure.Text} | |||
| 98 | + | } | |||
| 99 | + | var admission ports.BrokerAdmissionError | |||
| 100 | + | if errors.As(err, &admission) { | |||
| 101 | + | detail := brokerwire.ErrorDetail{Code: ports.BrokerErrorUnavailable} | |||
| 102 | + | switch { | |||
| 103 | + | case errors.Is(err, ports.BrokerAdmissionLimit): | |||
| 104 | + | detail.AdmissionCode = 1 | |||
| 105 | + | case errors.Is(err, ports.BrokerAdmissionClosed): | |||
| 106 | + | detail.AdmissionCode = 2 | |||
| 107 | + | case errors.Is(err, ports.BrokerAdmissionStale): | |||
| 108 | + | detail.AdmissionCode = 3 | |||
| 109 | + | default: | |||
| 110 | + | detail.AdmissionCode = 4 | |||
| 111 | + | } | |||
| 112 | + | return detail | |||
| 113 | + | } | |||
| 114 | + | switch { | |||
| 115 | + | case errors.Is(err, context.DeadlineExceeded): | |||
| 116 | + | return brokerwire.ErrorDetail{Code: ports.BrokerErrorTimeout} | |||
| 117 | + | case errors.Is(err, context.Canceled): | |||
| 118 | + | return brokerwire.ErrorDetail{Code: ports.BrokerErrorCancelled} | |||
| 119 | + | default: | |||
| 120 | + | return brokerwire.ErrorDetail{Code: ports.BrokerErrorUnavailable} | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // failureFromDetail converts one received detail back into the typed local | |||
| 125 | + | // failure. A detail with no code is the absent-detail case and yields nil. | |||
| 126 | + | func failureFromDetail(detail brokerwire.ErrorDetail) error { | |||
| 127 | + | switch detail.AdmissionCode { | |||
| 128 | + | case 1: | |||
| 129 | + | return ports.BrokerAdmissionLimit | |||
| 130 | + | case 2: | |||
| 131 | + | return ports.BrokerAdmissionClosed | |||
| 132 | + | case 3: | |||
| 133 | + | return ports.BrokerAdmissionStale | |||
| 134 | + | case 4: | |||
| 135 | + | return ports.BrokerAdmissionInvalid | |||
| 136 | + | } | |||
| 137 | + | if detail.Code == 0 { | |||
| 138 | + | return nil | |||
| 139 | + | } | |||
| 140 | + | return ports.BrokerError{Code: detail.Code, Text: detail.Text} | |||
| 141 | + | } | |||
A internal/adapters/brokeripc/helpers_test.go +554 -0
| @@ -0,0 +1,554 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 17 | + | "github.com/bnema/vev/internal/domain" | |||
| 18 | + | "github.com/bnema/vev/internal/ports" | |||
| 19 | + | "github.com/bnema/vev/internal/protocol" | |||
| 20 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 21 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // testSocketPath returns a short, owner-only parent directory and one broker | |||
| 25 | + | // endpoint inside it, so a real AF_UNIX path is exercised well within the | |||
| 26 | + | // cross-platform pathname limit. | |||
| 27 | + | func testSocketPath(t *testing.T) string { | |||
| 28 | + | t.Helper() | |||
| 29 | + | root, err := os.MkdirTemp("/tmp", "v") | |||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | t.Cleanup(func() { _ = os.RemoveAll(root) }) | |||
| 32 | + | return SocketPath(filepath.Join(root, "vev")) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // testPolicy builds one valid broker policy. | |||
| 36 | + | func testPolicy() ports.BrokerPolicy { | |||
| 37 | + | return ports.BrokerPolicy{ | |||
| 38 | + | ProtocolVersion: protocol.Version, | |||
| 39 | + | CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |||
| 40 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |||
| 41 | + | Transport: "unix", | |||
| 42 | + | Trust: "trusted", | |||
| 43 | + | Launch: "explicit", | |||
| 44 | + | Isolation: "per-user", | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // testHostAt builds one catalogue-valid host projection for index i. | |||
| 49 | + | func testHostAt(i int) ports.RemoteHostSnapshot { | |||
| 50 | + | endpoint := fmt.Sprintf("user%d@host%d:22", i, i) | |||
| 51 | + | return ports.RemoteHostSnapshot{ | |||
| 52 | + | Endpoint: endpoint, | |||
| 53 | + | DisplayOrigin: endpoint, | |||
| 54 | + | 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)}, | |||
| 55 | + | Availability: domain.RemoteAvailabilityReachable, | |||
| 56 | + | LastSuccess: time.Unix(1700000000+int64(i), 0).UTC(), | |||
| 57 | + | InventoryKnown: true, | |||
| 58 | + | Sessions: []catalogue.RemoteCatalogSession{}, | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // testSessionAt builds one catalogue-valid session for a host and index. | |||
| 63 | + | func testSessionAt(host, index int) catalogue.RemoteCatalogSession { | |||
| 64 | + | return catalogue.RemoteCatalogSession{ | |||
| 65 | + | LifecycleID: domain.SessionLifecycleID{byte(host + 1), byte(index >> 8), byte(index), 0x77}, | |||
| 66 | + | Name: fmt.Sprintf("s%02d-%03d", host, index), | |||
| 67 | + | State: catalogue.RemoteCatalogSessionDown, | |||
| 68 | + | Tabs: []catalogue.RemoteCatalogTab{}, | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // testSnapshot builds one valid publication for an epoch and revision. | |||
| 73 | + | func testSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision) ports.BrokerSnapshot { | |||
| 74 | + | host := testHostAt(0) | |||
| 75 | + | host.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)} | |||
| 76 | + | return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Hosts: []ports.RemoteHostSnapshot{host}} | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // snapshotHub is a multi-subscriber publication hub standing in for the broker | |||
| 80 | + | // registry: one immutable current snapshot, coalescing capacity-one subscribers. | |||
| 81 | + | type snapshotHub struct { | |||
| 82 | + | mu sync.Mutex | |||
| 83 | + | snapshot ports.BrokerSnapshot | |||
| 84 | + | subs map[*fakeSubscription]struct{} | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func newSnapshotHub(snapshot ports.BrokerSnapshot) *snapshotHub { | |||
| 88 | + | return &snapshotHub{snapshot: snapshot, subs: make(map[*fakeSubscription]struct{})} | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (h *snapshotHub) current() ports.BrokerSnapshot { | |||
| 92 | + | h.mu.Lock() | |||
| 93 | + | defer h.mu.Unlock() | |||
| 94 | + | return h.snapshot.Clone() | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (h *snapshotHub) subscribe() *fakeSubscription { | |||
| 98 | + | sub := &fakeSubscription{hub: h, changed: make(chan struct{}, 1)} | |||
| 99 | + | h.mu.Lock() | |||
| 100 | + | h.subs[sub] = struct{}{} | |||
| 101 | + | h.mu.Unlock() | |||
| 102 | + | return sub | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (h *snapshotHub) publish(snapshot ports.BrokerSnapshot) { | |||
| 106 | + | h.mu.Lock() | |||
| 107 | + | h.snapshot = snapshot.Clone() | |||
| 108 | + | subs := make([]*fakeSubscription, 0, len(h.subs)) | |||
| 109 | + | for sub := range h.subs { | |||
| 110 | + | subs = append(subs, sub) | |||
| 111 | + | } | |||
| 112 | + | h.mu.Unlock() | |||
| 113 | + | for _, sub := range subs { | |||
| 114 | + | sub.notify() | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (h *snapshotHub) remove(sub *fakeSubscription) { | |||
| 119 | + | h.mu.Lock() | |||
| 120 | + | delete(h.subs, sub) | |||
| 121 | + | h.mu.Unlock() | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // fakeSubscription is one coalescing capacity-one subscription. | |||
| 125 | + | type fakeSubscription struct { | |||
| 126 | + | hub *snapshotHub | |||
| 127 | + | changed chan struct{} | |||
| 128 | + | once sync.Once | |||
| 129 | + | mu sync.Mutex | |||
| 130 | + | closed bool | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (s *fakeSubscription) Changed() <-chan struct{} { return s.changed } | |||
| 134 | + | ||||
| 135 | + | func (s *fakeSubscription) Close() { | |||
| 136 | + | s.once.Do(func() { | |||
| 137 | + | s.mu.Lock() | |||
| 138 | + | s.closed = true | |||
| 139 | + | s.mu.Unlock() | |||
| 140 | + | s.hub.remove(s) | |||
| 141 | + | }) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (s *fakeSubscription) notify() { | |||
| 145 | + | s.mu.Lock() | |||
| 146 | + | defer s.mu.Unlock() | |||
| 147 | + | if s.closed { | |||
| 148 | + | return | |||
| 149 | + | } | |||
| 150 | + | select { | |||
| 151 | + | case s.changed <- struct{}{}: | |||
| 152 | + | default: | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // fakeLogicalConn is one admitted core logical connection: it records messages | |||
| 157 | + | // the broker relays toward the daemon and lets a test inject daemon replies. | |||
| 158 | + | type fakeLogicalConn struct { | |||
| 159 | + | fromClient chan protocol.ClientMessage | |||
| 160 | + | toClient chan protocol.ServerMessage | |||
| 161 | + | done chan struct{} | |||
| 162 | + | once sync.Once | |||
| 163 | + | mu sync.Mutex | |||
| 164 | + | err error | |||
| 165 | + | closed bool | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func newFakeLogicalConn() *fakeLogicalConn { | |||
| 169 | + | return &fakeLogicalConn{ | |||
| 170 | + | fromClient: make(chan protocol.ClientMessage, 64), | |||
| 171 | + | toClient: make(chan protocol.ServerMessage, 64), | |||
| 172 | + | done: make(chan struct{}), | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func (c *fakeLogicalConn) SendClient(message protocol.ClientMessage) error { | |||
| 177 | + | select { | |||
| 178 | + | case c.fromClient <- message: | |||
| 179 | + | return nil | |||
| 180 | + | case <-c.done: | |||
| 181 | + | return ports.BrokerAdmissionClosed | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func (c *fakeLogicalConn) ReceiveServer() (protocol.ServerMessage, error) { | |||
| 186 | + | select { | |||
| 187 | + | case message := <-c.toClient: | |||
| 188 | + | return message, nil | |||
| 189 | + | case <-c.done: | |||
| 190 | + | if err := c.Err(); err != nil { | |||
| 191 | + | return nil, err | |||
| 192 | + | } | |||
| 193 | + | return nil, io.EOF | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (c *fakeLogicalConn) Capabilities() protocol.ConnectionCapabilities { | |||
| 198 | + | return protocol.ConnectionCapabilities{OutputDataLimit: protocol.MaxOutputDataLen} | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func (c *fakeLogicalConn) LinkState() ports.LinkState { return ports.LinkStateConnected } | |||
| 202 | + | func (c *fakeLogicalConn) LinkEvents() <-chan ports.LinkEvent { return nil } | |||
| 203 | + | func (c *fakeLogicalConn) Done() <-chan struct{} { return c.done } | |||
| 204 | + | func (c *fakeLogicalConn) Err() error { c.mu.Lock(); defer c.mu.Unlock(); return c.err } | |||
| 205 | + | ||||
| 206 | + | func (c *fakeLogicalConn) Close() error { | |||
| 207 | + | c.once.Do(func() { | |||
| 208 | + | c.mu.Lock() | |||
| 209 | + | c.closed = true | |||
| 210 | + | c.mu.Unlock() | |||
| 211 | + | close(c.done) | |||
| 212 | + | }) | |||
| 213 | + | return nil | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // fail publishes one terminal core failure. | |||
| 217 | + | func (c *fakeLogicalConn) fail(err error) { | |||
| 218 | + | c.mu.Lock() | |||
| 219 | + | c.err = err | |||
| 220 | + | c.mu.Unlock() | |||
| 221 | + | _ = c.Close() | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // fakeCore is one admitted per-connection broker service. | |||
| 225 | + | type fakeCore struct { | |||
| 226 | + | mu sync.Mutex | |||
| 227 | + | id ports.BrokerConnectionID | |||
| 228 | + | hub *snapshotHub | |||
| 229 | + | streams map[ports.BrokerStreamID]*fakeLogicalConn | |||
| 230 | + | opens []ports.BrokerOpenStreamRequest | |||
| 231 | + | closedIDs []ports.BrokerStreamID | |||
| 232 | + | added []string | |||
| 233 | + | removed []string | |||
| 234 | + | reconcile []string | |||
| 235 | + | openErr error | |||
| 236 | + | blockAdd chan struct{} | |||
| 237 | + | closed bool | |||
| 238 | + | done chan struct{} | |||
| 239 | + | once sync.Once | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id } | |||
| 243 | + | ||||
| 244 | + | func (c *fakeCore) Snapshot() ports.BrokerSnapshot { return c.hub.current() } | |||
| 245 | + | ||||
| 246 | + | func (c *fakeCore) Subscribe() (ports.BrokerSubscription, error) { return c.hub.subscribe(), nil } | |||
| 247 | + | ||||
| 248 | + | func (c *fakeCore) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 249 | + | c.mu.Lock() | |||
| 250 | + | defer c.mu.Unlock() | |||
| 251 | + | if c.openErr != nil { | |||
| 252 | + | return nil, c.openErr | |||
| 253 | + | } | |||
| 254 | + | if c.closed { | |||
| 255 | + | return nil, ports.BrokerAdmissionClosed | |||
| 256 | + | } | |||
| 257 | + | conn := newFakeLogicalConn() | |||
| 258 | + | c.streams[request.Stream] = conn | |||
| 259 | + | c.opens = append(c.opens, request) | |||
| 260 | + | return conn, nil | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func (c *fakeCore) CloseStream(connection ports.BrokerConnectionID, stream ports.BrokerStreamID) error { | |||
| 264 | + | if connection != c.id { | |||
| 265 | + | return ports.BrokerAdmissionStale | |||
| 266 | + | } | |||
| 267 | + | c.mu.Lock() | |||
| 268 | + | conn, ok := c.streams[stream] | |||
| 269 | + | if ok { | |||
| 270 | + | delete(c.streams, stream) | |||
| 271 | + | } | |||
| 272 | + | c.closedIDs = append(c.closedIDs, stream) | |||
| 273 | + | c.mu.Unlock() | |||
| 274 | + | if !ok { | |||
| 275 | + | return ports.BrokerAdmissionStale | |||
| 276 | + | } | |||
| 277 | + | return conn.Close() | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | func (c *fakeCore) AddHost(ctx context.Context, target string) error { | |||
| 281 | + | if c.blockAdd != nil { | |||
| 282 | + | select { | |||
| 283 | + | case <-c.blockAdd: | |||
| 284 | + | case <-ctx.Done(): | |||
| 285 | + | return ctx.Err() | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | if err := ctx.Err(); err != nil { | |||
| 289 | + | return err | |||
| 290 | + | } | |||
| 291 | + | c.mu.Lock() | |||
| 292 | + | c.added = append(c.added, target) | |||
| 293 | + | c.mu.Unlock() | |||
| 294 | + | return nil | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func (c *fakeCore) RemoveHost(ctx context.Context, target string) (bool, error) { | |||
| 298 | + | if err := ctx.Err(); err != nil { | |||
| 299 | + | return false, err | |||
| 300 | + | } | |||
| 301 | + | c.mu.Lock() | |||
| 302 | + | c.removed = append(c.removed, target) | |||
| 303 | + | c.mu.Unlock() | |||
| 304 | + | return target == "known", nil | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func (c *fakeCore) RequestReconcile(endpoint string) { | |||
| 308 | + | c.mu.Lock() | |||
| 309 | + | c.reconcile = append(c.reconcile, endpoint) | |||
| 310 | + | c.mu.Unlock() | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func (c *fakeCore) Close() error { | |||
| 314 | + | c.once.Do(func() { | |||
| 315 | + | c.mu.Lock() | |||
| 316 | + | c.closed = true | |||
| 317 | + | conns := make([]*fakeLogicalConn, 0, len(c.streams)) | |||
| 318 | + | for _, conn := range c.streams { | |||
| 319 | + | conns = append(conns, conn) | |||
| 320 | + | } | |||
| 321 | + | c.streams = make(map[ports.BrokerStreamID]*fakeLogicalConn) | |||
| 322 | + | c.mu.Unlock() | |||
| 323 | + | for _, conn := range conns { | |||
| 324 | + | _ = conn.Close() | |||
| 325 | + | } | |||
| 326 | + | close(c.done) | |||
| 327 | + | }) | |||
| 328 | + | return nil | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // logicalConn returns one admitted core connection by stream identity. | |||
| 332 | + | func (c *fakeCore) logicalConn(stream ports.BrokerStreamID) *fakeLogicalConn { | |||
| 333 | + | c.mu.Lock() | |||
| 334 | + | defer c.mu.Unlock() | |||
| 335 | + | return c.streams[stream] | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | // newTestCore builds one admitted core service for a session test: a fresh hub | |||
| 339 | + | // and a connection identity bound to epoch. | |||
| 340 | + | func newTestCore(epoch ports.BrokerEpoch) *fakeCore { | |||
| 341 | + | var id ports.BrokerConnectionID | |||
| 342 | + | binary.BigEndian.PutUint64(id[:8], uint64(epoch)) | |||
| 343 | + | id[15] = 1 | |||
| 344 | + | return &fakeCore{ | |||
| 345 | + | id: id, | |||
| 346 | + | hub: newSnapshotHub(ports.BrokerSnapshot{}), | |||
| 347 | + | streams: make(map[ports.BrokerStreamID]*fakeLogicalConn), | |||
| 348 | + | done: make(chan struct{}), | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | // fakeAuthority admits clients for one listener and hands out per-connection | |||
| 353 | + | // core services. | |||
| 354 | + | type fakeAuthority struct { | |||
| 355 | + | mu sync.Mutex | |||
| 356 | + | hub *snapshotHub | |||
| 357 | + | epoch ports.BrokerEpoch | |||
| 358 | + | next uint64 | |||
| 359 | + | clients []*fakeCore | |||
| 360 | + | admitErr error | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | func newFakeAuthority(epoch ports.BrokerEpoch, snapshot ports.BrokerSnapshot) *fakeAuthority { | |||
| 364 | + | return &fakeAuthority{hub: newSnapshotHub(snapshot), epoch: epoch} | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func (a *fakeAuthority) AdmitClient(ctx context.Context) (ports.BrokerService, error) { | |||
| 368 | + | if err := ctx.Err(); err != nil { | |||
| 369 | + | return nil, err | |||
| 370 | + | } | |||
| 371 | + | a.mu.Lock() | |||
| 372 | + | defer a.mu.Unlock() | |||
| 373 | + | if a.admitErr != nil { | |||
| 374 | + | return nil, a.admitErr | |||
| 375 | + | } | |||
| 376 | + | a.next++ | |||
| 377 | + | var id ports.BrokerConnectionID | |||
| 378 | + | binary.BigEndian.PutUint64(id[:8], uint64(a.epoch)) | |||
| 379 | + | binary.BigEndian.PutUint64(id[8:], a.next) | |||
| 380 | + | core := &fakeCore{id: id, hub: a.hub, streams: make(map[ports.BrokerStreamID]*fakeLogicalConn), done: make(chan struct{})} | |||
| 381 | + | a.clients = append(a.clients, core) | |||
| 382 | + | return core, nil | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | func (a *fakeAuthority) last() *fakeCore { | |||
| 386 | + | a.mu.Lock() | |||
| 387 | + | defer a.mu.Unlock() | |||
| 388 | + | if len(a.clients) == 0 { | |||
| 389 | + | return nil | |||
| 390 | + | } | |||
| 391 | + | return a.clients[len(a.clients)-1] | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func (a *fakeAuthority) publish(snapshot ports.BrokerSnapshot) { a.hub.publish(snapshot) } | |||
| 395 | + | ||||
| 396 | + | // endpoint is one running broker IPC listener with its authority. | |||
| 397 | + | type endpoint struct { | |||
| 398 | + | t *testing.T | |||
| 399 | + | path string | |||
| 400 | + | listener ports.BrokerListener | |||
| 401 | + | authority *fakeAuthority | |||
| 402 | + | epoch ports.BrokerEpoch | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func startEndpoint(t *testing.T, cfg Config) *endpoint { | |||
| 406 | + | t.Helper() | |||
| 407 | + | epoch := ports.BrokerEpoch(0x51) | |||
| 408 | + | authority := newFakeAuthority(epoch, ports.BrokerSnapshot{}) | |||
| 409 | + | listener, err := Listen(testSocketPath(t), epoch, authority, cfg) | |||
| 410 | + | require.NoError(t, err) | |||
| 411 | + | t.Cleanup(func() { _ = listener.Close() }) | |||
| 412 | + | return &endpoint{t: t, path: listener.Addr(), listener: listener, authority: authority, epoch: epoch} | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | // accept returns the next admitted session. | |||
| 416 | + | func (e *endpoint) accept() ports.BrokerService { | |||
| 417 | + | e.t.Helper() | |||
| 418 | + | type result struct { | |||
| 419 | + | service ports.BrokerService | |||
| 420 | + | err error | |||
| 421 | + | } | |||
| 422 | + | out := make(chan result, 1) | |||
| 423 | + | go func() { | |||
| 424 | + | service, err := e.listener.Accept() | |||
| 425 | + | out <- result{service, err} | |||
| 426 | + | }() | |||
| 427 | + | select { | |||
| 428 | + | case got := <-out: | |||
| 429 | + | require.NoError(e.t, got.err) | |||
| 430 | + | return got.service | |||
| 431 | + | case <-time.After(5 * time.Second): | |||
| 432 | + | e.t.Fatal("listener.Accept did not return") | |||
| 433 | + | return nil | |||
| 434 | + | } | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | // dial connects one client adapter with the default test configuration. | |||
| 438 | + | func (e *endpoint) dial() ports.BrokerService { return e.dialWith(Config{}) } | |||
| 439 | + | ||||
| 440 | + | // dialWith connects one client adapter with an explicit configuration. | |||
| 441 | + | func (e *endpoint) dialWith(cfg Config) ports.BrokerService { | |||
| 442 | + | e.t.Helper() | |||
| 443 | + | cfg.HandshakeTimeout = 2 * time.Second | |||
| 444 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 445 | + | defer cancel() | |||
| 446 | + | client, err := Dial(ctx, e.path, cfg) | |||
| 447 | + | require.NoError(e.t, err) | |||
| 448 | + | e.t.Cleanup(func() { _ = client.Close() }) | |||
| 449 | + | return client | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | // pair dials one client and accepts its session. | |||
| 453 | + | func (e *endpoint) pair() (ports.BrokerService, ports.BrokerService) { | |||
| 454 | + | e.t.Helper() | |||
| 455 | + | client := e.dial() | |||
| 456 | + | session := e.accept() | |||
| 457 | + | return client, session | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | // publish makes one snapshot current and wakes every subscriber. | |||
| 461 | + | func (e *endpoint) publish(epoch ports.BrokerEpoch, revision ports.BrokerRevision) ports.BrokerSnapshot { | |||
| 462 | + | e.t.Helper() | |||
| 463 | + | snapshot := testSnapshot(epoch, revision) | |||
| 464 | + | e.authority.publish(snapshot) | |||
| 465 | + | return snapshot | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | // recordingTransport is one in-memory server carriage for a session: it decodes | |||
| 469 | + | // and records every complete snapshot transfer the session writes and lets a | |||
| 470 | + | // test wait for a generation to publish. It never yields an inbound frame, so | |||
| 471 | + | // only the code under test drives publication. | |||
| 472 | + | type recordingTransport struct { | |||
| 473 | + | mu sync.Mutex | |||
| 474 | + | ends map[brokerwire.SubscriptionGeneration]map[ports.BrokerRevision]int | |||
| 475 | + | changed chan struct{} | |||
| 476 | + | closedC chan struct{} | |||
| 477 | + | once sync.Once | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | func newRecordingTransport() *recordingTransport { | |||
| 481 | + | return &recordingTransport{ | |||
| 482 | + | ends: make(map[brokerwire.SubscriptionGeneration]map[ports.BrokerRevision]int), | |||
| 483 | + | changed: make(chan struct{}, 1), | |||
| 484 | + | closedC: make(chan struct{}), | |||
| 485 | + | } | |||
| 486 | + | } | |||
| 487 | + | ||||
| 488 | + | // Send decodes one server frame and records a completed snapshot transfer. | |||
| 489 | + | func (t *recordingTransport) Send(envelope wire.Envelope) error { | |||
| 490 | + | message, err := brokerwire.DecodeServer(envelope.Payload, brokerwire.MaxBrokerEnvelopeBytes, brokerwire.MaxStreamChunkBytes) | |||
| 491 | + | if err != nil { | |||
| 492 | + | return err | |||
| 493 | + | } | |||
| 494 | + | part, ok := message.(brokerwire.SnapshotPart) | |||
| 495 | + | if !ok { | |||
| 496 | + | return nil | |||
| 497 | + | } | |||
| 498 | + | if _, ok := part.Part.(brokerwire.SnapshotEnd); !ok { | |||
| 499 | + | return nil | |||
| 500 | + | } | |||
| 501 | + | t.mu.Lock() | |||
| 502 | + | if t.ends[part.Generation] == nil { | |||
| 503 | + | t.ends[part.Generation] = make(map[ports.BrokerRevision]int) | |||
| 504 | + | } | |||
| 505 | + | t.ends[part.Generation][part.Revision]++ | |||
| 506 | + | t.mu.Unlock() | |||
| 507 | + | select { | |||
| 508 | + | case t.changed <- struct{}{}: | |||
| 509 | + | default: | |||
| 510 | + | } | |||
| 511 | + | return nil | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | func (t *recordingTransport) RecvBounded(uint64) (wire.Envelope, error) { | |||
| 515 | + | <-t.closedC | |||
| 516 | + | return wire.Envelope{}, io.EOF | |||
| 517 | + | } | |||
| 518 | + | ||||
| 519 | + | func (t *recordingTransport) Recv() (wire.Envelope, error) { return t.RecvBounded(0) } | |||
| 520 | + | ||||
| 521 | + | func (t *recordingTransport) Close() error { | |||
| 522 | + | t.once.Do(func() { close(t.closedC) }) | |||
| 523 | + | return nil | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | // transferCount reports how many complete transfers of one generation committed | |||
| 527 | + | // one revision. | |||
| 528 | + | func (t *recordingTransport) transferCount(generation brokerwire.SubscriptionGeneration, revision ports.BrokerRevision) int { | |||
| 529 | + | t.mu.Lock() | |||
| 530 | + | defer t.mu.Unlock() | |||
| 531 | + | return t.ends[generation][revision] | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | // waitForTransfer blocks until generation committed revision at least once. | |||
| 535 | + | func (t *recordingTransport) waitForTransfer(generation brokerwire.SubscriptionGeneration, revision ports.BrokerRevision, timeout time.Duration) bool { | |||
| 536 | + | return t.waitForTransferCount(generation, revision, 1, timeout) | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | // waitForTransferCount blocks until generation committed revision at least | |||
| 540 | + | // minimum times, or the timeout elapses. | |||
| 541 | + | func (t *recordingTransport) waitForTransferCount(generation brokerwire.SubscriptionGeneration, revision ports.BrokerRevision, minimum int, timeout time.Duration) bool { | |||
| 542 | + | deadline := time.NewTimer(timeout) | |||
| 543 | + | defer deadline.Stop() | |||
| 544 | + | for { | |||
| 545 | + | if t.transferCount(generation, revision) >= minimum { | |||
| 546 | + | return true | |||
| 547 | + | } | |||
| 548 | + | select { | |||
| 549 | + | case <-t.changed: | |||
| 550 | + | case <-deadline.C: | |||
| 551 | + | return t.transferCount(generation, revision) >= minimum | |||
| 552 | + | } | |||
| 553 | + | } | |||
| 554 | + | } | |||
A internal/adapters/brokeripc/limits_test.go +324 -0
| @@ -0,0 +1,324 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 16 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // TestSnapshotPartsLayoutRoundTrips proves the emitter produces exactly the | |||
| 20 | + | // multipart layout the P3.1 assembler commits, including tombstones and | |||
| 21 | + | // session-less hosts. | |||
| 22 | + | func TestSnapshotPartsLayoutRoundTrips(t *testing.T) { | |||
| 23 | + | snapshot := testSnapshot(9, 12) | |||
| 24 | + | snapshot.Hosts = append(snapshot.Hosts, testHostAt(1)) | |||
| 25 | + | snapshot.Removed = []ports.BrokerHostTombstone{{ | |||
| 26 | + | Endpoint: "retired@old:22", | |||
| 27 | + | Registration: testHostAt(0).Registration, | |||
| 28 | + | RetiredRevision: 3, | |||
| 29 | + | }} | |||
| 30 | + | snapshot.Removed[0].Registration.Endpoint = "retired@old:22" | |||
| 31 | + | snapshot.Removed[0].Endpoint = "retired@old:22" | |||
| 32 | + | require.NoError(t, snapshot.Validate()) | |||
| 33 | + | ||||
| 34 | + | parts, err := snapshotParts(snapshot, 9, ports.BrokerConnectionID{9, 9}, 3) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | require.Len(t, parts, 1+2+2+1+1) | |||
| 37 | + | ||||
| 38 | + | begin, ok := parts[0].Part.(brokerwire.SnapshotBegin) | |||
| 39 | + | require.True(t, ok) | |||
| 40 | + | require.Equal(t, uint32(2), begin.HostCount) | |||
| 41 | + | require.Equal(t, uint32(2), begin.SessionCount) | |||
| 42 | + | require.Equal(t, uint32(1), begin.TombstoneCount) | |||
| 43 | + | _, ok = parts[len(parts)-1].Part.(brokerwire.SnapshotEnd) | |||
| 44 | + | require.True(t, ok) | |||
| 45 | + | ||||
| 46 | + | assembler := brokerwire.NewSnapshotAssembler(9, ports.BrokerConnectionID{9, 9}, brokerwire.WithGeneration(3)) | |||
| 47 | + | var committed ports.BrokerSnapshot | |||
| 48 | + | found := false | |||
| 49 | + | for index, part := range parts { | |||
| 50 | + | require.Equal(t, uint32(index), part.Index) | |||
| 51 | + | require.Equal(t, brokerwire.SubscriptionGeneration(3), part.Generation) | |||
| 52 | + | done, assembled, err := assembler.Add(part) | |||
| 53 | + | require.NoError(t, err, "part %d", index) | |||
| 54 | + | if done { | |||
| 55 | + | committed = assembled | |||
| 56 | + | found = true | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | require.True(t, found, "the transfer must commit") | |||
| 60 | + | require.Equal(t, snapshot.Revision, committed.Revision) | |||
| 61 | + | require.Len(t, committed.Hosts, 2) | |||
| 62 | + | require.Len(t, committed.Hosts[0].Sessions, 2) | |||
| 63 | + | for _, host := range committed.Hosts { | |||
| 64 | + | for _, session := range host.Sessions { | |||
| 65 | + | require.Equal(t, []catalogue.RemoteCatalogTab{}, session.Tabs) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | require.Len(t, committed.Removed, 1) | |||
| 69 | + | require.Equal(t, "retired@old:22", committed.Removed[0].Endpoint) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // TestSnapshotPartsRefusesOverBoundInventory proves the emitter fails closed on | |||
| 73 | + | // an inventory the peer's assembler would refuse, instead of silently sending a | |||
| 74 | + | // transfer that can never commit. | |||
| 75 | + | func TestSnapshotPartsRefusesOverBoundInventory(t *testing.T) { | |||
| 76 | + | snapshot := testSnapshot(1, 1) | |||
| 77 | + | over := make([]catalogue.RemoteCatalogSession, ports.BrokerMaxSessionsPerHost+1) | |||
| 78 | + | snapshot.Hosts[0].Sessions = over | |||
| 79 | + | _, err := snapshotParts(snapshot, 1, ports.BrokerConnectionID{1}, 1) | |||
| 80 | + | require.Error(t, err) | |||
| 81 | + | ||||
| 82 | + | snapshot = testSnapshot(0, 1) | |||
| 83 | + | _, err = snapshotParts(snapshot, 0, ports.BrokerConnectionID{1}, 1) | |||
| 84 | + | require.ErrorIs(t, err, ErrScopeMismatch) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // TestSnapshotPartsRequiresRevision proves a snapshot without a publication | |||
| 88 | + | // revision is refused rather than published as revision zero. | |||
| 89 | + | func TestSnapshotPartsRequiresRevision(t *testing.T) { | |||
| 90 | + | snapshot := testSnapshot(1, 1) | |||
| 91 | + | snapshot.Revision = 0 | |||
| 92 | + | _, err := snapshotParts(snapshot, 1, ports.BrokerConnectionID{1}, 1) | |||
| 93 | + | require.Error(t, err) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // TestConfigDefaultsAndValidation proves the zero configuration is valid and an | |||
| 97 | + | // unusable explicit bound is refused. | |||
| 98 | + | func TestConfigDefaultsAndValidation(t *testing.T) { | |||
| 99 | + | cfg := Config{}.withDefaults() | |||
| 100 | + | require.NoError(t, cfg.validate()) | |||
| 101 | + | require.Equal(t, DefaultMaxClients, cfg.MaxClients) | |||
| 102 | + | require.Equal(t, DefaultStreamInboundChunks, cfg.StreamInboundChunks) | |||
| 103 | + | require.Equal(t, uint64(DefaultStreamInboundBytes), cfg.StreamInboundBytes) | |||
| 104 | + | require.Equal(t, brokerwire.MaxPendingOperations, cfg.MaxPendingOperations) | |||
| 105 | + | ||||
| 106 | + | require.ErrorIs(t, (Config{StreamInboundBytes: 1}).withDefaults().validate(), ErrConfig) | |||
| 107 | + | require.ErrorIs(t, (Config{MaxPendingOperations: brokerwire.MaxPendingOperations + 1}).withDefaults().validate(), ErrConfig) | |||
| 108 | + | // A negative explicit bound is replaced by its default rather than refused. | |||
| 109 | + | require.NoError(t, (Config{MaxClients: -1, StreamInboundChunks: -1}).withDefaults().validate()) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // TestStreamPipeBoundsAndClose proves the per-stream byte channel is bounded, | |||
| 113 | + | // reports backpressure instead of growing, and releases a parked reader on | |||
| 114 | + | // close. | |||
| 115 | + | func TestStreamPipeBoundsAndClose(t *testing.T) { | |||
| 116 | + | pipe := newStreamPipe(2, 4096, func([]byte) error { return nil }) | |||
| 117 | + | require.NoError(t, pipe.deliver([]byte("one"))) | |||
| 118 | + | require.NoError(t, pipe.deliver([]byte("two"))) | |||
| 119 | + | require.ErrorIs(t, pipe.deliver([]byte("three")), ErrStreamBackpressure) | |||
| 120 | + | ||||
| 121 | + | buf := make([]byte, 3) | |||
| 122 | + | n, err := pipe.Read(buf) | |||
| 123 | + | require.NoError(t, err) | |||
| 124 | + | require.Equal(t, "one", string(buf[:n])) | |||
| 125 | + | require.NoError(t, pipe.deliver([]byte("three"))) | |||
| 126 | + | ||||
| 127 | + | require.NoError(t, pipe.Close()) | |||
| 128 | + | require.ErrorIs(t, pipe.deliver([]byte("four")), ErrStreamGone) | |||
| 129 | + | _, err = pipe.Read(buf) | |||
| 130 | + | require.ErrorIs(t, err, io.EOF) | |||
| 131 | + | ||||
| 132 | + | // A reader parked on an empty pipe is released by closeWith with its cause. | |||
| 133 | + | parked := newStreamPipe(1, 4096, func([]byte) error { return nil }) | |||
| 134 | + | done := make(chan error, 1) | |||
| 135 | + | go func() { | |||
| 136 | + | _, err := parked.Read(buf) | |||
| 137 | + | done <- err | |||
| 138 | + | }() | |||
| 139 | + | time.Sleep(20 * time.Millisecond) | |||
| 140 | + | parked.closeWith(ErrStreamBackpressure) | |||
| 141 | + | select { | |||
| 142 | + | case err := <-done: | |||
| 143 | + | require.ErrorIs(t, err, ErrStreamBackpressure) | |||
| 144 | + | case <-time.After(5 * time.Second): | |||
| 145 | + | t.Fatal("closeWith must release a parked reader") | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // TestStreamPipeOutboundError proves a failing outbound hook surfaces instead of | |||
| 150 | + | // silently dropping bytes. | |||
| 151 | + | func TestStreamPipeOutboundError(t *testing.T) { | |||
| 152 | + | pipe := newStreamPipe(1, 4096, func([]byte) error { return ErrStreamBackpressure }) | |||
| 153 | + | _, err := pipe.Write([]byte("payload")) | |||
| 154 | + | require.ErrorIs(t, err, ErrStreamBackpressure) | |||
| 155 | + | require.NoError(t, pipe.Close()) | |||
| 156 | + | _, err = pipe.Write([]byte("payload")) | |||
| 157 | + | require.ErrorIs(t, err, ErrStreamGone) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // TestErrorDetailMapping proves the wire detail and the local typed failure stay | |||
| 161 | + | // mutually consistent, including admission and context outcomes. | |||
| 162 | + | func TestErrorDetailMapping(t *testing.T) { | |||
| 163 | + | cases := []struct { | |||
| 164 | + | name string | |||
| 165 | + | err error | |||
| 166 | + | code ports.BrokerErrorCode | |||
| 167 | + | admission uint32 | |||
| 168 | + | }{ | |||
| 169 | + | {"typed", ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "no"}, ports.BrokerErrorIncompatible, 0}, | |||
| 170 | + | {"limit", ports.BrokerAdmissionLimit, ports.BrokerErrorUnavailable, 1}, | |||
| 171 | + | {"closed", ports.BrokerAdmissionClosed, ports.BrokerErrorUnavailable, 2}, | |||
| 172 | + | {"stale", ports.BrokerAdmissionStale, ports.BrokerErrorUnavailable, 3}, | |||
| 173 | + | {"invalid", ports.BrokerAdmissionInvalid, ports.BrokerErrorUnavailable, 4}, | |||
| 174 | + | {"deadline", context.DeadlineExceeded, ports.BrokerErrorTimeout, 0}, | |||
| 175 | + | {"cancelled", context.Canceled, ports.BrokerErrorCancelled, 0}, | |||
| 176 | + | {"unknown", errors.New("boom"), ports.BrokerErrorUnavailable, 0}, | |||
| 177 | + | } | |||
| 178 | + | for _, tc := range cases { | |||
| 179 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 180 | + | detail := errorDetail(tc.err) | |||
| 181 | + | require.Equal(t, tc.code, detail.Code) | |||
| 182 | + | require.Equal(t, tc.admission, detail.AdmissionCode) | |||
| 183 | + | // The wire encoder validates the detail exactly as a peer would. | |||
| 184 | + | _, err := brokerwire.EncodeServer(brokerwire.StreamClosed{ | |||
| 185 | + | Epoch: 1, Connection: ports.BrokerConnectionID{1}, Stream: 1, | |||
| 186 | + | Error: detail, HasError: detail != (brokerwire.ErrorDetail{}), | |||
| 187 | + | }, brokerwire.MaxBrokerEnvelopeBytes, brokerwire.MaxStreamChunkBytes) | |||
| 188 | + | require.NoError(t, err, "a produced detail must always be wire-valid") | |||
| 189 | + | }) | |||
| 190 | + | } | |||
| 191 | + | require.Equal(t, brokerwire.ErrorDetail{}, errorDetail(nil)) | |||
| 192 | + | require.NoError(t, failureFromDetail(brokerwire.ErrorDetail{}), "absent detail is an orderly outcome") | |||
| 193 | + | ||||
| 194 | + | scoped := failureFromDetail(errorDetail(ports.BrokerAdmissionLimit)) | |||
| 195 | + | require.ErrorIs(t, scoped, ports.BrokerAdmissionLimit) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // TestAdmissionErrorMapping proves connection-tracker refusals map onto the | |||
| 199 | + | // closed ports admission taxonomy. | |||
| 200 | + | func TestAdmissionErrorMapping(t *testing.T) { | |||
| 201 | + | require.NoError(t, admissionError(nil)) | |||
| 202 | + | require.ErrorIs(t, admissionError(brokerwire.ErrTooManyStreams), ports.BrokerAdmissionLimit) | |||
| 203 | + | require.ErrorIs(t, admissionError(brokerwire.ErrTooManyPendingOperations), ports.BrokerAdmissionLimit) | |||
| 204 | + | require.ErrorIs(t, admissionError(brokerwire.ErrConnectionClosed), ports.BrokerAdmissionClosed) | |||
| 205 | + | require.ErrorIs(t, admissionError(brokerwire.ErrStreamIDReused), ports.BrokerAdmissionStale) | |||
| 206 | + | require.ErrorIs(t, admissionError(brokerwire.ErrFutureStream), ports.BrokerAdmissionInvalid) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // TestScopeRequestFillsIdentity proves an absent identity is filled from the | |||
| 210 | + | // connection, a foreign one is refused as stale, and stream identities advance | |||
| 211 | + | // strictly. | |||
| 212 | + | func TestScopeRequestFillsIdentity(t *testing.T) { | |||
| 213 | + | c := &client{ | |||
| 214 | + | scope: brokerwire.Scope{Epoch: 4, Connection: ports.BrokerConnectionID{4}}, | |||
| 215 | + | ceilings: brokerwire.DefaultCeilings(), | |||
| 216 | + | pending: map[ports.BrokerOperationID]chan operationResult{}, | |||
| 217 | + | streams: map[ports.BrokerStreamID]*clientStream{}, | |||
| 218 | + | done: make(chan struct{}), | |||
| 219 | + | cfg: Config{}.withDefaults(), | |||
| 220 | + | transport: nil, | |||
| 221 | + | } | |||
| 222 | + | scoped, err := c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |||
| 223 | + | require.NoError(t, err) | |||
| 224 | + | require.Equal(t, ports.BrokerEpoch(4), scoped.Epoch) | |||
| 225 | + | require.Equal(t, ports.BrokerConnectionID{4}, scoped.Connection) | |||
| 226 | + | require.Equal(t, ports.BrokerStreamID(1), scoped.Stream) | |||
| 227 | + | ||||
| 228 | + | _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{ | |||
| 229 | + | Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), | |||
| 230 | + | Connection: ports.BrokerConnectionID{0x11}, | |||
| 231 | + | }) | |||
| 232 | + | require.ErrorIs(t, err, ports.BrokerAdmissionStale) | |||
| 233 | + | ||||
| 234 | + | _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{ | |||
| 235 | + | Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), Epoch: 99, | |||
| 236 | + | }) | |||
| 237 | + | require.ErrorIs(t, err, ErrScopeMismatch) | |||
| 238 | + | ||||
| 239 | + | scoped, err = c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 5, Policy: testPolicy()}) | |||
| 240 | + | require.NoError(t, err) | |||
| 241 | + | require.Equal(t, ports.BrokerStreamID(5), scoped.Stream) | |||
| 242 | + | _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 5, Policy: testPolicy()}) | |||
| 243 | + | require.ErrorIs(t, err, ports.BrokerAdmissionStale) | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // TestSocketPaths proves the endpoint name is distinct from the daemon socket | |||
| 247 | + | // and lives in the per-user runtime directory. | |||
| 248 | + | func TestSocketPaths(t *testing.T) { | |||
| 249 | + | require.Equal(t, "/run/x/vev/broker.sock", SocketPath("/run/x/vev")) | |||
| 250 | + | require.Equal(t, "broker.sock", SocketFileName) | |||
| 251 | + | require.NotEqual(t, "daemon.sock", SocketFileName) | |||
| 252 | + | require.Contains(t, DefaultSocketPath(), SocketFileName) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // TestPreambleCarriageIgnoresUnboundedTransport proves a carriage that cannot be | |||
| 256 | + | // bounded is refused rather than read without a bound. | |||
| 257 | + | func TestPreambleCarriageIgnoresUnboundedTransport(t *testing.T) { | |||
| 258 | + | _, err := recvPreamble(unboundedTransport{}) | |||
| 259 | + | require.ErrorIs(t, err, ErrConfig) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | // unboundedTransport implements only wire.Transport. | |||
| 263 | + | type unboundedTransport struct{} | |||
| 264 | + | ||||
| 265 | + | func (unboundedTransport) Send(wire.Envelope) error { return nil } | |||
| 266 | + | func (unboundedTransport) Recv() (wire.Envelope, error) { | |||
| 267 | + | return wire.Envelope{}, io.EOF | |||
| 268 | + | } | |||
| 269 | + | func (unboundedTransport) Close() error { return nil } | |||
| 270 | + | ||||
| 271 | + | // TestServerSessionRejectsInvalidConstruction proves construction refuses a nil | |||
| 272 | + | // carriage, a nil core, and a zero epoch instead of building a session that can | |||
| 273 | + | // never serve. | |||
| 274 | + | func TestServerSessionRejectsInvalidConstruction(t *testing.T) { | |||
| 275 | + | core := &fakeCore{id: ports.BrokerConnectionID{1}, hub: newSnapshotHub(ports.BrokerSnapshot{}), streams: map[ports.BrokerStreamID]*fakeLogicalConn{}, done: make(chan struct{})} | |||
| 276 | + | _, err := newServerSession(0, nil, brokerwire.DefaultCeilings(), core, Config{}, nil) | |||
| 277 | + | require.ErrorIs(t, err, ErrConfig) | |||
| 278 | + | _, err = newServerSession(1, nil, brokerwire.DefaultCeilings(), nil, Config{}, nil) | |||
| 279 | + | require.ErrorIs(t, err, ErrConfig) | |||
| 280 | + | require.NoError(t, brokerwire.DefaultCeilings().Validate()) | |||
| 281 | + | require.Error(t, brokerwire.Ceilings{}.Validate()) | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // TestOrderlyDisconnectClassification proves a peer disconnect and a local close | |||
| 285 | + | // are orderly ends while a protocol violation is not. | |||
| 286 | + | func TestOrderlyDisconnectClassification(t *testing.T) { | |||
| 287 | + | require.True(t, orderlyDisconnect(nil)) | |||
| 288 | + | require.True(t, orderlyDisconnect(io.EOF)) | |||
| 289 | + | require.True(t, orderlyDisconnect(ErrSessionClosed)) | |||
| 290 | + | require.True(t, orderlyDisconnect(ErrConnectionClosed)) | |||
| 291 | + | require.False(t, orderlyDisconnect(ErrMalformedFrame)) | |||
| 292 | + | require.False(t, orderlyDisconnect(ErrProtocol)) | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | // TestStreamBridgeRequiresConfig proves the bridge refuses missing participants | |||
| 296 | + | // rather than building a half-wired stream. | |||
| 297 | + | func TestStreamBridgeRequiresConfig(t *testing.T) { | |||
| 298 | + | _, err := newServerStream(nil, 1, nil) | |||
| 299 | + | require.ErrorIs(t, err, ErrConfig) | |||
| 300 | + | _, err = newClientStream(nil, 1) | |||
| 301 | + | require.ErrorIs(t, err, ErrConfig) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | // TestClientSubscriptionCoalesces proves the local subscription wakes once per | |||
| 305 | + | // publication and closes exactly once. | |||
| 306 | + | func TestClientSubscriptionCoalesces(t *testing.T) { | |||
| 307 | + | sub := newClientSubscription() | |||
| 308 | + | sub.notify() | |||
| 309 | + | sub.notify() | |||
| 310 | + | <-sub.Changed() | |||
| 311 | + | select { | |||
| 312 | + | case <-sub.Changed(): | |||
| 313 | + | t.Fatal("notifications must coalesce") | |||
| 314 | + | default: | |||
| 315 | + | } | |||
| 316 | + | var wg sync.WaitGroup | |||
| 317 | + | for i := 0; i < 4; i++ { | |||
| 318 | + | wg.Add(1) | |||
| 319 | + | go func() { defer wg.Done(); sub.Close() }() | |||
| 320 | + | } | |||
| 321 | + | wg.Wait() | |||
| 322 | + | _, open := <-sub.Changed() | |||
| 323 | + | require.False(t, open) | |||
| 324 | + | } | |||
A internal/adapters/brokeripc/listen_test.go +466 -0
| @@ -0,0 +1,466 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "net" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "sync" | |||
| 10 | + | "sync/atomic" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 17 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 18 | + | "github.com/bnema/vev/internal/ports" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // gatedAuthority blocks admission until a test releases it, so a listener Close | |||
| 22 | + | // can race an admission that has already reached the authority. | |||
| 23 | + | type gatedAuthority struct { | |||
| 24 | + | inner Authority | |||
| 25 | + | entered chan struct{} | |||
| 26 | + | gate chan struct{} | |||
| 27 | + | once sync.Once | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (a *gatedAuthority) AdmitClient(ctx context.Context) (ports.BrokerService, error) { | |||
| 31 | + | a.once.Do(func() { close(a.entered) }) | |||
| 32 | + | <-a.gate | |||
| 33 | + | return a.inner.AdmitClient(ctx) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // ctxAuthority blocks admission until its context is canceled, so a test can | |||
| 37 | + | // prove Listener.Close cancels an already-accepted client parked in admission | |||
| 38 | + | // instead of waiting out HandshakeTimeout. | |||
| 39 | + | type ctxAuthority struct { | |||
| 40 | + | entered chan struct{} | |||
| 41 | + | once sync.Once | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (a *ctxAuthority) AdmitClient(ctx context.Context) (ports.BrokerService, error) { | |||
| 45 | + | a.once.Do(func() { close(a.entered) }) | |||
| 46 | + | <-ctx.Done() | |||
| 47 | + | return nil, ctx.Err() | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // TestCloseRacingBlockedAdmissionDoesNotHang proves a Close that races a blocked | |||
| 51 | + | // admission never calls session.Close before the run loop starts, which would | |||
| 52 | + | // wait forever on an unstarted done channel and hang listener shutdown. | |||
| 53 | + | func TestCloseRacingBlockedAdmissionDoesNotHang(t *testing.T) { | |||
| 54 | + | const epoch = ports.BrokerEpoch(0x51) | |||
| 55 | + | path := testSocketPath(t) | |||
| 56 | + | gated := &gatedAuthority{ | |||
| 57 | + | inner: newFakeAuthority(epoch, ports.BrokerSnapshot{}), | |||
| 58 | + | entered: make(chan struct{}), | |||
| 59 | + | gate: make(chan struct{}), | |||
| 60 | + | } | |||
| 61 | + | bound, err := listen(path, epoch, gated, Config{MaxClients: 2, HandshakeTimeout: 30 * time.Second}, ipc.SameUserPeerVerifier()) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | ||||
| 64 | + | // Complete the preamble so the connection reaches admission, then leave it | |||
| 65 | + | // blocked there. | |||
| 66 | + | _ = rawDial(t, &endpoint{t: t, path: path, epoch: epoch}, brokerwire.DefaultCeilings()) | |||
| 67 | + | select { | |||
| 68 | + | case <-gated.entered: | |||
| 69 | + | case <-time.After(5 * time.Second): | |||
| 70 | + | t.Fatal("admission was never reached") | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | closed := make(chan error, 1) | |||
| 74 | + | go func() { closed <- bound.Close() }() | |||
| 75 | + | ||||
| 76 | + | // Release admission only once the listener is committed to closing, so the | |||
| 77 | + | // admission observes a closed listener before its run loop would start. | |||
| 78 | + | require.Eventually(t, func() bool { return bound.(*listener).isClosed() }, 5*time.Second, time.Millisecond) | |||
| 79 | + | close(gated.gate) | |||
| 80 | + | ||||
| 81 | + | select { | |||
| 82 | + | case err := <-closed: | |||
| 83 | + | require.NoError(t, err) | |||
| 84 | + | case <-time.After(5 * time.Second): | |||
| 85 | + | t.Fatal("Close must not wait on a session whose run loop never started") | |||
| 86 | + | } | |||
| 87 | + | require.Zero(t, bound.(*listener).liveSessions()) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // TestCloseCancelsStalledPreamble proves a client that connects but never sends | |||
| 91 | + | // its preamble is dropped by Listener.Close immediately: Close returns well | |||
| 92 | + | // before HandshakeTimeout, the accepted connection closes so blocked framing | |||
| 93 | + | // I/O unblocks, and the slot and admission goroutine are released. | |||
| 94 | + | func TestCloseCancelsStalledPreamble(t *testing.T) { | |||
| 95 | + | const epoch = ports.BrokerEpoch(0x51) | |||
| 96 | + | path := testSocketPath(t) | |||
| 97 | + | authority := newFakeAuthority(epoch, ports.BrokerSnapshot{}) | |||
| 98 | + | bound, err := Listen(path, epoch, authority, Config{MaxClients: 2, HandshakeTimeout: 30 * time.Second}) | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | l := bound.(*listener) | |||
| 101 | + | ||||
| 102 | + | // A raw client connects and never sends its preamble, so its accepted | |||
| 103 | + | // carriage is parked in framing I/O for the whole handshake budget. | |||
| 104 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 105 | + | defer cancel() | |||
| 106 | + | stalled, err := ipc.DialMuxContext(ctx, path) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | t.Cleanup(func() { _ = stalled.Close() }) | |||
| 109 | + | ||||
| 110 | + | require.Eventually(t, func() bool { return l.inFlight() == 1 }, 5*time.Second, time.Millisecond, | |||
| 111 | + | "the stalled connection must be accepted and parked before the close") | |||
| 112 | + | ||||
| 113 | + | closed := make(chan error, 1) | |||
| 114 | + | go func() { closed <- bound.Close() }() | |||
| 115 | + | select { | |||
| 116 | + | case err := <-closed: | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | case <-time.After(5 * time.Second): | |||
| 119 | + | t.Fatal("Close must not wait out HandshakeTimeout on a stalled preamble") | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // The accepted connection is closed, so the stalled client's framing read | |||
| 123 | + | // unblocks instead of hanging until the peer gives up. | |||
| 124 | + | readErr := make(chan error, 1) | |||
| 125 | + | go func() { | |||
| 126 | + | _, err := stalled.RecvBounded(brokerwire.BrokerPreambleLimit) | |||
| 127 | + | readErr <- err | |||
| 128 | + | }() | |||
| 129 | + | select { | |||
| 130 | + | case err := <-readErr: | |||
| 131 | + | require.Error(t, err, "the accepted connection must be closed") | |||
| 132 | + | case <-time.After(5 * time.Second): | |||
| 133 | + | t.Fatal("the stalled client's framing read must unblock on Close") | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | require.Zero(t, l.inFlight(), "no accepted carriage may stay pending") | |||
| 137 | + | require.Zero(t, l.liveSessions()) | |||
| 138 | + | require.Eventually(t, func() bool { return len(l.slots) == 0 }, 5*time.Second, time.Millisecond, | |||
| 139 | + | "the client slot must be released") | |||
| 140 | + | require.ErrorIs(t, l.refusal(), ErrListenerClosed, "a close-induced drop is ErrListenerClosed") | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // TestCloseCancelsClientStalledInAdmission proves Listener.Close cancels a | |||
| 144 | + | // client that completed its preamble and is parked in an authority honoring | |||
| 145 | + | // ctx, so Close returns promptly and the accepted carriage is closed. | |||
| 146 | + | func TestCloseCancelsClientStalledInAdmission(t *testing.T) { | |||
| 147 | + | const epoch = ports.BrokerEpoch(0x51) | |||
| 148 | + | path := testSocketPath(t) | |||
| 149 | + | stall := &ctxAuthority{entered: make(chan struct{})} | |||
| 150 | + | bound, err := Listen(path, epoch, stall, Config{MaxClients: 2, HandshakeTimeout: 30 * time.Second}) | |||
| 151 | + | require.NoError(t, err) | |||
| 152 | + | l := bound.(*listener) | |||
| 153 | + | ||||
| 154 | + | // Complete the preamble so the connection reaches admission, then leave the | |||
| 155 | + | // authority holding it until the listener is closed. | |||
| 156 | + | _ = rawDial(t, &endpoint{t: t, path: path, epoch: epoch}, brokerwire.DefaultCeilings()) | |||
| 157 | + | select { | |||
| 158 | + | case <-stall.entered: | |||
| 159 | + | case <-time.After(5 * time.Second): | |||
| 160 | + | t.Fatal("admission was never reached") | |||
| 161 | + | } | |||
| 162 | + | require.Equal(t, 1, l.inFlight()) | |||
| 163 | + | ||||
| 164 | + | closed := make(chan error, 1) | |||
| 165 | + | go func() { closed <- bound.Close() }() | |||
| 166 | + | select { | |||
| 167 | + | case err := <-closed: | |||
| 168 | + | require.NoError(t, err) | |||
| 169 | + | case <-time.After(5 * time.Second): | |||
| 170 | + | t.Fatal("Close must cancel an admission stalled in the authority") | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | require.Zero(t, l.inFlight(), "the canceled carriage must be released") | |||
| 174 | + | require.Zero(t, l.liveSessions()) | |||
| 175 | + | require.Eventually(t, func() bool { return len(l.slots) == 0 }, 5*time.Second, time.Millisecond, | |||
| 176 | + | "the client slot must be released") | |||
| 177 | + | require.ErrorIs(t, l.refusal(), ErrListenerClosed, "a close-induced drop is ErrListenerClosed") | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // TestStalledPreambleKeepsHandshakeTimeout proves a stalled preamble with the | |||
| 181 | + | // listener still open keeps its normal handshake-timeout classification rather | |||
| 182 | + | // than being reported as a listener close. | |||
| 183 | + | func TestStalledPreambleKeepsHandshakeTimeout(t *testing.T) { | |||
| 184 | + | const epoch = ports.BrokerEpoch(0x51) | |||
| 185 | + | path := testSocketPath(t) | |||
| 186 | + | authority := newFakeAuthority(epoch, ports.BrokerSnapshot{}) | |||
| 187 | + | bound, err := Listen(path, epoch, authority, Config{MaxClients: 2, HandshakeTimeout: 100 * time.Millisecond}) | |||
| 188 | + | require.NoError(t, err) | |||
| 189 | + | t.Cleanup(func() { _ = bound.Close() }) | |||
| 190 | + | l := bound.(*listener) | |||
| 191 | + | ||||
| 192 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 193 | + | defer cancel() | |||
| 194 | + | stalled, err := ipc.DialMuxContext(ctx, path) | |||
| 195 | + | require.NoError(t, err) | |||
| 196 | + | t.Cleanup(func() { _ = stalled.Close() }) | |||
| 197 | + | ||||
| 198 | + | require.Eventually(t, func() bool { return errors.Is(l.refusal(), context.DeadlineExceeded) }, 5*time.Second, 5*time.Millisecond, | |||
| 199 | + | "a stalled preamble must keep its handshake-timeout classification") | |||
| 200 | + | require.False(t, errors.Is(l.refusal(), ErrListenerClosed), "an open listener must not report a close") | |||
| 201 | + | require.Zero(t, l.inFlight()) | |||
| 202 | + | require.Zero(t, l.liveSessions()) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // TestAcceptTerminalCauseSurvivesInFlightRefusal proves the terminal carriage | |||
| 206 | + | // failure Accept reports is split from the diagnostic last refusal, so an | |||
| 207 | + | // in-flight refusal can never overwrite the cause that ended acceptance. | |||
| 208 | + | func TestAcceptTerminalCauseSurvivesInFlightRefusal(t *testing.T) { | |||
| 209 | + | fatal := errors.New("carriage accept failed") | |||
| 210 | + | refusal := ipc.ErrMuxPeerRejected | |||
| 211 | + | ||||
| 212 | + | t.Run("refusal after fatal", func(t *testing.T) { | |||
| 213 | + | l := &listener{fatal: make(chan struct{})} | |||
| 214 | + | l.failAccept(fatal) | |||
| 215 | + | l.confirmRefusal(refusal) | |||
| 216 | + | _, err := l.Accept() | |||
| 217 | + | require.ErrorIs(t, err, fatal) | |||
| 218 | + | require.ErrorIs(t, l.refusal(), refusal, "the diagnostic refusal is still recorded") | |||
| 219 | + | }) | |||
| 220 | + | ||||
| 221 | + | t.Run("fatal after refusal", func(t *testing.T) { | |||
| 222 | + | l := &listener{fatal: make(chan struct{})} | |||
| 223 | + | l.confirmRefusal(refusal) | |||
| 224 | + | l.failAccept(fatal) | |||
| 225 | + | _, err := l.Accept() | |||
| 226 | + | require.ErrorIs(t, err, fatal) | |||
| 227 | + | }) | |||
| 228 | + | ||||
| 229 | + | t.Run("concurrent order", func(t *testing.T) { | |||
| 230 | + | for i := 0; i < 100; i++ { | |||
| 231 | + | l := &listener{fatal: make(chan struct{})} | |||
| 232 | + | var wg sync.WaitGroup | |||
| 233 | + | wg.Add(2) | |||
| 234 | + | go func() { defer wg.Done(); l.failAccept(fatal) }() | |||
| 235 | + | go func() { defer wg.Done(); l.confirmRefusal(refusal) }() | |||
| 236 | + | wg.Wait() | |||
| 237 | + | _, err := l.Accept() | |||
| 238 | + | require.ErrorIs(t, err, fatal, "an in-flight refusal must never replace the terminal cause") | |||
| 239 | + | } | |||
| 240 | + | }) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // TestListenCreatesOwnerOnlyDirectoryAndSocket proves the endpoint is placed in | |||
| 244 | + | // an owner-only directory and that the bound socket itself is tightened, so the | |||
| 245 | + | // private carriage is never exposed through filesystem modes. | |||
| 246 | + | func TestListenCreatesOwnerOnlyDirectoryAndSocket(t *testing.T) { | |||
| 247 | + | path := testSocketPath(t) | |||
| 248 | + | authority := newFakeAuthority(1, ports.BrokerSnapshot{}) | |||
| 249 | + | listener, err := Listen(path, 1, authority, Config{}) | |||
| 250 | + | require.NoError(t, err) | |||
| 251 | + | t.Cleanup(func() { _ = listener.Close() }) | |||
| 252 | + | ||||
| 253 | + | dirInfo, err := os.Lstat(filepath.Dir(path)) | |||
| 254 | + | require.NoError(t, err) | |||
| 255 | + | require.True(t, dirInfo.IsDir()) | |||
| 256 | + | require.Equal(t, os.FileMode(0o700), dirInfo.Mode().Perm()) | |||
| 257 | + | ||||
| 258 | + | socketInfo, err := os.Lstat(path) | |||
| 259 | + | require.NoError(t, err) | |||
| 260 | + | require.NotZero(t, socketInfo.Mode()&os.ModeSocket, "endpoint must be a Unix socket") | |||
| 261 | + | require.Equal(t, os.FileMode(0o600), socketInfo.Mode().Perm()) | |||
| 262 | + | require.Equal(t, path, listener.Addr()) | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | // TestListenRecoversStaleSocket proves an endpoint left behind by a dead owner | |||
| 266 | + | // is recovered rather than refused. | |||
| 267 | + | func TestListenRecoversStaleSocket(t *testing.T) { | |||
| 268 | + | path := testSocketPath(t) | |||
| 269 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |||
| 270 | + | addr, err := net.ResolveUnixAddr("unix", path) | |||
| 271 | + | require.NoError(t, err) | |||
| 272 | + | stale, err := net.ListenUnix("unix", addr) | |||
| 273 | + | require.NoError(t, err) | |||
| 274 | + | stale.SetUnlinkOnClose(false) | |||
| 275 | + | require.NoError(t, stale.Close()) | |||
| 276 | + | _, err = os.Lstat(path) | |||
| 277 | + | require.NoError(t, err, "stale socket file must still exist") | |||
| 278 | + | ||||
| 279 | + | authority := newFakeAuthority(1, ports.BrokerSnapshot{}) | |||
| 280 | + | listener, err := Listen(path, 1, authority, Config{}) | |||
| 281 | + | require.NoError(t, err) | |||
| 282 | + | require.NoError(t, listener.Close()) | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // TestListenRefusesLiveOwner proves a listener never evicts a live endpoint. | |||
| 286 | + | func TestListenRefusesLiveOwner(t *testing.T) { | |||
| 287 | + | running := startEndpoint(t, Config{}) | |||
| 288 | + | authority := newFakeAuthority(1, ports.BrokerSnapshot{}) | |||
| 289 | + | _, err := Listen(running.path, 1, authority, Config{}) | |||
| 290 | + | require.ErrorIs(t, err, ipc.ErrDaemonRunning) | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // TestListenRefusesForeignPathWithoutRemovingIt proves a path that is not a | |||
| 294 | + | // socket is refused and never unlinked. | |||
| 295 | + | func TestListenRefusesForeignPathWithoutRemovingIt(t *testing.T) { | |||
| 296 | + | path := testSocketPath(t) | |||
| 297 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) | |||
| 298 | + | require.NoError(t, os.WriteFile(path, []byte("foreign"), 0o600)) | |||
| 299 | + | ||||
| 300 | + | authority := newFakeAuthority(1, ports.BrokerSnapshot{}) | |||
| 301 | + | _, err := Listen(path, 1, authority, Config{}) | |||
| 302 | + | require.ErrorIs(t, err, ipc.ErrMuxForeignPath) | |||
| 303 | + | content, readErr := os.ReadFile(path) | |||
| 304 | + | require.NoError(t, readErr) | |||
| 305 | + | require.Equal(t, "foreign", string(content)) | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // TestCloseRemovesOnlyItsOwnSocket proves Close unlinks the inode it created and | |||
| 309 | + | // preserves a replacement at the same path. | |||
| 310 | + | func TestCloseRemovesOnlyItsOwnSocket(t *testing.T) { | |||
| 311 | + | path := testSocketPath(t) | |||
| 312 | + | authority := newFakeAuthority(1, ports.BrokerSnapshot{}) | |||
| 313 | + | listener, err := Listen(path, 1, authority, Config{}) | |||
| 314 | + | require.NoError(t, err) | |||
| 315 | + | ||||
| 316 | + | require.NoError(t, os.Remove(path)) | |||
| 317 | + | require.NoError(t, os.WriteFile(path, []byte("replacement"), 0o600)) | |||
| 318 | + | require.NoError(t, listener.Close()) | |||
| 319 | + | ||||
| 320 | + | content, err := os.ReadFile(path) | |||
| 321 | + | require.NoError(t, err, "Close must preserve a path it did not create") | |||
| 322 | + | require.Equal(t, "replacement", string(content)) | |||
| 323 | + | ||||
| 324 | + | require.NoError(t, listener.Close(), "Close is idempotent") | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | // TestListenRejectsInvalidConfig proves a missing epoch or authority is refused. | |||
| 328 | + | func TestListenRejectsInvalidConfig(t *testing.T) { | |||
| 329 | + | path := testSocketPath(t) | |||
| 330 | + | authority := newFakeAuthority(1, ports.BrokerSnapshot{}) | |||
| 331 | + | _, err := Listen(path, 0, authority, Config{}) | |||
| 332 | + | require.ErrorIs(t, err, ErrConfig) | |||
| 333 | + | _, err = Listen(path, 1, nil, Config{}) | |||
| 334 | + | require.ErrorIs(t, err, ErrConfig) | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | // TestAcceptRefusesRejectedPeerAndKeepsServing proves a peer refused by the | |||
| 338 | + | // carriage is isolated: the refusal is recorded, no session is created, and the | |||
| 339 | + | // listener keeps admitting later same-user clients. | |||
| 340 | + | func TestAcceptRefusesRejectedPeerAndKeepsServing(t *testing.T) { | |||
| 341 | + | path := testSocketPath(t) | |||
| 342 | + | authority := newFakeAuthority(1, ports.BrokerSnapshot{}) | |||
| 343 | + | var calls atomic.Int32 | |||
| 344 | + | verify := func(conn *net.UnixConn) error { | |||
| 345 | + | if calls.Add(1) == 1 { | |||
| 346 | + | return ipc.ErrMuxPeerRejected | |||
| 347 | + | } | |||
| 348 | + | return ipc.SameUserPeerVerifier()(conn) | |||
| 349 | + | } | |||
| 350 | + | mux, err := listen(path, 1, authority, Config{}, verify) | |||
| 351 | + | require.NoError(t, err) | |||
| 352 | + | t.Cleanup(func() { _ = mux.Close() }) | |||
| 353 | + | ||||
| 354 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 355 | + | defer cancel() | |||
| 356 | + | refused, err := dial(ctx, path, Config{HandshakeTimeout: time.Second}, ipc.SameUserPeerVerifier()) | |||
| 357 | + | require.Error(t, err, "a refused peer must never be admitted") | |||
| 358 | + | require.Nil(t, refused) | |||
| 359 | + | ||||
| 360 | + | client, err := Dial(ctx, path, Config{HandshakeTimeout: time.Second}) | |||
| 361 | + | require.NoError(t, err, "the listener must keep serving after a refusal") | |||
| 362 | + | t.Cleanup(func() { _ = client.Close() }) | |||
| 363 | + | session, err := mux.Accept() | |||
| 364 | + | require.NoError(t, err) | |||
| 365 | + | require.Equal(t, client.ConnectionID(), session.ConnectionID()) | |||
| 366 | + | require.ErrorIs(t, mux.(*listener).refusal(), ipc.ErrMuxPeerRejected) | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | // TestDialRefusesRejectedPeer proves the client adapter fails closed when the | |||
| 370 | + | // carriage refuses the peer instead of admitting an unverified connection. | |||
| 371 | + | func TestDialRefusesRejectedPeer(t *testing.T) { | |||
| 372 | + | e := startEndpoint(t, Config{}) | |||
| 373 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 374 | + | defer cancel() | |||
| 375 | + | refusing := func(*net.UnixConn) error { return ipc.ErrMuxPeerRejected } | |||
| 376 | + | client, err := dial(ctx, e.path, Config{}, refusing) | |||
| 377 | + | require.ErrorIs(t, err, ipc.ErrMuxPeerRejected) | |||
| 378 | + | require.Nil(t, client) | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | // TestBoundedConcurrentClients proves the listener admits at most MaxClients | |||
| 382 | + | // concurrent connections and recovers a slot when a client disconnects. | |||
| 383 | + | func TestBoundedConcurrentClients(t *testing.T) { | |||
| 384 | + | e := startEndpoint(t, Config{MaxClients: 1, HandshakeTimeout: 300 * time.Millisecond}) | |||
| 385 | + | first, firstSession := e.pair() | |||
| 386 | + | ||||
| 387 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 388 | + | defer cancel() | |||
| 389 | + | _, err := Dial(ctx, e.path, Config{HandshakeTimeout: 250 * time.Millisecond}) | |||
| 390 | + | require.Error(t, err, "a second client must not be admitted past the bound") | |||
| 391 | + | ||||
| 392 | + | // Closing the accepted connection releases its slot, and the client slot is | |||
| 393 | + | // reusable for a fresh client. | |||
| 394 | + | require.NoError(t, first.Close()) | |||
| 395 | + | require.Eventually(t, func() bool { | |||
| 396 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 397 | + | defer cancel() | |||
| 398 | + | client, err := Dial(ctx, e.path, Config{HandshakeTimeout: 500 * time.Millisecond}) | |||
| 399 | + | if err != nil { | |||
| 400 | + | return false | |||
| 401 | + | } | |||
| 402 | + | _ = client.Close() | |||
| 403 | + | return true | |||
| 404 | + | }, 5*time.Second, 20*time.Millisecond) | |||
| 405 | + | require.NotEqual(t, ports.BrokerConnectionID{}, firstSession.ConnectionID()) | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | // TestStalledHandshakeDoesNotBlockOtherClients proves a peer that connects but | |||
| 409 | + | // never completes its preamble occupies only its own slot and goroutine: another | |||
| 410 | + | // client is admitted while it is still stalled. | |||
| 411 | + | func TestStalledHandshakeDoesNotBlockOtherClients(t *testing.T) { | |||
| 412 | + | e := startEndpoint(t, Config{MaxClients: 4, HandshakeTimeout: 400 * time.Millisecond}) | |||
| 413 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 414 | + | defer cancel() | |||
| 415 | + | stalled, err := ipc.DialMuxContext(ctx, e.path) | |||
| 416 | + | require.NoError(t, err) | |||
| 417 | + | t.Cleanup(func() { _ = stalled.Close() }) | |||
| 418 | + | ||||
| 419 | + | client, err := Dial(ctx, e.path, Config{HandshakeTimeout: 2 * time.Second}) | |||
| 420 | + | require.NoError(t, err) | |||
| 421 | + | t.Cleanup(func() { _ = client.Close() }) | |||
| 422 | + | session, err := e.listener.Accept() | |||
| 423 | + | require.NoError(t, err) | |||
| 424 | + | require.Equal(t, client.ConnectionID(), session.ConnectionID()) | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | // TestConcurrentDialAndClose proves concurrent clients may dial, register, run | |||
| 428 | + | // one operation, and close without corrupting listener or session state. | |||
| 429 | + | func TestConcurrentDialAndClose(t *testing.T) { | |||
| 430 | + | e := startEndpoint(t, Config{MaxClients: 8, HandshakeTimeout: 2 * time.Second}) | |||
| 431 | + | ||||
| 432 | + | sessions := make(chan ports.BrokerService, 8) | |||
| 433 | + | go func() { | |||
| 434 | + | defer close(sessions) | |||
| 435 | + | for i := 0; i < cap(sessions); i++ { | |||
| 436 | + | service, err := e.listener.Accept() | |||
| 437 | + | if err != nil { | |||
| 438 | + | return | |||
| 439 | + | } | |||
| 440 | + | sessions <- service | |||
| 441 | + | } | |||
| 442 | + | }() | |||
| 443 | + | ||||
| 444 | + | var wg sync.WaitGroup | |||
| 445 | + | for i := 0; i < 8; i++ { | |||
| 446 | + | wg.Add(1) | |||
| 447 | + | go func() { | |||
| 448 | + | defer wg.Done() | |||
| 449 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 450 | + | defer cancel() | |||
| 451 | + | client, err := Dial(ctx, e.path, Config{HandshakeTimeout: 2 * time.Second}) | |||
| 452 | + | if err != nil { | |||
| 453 | + | t.Errorf("Dial: %v", err) | |||
| 454 | + | return | |||
| 455 | + | } | |||
| 456 | + | if err := client.AddHost(ctx, "late@host:22"); err != nil { | |||
| 457 | + | t.Errorf("AddHost: %v", err) | |||
| 458 | + | } | |||
| 459 | + | _ = client.Close() | |||
| 460 | + | }() | |||
| 461 | + | } | |||
| 462 | + | wg.Wait() | |||
| 463 | + | for service := range sessions { | |||
| 464 | + | require.NoError(t, service.Close()) | |||
| 465 | + | } | |||
| 466 | + | } | |||
A internal/adapters/brokeripc/mutation.go +187 -0
| @@ -0,0 +1,187 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Snapshot publication and mutating operations (P3.3, server side). | |||
| 12 | + | // | |||
| 13 | + | // A Subscribe frame starts one publisher goroutine for exactly that connection | |||
| 14 | + | // and generation. The publisher waits on the admitted core service's coalescing | |||
| 15 | + | // subscription, reads the newest publication, and writes it as one multipart | |||
| 16 | + | // transfer. A slow client therefore blocks only its own publisher: the core | |||
| 17 | + | // subscription coalesces, so the next wake reads the newest snapshot rather than | |||
| 18 | + | // replaying a backlog, and no other connection is affected. Each generation owns | |||
| 19 | + | // its wake channel, so a superseded publisher can never consume the wake that | |||
| 20 | + | // was meant for its successor, and a resubscribe or resync always publishes the | |||
| 21 | + | // current snapshot for the generation that asked. | |||
| 22 | + | // | |||
| 23 | + | // Mutating operations run one goroutine each, bounded by the brokerwire | |||
| 24 | + | // per-connection pending-operation tracker, and complete exactly once with a | |||
| 25 | + | // typed result. The request context is the connection's, so shutdown cancels an | |||
| 26 | + | // operation that is still in flight. | |||
| 27 | + | ||||
| 28 | + | // publisher is one subscription generation's publication series: the core | |||
| 29 | + | // subscription it drains, the context that ends it, and the capacity-one wake | |||
| 30 | + | // channel that coalesces demands for it. The wake is owned by exactly one | |||
| 31 | + | // generation, so a superseded publisher can never drain a later generation's | |||
| 32 | + | // demand. | |||
| 33 | + | type publisher struct { | |||
| 34 | + | generation brokerwire.SubscriptionGeneration | |||
| 35 | + | sub ports.BrokerSubscription | |||
| 36 | + | cancel context.CancelFunc | |||
| 37 | + | wake chan struct{} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // retargetPublisher replaces any active publisher with one for the new | |||
| 41 | + | // generation and publishes the current snapshot immediately. A failed | |||
| 42 | + | // subscription ends the superseded series first, so no publisher survives to | |||
| 43 | + | // publish under a generation the connection tracker already retired. | |||
| 44 | + | func (s *serverSession) retargetPublisher(generation brokerwire.SubscriptionGeneration) error { | |||
| 45 | + | sub, err := s.core.Subscribe() | |||
| 46 | + | if err != nil { | |||
| 47 | + | s.stopPublisher() | |||
| 48 | + | detail := errorDetail(err) | |||
| 49 | + | return s.send(brokerwire.BrokerErrorMessage{Epoch: s.epoch, Connection: s.scope.Connection, Error: detail}) | |||
| 50 | + | } | |||
| 51 | + | s.stopPublisher() | |||
| 52 | + | ctx, cancel := context.WithCancel(s.ctx) | |||
| 53 | + | pub := &publisher{generation: generation, sub: sub, cancel: cancel, wake: make(chan struct{}, 1)} | |||
| 54 | + | s.subMu.Lock() | |||
| 55 | + | s.pub = pub | |||
| 56 | + | s.subMu.Unlock() | |||
| 57 | + | s.wg.Add(1) | |||
| 58 | + | go func() { | |||
| 59 | + | defer s.wg.Done() | |||
| 60 | + | defer sub.Close() | |||
| 61 | + | s.publishLoop(ctx, pub) | |||
| 62 | + | }() | |||
| 63 | + | s.wakePublisher() | |||
| 64 | + | return nil | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // publishLoop publishes the newest committed snapshot on every coalesced wake | |||
| 68 | + | // for one generation until it is superseded or the session ends. | |||
| 69 | + | func (s *serverSession) publishLoop(ctx context.Context, pub *publisher) { | |||
| 70 | + | for { | |||
| 71 | + | select { | |||
| 72 | + | case <-ctx.Done(): | |||
| 73 | + | return | |||
| 74 | + | case <-pub.wake: | |||
| 75 | + | case <-pub.sub.Changed(): | |||
| 76 | + | } | |||
| 77 | + | if err := s.publishSnapshot(pub.generation); err != nil { | |||
| 78 | + | // A publication failure is a carriage failure: settle the | |||
| 79 | + | // connection so its reader observes the terminal cause. | |||
| 80 | + | s.abort(err) | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // publishSnapshot writes one multipart transfer for the active generation. | |||
| 87 | + | func (s *serverSession) publishSnapshot(generation brokerwire.SubscriptionGeneration) error { | |||
| 88 | + | snapshot := s.core.Snapshot() | |||
| 89 | + | if snapshot.Epoch == 0 { | |||
| 90 | + | // The core has published nothing yet; there is no transfer to send. | |||
| 91 | + | return nil | |||
| 92 | + | } | |||
| 93 | + | if snapshot.Epoch != s.epoch { | |||
| 94 | + | // A publication from another broker incarnation can never be applied | |||
| 95 | + | // to this connection's scope. | |||
| 96 | + | return nil | |||
| 97 | + | } | |||
| 98 | + | parts, err := snapshotParts(snapshot, s.epoch, s.scope.Connection, generation) | |||
| 99 | + | if err != nil { | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 102 | + | total, err := snapshotTransferBytes(parts, s.ceilings.MaxReceiveEnvelopeBytes, s.ceilings.StreamChunkLimit) | |||
| 103 | + | if err != nil { | |||
| 104 | + | return err | |||
| 105 | + | } | |||
| 106 | + | if total > uint64(brokerwire.MaxSnapshotStagedBytes) { | |||
| 107 | + | return errors.New("brokeripc: snapshot publication exceeds the staged ceiling") | |||
| 108 | + | } | |||
| 109 | + | for _, part := range parts { | |||
| 110 | + | if err := s.send(part); err != nil { | |||
| 111 | + | return err | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | return nil | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // stopPublisher ends the active publication series, if any. The superseded | |||
| 118 | + | // publisher observes the canceled context and its closed subscription, and only | |||
| 119 | + | // its own wake channel could ever wake it, so it can never drain a demand meant | |||
| 120 | + | // for a later generation. | |||
| 121 | + | func (s *serverSession) stopPublisher() { | |||
| 122 | + | s.subMu.Lock() | |||
| 123 | + | pub := s.pub | |||
| 124 | + | s.pub = nil | |||
| 125 | + | s.subMu.Unlock() | |||
| 126 | + | if pub == nil { | |||
| 127 | + | return | |||
| 128 | + | } | |||
| 129 | + | pub.cancel() | |||
| 130 | + | pub.sub.Close() | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // wakePublisher coalesces one publication demand on the active generation's own | |||
| 134 | + | // wake channel. A demand raised while no publisher is active is dropped: the | |||
| 135 | + | // next subscription publishes the current snapshot as its first action. | |||
| 136 | + | func (s *serverSession) wakePublisher() { | |||
| 137 | + | s.subMu.Lock() | |||
| 138 | + | pub := s.pub | |||
| 139 | + | s.subMu.Unlock() | |||
| 140 | + | if pub == nil { | |||
| 141 | + | return | |||
| 142 | + | } | |||
| 143 | + | select { | |||
| 144 | + | case pub.wake <- struct{}{}: | |||
| 145 | + | default: | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // startMutation admits one mutating operation under the brokerwire pending | |||
| 150 | + | // bound and runs it in its own goroutine. A duplicate admission is dropped: | |||
| 151 | + | // exactly one completion ever travels for one operation identity, and the peer's | |||
| 152 | + | // own tracker already fences replays. | |||
| 153 | + | func (s *serverSession) startMutation(operation ports.BrokerOperationID, run func(context.Context) (bool, error)) error { | |||
| 154 | + | if err := s.conn.AdmitOperation(operation); err != nil { | |||
| 155 | + | switch { | |||
| 156 | + | case errors.Is(err, brokerwire.ErrOperationCompleted), errors.Is(err, brokerwire.ErrOperationPending): | |||
| 157 | + | return nil | |||
| 158 | + | case errors.Is(err, brokerwire.ErrTooManyPendingOperations): | |||
| 159 | + | detail := errorDetail(ports.BrokerAdmissionLimit) | |||
| 160 | + | return s.send(brokerwire.BrokerErrorMessage{Epoch: s.epoch, Connection: s.scope.Connection, Error: detail}) | |||
| 161 | + | default: | |||
| 162 | + | return errors.Join(ErrProtocol, err) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | s.wg.Add(1) | |||
| 166 | + | go func() { | |||
| 167 | + | defer s.wg.Done() | |||
| 168 | + | removed, err := run(s.ctx) | |||
| 169 | + | outcome := ports.BrokerOutcomeOK | |||
| 170 | + | if err != nil { | |||
| 171 | + | outcome = ports.BrokerOutcomeFailed | |||
| 172 | + | } | |||
| 173 | + | _ = s.conn.CompleteOperation(operation, outcome) | |||
| 174 | + | if s.ctx.Err() != nil { | |||
| 175 | + | return | |||
| 176 | + | } | |||
| 177 | + | detail := errorDetail(err) | |||
| 178 | + | if err := s.send(brokerwire.OperationResult{ | |||
| 179 | + | Epoch: s.epoch, Connection: s.scope.Connection, Operation: operation, | |||
| 180 | + | Outcome: outcome, Removed: removed, | |||
| 181 | + | Error: detail, HasError: detail != (brokerwire.ErrorDetail{}), | |||
| 182 | + | }); err != nil { | |||
| 183 | + | s.abort(err) | |||
| 184 | + | } | |||
| 185 | + | }() | |||
| 186 | + | return nil | |||
| 187 | + | } | |||
A internal/adapters/brokeripc/pipe.go +154 -0
| @@ -0,0 +1,154 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 5 | + | "sync" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // streamPipe is the private ordered, bounded byte channel between one broker | |||
| 9 | + | // wire session and the sessionwire framing of one logical stream. | |||
| 10 | + | // | |||
| 11 | + | // The wire reader never blocks on a slow consumer: a chunk the peer sent is | |||
| 12 | + | // appended to a bounded queue and refused with ErrStreamBackpressure once the | |||
| 13 | + | // bound is reached, so only that stream is settled and every sibling and the | |||
| 14 | + | // physical connection stay untouched. The consumer never blocks the writer: | |||
| 15 | + | // Read serves already-queued chunks or parks on the wake channel, and Close | |||
| 16 | + | // releases both ends promptly. Write hands each byte slice to the injected | |||
| 17 | + | // outbound hook, which owns chunking it under the negotiated stream chunk | |||
| 18 | + | // ceiling and enqueueing the frames on the connection. | |||
| 19 | + | type streamPipe struct { | |||
| 20 | + | mu sync.Mutex | |||
| 21 | + | queue [][]byte | |||
| 22 | + | queued int | |||
| 23 | + | closed bool | |||
| 24 | + | err error | |||
| 25 | + | maxChunk int | |||
| 26 | + | maxBytes int | |||
| 27 | + | ||||
| 28 | + | wake chan struct{} | |||
| 29 | + | closedCh chan struct{} | |||
| 30 | + | ||||
| 31 | + | outbound func([]byte) error | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // newStreamPipe builds one bounded stream carriage. outbound must not be nil. | |||
| 35 | + | func newStreamPipe(maxChunks int, maxBytes uint64, outbound func([]byte) error) *streamPipe { | |||
| 36 | + | if maxChunks <= 0 { | |||
| 37 | + | maxChunks = DefaultStreamInboundChunks | |||
| 38 | + | } | |||
| 39 | + | if maxBytes == 0 { | |||
| 40 | + | maxBytes = DefaultStreamInboundBytes | |||
| 41 | + | } | |||
| 42 | + | return &streamPipe{ | |||
| 43 | + | maxChunk: maxChunks, | |||
| 44 | + | maxBytes: int(maxBytes), | |||
| 45 | + | wake: make(chan struct{}, 1), | |||
| 46 | + | closedCh: make(chan struct{}), | |||
| 47 | + | outbound: outbound, | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Read serves the next queued chunk, blocking until one arrives or the pipe | |||
| 52 | + | // closes. A partial copy retains the unread remainder for the next call. | |||
| 53 | + | func (p *streamPipe) Read(b []byte) (int, error) { | |||
| 54 | + | if len(b) == 0 { | |||
| 55 | + | return 0, nil | |||
| 56 | + | } | |||
| 57 | + | for { | |||
| 58 | + | p.mu.Lock() | |||
| 59 | + | if len(p.queue) > 0 { | |||
| 60 | + | chunk := p.queue[0] | |||
| 61 | + | n := copy(b, chunk) | |||
| 62 | + | if n == len(chunk) { | |||
| 63 | + | p.queue[0] = nil | |||
| 64 | + | p.queue = p.queue[1:] | |||
| 65 | + | } else { | |||
| 66 | + | p.queue[0] = chunk[n:] | |||
| 67 | + | } | |||
| 68 | + | p.queued -= n | |||
| 69 | + | p.mu.Unlock() | |||
| 70 | + | return n, nil | |||
| 71 | + | } | |||
| 72 | + | if p.closed { | |||
| 73 | + | err := p.err | |||
| 74 | + | p.mu.Unlock() | |||
| 75 | + | if err != nil { | |||
| 76 | + | return 0, err | |||
| 77 | + | } | |||
| 78 | + | return 0, io.EOF | |||
| 79 | + | } | |||
| 80 | + | p.mu.Unlock() | |||
| 81 | + | select { | |||
| 82 | + | case <-p.wake: | |||
| 83 | + | case <-p.closedCh: | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // Write hands one byte slice to the outbound hook. It returns ErrClosed once the | |||
| 89 | + | // pipe reached its terminal outcome. | |||
| 90 | + | func (p *streamPipe) Write(b []byte) (int, error) { | |||
| 91 | + | p.mu.Lock() | |||
| 92 | + | closed := p.closed | |||
| 93 | + | p.mu.Unlock() | |||
| 94 | + | if closed { | |||
| 95 | + | return 0, ErrStreamGone | |||
| 96 | + | } | |||
| 97 | + | if err := p.outbound(b); err != nil { | |||
| 98 | + | return 0, err | |||
| 99 | + | } | |||
| 100 | + | return len(b), nil | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // deliver accepts one inbound chunk from the connection reader. It returns | |||
| 104 | + | // ErrStreamBackpressure when the bound is reached and ErrStreamGone once the | |||
| 105 | + | // pipe is closed; the caller settles the affected stream in both cases. | |||
| 106 | + | func (p *streamPipe) deliver(chunk []byte) error { | |||
| 107 | + | if len(chunk) == 0 { | |||
| 108 | + | return nil | |||
| 109 | + | } | |||
| 110 | + | p.mu.Lock() | |||
| 111 | + | if p.closed { | |||
| 112 | + | p.mu.Unlock() | |||
| 113 | + | return ErrStreamGone | |||
| 114 | + | } | |||
| 115 | + | if len(p.queue) >= p.maxChunk || p.queued+len(chunk) > p.maxBytes { | |||
| 116 | + | p.mu.Unlock() | |||
| 117 | + | return ErrStreamBackpressure | |||
| 118 | + | } | |||
| 119 | + | owned := append([]byte(nil), chunk...) | |||
| 120 | + | p.queue = append(p.queue, owned) | |||
| 121 | + | p.queued += len(owned) | |||
| 122 | + | p.mu.Unlock() | |||
| 123 | + | select { | |||
| 124 | + | case p.wake <- struct{}{}: | |||
| 125 | + | default: | |||
| 126 | + | } | |||
| 127 | + | return nil | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // closeWith settles the pipe exactly once with cause. A nil cause is an orderly | |||
| 131 | + | // close; every blocked Read observes it promptly. | |||
| 132 | + | func (p *streamPipe) closeWith(cause error) { | |||
| 133 | + | p.mu.Lock() | |||
| 134 | + | if p.closed { | |||
| 135 | + | p.mu.Unlock() | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 138 | + | p.closed = true | |||
| 139 | + | p.err = cause | |||
| 140 | + | p.queue = nil | |||
| 141 | + | p.queued = 0 | |||
| 142 | + | p.mu.Unlock() | |||
| 143 | + | close(p.closedCh) | |||
| 144 | + | select { | |||
| 145 | + | case p.wake <- struct{}{}: | |||
| 146 | + | default: | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // Close settles the pipe as an orderly close. | |||
| 151 | + | func (p *streamPipe) Close() error { | |||
| 152 | + | p.closeWith(nil) | |||
| 153 | + | return nil | |||
| 154 | + | } | |||
A internal/adapters/brokeripc/preamble.go +164 -0
| @@ -0,0 +1,164 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | ||||
| 8 | + | "google.golang.org/protobuf/proto" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 11 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Broker preamble exchange (P3.3). | |||
| 15 | + | // | |||
| 16 | + | // The first client frame on a broker connection is always the broker | |||
| 17 | + | // PreambleRequest (role 3) and the first server frame is always the broker | |||
| 18 | + | // PreambleResponse (role 4). Both are built, validated, and refusal-coded by | |||
| 19 | + | // brokerwire: this file only moves one bounded preamble envelope in each | |||
| 20 | + | // direction over the carriage and returns the negotiated effective ceilings. | |||
| 21 | + | // | |||
| 22 | + | // The preamble envelope is bounded to brokerwire.BrokerPreambleLimit (4 KiB) | |||
| 23 | + | // before allocation, strict-scanned with wire.ScanEnvelope before generated | |||
| 24 | + | // unmarshal, and is never restarted: the caller's context (the configured | |||
| 25 | + | // handshake timeout) bounds the whole exchange. | |||
| 26 | + | ||||
| 27 | + | // runClientPreamble sends the local broker PreambleRequest offer and accepts | |||
| 28 | + | // exactly one PreambleResponse. It returns the effective negotiated ceilings. | |||
| 29 | + | func runClientPreamble(ctx context.Context, transport wire.Transport, offer brokerwire.Ceilings) (brokerwire.Ceilings, error) { | |||
| 30 | + | if err := offer.Validate(); err != nil { | |||
| 31 | + | return brokerwire.Ceilings{}, errors.Join(ErrConfig, err) | |||
| 32 | + | } | |||
| 33 | + | type result struct { | |||
| 34 | + | ceilings brokerwire.Ceilings | |||
| 35 | + | err error | |||
| 36 | + | } | |||
| 37 | + | done := make(chan result, 1) | |||
| 38 | + | go func() { | |||
| 39 | + | request := brokerwire.EncodePreambleRequest(offer) | |||
| 40 | + | raw, err := proto.Marshal(request) | |||
| 41 | + | if err != nil { | |||
| 42 | + | done <- result{err: err} | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | if err := transport.Send(wire.Envelope{Payload: raw}); err != nil { | |||
| 46 | + | done <- result{err: transportFailure(err)} | |||
| 47 | + | return | |||
| 48 | + | } | |||
| 49 | + | payload, err := recvPreamble(transport) | |||
| 50 | + | if err != nil { | |||
| 51 | + | done <- result{err: err} | |||
| 52 | + | return | |||
| 53 | + | } | |||
| 54 | + | var response wire.PreambleResponse | |||
| 55 | + | if err := wire.ScanEnvelope(&response, payload); err != nil { | |||
| 56 | + | done <- result{err: errors.Join(ErrMalformedFrame, err)} | |||
| 57 | + | return | |||
| 58 | + | } | |||
| 59 | + | if err := (proto.UnmarshalOptions{DiscardUnknown: false}).Unmarshal(payload, &response); err != nil { | |||
| 60 | + | done <- result{err: errors.Join(ErrMalformedFrame, err)} | |||
| 61 | + | return | |||
| 62 | + | } | |||
| 63 | + | accepted, err := brokerwire.DecodePreambleResponse(&response) | |||
| 64 | + | if err != nil { | |||
| 65 | + | done <- result{err: err} | |||
| 66 | + | return | |||
| 67 | + | } | |||
| 68 | + | done <- result{ceilings: brokerwire.EffectiveCeilings(offer, accepted.Ceilings)} | |||
| 69 | + | }() | |||
| 70 | + | select { | |||
| 71 | + | case <-ctx.Done(): | |||
| 72 | + | return brokerwire.Ceilings{}, ctx.Err() | |||
| 73 | + | case outcome := <-done: | |||
| 74 | + | return outcome.ceilings, outcome.err | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // runServerPreamble accepts exactly one broker PreambleRequest, answers it with | |||
| 79 | + | // acceptance or the precise brokerwire refusal code, and returns the effective | |||
| 80 | + | // negotiated ceilings. A refused request is answered before the carriage is | |||
| 81 | + | // closed by the caller, so the client sees the typed refusal rather than a | |||
| 82 | + | // bare disconnect. | |||
| 83 | + | func runServerPreamble(ctx context.Context, transport wire.Transport, offer brokerwire.Ceilings) (brokerwire.Ceilings, error) { | |||
| 84 | + | if err := offer.Validate(); err != nil { | |||
| 85 | + | return brokerwire.Ceilings{}, errors.Join(ErrConfig, err) | |||
| 86 | + | } | |||
| 87 | + | type result struct { | |||
| 88 | + | ceilings brokerwire.Ceilings | |||
| 89 | + | err error | |||
| 90 | + | } | |||
| 91 | + | done := make(chan result, 1) | |||
| 92 | + | go func() { | |||
| 93 | + | payload, err := recvPreamble(transport) | |||
| 94 | + | if err != nil { | |||
| 95 | + | done <- result{err: err} | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | var request wire.PreambleRequest | |||
| 99 | + | if err := wire.ScanEnvelope(&request, payload); err != nil { | |||
| 100 | + | _ = sendPreambleResponse(transport, brokerwire.EncodePreambleResponse(false, offer, brokerwire.RejectionCodeFor(nil, err))) | |||
| 101 | + | done <- result{err: errors.Join(ErrMalformedFrame, err)} | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | if err := (proto.UnmarshalOptions{DiscardUnknown: false}).Unmarshal(payload, &request); err != nil { | |||
| 105 | + | _ = sendPreambleResponse(transport, brokerwire.EncodePreambleResponse(false, offer, brokerwire.RejectionCodeFor(nil, err))) | |||
| 106 | + | done <- result{err: errors.Join(ErrMalformedFrame, err)} | |||
| 107 | + | return | |||
| 108 | + | } | |||
| 109 | + | accepted, err := brokerwire.DecodePreambleRequest(&request) | |||
| 110 | + | if err != nil { | |||
| 111 | + | _ = sendPreambleResponse(transport, brokerwire.EncodePreambleResponse(false, offer, brokerwire.RejectionCodeFor(&request, err))) | |||
| 112 | + | done <- result{err: err} | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | effective := brokerwire.EffectiveCeilings(offer, accepted.Ceilings) | |||
| 116 | + | if err := sendPreambleResponse(transport, brokerwire.EncodePreambleResponse(true, effective, 0)); err != nil { | |||
| 117 | + | done <- result{err: transportFailure(err)} | |||
| 118 | + | return | |||
| 119 | + | } | |||
| 120 | + | done <- result{ceilings: effective} | |||
| 121 | + | }() | |||
| 122 | + | select { | |||
| 123 | + | case <-ctx.Done(): | |||
| 124 | + | return brokerwire.Ceilings{}, ctx.Err() | |||
| 125 | + | case outcome := <-done: | |||
| 126 | + | return outcome.ceilings, outcome.err | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // recvPreamble reads exactly one length-delimited preamble envelope, enforcing | |||
| 131 | + | // the 4 KiB broker preamble bound against the length prefix before any body | |||
| 132 | + | // allocation and before any scan or unmarshal. | |||
| 133 | + | func recvPreamble(transport wire.Transport) ([]byte, error) { | |||
| 134 | + | bounded, ok := transport.(wire.BoundedTransport) | |||
| 135 | + | if !ok { | |||
| 136 | + | return nil, errors.Join(ErrConfig, errors.New("brokeripc: carriage is not bounded")) | |||
| 137 | + | } | |||
| 138 | + | envelope, err := bounded.RecvBounded(brokerwire.BrokerPreambleLimit) | |||
| 139 | + | if err != nil { | |||
| 140 | + | if errors.Is(err, io.EOF) { | |||
| 141 | + | return nil, transportFailure(err) | |||
| 142 | + | } | |||
| 143 | + | if errors.Is(err, wire.ErrScanLength) { | |||
| 144 | + | return nil, errors.Join(ErrMalformedFrame, err) | |||
| 145 | + | } | |||
| 146 | + | return nil, transportFailure(err) | |||
| 147 | + | } | |||
| 148 | + | if err := brokerwire.CheckPreambleSize(envelope.Payload); err != nil { | |||
| 149 | + | return nil, errors.Join(ErrMalformedFrame, err) | |||
| 150 | + | } | |||
| 151 | + | return envelope.Payload, nil | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // sendPreambleResponse writes one already-validated broker preamble response. | |||
| 155 | + | func sendPreambleResponse(transport wire.Transport, response *wire.PreambleResponse) error { | |||
| 156 | + | raw, err := proto.Marshal(response) | |||
| 157 | + | if err != nil { | |||
| 158 | + | return err | |||
| 159 | + | } | |||
| 160 | + | if err := brokerwire.CheckPreambleSize(raw); err != nil { | |||
| 161 | + | return err | |||
| 162 | + | } | |||
| 163 | + | return transport.Send(wire.Envelope{Payload: raw}) | |||
| 164 | + | } | |||
A internal/adapters/brokeripc/protocol_test.go +281 -0
| @@ -0,0 +1,281 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "google.golang.org/protobuf/proto" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 13 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // rawCarriage is one raw broker carriage a test drives frame by frame. | |||
| 19 | + | type rawCarriage struct { | |||
| 20 | + | transport wire.BoundedTransport | |||
| 21 | + | ceilings brokerwire.Ceilings | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // rawDial opens one carriage and completes the broker preamble with an explicit | |||
| 25 | + | // offer. | |||
| 26 | + | func rawDial(t *testing.T, e *endpoint, offer brokerwire.Ceilings) *rawCarriage { | |||
| 27 | + | t.Helper() | |||
| 28 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 29 | + | defer cancel() | |||
| 30 | + | transport, err := ipc.DialMuxContext(ctx, e.path) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | t.Cleanup(func() { _ = transport.Close() }) | |||
| 33 | + | ceilings, err := runClientPreamble(ctx, transport, offer) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | return &rawCarriage{transport: transport, ceilings: ceilings} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // send writes one encoded client message. | |||
| 39 | + | func (r *rawCarriage) send(t *testing.T, message brokerwire.ClientMessage) { | |||
| 40 | + | t.Helper() | |||
| 41 | + | payload, err := brokerwire.EncodeClient(message, r.ceilings.MaxReceiveEnvelopeBytes, r.ceilings.StreamChunkLimit) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | require.NoError(t, r.transport.Send(wire.Envelope{Payload: payload})) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // recv reads and decodes one server message. | |||
| 47 | + | func (r *rawCarriage) recv(t *testing.T) brokerwire.ServerMessage { | |||
| 48 | + | t.Helper() | |||
| 49 | + | envelope, err := r.transport.RecvBounded(r.ceilings.MaxReceiveEnvelopeBytes) | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | message, err := brokerwire.DecodeServer(envelope.Payload, r.ceilings.MaxReceiveEnvelopeBytes, r.ceilings.StreamChunkLimit) | |||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | return message | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // register completes the registration exchange and returns the assigned scope. | |||
| 57 | + | func (r *rawCarriage) register(t *testing.T) brokerwire.Scope { | |||
| 58 | + | t.Helper() | |||
| 59 | + | r.send(t, brokerwire.Register{}) | |||
| 60 | + | message := r.recv(t) | |||
| 61 | + | registered, ok := message.(brokerwire.Registered) | |||
| 62 | + | require.True(t, ok, "the first server frame after Register must be Registered") | |||
| 63 | + | return brokerwire.Scope{Epoch: registered.Epoch, Connection: registered.Connection} | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // TestPreambleNegotiatesEffectiveCeilings proves the broker preamble negotiates | |||
| 67 | + | // the element-wise minima of both offers. | |||
| 68 | + | func TestPreambleNegotiatesEffectiveCeilings(t *testing.T) { | |||
| 69 | + | e := startEndpoint(t, Config{}) | |||
| 70 | + | offer := brokerwire.Ceilings{MaxReceiveEnvelopeBytes: 2 << 20, StreamChunkLimit: 4096} | |||
| 71 | + | raw := rawDial(t, e, offer) | |||
| 72 | + | require.Equal(t, offer, raw.ceilings) | |||
| 73 | + | require.Equal(t, e.epoch, raw.register(t).Epoch) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // TestOversizePreambleIsDropped proves a preamble whose length prefix exceeds the | |||
| 77 | + | // 4 KiB bound is refused before any body is read or allocated. | |||
| 78 | + | func TestOversizePreambleIsDropped(t *testing.T) { | |||
| 79 | + | e := startEndpoint(t, Config{}) | |||
| 80 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 81 | + | defer cancel() | |||
| 82 | + | transport, err := ipc.DialMuxContext(ctx, e.path) | |||
| 83 | + | require.NoError(t, err) | |||
| 84 | + | t.Cleanup(func() { _ = transport.Close() }) | |||
| 85 | + | ||||
| 86 | + | require.NoError(t, transport.Send(wire.Envelope{Payload: make([]byte, brokerwire.BrokerPreambleLimit+1)})) | |||
| 87 | + | _, err = transport.RecvBounded(brokerwire.BrokerPreambleLimit) | |||
| 88 | + | require.Error(t, err, "an oversize preamble must not be answered") | |||
| 89 | + | ||||
| 90 | + | // The listener keeps serving. | |||
| 91 | + | client, err := Dial(ctx, e.path, Config{HandshakeTimeout: time.Second}) | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | t.Cleanup(func() { _ = client.Close() }) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // TestMalformedPreambleIsRefusedWithTypedCode proves a preamble that fails strict | |||
| 97 | + | // scanning is answered with a typed refusal instead of being tolerated. | |||
| 98 | + | func TestMalformedPreambleIsRefusedWithTypedCode(t *testing.T) { | |||
| 99 | + | e := startEndpoint(t, Config{}) | |||
| 100 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 101 | + | defer cancel() | |||
| 102 | + | transport, err := ipc.DialMuxContext(ctx, e.path) | |||
| 103 | + | require.NoError(t, err) | |||
| 104 | + | t.Cleanup(func() { _ = transport.Close() }) | |||
| 105 | + | ||||
| 106 | + | // A field number that does not exist in the preamble shape is rejected by | |||
| 107 | + | // strict scanning before generated unmarshal. | |||
| 108 | + | require.NoError(t, transport.Send(wire.Envelope{Payload: []byte{0x78, 0x01}})) | |||
| 109 | + | envelope, err := transport.RecvBounded(brokerwire.BrokerPreambleLimit) | |||
| 110 | + | require.NoError(t, err) | |||
| 111 | + | response := &wire.PreambleResponse{} | |||
| 112 | + | require.NoError(t, (proto.UnmarshalOptions{DiscardUnknown: false}).Unmarshal(envelope.Payload, response)) | |||
| 113 | + | require.False(t, response.GetAccepted()) | |||
| 114 | + | require.Equal(t, brokerwire.RejectionLimitRefused, response.GetRejection().GetCode()) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // TestWrongDirectionFrameClosesConnection proves a server-direction envelope | |||
| 118 | + | // presented as a client frame settles that connection alone. | |||
| 119 | + | func TestWrongDirectionFrameClosesConnection(t *testing.T) { | |||
| 120 | + | e := startEndpoint(t, Config{}) | |||
| 121 | + | raw := rawDial(t, e, brokerwire.DefaultCeilings()) | |||
| 122 | + | raw.register(t) | |||
| 123 | + | ||||
| 124 | + | payload, err := brokerwire.EncodeServer(brokerwire.Registered{ | |||
| 125 | + | Epoch: e.epoch, Connection: ports.BrokerConnectionID{1}, | |||
| 126 | + | }, raw.ceilings.MaxReceiveEnvelopeBytes, raw.ceilings.StreamChunkLimit) | |||
| 127 | + | require.NoError(t, err) | |||
| 128 | + | require.NoError(t, raw.transport.Send(wire.Envelope{Payload: payload})) | |||
| 129 | + | ||||
| 130 | + | _, err = raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes) | |||
| 131 | + | require.Error(t, err, "a wrong-direction frame must settle the connection") | |||
| 132 | + | ||||
| 133 | + | // The listener keeps serving other clients. | |||
| 134 | + | client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second}) | |||
| 135 | + | require.NoError(t, err) | |||
| 136 | + | t.Cleanup(func() { _ = client.Close() }) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // TestMalformedApplicationFrameClosesConnection proves a malformed client | |||
| 140 | + | // envelope after registration settles only that connection. | |||
| 141 | + | func TestMalformedApplicationFrameClosesConnection(t *testing.T) { | |||
| 142 | + | e := startEndpoint(t, Config{}) | |||
| 143 | + | raw := rawDial(t, e, brokerwire.DefaultCeilings()) | |||
| 144 | + | raw.register(t) | |||
| 145 | + | ||||
| 146 | + | require.NoError(t, raw.transport.Send(wire.Envelope{Payload: []byte{0x78, 0x01}})) | |||
| 147 | + | _, err := raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes) | |||
| 148 | + | require.Error(t, err) | |||
| 149 | + | ||||
| 150 | + | client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second}) | |||
| 151 | + | require.NoError(t, err) | |||
| 152 | + | t.Cleanup(func() { _ = client.Close() }) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // TestNegotiatedChunkCeilingIsEnforced proves a stream chunk above the negotiated | |||
| 156 | + | // ceiling is refused rather than accepted. | |||
| 157 | + | func TestNegotiatedChunkCeilingIsEnforced(t *testing.T) { | |||
| 158 | + | e := startEndpoint(t, Config{}) | |||
| 159 | + | offer := brokerwire.Ceilings{MaxReceiveEnvelopeBytes: 2 << 20, StreamChunkLimit: 4096} | |||
| 160 | + | raw := rawDial(t, e, offer) | |||
| 161 | + | scope := raw.register(t) | |||
| 162 | + | ||||
| 163 | + | raw.send(t, brokerwire.OpenStream{ | |||
| 164 | + | Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1, | |||
| 165 | + | Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), | |||
| 166 | + | }) | |||
| 167 | + | opened, ok := raw.recv(t).(brokerwire.StreamOpened) | |||
| 168 | + | require.True(t, ok, "the stream must be established before chunk enforcement matters") | |||
| 169 | + | require.Equal(t, ports.BrokerStreamID(1), opened.Stream) | |||
| 170 | + | ||||
| 171 | + | // The brokerwire chunk codec refuses an over-ceiling chunk, so encoding it | |||
| 172 | + | // through the real path is impossible; a hostile peer instead sends a | |||
| 173 | + | // hand-built frame. The server must refuse it and keep the connection | |||
| 174 | + | // scope intact for legal frames. | |||
| 175 | + | over := brokerwire.ClientStreamData{ | |||
| 176 | + | Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1, | |||
| 177 | + | Data: make([]byte, int(offer.StreamChunkLimit)+1), | |||
| 178 | + | } | |||
| 179 | + | _, err := brokerwire.EncodeClient(over, offer.MaxReceiveEnvelopeBytes, offer.StreamChunkLimit) | |||
| 180 | + | require.ErrorIs(t, err, brokerwire.ErrTooLarge, "the codec must refuse an over-ceiling chunk") | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // TestScopeMismatchOpenStreamIsRefusedAsStale proves a stream open carrying a | |||
| 184 | + | // connection identity that is not the accepted one is refused as a stale | |||
| 185 | + | // admission rather than applied. | |||
| 186 | + | func TestScopeMismatchOpenStreamIsRefusedAsStale(t *testing.T) { | |||
| 187 | + | e := startEndpoint(t, Config{}) | |||
| 188 | + | raw := rawDial(t, e, brokerwire.DefaultCeilings()) | |||
| 189 | + | scope := raw.register(t) | |||
| 190 | + | ||||
| 191 | + | raw.send(t, brokerwire.OpenStream{ | |||
| 192 | + | Epoch: scope.Epoch, Connection: ports.BrokerConnectionID{0x7f}, | |||
| 193 | + | Stream: 1, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), | |||
| 194 | + | }) | |||
| 195 | + | closed, ok := raw.recv(t).(brokerwire.StreamClosed) | |||
| 196 | + | require.True(t, ok, "a mismatched scope must be answered with StreamClosed") | |||
| 197 | + | require.Equal(t, uint32(3), closed.Error.AdmissionCode) | |||
| 198 | + | require.ErrorIs(t, failureFromDetail(closed.Error), ports.BrokerAdmissionStale) | |||
| 199 | + | ||||
| 200 | + | // The connection still serves its own scope. | |||
| 201 | + | raw.send(t, brokerwire.OpenStream{ | |||
| 202 | + | Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1, | |||
| 203 | + | Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), | |||
| 204 | + | }) | |||
| 205 | + | _, ok = raw.recv(t).(brokerwire.StreamOpened) | |||
| 206 | + | require.True(t, ok) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // TestDuplicateRegisterClosesConnection proves a second Register is a protocol | |||
| 210 | + | // violation that settles the connection. | |||
| 211 | + | func TestDuplicateRegisterClosesConnection(t *testing.T) { | |||
| 212 | + | e := startEndpoint(t, Config{}) | |||
| 213 | + | raw := rawDial(t, e, brokerwire.DefaultCeilings()) | |||
| 214 | + | raw.register(t) | |||
| 215 | + | ||||
| 216 | + | raw.send(t, brokerwire.Register{}) | |||
| 217 | + | _, err := raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes) | |||
| 218 | + | require.Error(t, err) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // TestMismatchedScopeIsFenced proves a frame carrying a connection identity that | |||
| 222 | + | // is not the accepted one is fenced: it is neither applied nor answered, and the | |||
| 223 | + | // connection keeps serving its own scope. | |||
| 224 | + | func TestMismatchedScopeIsFenced(t *testing.T) { | |||
| 225 | + | e := startEndpoint(t, Config{}) | |||
| 226 | + | raw := rawDial(t, e, brokerwire.DefaultCeilings()) | |||
| 227 | + | raw.send(t, brokerwire.Subscribe{Epoch: e.epoch, Connection: ports.BrokerConnectionID{0x7f}, Generation: 1}) | |||
| 228 | + | ||||
| 229 | + | // Register still succeeds and its answer is the first server frame, so the | |||
| 230 | + | // fenced frame was neither applied nor answered. | |||
| 231 | + | scope := raw.register(t) | |||
| 232 | + | require.Equal(t, e.epoch, scope.Epoch) | |||
| 233 | + | require.False(t, scope.Connection.IsZero()) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // TestListenerCloseEndsSessions proves Close ends every accepted connection and | |||
| 237 | + | // unblocks a blocked Accept. | |||
| 238 | + | func TestListenerCloseEndsSessions(t *testing.T) { | |||
| 239 | + | e := startEndpoint(t, Config{}) | |||
| 240 | + | adapter, session := e.pair() | |||
| 241 | + | core := e.authority.last() | |||
| 242 | + | ||||
| 243 | + | require.NoError(t, e.listener.Close()) | |||
| 244 | + | select { | |||
| 245 | + | case <-core.done: | |||
| 246 | + | case <-time.After(5 * time.Second): | |||
| 247 | + | t.Fatal("Close must end the admitted core service") | |||
| 248 | + | } | |||
| 249 | + | select { | |||
| 250 | + | case <-adapter.(*client).done: | |||
| 251 | + | case <-time.After(5 * time.Second): | |||
| 252 | + | t.Fatal("the client connection must observe the closed carriage") | |||
| 253 | + | } | |||
| 254 | + | require.NotNil(t, session) | |||
| 255 | + | ||||
| 256 | + | _, err := e.listener.Accept() | |||
| 257 | + | require.ErrorIs(t, err, ErrListenerClosed) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // TestDialMissingEndpointFails proves a dial against a missing endpoint is a | |||
| 261 | + | // bounded failure and leaves nothing behind. | |||
| 262 | + | func TestDialMissingEndpointFails(t *testing.T) { | |||
| 263 | + | path := testSocketPath(t) | |||
| 264 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 265 | + | defer cancel() | |||
| 266 | + | client, err := Dial(ctx, path, Config{HandshakeTimeout: 200 * time.Millisecond}) | |||
| 267 | + | require.Error(t, err) | |||
| 268 | + | require.Nil(t, client) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // TestClosedListenerRejectsDial proves a closed endpoint refuses further clients. | |||
| 272 | + | func TestClosedListenerRejectsDial(t *testing.T) { | |||
| 273 | + | e := startEndpoint(t, Config{}) | |||
| 274 | + | require.NoError(t, e.listener.Close()) | |||
| 275 | + | ||||
| 276 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 277 | + | defer cancel() | |||
| 278 | + | client, err := Dial(ctx, e.path, Config{HandshakeTimeout: 200 * time.Millisecond}) | |||
| 279 | + | require.Error(t, err) | |||
| 280 | + | require.Nil(t, client) | |||
| 281 | + | } | |||
A internal/adapters/brokeripc/server.go +839 -0
| @@ -0,0 +1,839 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "sync" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 11 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Broker IPC listener and per-connection session (P3.3). | |||
| 17 | + | // | |||
| 18 | + | // The listener owns one per-user AF_UNIX endpoint through the P3.2 private IPC | |||
| 19 | + | // carriage: owner-only directory and 0600 socket, same-user kernel peer | |||
| 20 | + | // credentials on accept, race-safe stale-socket recovery, foreign-path refusal, | |||
| 21 | + | // and idempotent Close that unlinks only the socket inode it created. It caps | |||
| 22 | + | // concurrent accepted clients with one slot acquired before every accept, so | |||
| 23 | + | // the bound is enforced before a socket is admitted. | |||
| 24 | + | // | |||
| 25 | + | // Accept completes the broker preamble and admits the client to the broker core | |||
| 26 | + | // before it returns, so the returned ports.BrokerService knows the | |||
| 27 | + | // BrokerConnectionID assigned to exactly that connection. The session then | |||
| 28 | + | // runs the brokerwire conversation for that connection in its own goroutines: | |||
| 29 | + | // one reader, one snapshot publisher, one relay pair per logical stream, and | |||
| 30 | + | // one goroutine per admitted mutating operation. Every one of those is | |||
| 31 | + | // per-connection, so a slow or stalled client blocks only its own work. | |||
| 32 | + | ||||
| 33 | + | // Authority admits one accepted broker client to the broker core. The returned | |||
| 34 | + | // ports.BrokerService is the core's service for exactly that connection: its | |||
| 35 | + | // ConnectionID is the identity the session stamps on every frame, and Close | |||
| 36 | + | // releases the connection's core resources. P3.4 supplies the broker use case | |||
| 37 | + | // here; this adapter never constructs a broker itself. | |||
| 38 | + | // | |||
| 39 | + | // ctx bounds AdmitClient only. It carries the listener's handshake deadline and | |||
| 40 | + | // is canceled either as soon as admission returns or when the listener closes, | |||
| 41 | + | // so an implementation must not retain it or start work that outlives admission | |||
| 42 | + | // from it. Honoring ctx cancellation is what lets Listener.Close drop a client | |||
| 43 | + | // already parked in admission immediately instead of waiting out the handshake | |||
| 44 | + | // budget. The admitted service gets its own connection-lived context from the | |||
| 45 | + | // session. An authority that needs a longer-lived context must supply its own | |||
| 46 | + | // rather than keep this one. | |||
| 47 | + | type Authority interface { | |||
| 48 | + | AdmitClient(ctx context.Context) (ports.BrokerService, error) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Listen binds the private per-user broker endpoint at path and returns a | |||
| 52 | + | // ports.BrokerListener that admits same-user clients only. | |||
| 53 | + | // | |||
| 54 | + | // path must be an absolute, cleaned path inside an owner-only directory; the | |||
| 55 | + | // listener creates or validates that parent as 0700 and tightens the bound | |||
| 56 | + | // socket to 0600. A path that already exists and is not a socket is refused | |||
| 57 | + | // without being removed, a stale socket left by a dead owner is recovered | |||
| 58 | + | // race-safely, and a live owner is reported as ipc.ErrDaemonRunning instead of | |||
| 59 | + | // being evicted. | |||
| 60 | + | func Listen(path string, epoch ports.BrokerEpoch, authority Authority, cfg Config, opts ...ipc.Option) (ports.BrokerListener, error) { | |||
| 61 | + | return listen(path, epoch, authority, cfg, ipc.SameUserPeerVerifier(), opts...) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // listen is Listen with an injected peer verifier, so a test can observe a | |||
| 65 | + | // deterministic same-user refusal without root. A nil verifier falls back to | |||
| 66 | + | // the platform default. | |||
| 67 | + | func listen(path string, epoch ports.BrokerEpoch, authority Authority, cfg Config, verify ipc.PeerVerifier, opts ...ipc.Option) (ports.BrokerListener, error) { | |||
| 68 | + | cfg = cfg.withDefaults() | |||
| 69 | + | if err := cfg.validate(); err != nil { | |||
| 70 | + | return nil, err | |||
| 71 | + | } | |||
| 72 | + | if epoch == 0 || authority == nil { | |||
| 73 | + | return nil, ErrConfig | |||
| 74 | + | } | |||
| 75 | + | mux, err := ipc.ListenMuxWithPeerVerifier(path, verify, opts...) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return nil, err | |||
| 78 | + | } | |||
| 79 | + | l := &listener{ | |||
| 80 | + | epoch: epoch, | |||
| 81 | + | authority: authority, | |||
| 82 | + | cfg: cfg, | |||
| 83 | + | mux: mux, | |||
| 84 | + | slots: make(chan struct{}, cfg.MaxClients), | |||
| 85 | + | admitted: make(chan *serverSession, cfg.MaxClients), | |||
| 86 | + | done: make(chan struct{}), | |||
| 87 | + | fatal: make(chan struct{}), | |||
| 88 | + | sessions: make(map[*serverSession]struct{}), | |||
| 89 | + | pending: make(map[wire.BoundedTransport]struct{}), | |||
| 90 | + | } | |||
| 91 | + | l.ctx, l.cancel = context.WithCancel(context.Background()) | |||
| 92 | + | l.wg.Add(1) | |||
| 93 | + | go l.acceptLoop() | |||
| 94 | + | return l, nil | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // listener implements ports.BrokerListener over one bound private carriage. | |||
| 98 | + | // | |||
| 99 | + | // The listener owns its accept loop: each accepted carriage completes its | |||
| 100 | + | // preamble and broker admission in its own goroutine, bounded by one client slot | |||
| 101 | + | // acquired before the accept. A client that stalls during the preamble therefore | |||
| 102 | + | // occupies a slot and a goroutine, never the loop, so no stalled handshake can | |||
| 103 | + | // delay another client's admission. Admitted sessions are published through a | |||
| 104 | + | // bounded FIFO to Accept. | |||
| 105 | + | // | |||
| 106 | + | // The listener owns a listener-scoped context, canceled by Close, that bounds | |||
| 107 | + | // every in-flight preamble and admission. Each accepted carriage is also | |||
| 108 | + | // tracked until a running session owns it, so Close can close a carriage whose | |||
| 109 | + | // framing I/O would otherwise stay blocked. Together those make Close drop an | |||
| 110 | + | // already-accepted client immediately instead of waiting out HandshakeTimeout. | |||
| 111 | + | type listener struct { | |||
| 112 | + | epoch ports.BrokerEpoch | |||
| 113 | + | authority Authority | |||
| 114 | + | cfg Config | |||
| 115 | + | mux ipc.MuxListener | |||
| 116 | + | slots chan struct{} | |||
| 117 | + | admitted chan *serverSession | |||
| 118 | + | ||||
| 119 | + | ctx context.Context | |||
| 120 | + | cancel context.CancelFunc | |||
| 121 | + | ||||
| 122 | + | wg sync.WaitGroup | |||
| 123 | + | fatal chan struct{} | |||
| 124 | + | ||||
| 125 | + | mu sync.Mutex | |||
| 126 | + | closed bool | |||
| 127 | + | sessions map[*serverSession]struct{} | |||
| 128 | + | pending map[wire.BoundedTransport]struct{} | |||
| 129 | + | lastRefuse error | |||
| 130 | + | fatalCause error | |||
| 131 | + | fatalOnce sync.Once | |||
| 132 | + | closeOnce sync.Once | |||
| 133 | + | closeErr error | |||
| 134 | + | done chan struct{} | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | var _ ports.BrokerListener = (*listener)(nil) | |||
| 138 | + | ||||
| 139 | + | // acceptLoop accepts one verified same-user carriage at a time, bounded by the | |||
| 140 | + | // client slots, and hands each accepted carriage to its own admission goroutine. | |||
| 141 | + | // It stops when the listener closes or the carriage reports a terminal accept | |||
| 142 | + | // failure. | |||
| 143 | + | func (l *listener) acceptLoop() { | |||
| 144 | + | defer l.wg.Done() | |||
| 145 | + | for { | |||
| 146 | + | select { | |||
| 147 | + | case l.slots <- struct{}{}: | |||
| 148 | + | case <-l.done: | |||
| 149 | + | return | |||
| 150 | + | } | |||
| 151 | + | // The slot is released exactly once, whether admission fails here or the | |||
| 152 | + | // session reaches its terminal outcome later. | |||
| 153 | + | release := sync.OnceFunc(func() { <-l.slots }) | |||
| 154 | + | if l.isClosed() { | |||
| 155 | + | release() | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 158 | + | transport, err := l.mux.Accept() | |||
| 159 | + | if err != nil { | |||
| 160 | + | release() | |||
| 161 | + | if l.isClosed() { | |||
| 162 | + | return | |||
| 163 | + | } | |||
| 164 | + | if errors.Is(err, ipc.ErrMuxPeerRejected) { | |||
| 165 | + | // A refused peer is isolated to that connection: it is | |||
| 166 | + | // recorded for diagnostics and admission continues. | |||
| 167 | + | l.confirmRefusal(err) | |||
| 168 | + | continue | |||
| 169 | + | } | |||
| 170 | + | // Any other carriage failure stops admission deterministically; | |||
| 171 | + | // Accept reports it instead of spinning on a broken listener. | |||
| 172 | + | l.failAccept(err) | |||
| 173 | + | return | |||
| 174 | + | } | |||
| 175 | + | l.wg.Add(1) | |||
| 176 | + | go func() { | |||
| 177 | + | defer l.wg.Done() | |||
| 178 | + | l.admit(transport, release) | |||
| 179 | + | }() | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // failAccept records the one terminal accept failure and wakes every blocked | |||
| 184 | + | // Accept. The terminal cause is stored separately from the diagnostic last | |||
| 185 | + | // refusal, so an in-flight admission refusal can never overwrite the cause that | |||
| 186 | + | // ended acceptance. | |||
| 187 | + | func (l *listener) failAccept(err error) { | |||
| 188 | + | l.mu.Lock() | |||
| 189 | + | if l.fatalCause == nil { | |||
| 190 | + | l.fatalCause = err | |||
| 191 | + | } | |||
| 192 | + | l.mu.Unlock() | |||
| 193 | + | l.fatalOnce.Do(func() { | |||
| 194 | + | close(l.fatal) | |||
| 195 | + | }) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // fatalErr returns the terminal accept failure, if any. It is never the | |||
| 199 | + | // diagnostic last refusal. | |||
| 200 | + | func (l *listener) fatalErr() error { | |||
| 201 | + | l.mu.Lock() | |||
| 202 | + | defer l.mu.Unlock() | |||
| 203 | + | return l.fatalCause | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // confirmRefusal records the most recent refused or failed admission. It is | |||
| 207 | + | // diagnostic only: a refusal is isolated to the peer that caused it. | |||
| 208 | + | func (l *listener) confirmRefusal(err error) { | |||
| 209 | + | if err == nil { | |||
| 210 | + | return | |||
| 211 | + | } | |||
| 212 | + | l.mu.Lock() | |||
| 213 | + | l.lastRefuse = err | |||
| 214 | + | l.mu.Unlock() | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // refusal reports the most recent refused or failed admission. | |||
| 218 | + | func (l *listener) refusal() error { | |||
| 219 | + | l.mu.Lock() | |||
| 220 | + | defer l.mu.Unlock() | |||
| 221 | + | return l.lastRefuse | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // admit completes the preamble and broker admission for one accepted carriage | |||
| 225 | + | // and publishes the running session. Slot ownership transfers to the session on | |||
| 226 | + | // success. | |||
| 227 | + | func (l *listener) admit(transport wire.BoundedTransport, release func()) { | |||
| 228 | + | l.trackPending(transport) | |||
| 229 | + | session, err := l.startSession(transport, release) | |||
| 230 | + | l.untrackPending(transport) | |||
| 231 | + | if err != nil { | |||
| 232 | + | release() | |||
| 233 | + | l.confirmRefusal(err) | |||
| 234 | + | return | |||
| 235 | + | } | |||
| 236 | + | select { | |||
| 237 | + | case l.admitted <- session: | |||
| 238 | + | case <-l.done: | |||
| 239 | + | _ = session.Close() | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // trackPending records one accepted carriage that no running session owns yet, | |||
| 244 | + | // so Close can close it directly and unblock its framing I/O. | |||
| 245 | + | func (l *listener) trackPending(transport wire.BoundedTransport) { | |||
| 246 | + | l.mu.Lock() | |||
| 247 | + | l.pending[transport] = struct{}{} | |||
| 248 | + | l.mu.Unlock() | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // untrackPending drops one accepted carriage whose admission has finished. | |||
| 252 | + | func (l *listener) untrackPending(transport wire.BoundedTransport) { | |||
| 253 | + | l.mu.Lock() | |||
| 254 | + | delete(l.pending, transport) | |||
| 255 | + | l.mu.Unlock() | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // closePending closes every accepted carriage not yet owned by a running | |||
| 259 | + | // session, so a client stalled in framing or admission is disconnected at once | |||
| 260 | + | // rather than waiting out HandshakeTimeout. | |||
| 261 | + | func (l *listener) closePending() { | |||
| 262 | + | l.mu.Lock() | |||
| 263 | + | pending := make([]wire.BoundedTransport, 0, len(l.pending)) | |||
| 264 | + | for transport := range l.pending { | |||
| 265 | + | pending = append(pending, transport) | |||
| 266 | + | } | |||
| 267 | + | l.mu.Unlock() | |||
| 268 | + | for _, transport := range pending { | |||
| 269 | + | _ = transport.Close() | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // inFlight reports how many accepted carriages are still completing preamble or | |||
| 274 | + | // admission. | |||
| 275 | + | func (l *listener) inFlight() int { | |||
| 276 | + | l.mu.Lock() | |||
| 277 | + | defer l.mu.Unlock() | |||
| 278 | + | return len(l.pending) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // classifyAdmissionFailure maps one preamble or authority failure. Once the | |||
| 282 | + | // listener is closing, an accepted client that never reached admission was | |||
| 283 | + | // dropped by the close, so the failure is reported as ErrListenerClosed. A | |||
| 284 | + | // genuine handshake timeout with the listener still open keeps its | |||
| 285 | + | // context.DeadlineExceeded classification. | |||
| 286 | + | func (l *listener) classifyAdmissionFailure(err error) error { | |||
| 287 | + | if l.isClosed() { | |||
| 288 | + | return errors.Join(ErrListenerClosed, err) | |||
| 289 | + | } | |||
| 290 | + | return err | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // Accept returns the next fully admitted client session, in accept order, and | |||
| 294 | + | // blocks until one is available, the listener closes, or the carriage reports a | |||
| 295 | + | // terminal accept failure. The returned ports.BrokerService is bound to exactly | |||
| 296 | + | // the connection admitted at accept; Close on the listener ends it. | |||
| 297 | + | // | |||
| 298 | + | // A peer the carriage refuses (a non-same-user credential, an unreadable | |||
| 299 | + | // credential, or an unsupported build) or a client that fails the preamble or | |||
| 300 | + | // admission is isolated: that peer is dropped and the listener keeps serving. | |||
| 301 | + | // The most recent refusal is available to diagnostics through refusal. | |||
| 302 | + | func (l *listener) Accept() (ports.BrokerService, error) { | |||
| 303 | + | if l == nil { | |||
| 304 | + | return nil, ErrListenerClosed | |||
| 305 | + | } | |||
| 306 | + | select { | |||
| 307 | + | case session := <-l.admitted: | |||
| 308 | + | return session, nil | |||
| 309 | + | case <-l.fatal: | |||
| 310 | + | select { | |||
| 311 | + | case session := <-l.admitted: | |||
| 312 | + | return session, nil | |||
| 313 | + | default: | |||
| 314 | + | } | |||
| 315 | + | return nil, l.fatalErr() | |||
| 316 | + | default: | |||
| 317 | + | } | |||
| 318 | + | select { | |||
| 319 | + | case session := <-l.admitted: | |||
| 320 | + | return session, nil | |||
| 321 | + | case <-l.fatal: | |||
| 322 | + | select { | |||
| 323 | + | case session := <-l.admitted: | |||
| 324 | + | return session, nil | |||
| 325 | + | default: | |||
| 326 | + | } | |||
| 327 | + | return nil, l.fatalErr() | |||
| 328 | + | case <-l.done: | |||
| 329 | + | select { | |||
| 330 | + | case session := <-l.admitted: | |||
| 331 | + | return session, nil | |||
| 332 | + | default: | |||
| 333 | + | } | |||
| 334 | + | return nil, ErrListenerClosed | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | // startSession completes the preamble and admission for one accepted carriage | |||
| 339 | + | // and starts the session. The admission context is bounded to the handshake | |||
| 340 | + | // budget and is canceled as soon as admission returns or when the listener | |||
| 341 | + | // closes, so the admitted service must not retain it (see Authority) and | |||
| 342 | + | // Close can drop a client still in preamble or admission. Slot ownership | |||
| 343 | + | // transfers to the session on success. | |||
| 344 | + | func (l *listener) startSession(transport wire.BoundedTransport, release func()) (*serverSession, error) { | |||
| 345 | + | // The handshake budget is bounded by the listener-scoped context, so | |||
| 346 | + | // Listener.Close cancels a preamble or admission that is still waiting | |||
| 347 | + | // instead of leaving it to run out HandshakeTimeout. | |||
| 348 | + | ctx, cancel := context.WithTimeout(l.ctx, l.cfg.HandshakeTimeout) | |||
| 349 | + | defer cancel() | |||
| 350 | + | ceilings, err := runServerPreamble(ctx, transport, brokerwire.DefaultCeilings()) | |||
| 351 | + | if err != nil { | |||
| 352 | + | _ = transport.Close() | |||
| 353 | + | return nil, l.classifyAdmissionFailure(err) | |||
| 354 | + | } | |||
| 355 | + | core, err := l.authority.AdmitClient(ctx) | |||
| 356 | + | if err != nil { | |||
| 357 | + | _ = transport.Close() | |||
| 358 | + | return nil, l.classifyAdmissionFailure(err) | |||
| 359 | + | } | |||
| 360 | + | if core == nil { | |||
| 361 | + | _ = transport.Close() | |||
| 362 | + | return nil, fmt.Errorf("%w: authority admitted a nil service", ErrConfig) | |||
| 363 | + | } | |||
| 364 | + | session, err := newServerSession(l.epoch, transport, ceilings, core, l.cfg, release) | |||
| 365 | + | if err != nil { | |||
| 366 | + | _ = core.Close() | |||
| 367 | + | _ = transport.Close() | |||
| 368 | + | return nil, err | |||
| 369 | + | } | |||
| 370 | + | // The session deregisters itself once it reaches its terminal state, so a | |||
| 371 | + | // long-lived listener never accumulates retired connections. | |||
| 372 | + | session.onShutdown = func() { l.forgetSession(session) } | |||
| 373 | + | l.mu.Lock() | |||
| 374 | + | if l.closed { | |||
| 375 | + | l.mu.Unlock() | |||
| 376 | + | // The run loop was never started, so Close (which waits on the run | |||
| 377 | + | // loop's done channel) would wait forever. Tear the unstarted session | |||
| 378 | + | // down directly instead. | |||
| 379 | + | session.shutdown() | |||
| 380 | + | return nil, ErrListenerClosed | |||
| 381 | + | } | |||
| 382 | + | l.sessions[session] = struct{}{} | |||
| 383 | + | l.mu.Unlock() | |||
| 384 | + | go session.run() | |||
| 385 | + | return session, nil | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | // Close stops accepting, unblocks a blocked Accept, cancels every in-flight | |||
| 389 | + | // preamble and admission, closes every accepted carriage not yet owned by a | |||
| 390 | + | // running session, closes every live session (ending each connection and | |||
| 391 | + | // joining its workers), and unlinks only the socket inode this listener | |||
| 392 | + | // created. It is idempotent and concurrent-safe. | |||
| 393 | + | func (l *listener) Close() error { | |||
| 394 | + | l.closeOnce.Do(func() { | |||
| 395 | + | l.mu.Lock() | |||
| 396 | + | l.closed = true | |||
| 397 | + | l.mu.Unlock() | |||
| 398 | + | // Mark the listener closed before canceling, so an admission that wakes | |||
| 399 | + | // on the canceled context classifies its failure as ErrListenerClosed. | |||
| 400 | + | close(l.done) | |||
| 401 | + | l.cancel() | |||
| 402 | + | l.closeErr = l.mux.Close() | |||
| 403 | + | l.closePending() | |||
| 404 | + | // The accept loop exits on the closed carriage; every admission | |||
| 405 | + | // goroutine then finishes or drops its session because the listener is | |||
| 406 | + | // closed. | |||
| 407 | + | l.wg.Wait() | |||
| 408 | + | drained := make([]*serverSession, 0, cap(l.admitted)) | |||
| 409 | + | for { | |||
| 410 | + | select { | |||
| 411 | + | case session := <-l.admitted: | |||
| 412 | + | drained = append(drained, session) | |||
| 413 | + | continue | |||
| 414 | + | default: | |||
| 415 | + | } | |||
| 416 | + | break | |||
| 417 | + | } | |||
| 418 | + | l.mu.Lock() | |||
| 419 | + | for session := range l.sessions { | |||
| 420 | + | drained = append(drained, session) | |||
| 421 | + | } | |||
| 422 | + | l.sessions = make(map[*serverSession]struct{}) | |||
| 423 | + | l.mu.Unlock() | |||
| 424 | + | seen := make(map[*serverSession]struct{}, len(drained)) | |||
| 425 | + | for _, session := range drained { | |||
| 426 | + | if _, duplicate := seen[session]; duplicate { | |||
| 427 | + | continue | |||
| 428 | + | } | |||
| 429 | + | seen[session] = struct{}{} | |||
| 430 | + | if err := session.Close(); err != nil { | |||
| 431 | + | l.closeErr = errors.Join(l.closeErr, err) | |||
| 432 | + | } | |||
| 433 | + | } | |||
| 434 | + | }) | |||
| 435 | + | return l.closeErr | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | // Addr returns the bound endpoint path. | |||
| 439 | + | func (l *listener) Addr() string { | |||
| 440 | + | if l == nil || l.mux == nil { | |||
| 441 | + | return "" | |||
| 442 | + | } | |||
| 443 | + | return l.mux.Addr() | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | // forgetSession drops one retired session from the live set. | |||
| 447 | + | func (l *listener) forgetSession(session *serverSession) { | |||
| 448 | + | l.mu.Lock() | |||
| 449 | + | delete(l.sessions, session) | |||
| 450 | + | l.mu.Unlock() | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | // liveSessions reports how many admitted sessions are still live. | |||
| 454 | + | func (l *listener) liveSessions() int { | |||
| 455 | + | l.mu.Lock() | |||
| 456 | + | defer l.mu.Unlock() | |||
| 457 | + | return len(l.sessions) | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | func (l *listener) isClosed() bool { | |||
| 461 | + | if l == nil { | |||
| 462 | + | return true | |||
| 463 | + | } | |||
| 464 | + | select { | |||
| 465 | + | case <-l.done: | |||
| 466 | + | return true | |||
| 467 | + | default: | |||
| 468 | + | return false | |||
| 469 | + | } | |||
| 470 | + | } | |||
| 471 | + | ||||
| 472 | + | // serverSession is one admitted broker connection: the brokerwire connection | |||
| 473 | + | // state machine, the outbound frame path, one snapshot publisher, and the | |||
| 474 | + | // bridged logical streams for exactly one client. | |||
| 475 | + | type serverSession struct { | |||
| 476 | + | epoch ports.BrokerEpoch | |||
| 477 | + | scope brokerwire.Scope | |||
| 478 | + | conn *brokerwire.Connection | |||
| 479 | + | core ports.BrokerService | |||
| 480 | + | transport wire.BoundedTransport | |||
| 481 | + | ceilings brokerwire.Ceilings | |||
| 482 | + | cfg Config | |||
| 483 | + | release func() | |||
| 484 | + | ||||
| 485 | + | ctx context.Context | |||
| 486 | + | cancel context.CancelFunc | |||
| 487 | + | wg sync.WaitGroup | |||
| 488 | + | ||||
| 489 | + | subMu sync.Mutex | |||
| 490 | + | pub *publisher | |||
| 491 | + | ||||
| 492 | + | streamsMu sync.Mutex | |||
| 493 | + | streams map[ports.BrokerStreamID]*serverStream | |||
| 494 | + | ||||
| 495 | + | // onShutdown runs once, before done closes, so the owner can drop this | |||
| 496 | + | // session from its live set. It is set before run starts. | |||
| 497 | + | onShutdown func() | |||
| 498 | + | ||||
| 499 | + | errMu sync.Mutex | |||
| 500 | + | closeErr error | |||
| 501 | + | done chan struct{} | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | var _ ports.BrokerService = (*serverSession)(nil) | |||
| 505 | + | ||||
| 506 | + | // newServerSession binds one admitted connection to its exact scope. | |||
| 507 | + | func newServerSession(epoch ports.BrokerEpoch, transport wire.BoundedTransport, ceilings brokerwire.Ceilings, core ports.BrokerService, cfg Config, release func()) (*serverSession, error) { | |||
| 508 | + | if epoch == 0 || transport == nil || core == nil { | |||
| 509 | + | return nil, ErrConfig | |||
| 510 | + | } | |||
| 511 | + | connection := core.ConnectionID() | |||
| 512 | + | if err := connection.Validate(); err != nil { | |||
| 513 | + | return nil, errors.Join(ErrConfig, err) | |||
| 514 | + | } | |||
| 515 | + | if err := ceilings.Validate(); err != nil { | |||
| 516 | + | return nil, errors.Join(ErrConfig, err) | |||
| 517 | + | } | |||
| 518 | + | conn, err := brokerwire.NewConnection(brokerwire.Scope{Epoch: epoch, Connection: connection}) | |||
| 519 | + | if err != nil { | |||
| 520 | + | return nil, err | |||
| 521 | + | } | |||
| 522 | + | // The broker preamble was validated before this session was constructed, | |||
| 523 | + | // so the connection moves past New immediately and waits for exactly one | |||
| 524 | + | // Register. | |||
| 525 | + | if err := conn.SignalPreamble(); err != nil { | |||
| 526 | + | return nil, err | |||
| 527 | + | } | |||
| 528 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 529 | + | if release == nil { | |||
| 530 | + | release = func() {} | |||
| 531 | + | } | |||
| 532 | + | return &serverSession{ | |||
| 533 | + | epoch: epoch, | |||
| 534 | + | scope: conn.Scope(), | |||
| 535 | + | conn: conn, | |||
| 536 | + | core: core, | |||
| 537 | + | transport: transport, | |||
| 538 | + | ceilings: ceilings, | |||
| 539 | + | cfg: cfg, | |||
| 540 | + | release: release, | |||
| 541 | + | ctx: ctx, | |||
| 542 | + | cancel: cancel, | |||
| 543 | + | streams: make(map[ports.BrokerStreamID]*serverStream), | |||
| 544 | + | done: make(chan struct{}), | |||
| 545 | + | }, nil | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | // run is the connection's single reader. It decodes one whole broker envelope at | |||
| 549 | + | // a time under the negotiated envelope and chunk ceilings; a malformed, | |||
| 550 | + | // oversize, or wrong-direction frame, a protocol-ordering violation, or a | |||
| 551 | + | // carriage error settles exactly this connection. | |||
| 552 | + | func (s *serverSession) run() { | |||
| 553 | + | defer s.shutdown() | |||
| 554 | + | for { | |||
| 555 | + | envelope, err := s.transport.RecvBounded(s.ceilings.MaxReceiveEnvelopeBytes) | |||
| 556 | + | if err != nil { | |||
| 557 | + | s.setCloseErr(transportFailure(err)) | |||
| 558 | + | return | |||
| 559 | + | } | |||
| 560 | + | message, err := brokerwire.DecodeClient(envelope.Payload, s.ceilings.MaxReceiveEnvelopeBytes, s.ceilings.StreamChunkLimit) | |||
| 561 | + | if err != nil { | |||
| 562 | + | s.setCloseErr(errors.Join(ErrMalformedFrame, err)) | |||
| 563 | + | return | |||
| 564 | + | } | |||
| 565 | + | if err := s.dispatch(message); err != nil { | |||
| 566 | + | s.setCloseErr(err) | |||
| 567 | + | return | |||
| 568 | + | } | |||
| 569 | + | } | |||
| 570 | + | } | |||
| 571 | + | ||||
| 572 | + | // dispatch applies one client frame. A returned error is a protocol or carriage | |||
| 573 | + | // violation that settles the connection; a refusal the peer can observe is sent | |||
| 574 | + | // on the wire and returns nil. | |||
| 575 | + | func (s *serverSession) dispatch(message brokerwire.ClientMessage) error { | |||
| 576 | + | switch m := message.(type) { | |||
| 577 | + | case brokerwire.Register: | |||
| 578 | + | if err := s.conn.Register(); err != nil { | |||
| 579 | + | return errors.Join(ErrProtocol, err) | |||
| 580 | + | } | |||
| 581 | + | return s.send(brokerwire.Registered{Epoch: s.epoch, Connection: s.scope.Connection}) | |||
| 582 | + | case brokerwire.Subscribe: | |||
| 583 | + | if !s.scopeMatches(m.Epoch, m.Connection) { | |||
| 584 | + | return s.refuseScope() | |||
| 585 | + | } | |||
| 586 | + | if err := s.conn.Subscribe(m.Generation); err != nil { | |||
| 587 | + | return errors.Join(ErrProtocol, err) | |||
| 588 | + | } | |||
| 589 | + | return s.retargetPublisher(m.Generation) | |||
| 590 | + | case brokerwire.Resync: | |||
| 591 | + | if !s.scopeMatches(m.Epoch, m.Connection) { | |||
| 592 | + | return s.refuseScope() | |||
| 593 | + | } | |||
| 594 | + | if err := s.conn.Resync(m.Generation); err != nil { | |||
| 595 | + | return errors.Join(ErrProtocol, err) | |||
| 596 | + | } | |||
| 597 | + | s.wakePublisher() | |||
| 598 | + | return nil | |||
| 599 | + | case brokerwire.Unsubscribe: | |||
| 600 | + | if !s.scopeMatches(m.Epoch, m.Connection) { | |||
| 601 | + | return s.refuseScope() | |||
| 602 | + | } | |||
| 603 | + | if err := s.conn.Unsubscribe(m.Generation); err != nil { | |||
| 604 | + | return errors.Join(ErrProtocol, err) | |||
| 605 | + | } | |||
| 606 | + | s.stopPublisher() | |||
| 607 | + | return nil | |||
| 608 | + | case brokerwire.AddHost: | |||
| 609 | + | if !s.scopeMatches(m.Epoch, m.Connection) { | |||
| 610 | + | return s.refuseScope() | |||
| 611 | + | } | |||
| 612 | + | return s.startMutation(m.Operation, func(ctx context.Context) (bool, error) { | |||
| 613 | + | return false, s.core.AddHost(ctx, m.Endpoint) | |||
| 614 | + | }) | |||
| 615 | + | case brokerwire.RemoveHost: | |||
| 616 | + | if !s.scopeMatches(m.Epoch, m.Connection) { | |||
| 617 | + | return s.refuseScope() | |||
| 618 | + | } | |||
| 619 | + | return s.startMutation(m.Operation, func(ctx context.Context) (bool, error) { | |||
| 620 | + | removed, err := s.core.RemoveHost(ctx, m.Endpoint) | |||
| 621 | + | return removed, err | |||
| 622 | + | }) | |||
| 623 | + | case brokerwire.Reconcile: | |||
| 624 | + | if !s.scopeMatches(m.Epoch, m.Connection) { | |||
| 625 | + | return s.refuseScope() | |||
| 626 | + | } | |||
| 627 | + | s.core.RequestReconcile(m.Registration.Endpoint) | |||
| 628 | + | return nil | |||
| 629 | + | case brokerwire.OpenStream: | |||
| 630 | + | if !s.scopeMatches(m.Epoch, m.Connection) { | |||
| 631 | + | return s.refuseStream(m.Stream, scopeRefusal()) | |||
| 632 | + | } | |||
| 633 | + | s.startStream(m) | |||
| 634 | + | return nil | |||
| 635 | + | case brokerwire.ClientStreamData: | |||
| 636 | + | if !s.scopeMatches(m.Epoch, m.Connection) { | |||
| 637 | + | return s.refuseScope() | |||
| 638 | + | } | |||
| 639 | + | return s.deliverStreamData(m) | |||
| 640 | + | case brokerwire.CloseStream: | |||
| 641 | + | if !s.scopeMatches(m.Epoch, m.Connection) { | |||
| 642 | + | return s.refuseScope() | |||
| 643 | + | } | |||
| 644 | + | s.closeStreamByPeer(m.Stream) | |||
| 645 | + | return nil | |||
| 646 | + | default: | |||
| 647 | + | return errors.Join(ErrProtocol, fmt.Errorf("brokeripc: unexpected client message %T", message)) | |||
| 648 | + | } | |||
| 649 | + | } | |||
| 650 | + | ||||
| 651 | + | // scopeMatches reports whether one frame carries the epoch and connection | |||
| 652 | + | // identity assigned at accept. Every frame must: a mismatched frame is never | |||
| 653 | + | // applied to this connection's state. | |||
| 654 | + | func (s *serverSession) scopeMatches(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID) bool { | |||
| 655 | + | return epoch == s.epoch && connection == s.scope.Connection | |||
| 656 | + | } | |||
| 657 | + | ||||
| 658 | + | // refuseScope drops one frame whose scope is not this connection's. It is | |||
| 659 | + | // deliberately not an error: the frame is fenced rather than obeyed, and the | |||
| 660 | + | // connection keeps serving its own scope. A stale stream open is answered with | |||
| 661 | + | // a typed stale refusal instead (see refuseStream). | |||
| 662 | + | func (s *serverSession) refuseScope() error { return nil } | |||
| 663 | + | ||||
| 664 | + | // scopeRefusal is the typed refusal for a frame that does not carry this | |||
| 665 | + | // connection's accepted scope: a stale admission the peer can classify. | |||
| 666 | + | func scopeRefusal() error { | |||
| 667 | + | return errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale) | |||
| 668 | + | } | |||
| 669 | + | ||||
| 670 | + | // refuseStream tells the peer one logical stream was refused, with the closed | |||
| 671 | + | // admission or broker taxonomy carried on the detail. | |||
| 672 | + | func (s *serverSession) refuseStream(stream ports.BrokerStreamID, err error) error { | |||
| 673 | + | detail := errorDetail(err) | |||
| 674 | + | return s.send(brokerwire.StreamClosed{ | |||
| 675 | + | Epoch: s.epoch, Connection: s.scope.Connection, Stream: stream, | |||
| 676 | + | Error: detail, HasError: detail != (brokerwire.ErrorDetail{}), | |||
| 677 | + | }) | |||
| 678 | + | } | |||
| 679 | + | ||||
| 680 | + | // send encodes one server frame under the negotiated ceilings and writes it. | |||
| 681 | + | func (s *serverSession) send(message brokerwire.ServerMessage) error { | |||
| 682 | + | payload, err := brokerwire.EncodeServer(message, s.ceilings.MaxReceiveEnvelopeBytes, s.ceilings.StreamChunkLimit) | |||
| 683 | + | if err != nil { | |||
| 684 | + | return err | |||
| 685 | + | } | |||
| 686 | + | if err := s.transport.Send(wire.Envelope{Payload: payload}); err != nil { | |||
| 687 | + | return transportFailure(err) | |||
| 688 | + | } | |||
| 689 | + | return nil | |||
| 690 | + | } | |||
| 691 | + | ||||
| 692 | + | // setCloseErr records the first terminal cause. Later causes never overwrite a | |||
| 693 | + | // cause that already settled the connection, so a diagnostic from a worker that | |||
| 694 | + | // observed the same terminal state cannot mask the original. | |||
| 695 | + | func (s *serverSession) setCloseErr(err error) { | |||
| 696 | + | if err == nil { | |||
| 697 | + | return | |||
| 698 | + | } | |||
| 699 | + | s.errMu.Lock() | |||
| 700 | + | if s.closeErr == nil { | |||
| 701 | + | s.closeErr = err | |||
| 702 | + | } | |||
| 703 | + | s.errMu.Unlock() | |||
| 704 | + | } | |||
| 705 | + | ||||
| 706 | + | // terminalErr reports the cause that settled the connection, or nil for an | |||
| 707 | + | // orderly end. | |||
| 708 | + | func (s *serverSession) terminalErr() error { | |||
| 709 | + | s.errMu.Lock() | |||
| 710 | + | defer s.errMu.Unlock() | |||
| 711 | + | return s.closeErr | |||
| 712 | + | } | |||
| 713 | + | ||||
| 714 | + | // abort records a terminal cause from a worker goroutine and closes the | |||
| 715 | + | // carriage so the reader observes it promptly. | |||
| 716 | + | func (s *serverSession) abort(err error) { | |||
| 717 | + | s.setCloseErr(err) | |||
| 718 | + | _ = s.transport.Close() | |||
| 719 | + | } | |||
| 720 | + | ||||
| 721 | + | // shutdown tears the session down exactly once, in a fixed order: cancel every | |||
| 722 | + | // owned context, stop the publisher, close the outer carriage (which unblocks a | |||
| 723 | + | // blocked read and any frame send), settle every bridged stream, close the | |||
| 724 | + | // brokerwire connection state, join every worker, release the stream | |||
| 725 | + | // resources, close the admitted core service, and release the listener's | |||
| 726 | + | // client slot. | |||
| 727 | + | // | |||
| 728 | + | // The outer carriage closes before the bridged streams are settled because | |||
| 729 | + | // settling a stream joins that stream's own carriage writer, and a writer | |||
| 730 | + | // parked on a non-reading peer is only interrupted when the outer carriage | |||
| 731 | + | // closes: settling streams first would wait on the peer instead of settling the | |||
| 732 | + | // connection, so the session slot and done would never be released. | |||
| 733 | + | func (s *serverSession) shutdown() { | |||
| 734 | + | s.cancel() | |||
| 735 | + | s.stopPublisher() | |||
| 736 | + | _ = s.transport.Close() | |||
| 737 | + | s.closeStreams() | |||
| 738 | + | _ = s.conn.BeginClose() | |||
| 739 | + | s.wg.Wait() | |||
| 740 | + | _ = s.conn.FinishClose() | |||
| 741 | + | if s.core != nil { | |||
| 742 | + | if err := s.core.Close(); err != nil { | |||
| 743 | + | s.setCloseErr(err) | |||
| 744 | + | } | |||
| 745 | + | } | |||
| 746 | + | if s.release != nil { | |||
| 747 | + | s.release() | |||
| 748 | + | } | |||
| 749 | + | if s.onShutdown != nil { | |||
| 750 | + | s.onShutdown() | |||
| 751 | + | } | |||
| 752 | + | close(s.done) | |||
| 753 | + | } | |||
| 754 | + | ||||
| 755 | + | // Close ends the connection and waits for every worker to stop. It is | |||
| 756 | + | // idempotent and concurrent-safe. It reports a non-nil error only for a session | |||
| 757 | + | // that ended on a protocol violation or an owned-resource failure: a peer | |||
| 758 | + | // disconnect and a local close are orderly ends, so an owner draining accepted | |||
| 759 | + | // sessions does not see a peer's departure as its own failure. | |||
| 760 | + | func (s *serverSession) Close() error { | |||
| 761 | + | if s == nil { | |||
| 762 | + | return nil | |||
| 763 | + | } | |||
| 764 | + | _ = s.transport.Close() | |||
| 765 | + | <-s.done | |||
| 766 | + | err := s.terminalErr() | |||
| 767 | + | if orderlyDisconnect(err) { | |||
| 768 | + | return nil | |||
| 769 | + | } | |||
| 770 | + | return err | |||
| 771 | + | } | |||
| 772 | + | ||||
| 773 | + | // orderlyDisconnect reports whether one terminal cause is a peer disconnect or a | |||
| 774 | + | // local close rather than a failure. | |||
| 775 | + | func orderlyDisconnect(err error) bool { | |||
| 776 | + | return err == nil || errors.Is(err, io.EOF) || errors.Is(err, ErrConnectionClosed) || errors.Is(err, ErrSessionClosed) | |||
| 777 | + | } | |||
| 778 | + | ||||
| 779 | + | // ConnectionID returns the identity assigned to this connection at accept. | |||
| 780 | + | func (s *serverSession) ConnectionID() ports.BrokerConnectionID { return s.scope.Connection } | |||
| 781 | + | ||||
| 782 | + | // Snapshot returns the admitted core service's current publication. | |||
| 783 | + | func (s *serverSession) Snapshot() ports.BrokerSnapshot { return s.core.Snapshot() } | |||
| 784 | + | ||||
| 785 | + | // Subscribe returns the admitted core service's subscription. The wire | |||
| 786 | + | // protocol's Subscribe is driven by the connection's reader, which publishes | |||
| 787 | + | // the broker core's newest snapshot for the client's generation. | |||
| 788 | + | func (s *serverSession) Subscribe() (ports.BrokerSubscription, error) { return s.core.Subscribe() } | |||
| 789 | + | ||||
| 790 | + | // OpenStream delegates one scope-checked stream request to the admitted core | |||
| 791 | + | // service. The connection's own reader drives the same core through | |||
| 792 | + | // openStreamMessage, which additionally bridges the stream onto the wire; this | |||
| 793 | + | // method exists so a composed caller can use the session as a scoped | |||
| 794 | + | // ports.BrokerService. | |||
| 795 | + | func (s *serverSession) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) { | |||
| 796 | + | if err := s.checkRequestScope(request.Epoch, request.Connection); err != nil { | |||
| 797 | + | return nil, err | |||
| 798 | + | } | |||
| 799 | + | scoped := request | |||
| 800 | + | scoped.Epoch = s.epoch | |||
| 801 | + | scoped.Connection = s.scope.Connection | |||
| 802 | + | return s.core.OpenStream(ctx, scoped) | |||
| 803 | + | } | |||
| 804 | + | ||||
| 805 | + | // CloseStream retires one scope-checked stream. An absent connection identity is | |||
| 806 | + | // filled from this session exactly like OpenStream, so a zero identity never | |||
| 807 | + | // reaches the core as a stale request, and a foreign one is still refused. | |||
| 808 | + | func (s *serverSession) CloseStream(connection ports.BrokerConnectionID, stream ports.BrokerStreamID) error { | |||
| 809 | + | if err := s.checkRequestScope(s.epoch, connection); err != nil { | |||
| 810 | + | return err | |||
| 811 | + | } | |||
| 812 | + | return s.core.CloseStream(s.scope.Connection, stream) | |||
| 813 | + | } | |||
| 814 | + | ||||
| 815 | + | // AddHost delegates one mutating operation to the admitted core service. | |||
| 816 | + | func (s *serverSession) AddHost(ctx context.Context, target string) error { | |||
| 817 | + | return s.core.AddHost(ctx, target) | |||
| 818 | + | } | |||
| 819 | + | ||||
| 820 | + | // RemoveHost delegates one mutating operation to the admitted core service. | |||
| 821 | + | func (s *serverSession) RemoveHost(ctx context.Context, target string) (bool, error) { | |||
| 822 | + | return s.core.RemoveHost(ctx, target) | |||
| 823 | + | } | |||
| 824 | + | ||||
| 825 | + | // RequestReconcile delegates one reconcile hint to the admitted core service. | |||
| 826 | + | func (s *serverSession) RequestReconcile(endpoint string) { s.core.RequestReconcile(endpoint) } | |||
| 827 | + | ||||
| 828 | + | // checkRequestScope refuses a caller-supplied identity that is neither absent | |||
| 829 | + | // nor this connection's own, so a caller can never steer another connection's | |||
| 830 | + | // work through this session. | |||
| 831 | + | func (s *serverSession) checkRequestScope(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID) error { | |||
| 832 | + | if epoch != 0 && epoch != s.epoch { | |||
| 833 | + | return errors.Join(ErrScopeMismatch, ports.BrokerError{Code: ports.BrokerErrorStaleEpoch}) | |||
| 834 | + | } | |||
| 835 | + | if !connection.IsZero() && connection != s.scope.Connection { | |||
| 836 | + | return errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale) | |||
| 837 | + | } | |||
| 838 | + | return nil | |||
| 839 | + | } | |||
A internal/adapters/brokeripc/session_shutdown_test.go +130 -0
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "io" | |||
| 6 | + | "net" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 13 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | "github.com/bnema/vev/internal/protocol" | |||
| 16 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // Session shutdown ordering (GO-007). A bridged stream writes through its own | |||
| 20 | + | // carriage, whose writer is parked inside the outer carriage write while the | |||
| 21 | + | // peer is not reading. Settling a stream joins that writer, so shutdown must | |||
| 22 | + | // close the outer carriage first: otherwise the join waits on the peer and the | |||
| 23 | + | // session never releases its slot or done. | |||
| 24 | + | ||||
| 25 | + | // rawPeer drives one broker peer over a bare net carriage, framing by hand | |||
| 26 | + | // instead of composing a transport. Consuming a length prefix and deliberately | |||
| 27 | + | // leaving the body unread is what parks the server's write on this non-reading | |||
| 28 | + | // peer deterministically. | |||
| 29 | + | type rawPeer struct { | |||
| 30 | + | conn net.Conn | |||
| 31 | + | ceilings brokerwire.Ceilings | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // send writes one encoded client frame. | |||
| 35 | + | func (p *rawPeer) send(t *testing.T, message brokerwire.ClientMessage) { | |||
| 36 | + | t.Helper() | |||
| 37 | + | payload, err := brokerwire.EncodeClient(message, p.ceilings.MaxReceiveEnvelopeBytes, p.ceilings.StreamChunkLimit) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | frame := make([]byte, 4+len(payload)) | |||
| 40 | + | binary.BigEndian.PutUint32(frame[:4], uint32(len(payload))) | |||
| 41 | + | copy(frame[4:], payload) | |||
| 42 | + | _, err = p.conn.Write(frame) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // recv reads and decodes one whole server frame. | |||
| 47 | + | func (p *rawPeer) recv(t *testing.T) brokerwire.ServerMessage { | |||
| 48 | + | t.Helper() | |||
| 49 | + | var header [4]byte | |||
| 50 | + | _, err := io.ReadFull(p.conn, header[:]) | |||
| 51 | + | require.NoError(t, err) | |||
| 52 | + | payload := make([]byte, binary.BigEndian.Uint32(header[:])) | |||
| 53 | + | _, err = io.ReadFull(p.conn, payload) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | message, err := brokerwire.DecodeServer(payload, p.ceilings.MaxReceiveEnvelopeBytes, p.ceilings.StreamChunkLimit) | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | return message | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // recvHeader consumes only the length prefix of the next server frame, leaving | |||
| 61 | + | // its body unread: the server's frame write is then provably still in flight on | |||
| 62 | + | // this peer, so the stream writer that issued it is parked inside it. | |||
| 63 | + | func (p *rawPeer) recvHeader(t *testing.T) uint32 { | |||
| 64 | + | t.Helper() | |||
| 65 | + | var header [4]byte | |||
| 66 | + | _, err := io.ReadFull(p.conn, header[:]) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | length := binary.BigEndian.Uint32(header[:]) | |||
| 69 | + | require.NotZero(t, length, "the parked frame must carry a payload") | |||
| 70 | + | return length | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // TestProtocolViolationWithParkedStreamWriterReleasesSession proves a protocol | |||
| 74 | + | // violation settles a session whose bridged stream writer is parked on a | |||
| 75 | + | // non-reading peer: the violation alone closes done, closes the admitted core | |||
| 76 | + | // service, and releases the listener's client slot, with no external session or | |||
| 77 | + | // listener Close. | |||
| 78 | + | func TestProtocolViolationWithParkedStreamWriterReleasesSession(t *testing.T) { | |||
| 79 | + | const epoch = ports.BrokerEpoch(0x55) | |||
| 80 | + | serverConn, peerConn := net.Pipe() | |||
| 81 | + | t.Cleanup(func() { _ = peerConn.Close() }) | |||
| 82 | + | ||||
| 83 | + | transport, ok := ipc.NewTransport(serverConn).(wire.BoundedTransport) | |||
| 84 | + | require.True(t, ok, "the IPC carriage must be a bounded transport") | |||
| 85 | + | core := newTestCore(epoch) | |||
| 86 | + | released := make(chan struct{}, 1) | |||
| 87 | + | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() { released <- struct{}{} }) | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | ||||
| 90 | + | // The peer's Register already happened; the duplicate Register below is the | |||
| 91 | + | // violation. | |||
| 92 | + | require.NoError(t, session.conn.Register()) | |||
| 93 | + | go session.run() | |||
| 94 | + | peer := &rawPeer{conn: peerConn, ceilings: brokerwire.DefaultCeilings()} | |||
| 95 | + | ||||
| 96 | + | session.startStream(brokerwire.OpenStream{ | |||
| 97 | + | Epoch: epoch, Connection: session.scope.Connection, Stream: 1, | |||
| 98 | + | Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), | |||
| 99 | + | }) | |||
| 100 | + | opened, isOpened := peer.recv(t).(brokerwire.StreamOpened) | |||
| 101 | + | require.True(t, isOpened, "the stream must be established before its writer can be parked") | |||
| 102 | + | require.Equal(t, ports.BrokerStreamID(1), opened.Stream) | |||
| 103 | + | ||||
| 104 | + | // One daemon message reaches the stream's relay, and the peer consumes only | |||
| 105 | + | // the length prefix of the frame it produces: the broker's carriage write can | |||
| 106 | + | // no longer complete, so the stream writer is parked inside it. | |||
| 107 | + | conn := core.logicalConn(1) | |||
| 108 | + | require.NotNil(t, conn, "the broker core must have admitted the stream") | |||
| 109 | + | conn.toClient <- protocol.Pong{} | |||
| 110 | + | peer.recvHeader(t) | |||
| 111 | + | ||||
| 112 | + | peer.send(t, brokerwire.Register{}) | |||
| 113 | + | ||||
| 114 | + | select { | |||
| 115 | + | case <-session.done: | |||
| 116 | + | case <-time.After(5 * time.Second): | |||
| 117 | + | t.Fatal("a protocol violation must settle a session with a parked stream writer") | |||
| 118 | + | } | |||
| 119 | + | select { | |||
| 120 | + | case <-released: | |||
| 121 | + | case <-time.After(5 * time.Second): | |||
| 122 | + | t.Fatal("a settled session must release its client slot") | |||
| 123 | + | } | |||
| 124 | + | require.ErrorIs(t, session.terminalErr(), ErrProtocol) | |||
| 125 | + | select { | |||
| 126 | + | case <-core.done: | |||
| 127 | + | default: | |||
| 128 | + | t.Fatal("a settled session must close its admitted core service") | |||
| 129 | + | } | |||
| 130 | + | } | |||
A internal/adapters/brokeripc/session_test.go +700 -0
| @@ -0,0 +1,700 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | "github.com/bnema/vev/internal/protocol" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // TestResubscribePublishesCurrentSnapshotPerGeneration races repeated | |||
| 19 | + | // resubscriptions: a superseded publisher must never consume the wake meant for | |||
| 20 | + | // its successor, so the generation that ends a burst publishes the current | |||
| 21 | + | // snapshot without any further core wake, and a resync republishes it. The | |||
| 22 | + | // whole burst runs with no yield between generations, so a shared wake channel | |||
| 23 | + | // would coalesce a later generation's demand into a superseded publisher. | |||
| 24 | + | func TestResubscribePublishesCurrentSnapshotPerGeneration(t *testing.T) { | |||
| 25 | + | const ( | |||
| 26 | + | epoch = ports.BrokerEpoch(0x51) | |||
| 27 | + | generations = 32 | |||
| 28 | + | bursts = 50 | |||
| 29 | + | ) | |||
| 30 | + | for burst := 0; burst < bursts; burst++ { | |||
| 31 | + | transport := newRecordingTransport() | |||
| 32 | + | core := newTestCore(epoch) | |||
| 33 | + | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() {}) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | require.NoError(t, session.conn.Register()) | |||
| 36 | + | ||||
| 37 | + | for index := 1; index <= generations; index++ { | |||
| 38 | + | generation := brokerwire.SubscriptionGeneration(index) | |||
| 39 | + | core.hub.publish(testSnapshot(epoch, ports.BrokerRevision(index))) | |||
| 40 | + | require.NoError(t, session.conn.Subscribe(generation)) | |||
| 41 | + | require.NoError(t, session.retargetPublisher(generation)) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Each publisher owns its wake, so a later generation can never be left | |||
| 45 | + | // waiting on a demand a superseded publisher consumed. | |||
| 46 | + | session.subMu.Lock() | |||
| 47 | + | active := session.pub | |||
| 48 | + | session.subMu.Unlock() | |||
| 49 | + | require.NotNil(t, active) | |||
| 50 | + | require.Equal(t, brokerwire.SubscriptionGeneration(generations), active.generation) | |||
| 51 | + | require.True(t, | |||
| 52 | + | transport.waitForTransfer(brokerwire.SubscriptionGeneration(generations), ports.BrokerRevision(generations), 5*time.Second), | |||
| 53 | + | "the final generation must publish the current snapshot for itself") | |||
| 54 | + | ||||
| 55 | + | // A resync republishes the current snapshot for the active generation. | |||
| 56 | + | before := transport.transferCount(brokerwire.SubscriptionGeneration(generations), ports.BrokerRevision(generations)) | |||
| 57 | + | require.NoError(t, session.conn.Resync(brokerwire.SubscriptionGeneration(generations))) | |||
| 58 | + | session.wakePublisher() | |||
| 59 | + | require.True(t, | |||
| 60 | + | transport.waitForTransferCount(brokerwire.SubscriptionGeneration(generations), ports.BrokerRevision(generations), before+1, 5*time.Second), | |||
| 61 | + | "a resync must republish the current snapshot") | |||
| 62 | + | ||||
| 63 | + | session.shutdown() | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // TestSupersededPublisherOwnsItsWake proves each subscription generation owns | |||
| 68 | + | // its own coalescing wake, so a superseded publisher cannot consume the demand | |||
| 69 | + | // intended for its successor. | |||
| 70 | + | func TestSupersededPublisherOwnsItsWake(t *testing.T) { | |||
| 71 | + | const epoch = ports.BrokerEpoch(0x52) | |||
| 72 | + | transport := newRecordingTransport() | |||
| 73 | + | core := newTestCore(epoch) | |||
| 74 | + | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() {}) | |||
| 75 | + | require.NoError(t, err) | |||
| 76 | + | require.NoError(t, session.conn.Register()) | |||
| 77 | + | t.Cleanup(func() { session.shutdown() }) | |||
| 78 | + | ||||
| 79 | + | core.hub.publish(testSnapshot(epoch, 1)) | |||
| 80 | + | require.NoError(t, session.conn.Subscribe(1)) | |||
| 81 | + | require.NoError(t, session.retargetPublisher(1)) | |||
| 82 | + | session.subMu.Lock() | |||
| 83 | + | first := session.pub | |||
| 84 | + | session.subMu.Unlock() | |||
| 85 | + | ||||
| 86 | + | core.hub.publish(testSnapshot(epoch, 2)) | |||
| 87 | + | require.NoError(t, session.conn.Subscribe(2)) | |||
| 88 | + | require.NoError(t, session.retargetPublisher(2)) | |||
| 89 | + | session.subMu.Lock() | |||
| 90 | + | second := session.pub | |||
| 91 | + | session.subMu.Unlock() | |||
| 92 | + | ||||
| 93 | + | require.NotNil(t, first) | |||
| 94 | + | require.NotNil(t, second) | |||
| 95 | + | require.NotSame(t, first, second) | |||
| 96 | + | require.NotEqual(t, first.wake, second.wake, "each generation must own its wake channel") | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // TestSessionCloseStreamFillsConnectionScope proves the session CloseStream | |||
| 100 | + | // delegates with this connection's scope exactly like OpenStream: an absent | |||
| 101 | + | // identity is filled from the session and a foreign one is refused. | |||
| 102 | + | func TestSessionCloseStreamFillsConnectionScope(t *testing.T) { | |||
| 103 | + | const epoch = ports.BrokerEpoch(0x53) | |||
| 104 | + | transport := newRecordingTransport() | |||
| 105 | + | core := newTestCore(epoch) | |||
| 106 | + | session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() {}) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | t.Cleanup(func() { session.shutdown() }) | |||
| 109 | + | ||||
| 110 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 111 | + | defer cancel() | |||
| 112 | + | opened, err := session.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |||
| 113 | + | Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy(), | |||
| 114 | + | }) | |||
| 115 | + | require.NoError(t, err) | |||
| 116 | + | require.NotNil(t, opened) | |||
| 117 | + | ||||
| 118 | + | core.mu.Lock() | |||
| 119 | + | require.Len(t, core.opens, 1) | |||
| 120 | + | require.Equal(t, core.id, core.opens[0].Connection, "OpenStream fills the session scope") | |||
| 121 | + | require.Equal(t, ports.BrokerStreamID(1), core.opens[0].Stream) | |||
| 122 | + | core.mu.Unlock() | |||
| 123 | + | ||||
| 124 | + | // A zero identity is filled from the session instead of reaching the core | |||
| 125 | + | // as a stale request. | |||
| 126 | + | require.NoError(t, session.CloseStream(ports.BrokerConnectionID{}, 1)) | |||
| 127 | + | core.mu.Lock() | |||
| 128 | + | require.Equal(t, []ports.BrokerStreamID{1}, core.closedIDs) | |||
| 129 | + | core.mu.Unlock() | |||
| 130 | + | ||||
| 131 | + | // A foreign identity is still refused. | |||
| 132 | + | require.ErrorIs(t, session.CloseStream(ports.BrokerConnectionID{0x99}, 2), ports.BrokerAdmissionStale) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // TestRegistrationAssignsScopeAtAccept proves the client adapter adopts exactly | |||
| 136 | + | // the scope the listener assigned at accept. | |||
| 137 | + | func TestRegistrationAssignsScopeAtAccept(t *testing.T) { | |||
| 138 | + | e := startEndpoint(t, Config{}) | |||
| 139 | + | client, session := e.pair() | |||
| 140 | + | require.Equal(t, session.ConnectionID(), client.ConnectionID()) | |||
| 141 | + | require.False(t, client.ConnectionID().IsZero()) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // TestSubscribePublishesSnapshot proves subscription traffic: a committed | |||
| 145 | + | // publication reaches the client, wakes its subscription, and round-trips host, | |||
| 146 | + | // session, and tombstone state. | |||
| 147 | + | func TestSubscribePublishesSnapshot(t *testing.T) { | |||
| 148 | + | e := startEndpoint(t, Config{}) | |||
| 149 | + | client, _ := e.pair() | |||
| 150 | + | sub, err := client.Subscribe() | |||
| 151 | + | require.NoError(t, err) | |||
| 152 | + | t.Cleanup(sub.Close) | |||
| 153 | + | ||||
| 154 | + | snapshot := e.publish(e.epoch, 7) | |||
| 155 | + | require.Eventually(t, func() bool { | |||
| 156 | + | return client.Snapshot().Revision == snapshot.Revision | |||
| 157 | + | }, 5*time.Second, 10*time.Millisecond, "committed publication must reach the client") | |||
| 158 | + | ||||
| 159 | + | got := client.Snapshot() | |||
| 160 | + | require.Equal(t, e.epoch, got.Epoch) | |||
| 161 | + | require.Len(t, got.Hosts, 1) | |||
| 162 | + | require.Equal(t, snapshot.Hosts[0].Endpoint, got.Hosts[0].Endpoint) | |||
| 163 | + | require.Len(t, got.Hosts[0].Sessions, 2) | |||
| 164 | + | require.Equal(t, snapshot.Hosts[0].Sessions[0].Name, got.Hosts[0].Sessions[0].Name) | |||
| 165 | + | ||||
| 166 | + | select { | |||
| 167 | + | case <-sub.Changed(): | |||
| 168 | + | case <-time.After(5 * time.Second): | |||
| 169 | + | t.Fatal("subscription was not woken by a committed publication") | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // TestSubscribeGenerationAdvancesAndReplaces proves a second Subscribe advances | |||
| 174 | + | // the generation series (refusing reuse) and replaces the previous | |||
| 175 | + | // subscription. | |||
| 176 | + | func TestSubscribeGenerationAdvancesAndReplaces(t *testing.T) { | |||
| 177 | + | e := startEndpoint(t, Config{}) | |||
| 178 | + | client, _ := e.pair() | |||
| 179 | + | first, err := client.Subscribe() | |||
| 180 | + | require.NoError(t, err) | |||
| 181 | + | second, err := client.Subscribe() | |||
| 182 | + | require.NoError(t, err) | |||
| 183 | + | t.Cleanup(second.Close) | |||
| 184 | + | ||||
| 185 | + | select { | |||
| 186 | + | case _, open := <-first.Changed(): | |||
| 187 | + | // The previous subscription is closed: a closed channel reads | |||
| 188 | + | // immediately with open=false. | |||
| 189 | + | require.False(t, open, "a superseded subscription must be closed") | |||
| 190 | + | case <-time.After(time.Second): | |||
| 191 | + | t.Fatal("the superseded subscription must be closed") | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | e.publish(e.epoch, 3) | |||
| 195 | + | require.Eventually(t, func() bool { return client.Snapshot().Revision == 3 }, 5*time.Second, 10*time.Millisecond) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // subscribeResult is one Subscribe outcome. | |||
| 199 | + | type subscribeResult struct { | |||
| 200 | + | sub ports.BrokerSubscription | |||
| 201 | + | err error | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // subscribeGenerationOf extracts the generation of one captured Subscribe | |||
| 205 | + | // frame. | |||
| 206 | + | func subscribeGenerationOf(t *testing.T, message brokerwire.ClientMessage) uint64 { | |||
| 207 | + | t.Helper() | |||
| 208 | + | subscribe, ok := message.(brokerwire.Subscribe) | |||
| 209 | + | require.True(t, ok, "the captured attempt must be a Subscribe frame") | |||
| 210 | + | return subscribe.Generation | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // awaitSubscribe waits for one Subscribe call to return. | |||
| 214 | + | func awaitSubscribe(t *testing.T, results <-chan subscribeResult) subscribeResult { | |||
| 215 | + | t.Helper() | |||
| 216 | + | select { | |||
| 217 | + | case got := <-results: | |||
| 218 | + | return got | |||
| 219 | + | case <-time.After(5 * time.Second): | |||
| 220 | + | t.Fatal("Subscribe did not return") | |||
| 221 | + | return subscribeResult{} | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // TestSubscribeSendFailureRetiresPreviousSubscription proves a Subscribe whose | |||
| 226 | + | // frame never leaves has already consumed the connection's generation series: | |||
| 227 | + | // the superseded subscription is retired instead of being restored, so no stale | |||
| 228 | + | // subscription is presented as the live one and none stays current. | |||
| 229 | + | func TestSubscribeSendFailureRetiresPreviousSubscription(t *testing.T) { | |||
| 230 | + | c, carriage := newGatedClient(t, Config{}) | |||
| 231 | + | ||||
| 232 | + | first := make(chan subscribeResult, 1) | |||
| 233 | + | go func() { | |||
| 234 | + | sub, err := c.Subscribe() | |||
| 235 | + | first <- subscribeResult{sub, err} | |||
| 236 | + | }() | |||
| 237 | + | require.Equal(t, uint64(1), subscribeGenerationOf(t, awaitSend(t, carriage))) | |||
| 238 | + | carriage.releaseSend(nil) | |||
| 239 | + | previous := awaitSubscribe(t, first) | |||
| 240 | + | require.NoError(t, previous.err) | |||
| 241 | + | require.NotNil(t, previous.sub) | |||
| 242 | + | ||||
| 243 | + | // The second Subscribe consumes generation 2 on the connection's tracker | |||
| 244 | + | // before its carriage write fails. | |||
| 245 | + | second := make(chan subscribeResult, 1) | |||
| 246 | + | go func() { | |||
| 247 | + | sub, err := c.Subscribe() | |||
| 248 | + | second <- subscribeResult{sub, err} | |||
| 249 | + | }() | |||
| 250 | + | require.Equal(t, uint64(2), subscribeGenerationOf(t, awaitSend(t, carriage))) | |||
| 251 | + | carriage.releaseSend(errTestCarriage) | |||
| 252 | + | ||||
| 253 | + | failed := awaitSubscribe(t, second) | |||
| 254 | + | require.ErrorIs(t, failed.err, errTestCarriage) | |||
| 255 | + | require.Nil(t, failed.sub, "a failed Subscribe returns no subscription") | |||
| 256 | + | ||||
| 257 | + | select { | |||
| 258 | + | case _, open := <-previous.sub.Changed(): | |||
| 259 | + | require.False(t, open, "the superseded subscription must be closed") | |||
| 260 | + | case <-time.After(time.Second): | |||
| 261 | + | t.Fatal("the previous subscription must be closed") | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | c.mu.Lock() | |||
| 265 | + | current, generation := c.sub, c.generation | |||
| 266 | + | c.mu.Unlock() | |||
| 267 | + | require.Nil(t, current, "a failed Subscribe must not leave a current subscription") | |||
| 268 | + | require.Equal(t, uint64(2), uint64(generation), "the consumed generation is not rolled back") | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // TestOpenStreamCarriesTypedTraffic proves a logical stream carries the typed | |||
| 272 | + | // session protocol in both directions through the broker. | |||
| 273 | + | func TestOpenStreamCarriesTypedTraffic(t *testing.T) { | |||
| 274 | + | e := startEndpoint(t, Config{}) | |||
| 275 | + | client, _ := e.pair() | |||
| 276 | + | core := e.authority.last() | |||
| 277 | + | ||||
| 278 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 279 | + | defer cancel() | |||
| 280 | + | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |||
| 281 | + | Purpose: ports.BrokerStreamControl, | |||
| 282 | + | Local: true, | |||
| 283 | + | Policy: testPolicy(), | |||
| 284 | + | }) | |||
| 285 | + | require.NoError(t, err) | |||
| 286 | + | t.Cleanup(func() { _ = stream.Close() }) | |||
| 287 | + | ||||
| 288 | + | conn := core.logicalConn(1) | |||
| 289 | + | require.NotNil(t, conn, "the broker core must admit exactly one stream") | |||
| 290 | + | ||||
| 291 | + | require.NoError(t, stream.SendClient(protocol.Ping{})) | |||
| 292 | + | select { | |||
| 293 | + | case message := <-conn.fromClient: | |||
| 294 | + | require.Equal(t, protocol.Ping{}, message, "the client message must reach the broker core") | |||
| 295 | + | case <-time.After(5 * time.Second): | |||
| 296 | + | t.Fatal("client message never reached the broker core") | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | conn.toClient <- protocol.Pong{} | |||
| 300 | + | done := make(chan protocol.ServerMessage, 1) | |||
| 301 | + | go func() { | |||
| 302 | + | message, err := stream.ReceiveServer() | |||
| 303 | + | if err != nil { | |||
| 304 | + | done <- nil | |||
| 305 | + | return | |||
| 306 | + | } | |||
| 307 | + | done <- message | |||
| 308 | + | }() | |||
| 309 | + | select { | |||
| 310 | + | case message := <-done: | |||
| 311 | + | require.Equal(t, protocol.Pong{}, message, "the daemon message must reach the client") | |||
| 312 | + | case <-time.After(5 * time.Second): | |||
| 313 | + | t.Fatal("daemon message never reached the client") | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // TestOpenStreamRefusedByCoreReachesClient proves a core refusal settles only | |||
| 318 | + | // that stream and reaches the caller as a typed failure. | |||
| 319 | + | func TestOpenStreamRefusedByCoreReachesClient(t *testing.T) { | |||
| 320 | + | e := startEndpoint(t, Config{}) | |||
| 321 | + | client, _ := e.pair() | |||
| 322 | + | core := e.authority.last() | |||
| 323 | + | core.openErr = ports.BrokerError{Code: ports.BrokerErrorIncompatible} | |||
| 324 | + | ||||
| 325 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 326 | + | defer cancel() | |||
| 327 | + | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |||
| 328 | + | Purpose: ports.BrokerStreamControl, | |||
| 329 | + | Local: true, | |||
| 330 | + | Policy: testPolicy(), | |||
| 331 | + | }) | |||
| 332 | + | require.Error(t, err) | |||
| 333 | + | require.Nil(t, stream) | |||
| 334 | + | var failure ports.BrokerError | |||
| 335 | + | require.ErrorAs(t, err, &failure) | |||
| 336 | + | require.Equal(t, ports.BrokerErrorIncompatible, failure.Code) | |||
| 337 | + | ||||
| 338 | + | // The connection survives a stream-local refusal: a later operation still | |||
| 339 | + | // completes. | |||
| 340 | + | require.NoError(t, client.AddHost(ctx, "after@refusal:22")) | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | // TestTerminalStreamFailureFailsOnlyThatStream proves one stream's terminal | |||
| 344 | + | // failure is reported to its owner and leaves a sibling stream usable. | |||
| 345 | + | func TestTerminalStreamFailureFailsOnlyThatStream(t *testing.T) { | |||
| 346 | + | e := startEndpoint(t, Config{}) | |||
| 347 | + | client, _ := e.pair() | |||
| 348 | + | core := e.authority.last() | |||
| 349 | + | ||||
| 350 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 351 | + | defer cancel() | |||
| 352 | + | first, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |||
| 353 | + | require.NoError(t, err) | |||
| 354 | + | second, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |||
| 355 | + | require.NoError(t, err) | |||
| 356 | + | t.Cleanup(func() { _ = first.Close(); _ = second.Close() }) | |||
| 357 | + | ||||
| 358 | + | core.logicalConn(1).fail(ports.BrokerError{Code: ports.BrokerErrorAttachmentLost}) | |||
| 359 | + | ||||
| 360 | + | select { | |||
| 361 | + | case <-first.Done(): | |||
| 362 | + | case <-time.After(5 * time.Second): | |||
| 363 | + | t.Fatal("a failed stream must publish its terminal outcome") | |||
| 364 | + | } | |||
| 365 | + | require.Error(t, first.Err()) | |||
| 366 | + | select { | |||
| 367 | + | case <-second.Done(): | |||
| 368 | + | t.Fatal("a failing sibling must not settle an unrelated stream") | |||
| 369 | + | default: | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | // The sibling still carries traffic. | |||
| 373 | + | require.NoError(t, second.SendClient(protocol.Ping{})) | |||
| 374 | + | select { | |||
| 375 | + | case message := <-core.logicalConn(2).fromClient: | |||
| 376 | + | require.Equal(t, protocol.Ping{}, message) | |||
| 377 | + | case <-time.After(5 * time.Second): | |||
| 378 | + | t.Fatal("sibling stream stopped carrying traffic") | |||
| 379 | + | } | |||
| 380 | + | require.NoError(t, client.AddHost(ctx, "sibling@host:22")) | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | // TestCloseStreamRetiresBothSides proves a client close reaches the broker core | |||
| 384 | + | // and retires the stream identity. | |||
| 385 | + | func TestCloseStreamRetiresBothSides(t *testing.T) { | |||
| 386 | + | e := startEndpoint(t, Config{}) | |||
| 387 | + | client, _ := e.pair() | |||
| 388 | + | core := e.authority.last() | |||
| 389 | + | ||||
| 390 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 391 | + | defer cancel() | |||
| 392 | + | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |||
| 393 | + | require.NoError(t, err) | |||
| 394 | + | require.NoError(t, client.CloseStream(client.ConnectionID(), 1)) | |||
| 395 | + | ||||
| 396 | + | require.Eventually(t, func() bool { | |||
| 397 | + | core.mu.Lock() | |||
| 398 | + | defer core.mu.Unlock() | |||
| 399 | + | for _, id := range core.closedIDs { | |||
| 400 | + | if id == 1 { | |||
| 401 | + | return true | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | return false | |||
| 405 | + | }, 5*time.Second, 10*time.Millisecond, "the broker core must retire the closed stream") | |||
| 406 | + | require.NoError(t, stream.Close(), "closing an already retired stream is idempotent") | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | // TestMutatingOperationsComplete proves AddHost and RemoveHost complete with | |||
| 410 | + | // typed results and reach the admitted core service. | |||
| 411 | + | func TestMutatingOperationsComplete(t *testing.T) { | |||
| 412 | + | e := startEndpoint(t, Config{}) | |||
| 413 | + | client, _ := e.pair() | |||
| 414 | + | core := e.authority.last() | |||
| 415 | + | ||||
| 416 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 417 | + | defer cancel() | |||
| 418 | + | require.NoError(t, client.AddHost(ctx, "new@host:22")) | |||
| 419 | + | removed, err := client.RemoveHost(ctx, "known") | |||
| 420 | + | require.NoError(t, err) | |||
| 421 | + | require.True(t, removed) | |||
| 422 | + | removed, err = client.RemoveHost(ctx, "missing") | |||
| 423 | + | require.NoError(t, err) | |||
| 424 | + | require.False(t, removed) | |||
| 425 | + | ||||
| 426 | + | core.mu.Lock() | |||
| 427 | + | defer core.mu.Unlock() | |||
| 428 | + | require.Equal(t, []string{"new@host:22"}, core.added) | |||
| 429 | + | require.Equal(t, []string{"known", "missing"}, core.removed) | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | // TestLostOperationReplyIsOutcomeUnknown proves a mutating operation whose reply | |||
| 433 | + | // never arrives is reported as outcome-unknown rather than retried or reported | |||
| 434 | + | // as a plain failure. | |||
| 435 | + | func TestLostOperationReplyIsOutcomeUnknown(t *testing.T) { | |||
| 436 | + | e := startEndpoint(t, Config{}) | |||
| 437 | + | client, _ := e.pair() | |||
| 438 | + | core := e.authority.last() | |||
| 439 | + | core.blockAdd = make(chan struct{}) | |||
| 440 | + | t.Cleanup(func() { close(core.blockAdd) }) | |||
| 441 | + | ||||
| 442 | + | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | |||
| 443 | + | defer cancel() | |||
| 444 | + | err := client.AddHost(ctx, "slow@host:22") | |||
| 445 | + | require.Error(t, err) | |||
| 446 | + | var failure ports.BrokerError | |||
| 447 | + | require.ErrorAs(t, err, &failure) | |||
| 448 | + | require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code) | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | // TestDisconnectCleanupReleasesEveryOwnedResource proves a client disconnect | |||
| 452 | + | // deterministically retires its streams, closes its admitted core service, and | |||
| 453 | + | // releases its listener slot. | |||
| 454 | + | func TestDisconnectCleanupReleasesEveryOwnedResource(t *testing.T) { | |||
| 455 | + | e := startEndpoint(t, Config{MaxClients: 2, HandshakeTimeout: time.Second}) | |||
| 456 | + | client, _ := e.pair() | |||
| 457 | + | core := e.authority.last() | |||
| 458 | + | ||||
| 459 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 460 | + | defer cancel() | |||
| 461 | + | _, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |||
| 462 | + | require.NoError(t, err) | |||
| 463 | + | conn := core.logicalConn(1) | |||
| 464 | + | require.NotNil(t, conn) | |||
| 465 | + | ||||
| 466 | + | require.NoError(t, client.Close()) | |||
| 467 | + | ||||
| 468 | + | select { | |||
| 469 | + | case <-core.done: | |||
| 470 | + | case <-time.After(5 * time.Second): | |||
| 471 | + | t.Fatal("the admitted core service must be closed on disconnect") | |||
| 472 | + | } | |||
| 473 | + | select { | |||
| 474 | + | case <-conn.done: | |||
| 475 | + | case <-time.After(5 * time.Second): | |||
| 476 | + | t.Fatal("the bridged core connection must be closed on disconnect") | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | require.Eventually(t, func() bool { | |||
| 480 | + | return e.listener.(*listener).liveSessions() == 0 | |||
| 481 | + | }, 5*time.Second, 10*time.Millisecond, "a retired session must leave the listener's live set") | |||
| 482 | + | ||||
| 483 | + | // The listener slot is reusable: a fresh client is admitted. | |||
| 484 | + | fresh := e.dial() | |||
| 485 | + | session := e.accept() | |||
| 486 | + | require.Equal(t, fresh.ConnectionID(), session.ConnectionID()) | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | // TestStalledSubscriberDoesNotBlockOtherClients proves publications are | |||
| 490 | + | // per-connection: a client that subscribes and never reads cannot delay another | |||
| 491 | + | // client's publication or operations. | |||
| 492 | + | func TestStalledSubscriberDoesNotBlockOtherClients(t *testing.T) { | |||
| 493 | + | e := startEndpoint(t, Config{MaxClients: 4}) | |||
| 494 | + | stalled, _ := e.pair() | |||
| 495 | + | _, err := stalled.Subscribe() | |||
| 496 | + | require.NoError(t, err) | |||
| 497 | + | ||||
| 498 | + | active, _ := e.pair() | |||
| 499 | + | sub, err := active.Subscribe() | |||
| 500 | + | require.NoError(t, err) | |||
| 501 | + | t.Cleanup(sub.Close) | |||
| 502 | + | ||||
| 503 | + | for revision := ports.BrokerRevision(1); revision <= 20; revision++ { | |||
| 504 | + | e.publish(e.epoch, revision) | |||
| 505 | + | } | |||
| 506 | + | require.Eventually(t, func() bool { return active.Snapshot().Revision == 20 }, 5*time.Second, 10*time.Millisecond) | |||
| 507 | + | ||||
| 508 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 509 | + | defer cancel() | |||
| 510 | + | require.NoError(t, active.AddHost(ctx, "active@host:22")) | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | // TestClientStreamBackpressureSettlesOnlyThatStream proves a local consumer that | |||
| 514 | + | // stops draining a stream's bounded inbound queue settles exactly that stream: | |||
| 515 | + | // the peer is told, the stream reports backpressure, and the connection and its | |||
| 516 | + | // operations stay up. | |||
| 517 | + | func TestClientStreamBackpressureSettlesOnlyThatStream(t *testing.T) { | |||
| 518 | + | e := startEndpoint(t, Config{MaxClients: 2}) | |||
| 519 | + | client := e.dialWith(Config{StreamInboundChunks: 1}) | |||
| 520 | + | _ = e.accept() | |||
| 521 | + | core := e.authority.last() | |||
| 522 | + | ||||
| 523 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 524 | + | defer cancel() | |||
| 525 | + | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |||
| 526 | + | require.NoError(t, err) | |||
| 527 | + | conn := core.logicalConn(1) | |||
| 528 | + | require.NotNil(t, conn) | |||
| 529 | + | ||||
| 530 | + | // Two daemon messages arrive while the local consumer never reads: the | |||
| 531 | + | // bounded capacity-one queue refuses the second one. | |||
| 532 | + | conn.toClient <- protocol.Pong{} | |||
| 533 | + | conn.toClient <- protocol.Pong{} | |||
| 534 | + | ||||
| 535 | + | select { | |||
| 536 | + | case <-stream.Done(): | |||
| 537 | + | case <-time.After(5 * time.Second): | |||
| 538 | + | t.Fatal("a stalled stream consumer must settle the stream") | |||
| 539 | + | } | |||
| 540 | + | require.ErrorIs(t, stream.(*clientStream).Err(), ErrStreamBackpressure) | |||
| 541 | + | ||||
| 542 | + | require.Eventually(t, func() bool { | |||
| 543 | + | core.mu.Lock() | |||
| 544 | + | defer core.mu.Unlock() | |||
| 545 | + | return len(core.closedIDs) > 0 | |||
| 546 | + | }, 5*time.Second, 10*time.Millisecond, "the broker must be told to retire the stalled stream") | |||
| 547 | + | ||||
| 548 | + | // The connection survives a stream-local stall. | |||
| 549 | + | require.NoError(t, client.AddHost(ctx, "after@stall:22")) | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | // TestOrderlyStreamCloseReportsNoError proves a local close is an orderly end: | |||
| 553 | + | // Done closes and Err stays nil, so a caller can distinguish it from a failure. | |||
| 554 | + | func TestOrderlyStreamCloseReportsNoError(t *testing.T) { | |||
| 555 | + | e := startEndpoint(t, Config{}) | |||
| 556 | + | client, _ := e.pair() | |||
| 557 | + | ||||
| 558 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 559 | + | defer cancel() | |||
| 560 | + | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |||
| 561 | + | require.NoError(t, err) | |||
| 562 | + | require.NoError(t, stream.Close()) | |||
| 563 | + | select { | |||
| 564 | + | case <-stream.Done(): | |||
| 565 | + | case <-time.After(5 * time.Second): | |||
| 566 | + | t.Fatal("a local close must publish its terminal outcome") | |||
| 567 | + | } | |||
| 568 | + | require.NoError(t, stream.Err()) | |||
| 569 | + | require.NoError(t, stream.Close(), "closing twice is idempotent") | |||
| 570 | + | } | |||
| 571 | + | ||||
| 572 | + | // TestScopeIdentityFencing proves the client adapter refuses a caller-supplied | |||
| 573 | + | // identity that belongs to another connection and stream identities that do not | |||
| 574 | + | // advance. | |||
| 575 | + | func TestScopeIdentityFencing(t *testing.T) { | |||
| 576 | + | e := startEndpoint(t, Config{}) | |||
| 577 | + | client, _ := e.pair() | |||
| 578 | + | ||||
| 579 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 580 | + | defer cancel() | |||
| 581 | + | foreign := ports.BrokerConnectionID{0x99} | |||
| 582 | + | _, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |||
| 583 | + | Purpose: ports.BrokerStreamControl, Local: true, Connection: foreign, Policy: testPolicy(), | |||
| 584 | + | }) | |||
| 585 | + | require.ErrorIs(t, err, ports.BrokerAdmissionStale) | |||
| 586 | + | ||||
| 587 | + | _, err = client.OpenStream(ctx, ports.BrokerOpenStreamRequest{ | |||
| 588 | + | Purpose: ports.BrokerStreamControl, Local: true, Epoch: e.epoch + 1, Policy: testPolicy(), | |||
| 589 | + | }) | |||
| 590 | + | require.ErrorIs(t, err, ErrScopeMismatch) | |||
| 591 | + | var stale ports.BrokerError | |||
| 592 | + | require.ErrorAs(t, err, &stale) | |||
| 593 | + | require.Equal(t, ports.BrokerErrorStaleEpoch, stale.Code) | |||
| 594 | + | ||||
| 595 | + | first, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |||
| 596 | + | require.NoError(t, err) | |||
| 597 | + | t.Cleanup(func() { _ = first.Close() }) | |||
| 598 | + | _, err = client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy()}) | |||
| 599 | + | require.ErrorIs(t, err, ports.BrokerAdmissionStale) | |||
| 600 | + | } | |||
| 601 | + | ||||
| 602 | + | // TestRequestReconcileIsBoundedHint proves the reconcile hint is sent only for an | |||
| 603 | + | // endpoint the connection already holds a registration for, and that it is | |||
| 604 | + | // otherwise a no-op. | |||
| 605 | + | func TestRequestReconcileIsBoundedHint(t *testing.T) { | |||
| 606 | + | e := startEndpoint(t, Config{}) | |||
| 607 | + | client, _ := e.pair() | |||
| 608 | + | core := e.authority.last() | |||
| 609 | + | ||||
| 610 | + | client.RequestReconcile("unknown@host:22") | |||
| 611 | + | time.Sleep(50 * time.Millisecond) | |||
| 612 | + | core.mu.Lock() | |||
| 613 | + | require.Empty(t, core.reconcile) | |||
| 614 | + | core.mu.Unlock() | |||
| 615 | + | ||||
| 616 | + | sub, err := client.Subscribe() | |||
| 617 | + | require.NoError(t, err) | |||
| 618 | + | t.Cleanup(sub.Close) | |||
| 619 | + | snapshot := e.publish(e.epoch, 4) | |||
| 620 | + | require.Eventually(t, func() bool { return client.Snapshot().Revision == 4 }, 5*time.Second, 10*time.Millisecond) | |||
| 621 | + | ||||
| 622 | + | client.RequestReconcile(snapshot.Hosts[0].Endpoint) | |||
| 623 | + | require.Eventually(t, func() bool { | |||
| 624 | + | core.mu.Lock() | |||
| 625 | + | defer core.mu.Unlock() | |||
| 626 | + | return len(core.reconcile) == 1 && core.reconcile[0] == snapshot.Hosts[0].Endpoint | |||
| 627 | + | }, 5*time.Second, 10*time.Millisecond) | |||
| 628 | + | } | |||
| 629 | + | ||||
| 630 | + | // TestCoreErrorSurfacesAsTypedFailure proves a typed core failure reaches the | |||
| 631 | + | // client through the stream lifecycle. | |||
| 632 | + | func TestCoreErrorSurfacesAsTypedFailure(t *testing.T) { | |||
| 633 | + | e := startEndpoint(t, Config{}) | |||
| 634 | + | client, _ := e.pair() | |||
| 635 | + | core := e.authority.last() | |||
| 636 | + | ||||
| 637 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 638 | + | defer cancel() | |||
| 639 | + | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |||
| 640 | + | require.NoError(t, err) | |||
| 641 | + | t.Cleanup(func() { _ = stream.Close() }) | |||
| 642 | + | ||||
| 643 | + | core.logicalConn(1).fail(ports.BrokerError{Code: ports.BrokerErrorAttachmentLost, Text: "daemon went away"}) | |||
| 644 | + | select { | |||
| 645 | + | case <-stream.Done(): | |||
| 646 | + | case <-time.After(5 * time.Second): | |||
| 647 | + | t.Fatal("core loss must settle the stream") | |||
| 648 | + | } | |||
| 649 | + | var failure ports.BrokerError | |||
| 650 | + | require.ErrorAs(t, stream.Err(), &failure) | |||
| 651 | + | require.Equal(t, ports.BrokerErrorAttachmentLost, failure.Code) | |||
| 652 | + | require.Equal(t, "daemon went away", failure.Text) | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | // TestSessionHandshakeFailureIsStreamLocal proves a malformed inner session | |||
| 656 | + | // stream settles only that stream. | |||
| 657 | + | func TestSessionHandshakeFailureIsStreamLocal(t *testing.T) { | |||
| 658 | + | e := startEndpoint(t, Config{}) | |||
| 659 | + | client, _ := e.pair() | |||
| 660 | + | core := e.authority.last() | |||
| 661 | + | ||||
| 662 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 663 | + | defer cancel() | |||
| 664 | + | stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}) | |||
| 665 | + | require.NoError(t, err) | |||
| 666 | + | conn := core.logicalConn(1) | |||
| 667 | + | require.NotNil(t, conn) | |||
| 668 | + | ||||
| 669 | + | // The client emits a malformed inner session frame: the broker's session | |||
| 670 | + | // carriage refuses it and resets only this stream. | |||
| 671 | + | raw := stream.(*clientStream) | |||
| 672 | + | _, err = raw.pipe.Write([]byte{0, 0, 0, 4, 0xff, 0xff, 0xff, 0xff}) | |||
| 673 | + | require.NoError(t, err) | |||
| 674 | + | select { | |||
| 675 | + | case <-stream.Done(): | |||
| 676 | + | case <-time.After(5 * time.Second): | |||
| 677 | + | t.Fatal("a malformed inner frame must settle its stream") | |||
| 678 | + | } | |||
| 679 | + | require.NoError(t, client.AddHost(ctx, "survivor@host:22")) | |||
| 680 | + | ||||
| 681 | + | // The broker core sees the stream retired. | |||
| 682 | + | require.Eventually(t, func() bool { | |||
| 683 | + | return core.logicalConn(1) == nil | |||
| 684 | + | }, 5*time.Second, 10*time.Millisecond) | |||
| 685 | + | } | |||
| 686 | + | ||||
| 687 | + | // TestDomainFixturesAreValid guards the test fixtures against drifting from the | |||
| 688 | + | // contracts the wire enforces. | |||
| 689 | + | func TestDomainFixturesAreValid(t *testing.T) { | |||
| 690 | + | snapshot := testSnapshot(1, 1) | |||
| 691 | + | require.NoError(t, snapshot.Validate()) | |||
| 692 | + | for _, host := range snapshot.Hosts { | |||
| 693 | + | require.NoError(t, ports.ValidateDurableHostProjection(host)) | |||
| 694 | + | } | |||
| 695 | + | require.NoError(t, testPolicy().Validate()) | |||
| 696 | + | catalogueSession := testSessionAt(0, 0) | |||
| 697 | + | require.False(t, strings.HasPrefix(catalogueSession.Name, " ")) | |||
| 698 | + | require.NotEqual(t, domain.RemoteAvailabilityUnknown, testHostAt(0).Availability) | |||
| 699 | + | require.True(t, errors.Is(errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale), ports.BrokerAdmissionStale)) | |||
| 700 | + | } | |||
A internal/adapters/brokeripc/socket.go +30 -0
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // SocketFileName is the fixed name of the per-user broker IPC socket inside its | |||
| 10 | + | // runtime directory. It is deliberately distinct from the daemon's | |||
| 11 | + | // daemon.sock, so the broker endpoint and a session daemon endpoint can never | |||
| 12 | + | // be confused for one another. | |||
| 13 | + | const SocketFileName = "broker.sock" | |||
| 14 | + | ||||
| 15 | + | // SocketPath returns the broker IPC socket path inside runtimeDir. | |||
| 16 | + | func SocketPath(runtimeDir string) string { | |||
| 17 | + | return filepath.Join(runtimeDir, SocketFileName) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // SocketDir returns the per-user directory the broker endpoint belongs in: | |||
| 21 | + | // $XDG_RUNTIME_DIR/vev when set, else /run/user/<uid>/vev when that directory | |||
| 22 | + | // exists, else /tmp/vev-<uid>. It is the daemon's per-user runtime directory as | |||
| 23 | + | // well, so both endpoints share one owner-only directory while keeping | |||
| 24 | + | // distinct socket names. | |||
| 25 | + | func SocketDir() string { return ipc.SocketDir() } | |||
| 26 | + | ||||
| 27 | + | // DefaultSocketPath returns the broker endpoint path in the per-user runtime | |||
| 28 | + | // directory. Callers that need a private temporary endpoint supply their own | |||
| 29 | + | // parent directory to SocketPath instead. | |||
| 30 | + | func DefaultSocketPath() string { return SocketPath(SocketDir()) } | |||
A internal/adapters/brokeripc/stream.go +493 -0
| @@ -0,0 +1,493 @@ | |||||
| 1 | + | package brokeripc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "sync" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/adapters/brokerwire" | |||
| 10 | + | "github.com/bnema/vev/internal/adapters/sessionwire" | |||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Logical stream bridging (P3.3). | |||
| 15 | + | // | |||
| 16 | + | // One brokerwire logical stream carries the typed session protocol between a | |||
| 17 | + | // client and the daemon behind the broker. The broker terminates both hops: the | |||
| 18 | + | // client's session connection is built by sessionwire over the stream's private | |||
| 19 | + | // bounded byte pipe, and the admitted broker core hands back its own typed | |||
| 20 | + | // connection to the daemon. Two relay goroutines carry typed messages between | |||
| 21 | + | // them, so no session byte is ever reinterpreted here. | |||
| 22 | + | // | |||
| 23 | + | // Identity and cancellation stay per stream: a stream the core loses, a stream | |||
| 24 | + | // whose local consumer stalled, and a stream the peer closed each settle alone, | |||
| 25 | + | // sending at most one typed StreamClosed and releasing only that stream's | |||
| 26 | + | // resources. The connection and every sibling stream stay up. | |||
| 27 | + | ||||
| 28 | + | // serverStream is the broker-side half of one logical stream. | |||
| 29 | + | type serverStream struct { | |||
| 30 | + | session *serverSession | |||
| 31 | + | id ports.BrokerStreamID | |||
| 32 | + | core ports.BrokerLogicalConnection | |||
| 33 | + | pipe *streamPipe | |||
| 34 | + | carriage *carriage | |||
| 35 | + | wire ports.ServerConnection | |||
| 36 | + | ||||
| 37 | + | mu sync.Mutex | |||
| 38 | + | closed bool | |||
| 39 | + | done chan struct{} | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // newServerStream builds one bridged stream over the admitted core connection. | |||
| 43 | + | func newServerStream(s *serverSession, id ports.BrokerStreamID, core ports.BrokerLogicalConnection) (*serverStream, error) { | |||
| 44 | + | if s == nil || core == nil || id == 0 { | |||
| 45 | + | return nil, ErrConfig | |||
| 46 | + | } | |||
| 47 | + | st := &serverStream{session: s, id: id, core: core, done: make(chan struct{})} | |||
| 48 | + | st.pipe = newStreamPipe(s.cfg.StreamInboundChunks, s.cfg.StreamInboundBytes, st.sendChunk) | |||
| 49 | + | st.carriage = newCarriage(st.pipe) | |||
| 50 | + | st.wire = sessionwire.NewServerConnection(st.carriage) | |||
| 51 | + | if st.wire == nil { | |||
| 52 | + | return nil, ErrConfig | |||
| 53 | + | } | |||
| 54 | + | return st, nil | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // sendChunk splits one outbound byte slice under the negotiated stream chunk | |||
| 58 | + | // ceiling and writes each frame in order. | |||
| 59 | + | func (st *serverStream) sendChunk(data []byte) error { | |||
| 60 | + | ceiling := int(st.session.ceilings.StreamChunkLimit) | |||
| 61 | + | if ceiling <= 0 { | |||
| 62 | + | return ErrConfig | |||
| 63 | + | } | |||
| 64 | + | for len(data) > 0 { | |||
| 65 | + | n := min(len(data), ceiling) | |||
| 66 | + | if err := st.session.send(brokerwire.ServerStreamData{ | |||
| 67 | + | Epoch: st.session.epoch, Connection: st.session.scope.Connection, Stream: st.id, Data: data[:n], | |||
| 68 | + | }); err != nil { | |||
| 69 | + | return err | |||
| 70 | + | } | |||
| 71 | + | data = data[n:] | |||
| 72 | + | } | |||
| 73 | + | return nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // deliver accepts one inbound chunk from the connection reader. | |||
| 77 | + | func (st *serverStream) deliver(chunk []byte) error { return st.pipe.deliver(chunk) } | |||
| 78 | + | ||||
| 79 | + | // start launches the three per-stream workers. Every one is joined by the | |||
| 80 | + | // session's WaitGroup before shutdown returns. | |||
| 81 | + | func (st *serverStream) start() { | |||
| 82 | + | st.session.wg.Add(3) | |||
| 83 | + | go st.relayClient() | |||
| 84 | + | go st.relayServer() | |||
| 85 | + | go st.watchCore() | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // relayClient carries client-to-daemon messages from the wire session | |||
| 89 | + | // connection to the admitted core connection. | |||
| 90 | + | func (st *serverStream) relayClient() { | |||
| 91 | + | defer st.session.wg.Done() | |||
| 92 | + | for { | |||
| 93 | + | message, err := st.wire.ReceiveClient() | |||
| 94 | + | if err != nil { | |||
| 95 | + | st.fail(cancelOrCause(st.session.ctx, streamFailure(err))) | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | if err := st.core.SendClient(message); err != nil { | |||
| 99 | + | st.fail(err) | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // relayServer carries daemon-to-client messages from the admitted core | |||
| 106 | + | // connection to the wire session connection. | |||
| 107 | + | func (st *serverStream) relayServer() { | |||
| 108 | + | defer st.session.wg.Done() | |||
| 109 | + | for { | |||
| 110 | + | message, err := st.core.ReceiveServer() | |||
| 111 | + | if err != nil { | |||
| 112 | + | st.fail(coreFailure(st.core, err)) | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | if err := st.wire.SendServer(message); err != nil { | |||
| 116 | + | st.fail(streamFailure(err)) | |||
| 117 | + | return | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // watchCore publishes the core connection's terminal outcome even when no read | |||
| 123 | + | // is in flight. | |||
| 124 | + | func (st *serverStream) watchCore() { | |||
| 125 | + | defer st.session.wg.Done() | |||
| 126 | + | select { | |||
| 127 | + | case <-st.core.Done(): | |||
| 128 | + | st.fail(coreFailure(st.core, nil)) | |||
| 129 | + | case <-st.done: | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // fail settles the stream once and reports it to the session, which retires its | |||
| 134 | + | // wire identity and sends at most one typed StreamClosed. | |||
| 135 | + | func (st *serverStream) fail(err error) { | |||
| 136 | + | if st.close(err) { | |||
| 137 | + | st.session.settleStream(st, err) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // close releases the stream exactly once: the pipe unblocks a stalled reader, | |||
| 142 | + | // the wire session connection releases the carriage, and the core connection is | |||
| 143 | + | // closed. It reports whether this call was the one that settled the stream. | |||
| 144 | + | func (st *serverStream) close(cause error) bool { | |||
| 145 | + | if st == nil { | |||
| 146 | + | return false | |||
| 147 | + | } | |||
| 148 | + | st.mu.Lock() | |||
| 149 | + | if st.closed { | |||
| 150 | + | st.mu.Unlock() | |||
| 151 | + | return false | |||
| 152 | + | } | |||
| 153 | + | st.closed = true | |||
| 154 | + | st.mu.Unlock() | |||
| 155 | + | st.pipe.closeWith(cause) | |||
| 156 | + | _ = st.wire.Close() | |||
| 157 | + | _ = st.core.Close() | |||
| 158 | + | close(st.done) | |||
| 159 | + | return true | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // streamFailure classifies one wire-side stream error: an orderly carriage end | |||
| 163 | + | // is a peer close, not a transport failure. | |||
| 164 | + | func streamFailure(err error) error { | |||
| 165 | + | if err == nil || errors.Is(err, io.EOF) { | |||
| 166 | + | return ErrStreamGone | |||
| 167 | + | } | |||
| 168 | + | return err | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // coreFailure classifies one core connection outcome. The core's typed terminal | |||
| 172 | + | // error wins; an orderly end is a peer close. | |||
| 173 | + | func coreFailure(core ports.BrokerLogicalConnection, err error) error { | |||
| 174 | + | if err == nil && core != nil { | |||
| 175 | + | err = core.Err() | |||
| 176 | + | } | |||
| 177 | + | if err == nil { | |||
| 178 | + | return ErrStreamGone | |||
| 179 | + | } | |||
| 180 | + | var failure ports.BrokerError | |||
| 181 | + | if errors.As(err, &failure) { | |||
| 182 | + | return err | |||
| 183 | + | } | |||
| 184 | + | if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { | |||
| 185 | + | return ErrStreamGone | |||
| 186 | + | } | |||
| 187 | + | return err | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // orderlyStream reports whether one stream outcome closes the stream without a | |||
| 191 | + | // failure detail. | |||
| 192 | + | func orderlyStream(err error) bool { | |||
| 193 | + | return err == nil || errors.Is(err, ErrStreamGone) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // startStream admits one inbound open on the wire tracker and starts its bridge | |||
| 197 | + | // once the broker core established the stream. | |||
| 198 | + | func (s *serverSession) startStream(m brokerwire.OpenStream) { | |||
| 199 | + | if err := s.conn.OpenStream(m.Stream); err != nil { | |||
| 200 | + | if sendErr := s.refuseStream(m.Stream, admissionError(err)); sendErr != nil { | |||
| 201 | + | s.abort(sendErr) | |||
| 202 | + | } | |||
| 203 | + | return | |||
| 204 | + | } | |||
| 205 | + | request := ports.BrokerOpenStreamRequest{ | |||
| 206 | + | Epoch: m.Epoch, Purpose: m.Purpose, Local: m.Local, | |||
| 207 | + | Connection: m.Connection, Stream: m.Stream, | |||
| 208 | + | Endpoint: m.Endpoint, Registration: m.Registration, | |||
| 209 | + | Target: m.Target, Env: m.Env, Policy: m.Policy, | |||
| 210 | + | } | |||
| 211 | + | s.wg.Add(1) | |||
| 212 | + | go func() { | |||
| 213 | + | defer s.wg.Done() | |||
| 214 | + | core, err := s.core.OpenStream(s.ctx, request) | |||
| 215 | + | if err != nil { | |||
| 216 | + | _, _ = s.conn.CloseStream(m.Stream) | |||
| 217 | + | if sendErr := s.refuseStream(m.Stream, err); sendErr != nil { | |||
| 218 | + | s.abort(sendErr) | |||
| 219 | + | } | |||
| 220 | + | return | |||
| 221 | + | } | |||
| 222 | + | if core == nil { | |||
| 223 | + | _, _ = s.conn.CloseStream(m.Stream) | |||
| 224 | + | if sendErr := s.refuseStream(m.Stream, errors.Join(ports.BrokerAdmissionInvalid, errors.New("brokeripc: core returned a nil logical connection"))); sendErr != nil { | |||
| 225 | + | s.abort(sendErr) | |||
| 226 | + | } | |||
| 227 | + | return | |||
| 228 | + | } | |||
| 229 | + | stream, err := newServerStream(s, m.Stream, core) | |||
| 230 | + | if err != nil { | |||
| 231 | + | _ = core.Close() | |||
| 232 | + | _, _ = s.conn.CloseStream(m.Stream) | |||
| 233 | + | if sendErr := s.refuseStream(m.Stream, err); sendErr != nil { | |||
| 234 | + | s.abort(sendErr) | |||
| 235 | + | } | |||
| 236 | + | return | |||
| 237 | + | } | |||
| 238 | + | if !s.registerStream(stream) { | |||
| 239 | + | stream.close(ErrSessionClosed) | |||
| 240 | + | return | |||
| 241 | + | } | |||
| 242 | + | if err := s.conn.StreamOpened(m.Stream); err != nil { | |||
| 243 | + | s.settleStream(stream, errors.Join(ErrProtocol, err)) | |||
| 244 | + | return | |||
| 245 | + | } | |||
| 246 | + | if err := s.send(brokerwire.StreamOpened{Epoch: s.epoch, Connection: s.scope.Connection, Stream: m.Stream}); err != nil { | |||
| 247 | + | s.settleStream(stream, transportFailure(err)) | |||
| 248 | + | return | |||
| 249 | + | } | |||
| 250 | + | stream.start() | |||
| 251 | + | }() | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // registerStream installs one bridge, or reports false when the session is | |||
| 255 | + | // closing. | |||
| 256 | + | func (s *serverSession) registerStream(st *serverStream) bool { | |||
| 257 | + | s.streamsMu.Lock() | |||
| 258 | + | defer s.streamsMu.Unlock() | |||
| 259 | + | if s.ctx.Err() != nil { | |||
| 260 | + | return false | |||
| 261 | + | } | |||
| 262 | + | s.streams[st.id] = st | |||
| 263 | + | return true | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // unregisterStream removes one bridge, if present. | |||
| 267 | + | func (s *serverSession) unregisterStream(id ports.BrokerStreamID) { | |||
| 268 | + | s.streamsMu.Lock() | |||
| 269 | + | delete(s.streams, id) | |||
| 270 | + | s.streamsMu.Unlock() | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // lookupStream returns the bridge for one stream identity. | |||
| 274 | + | func (s *serverSession) lookupStream(id ports.BrokerStreamID) *serverStream { | |||
| 275 | + | s.streamsMu.Lock() | |||
| 276 | + | defer s.streamsMu.Unlock() | |||
| 277 | + | return s.streams[id] | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // settleStream retires one stream's wire identity and reports its outcome to the | |||
| 281 | + | // peer. A session that is already shutting down sends nothing: the carriage is | |||
| 282 | + | // closing and the peer is gone. | |||
| 283 | + | func (s *serverSession) settleStream(st *serverStream, err error) { | |||
| 284 | + | if st == nil { | |||
| 285 | + | return | |||
| 286 | + | } | |||
| 287 | + | st.close(err) | |||
| 288 | + | s.unregisterStream(st.id) | |||
| 289 | + | _, _ = s.conn.CloseStream(st.id) | |||
| 290 | + | // Retire the broker-side stream reservation as well, so a stream that | |||
| 291 | + | // settled for any reason is released at the core and not only in its | |||
| 292 | + | // carriage. | |||
| 293 | + | _ = s.core.CloseStream(s.scope.Connection, st.id) | |||
| 294 | + | if s.ctx.Err() != nil { | |||
| 295 | + | return | |||
| 296 | + | } | |||
| 297 | + | detail := brokerwire.ErrorDetail{} | |||
| 298 | + | if !orderlyStream(err) { | |||
| 299 | + | detail = errorDetail(err) | |||
| 300 | + | } | |||
| 301 | + | if sendErr := s.send(brokerwire.StreamClosed{ | |||
| 302 | + | Epoch: s.epoch, Connection: s.scope.Connection, Stream: st.id, | |||
| 303 | + | Error: detail, HasError: detail != (brokerwire.ErrorDetail{}), | |||
| 304 | + | }); sendErr != nil { | |||
| 305 | + | s.abort(sendErr) | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | // closeStreams settles every bridged stream during shutdown, releasing each | |||
| 310 | + | // core connection and session carriage exactly once. | |||
| 311 | + | func (s *serverSession) closeStreams() { | |||
| 312 | + | s.streamsMu.Lock() | |||
| 313 | + | streams := make([]*serverStream, 0, len(s.streams)) | |||
| 314 | + | for _, st := range s.streams { | |||
| 315 | + | streams = append(streams, st) | |||
| 316 | + | } | |||
| 317 | + | s.streams = make(map[ports.BrokerStreamID]*serverStream) | |||
| 318 | + | s.streamsMu.Unlock() | |||
| 319 | + | for _, st := range streams { | |||
| 320 | + | st.close(ErrSessionClosed) | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | // closeStreamByPeer retires one stream the peer closed. No StreamClosed is sent | |||
| 325 | + | // back: the peer already knows. | |||
| 326 | + | func (s *serverSession) closeStreamByPeer(id ports.BrokerStreamID) { | |||
| 327 | + | st := s.lookupStream(id) | |||
| 328 | + | if _, err := s.conn.CloseStream(id); err != nil && !errors.Is(err, brokerwire.ErrFutureStream) { | |||
| 329 | + | // A close for a stream that was never allocated is a peer protocol | |||
| 330 | + | // violation; a late close for a retired stream is simply discarded. | |||
| 331 | + | if errors.Is(err, brokerwire.ErrConnectionState) || errors.Is(err, brokerwire.ErrScopeMismatch) { | |||
| 332 | + | s.abort(errors.Join(ErrProtocol, err)) | |||
| 333 | + | return | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | if st == nil { | |||
| 337 | + | return | |||
| 338 | + | } | |||
| 339 | + | s.unregisterStream(id) | |||
| 340 | + | st.close(ErrStreamGone) | |||
| 341 | + | // Retire the broker-side stream reservation as well: the peer's close ends | |||
| 342 | + | // the client-scoped stream, not merely its carriage. | |||
| 343 | + | _ = s.core.CloseStream(s.scope.Connection, id) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // deliverStreamData routes one inbound client chunk to its bridge. Backpressure | |||
| 347 | + | // settles only that stream; a chunk for an identity the peer never opened is a | |||
| 348 | + | // protocol violation. | |||
| 349 | + | func (s *serverSession) deliverStreamData(m brokerwire.ClientStreamData) error { | |||
| 350 | + | disposition, err := s.conn.StreamData(m.Stream) | |||
| 351 | + | if err != nil { | |||
| 352 | + | // Stream data for a stream that was never allocated, or before it was | |||
| 353 | + | // confirmed open, is a peer protocol violation. | |||
| 354 | + | return errors.Join(ErrProtocol, err) | |||
| 355 | + | } | |||
| 356 | + | if disposition == brokerwire.StreamDiscarded { | |||
| 357 | + | return nil | |||
| 358 | + | } | |||
| 359 | + | st := s.lookupStream(m.Stream) | |||
| 360 | + | if st == nil { | |||
| 361 | + | // The bridge was settled between the tracker check and the lookup (a | |||
| 362 | + | // stream-local failure won the race); the chunk is discarded with it. | |||
| 363 | + | return nil | |||
| 364 | + | } | |||
| 365 | + | switch err := st.deliver(m.Data); { | |||
| 366 | + | case err == nil: | |||
| 367 | + | return nil | |||
| 368 | + | case errors.Is(err, ErrStreamBackpressure): | |||
| 369 | + | s.settleStream(st, ErrStreamBackpressure) | |||
| 370 | + | return nil | |||
| 371 | + | default: | |||
| 372 | + | // The stream already reached its terminal outcome; its data is | |||
| 373 | + | // discarded. | |||
| 374 | + | return nil | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | // clientStream is the client-side half of one logical stream: the sessionwire | |||
| 379 | + | // client connection over the stream's bounded pipe, plus the terminal outcome a | |||
| 380 | + | // ports.BrokerLogicalConnection exposes independently of reads. | |||
| 381 | + | type clientStream struct { | |||
| 382 | + | ports.ClientConnection | |||
| 383 | + | client *client | |||
| 384 | + | id ports.BrokerStreamID | |||
| 385 | + | pipe *streamPipe | |||
| 386 | + | carriage *carriage | |||
| 387 | + | ||||
| 388 | + | opened chan error | |||
| 389 | + | mu sync.Mutex | |||
| 390 | + | closed bool | |||
| 391 | + | err error | |||
| 392 | + | done chan struct{} | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | // newClientStream builds one client-side stream over the broker connection. | |||
| 396 | + | func newClientStream(c *client, id ports.BrokerStreamID) (*clientStream, error) { | |||
| 397 | + | if c == nil || id == 0 { | |||
| 398 | + | return nil, ErrConfig | |||
| 399 | + | } | |||
| 400 | + | st := &clientStream{ | |||
| 401 | + | client: c, | |||
| 402 | + | id: id, | |||
| 403 | + | opened: make(chan error, 1), | |||
| 404 | + | done: make(chan struct{}), | |||
| 405 | + | } | |||
| 406 | + | st.pipe = newStreamPipe(c.cfg.StreamInboundChunks, c.cfg.StreamInboundBytes, st.sendChunk) | |||
| 407 | + | st.carriage = newCarriage(st.pipe) | |||
| 408 | + | st.ClientConnection = sessionwire.NewClientConnection(st.carriage) | |||
| 409 | + | if st.ClientConnection == nil { | |||
| 410 | + | return nil, ErrConfig | |||
| 411 | + | } | |||
| 412 | + | return st, nil | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | // sendChunk splits one outbound client byte slice under the negotiated stream | |||
| 416 | + | // chunk ceiling. | |||
| 417 | + | func (st *clientStream) sendChunk(data []byte) error { | |||
| 418 | + | ceiling := int(st.client.ceilings.StreamChunkLimit) | |||
| 419 | + | if ceiling <= 0 { | |||
| 420 | + | return ErrConfig | |||
| 421 | + | } | |||
| 422 | + | for len(data) > 0 { | |||
| 423 | + | n := min(len(data), ceiling) | |||
| 424 | + | if err := st.client.send(brokerwire.ClientStreamData{ | |||
| 425 | + | Epoch: st.client.scope.Epoch, Connection: st.client.scope.Connection, Stream: st.id, Data: data[:n], | |||
| 426 | + | }); err != nil { | |||
| 427 | + | return err | |||
| 428 | + | } | |||
| 429 | + | data = data[n:] | |||
| 430 | + | } | |||
| 431 | + | return nil | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | // deliver accepts one inbound server chunk. | |||
| 435 | + | func (st *clientStream) deliver(chunk []byte) error { return st.pipe.deliver(chunk) } | |||
| 436 | + | ||||
| 437 | + | // Done closes when the stream reached its terminal outcome. | |||
| 438 | + | func (st *clientStream) Done() <-chan struct{} { return st.done } | |||
| 439 | + | ||||
| 440 | + | // Err reports the terminal cause; nil is an orderly close. | |||
| 441 | + | func (st *clientStream) Err() error { | |||
| 442 | + | st.mu.Lock() | |||
| 443 | + | defer st.mu.Unlock() | |||
| 444 | + | return st.err | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | // Close ends the stream: it sends one CloseStream frame best-effort, releases | |||
| 448 | + | // the carriage, and resolves any pending open. It is idempotent and | |||
| 449 | + | // concurrent-safe. | |||
| 450 | + | func (st *clientStream) Close() error { | |||
| 451 | + | if st == nil { | |||
| 452 | + | return nil | |||
| 453 | + | } | |||
| 454 | + | st.client.requestStreamClose(st.id) | |||
| 455 | + | st.terminate(nil) | |||
| 456 | + | return nil | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | // terminate settles the stream exactly once, publishes its terminal cause (nil | |||
| 460 | + | // for an orderly close), and resolves a pending open. | |||
| 461 | + | func (st *clientStream) terminate(cause error) bool { | |||
| 462 | + | if st == nil { | |||
| 463 | + | return false | |||
| 464 | + | } | |||
| 465 | + | st.mu.Lock() | |||
| 466 | + | if st.closed { | |||
| 467 | + | st.mu.Unlock() | |||
| 468 | + | return false | |||
| 469 | + | } | |||
| 470 | + | st.closed = true | |||
| 471 | + | st.err = cause | |||
| 472 | + | st.mu.Unlock() | |||
| 473 | + | st.pipe.closeWith(cause) | |||
| 474 | + | _ = st.carriage.Close() | |||
| 475 | + | st.client.unregisterStream(st.id) | |||
| 476 | + | // Retire the identity on the connection's tracker as well, so a frame that | |||
| 477 | + | // arrives after local retirement is classified as discarded. | |||
| 478 | + | _, _ = st.client.conn.CloseStream(st.id) | |||
| 479 | + | select { | |||
| 480 | + | case st.opened <- cause: | |||
| 481 | + | default: | |||
| 482 | + | } | |||
| 483 | + | close(st.done) | |||
| 484 | + | return true | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | // fail settles the stream and tells the broker to retire it, so a stream-local | |||
| 488 | + | // failure never leaves the peer half-open. | |||
| 489 | + | func (st *clientStream) fail(err error) { | |||
| 490 | + | if st.terminate(err) { | |||
| 491 | + | st.client.requestStreamClose(st.id) | |||
| 492 | + | } | |||
| 493 | + | } | |||
M internal/adapters/brokerwire/codec_test.go +34 -1
| @@ -146,6 +146,39 @@ func TestBrokerPreambleByteLengthHelper(t *testing.T) { | |||||
| 146 | // TestBrokerPreambleRoles proves the broker preamble uses roles 3/4, the | 146 | // TestBrokerPreambleRoles proves the broker preamble uses roles 3/4, the | ||
| 147 | // existing magic/epoch/exact version, zero capability bits, the 4 KiB | 147 | // existing magic/epoch/exact version, zero capability bits, the 4 KiB | ||
| 148 | // preamble bound, and precise refusal mapping. | 148 | // preamble bound, and precise refusal mapping. | ||
| 149 | + | // TestBrokerCeilingsExportedSurface proves the exported ceiling value the | |||
| 150 | + | // connection dispatcher consumes stays equivalent to the preamble helpers' | |||
| 151 | + | // internal view: defaults are negotiable, minima are element-wise, and a | |||
| 152 | + | // non-negotiable advertisement is refused. | |||
| 153 | + | func TestBrokerCeilingsExportedSurface(t *testing.T) { | |||
| 154 | + | defaults := DefaultCeilings() | |||
| 155 | + | require.NoError(t, defaults.Validate()) | |||
| 156 | + | require.Equal(t, defaultBrokerCeilings(), defaults) | |||
| 157 | + | ||||
| 158 | + | smaller := Ceilings{MaxReceiveEnvelopeBytes: MinBrokerEnvelopeBytes, StreamChunkLimit: 1024} | |||
| 159 | + | require.NoError(t, smaller.Validate()) | |||
| 160 | + | require.Equal(t, smaller, EffectiveCeilings(defaults, smaller)) | |||
| 161 | + | require.Equal(t, smaller, EffectiveCeilings(smaller, defaults)) | |||
| 162 | + | require.Equal(t, defaults, EffectiveCeilings(defaults, defaults)) | |||
| 163 | + | ||||
| 164 | + | for _, invalid := range []Ceilings{ | |||
| 165 | + | {}, | |||
| 166 | + | {MaxReceiveEnvelopeBytes: MinBrokerEnvelopeBytes - 1, StreamChunkLimit: MaxStreamChunkBytes}, | |||
| 167 | + | {MaxReceiveEnvelopeBytes: MaxBrokerEnvelopeBytes + 1, StreamChunkLimit: MaxStreamChunkBytes}, | |||
| 168 | + | {MaxReceiveEnvelopeBytes: MaxBrokerEnvelopeBytes, StreamChunkLimit: 0}, | |||
| 169 | + | {MaxReceiveEnvelopeBytes: MaxBrokerEnvelopeBytes, StreamChunkLimit: MaxStreamChunkBytes + 1}, | |||
| 170 | + | } { | |||
| 171 | + | require.ErrorIs(t, invalid.Validate(), ErrPreambleRejected) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // The decoded preamble's exported ceilings drive the codec ceilings. | |||
| 175 | + | request := EncodePreambleRequest(smaller) | |||
| 176 | + | decoded, err := DecodePreambleRequest(request) | |||
| 177 | + | require.NoError(t, err) | |||
| 178 | + | require.Equal(t, smaller, decoded.Ceilings) | |||
| 179 | + | require.NoError(t, decoded.Ceilings.Validate()) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 149 | func TestBrokerPreambleRoles(t *testing.T) { | 182 | func TestBrokerPreambleRoles(t *testing.T) { | ||
| 150 | t.Run("client role is 3 and server role is 4", func(t *testing.T) { | 183 | t.Run("client role is 3 and server role is 4", func(t *testing.T) { | ||
| 151 | request := EncodePreambleRequest(defaultBrokerCeilings()) | 184 | request := EncodePreambleRequest(defaultBrokerCeilings()) | ||
| @@ -192,7 +225,7 @@ func TestBrokerPreambleRoles(t *testing.T) { | |||||
| 192 | }) | 225 | }) | ||
| 193 | t.Run("round trip negotiates minima", func(t *testing.T) { | 226 | t.Run("round trip negotiates minima", func(t *testing.T) { | ||
| 194 | local := defaultBrokerCeilings() | 227 | local := defaultBrokerCeilings() | ||
| 195 | - | remote := brokerCeilings{maxReceiveEnvelopeBytes: testEnvelopeCeiling / 2, streamChunkLimit: 1024} | 228 | + | remote := brokerCeilings{MaxReceiveEnvelopeBytes: testEnvelopeCeiling / 2, StreamChunkLimit: 1024} |
| 196 | decoded, err := DecodePreambleRequest(EncodePreambleRequest(remote)) | 229 | decoded, err := DecodePreambleRequest(EncodePreambleRequest(remote)) | ||
| 197 | require.NoError(t, err) | 230 | require.NoError(t, err) | ||
| 198 | require.Equal(t, remote, decoded.Ceilings) | 231 | require.Equal(t, remote, decoded.Ceilings) | ||
M internal/adapters/brokerwire/limits.go +45 -20
| @@ -3,7 +3,7 @@ package brokerwire | |||||
| 3 | // Stateless bound refusals (P3.1). | 3 | // Stateless bound refusals (P3.1). | ||
| 4 | // | 4 | // | ||
| 5 | // The broker preamble negotiates one immutable pair of ceilings per | 5 | // The broker preamble negotiates one immutable pair of ceilings per | ||
| 6 | - | // connection: maxReceiveEnvelopeBytes bounds every envelope this side | 6 | + | // connection: MaxReceiveEnvelopeBytes bounds every envelope this side |
| 7 | // accepts, and StreamChunkLimit bounds one opaque ClientStreamData or | 7 | // accepts, and StreamChunkLimit bounds one opaque ClientStreamData or | ||
| 8 | // ServerStreamData frame. Envelope ceilings are fixed: ordinary envelopes | 8 | // ServerStreamData frame. Envelope ceilings are fixed: ordinary envelopes | ||
| 9 | // never exceed the advertised floor, and the preamble advertisement itself | 9 | // never exceed the advertised floor, and the preamble advertisement itself | ||
| @@ -41,46 +41,71 @@ const ( | |||||
| 41 | MaxStreamChunkBytes uint64 = 64 << 10 | 41 | MaxStreamChunkBytes uint64 = 64 << 10 | ||
| 42 | ) | 42 | ) | ||
| 43 | 43 | ||||
| 44 | - | // brokerCeilings are the immutable per-connection ceilings negotiated once | 44 | + | // Ceilings are the immutable per-connection ceilings negotiated once by the |
| 45 | - | // by the broker preamble. StreamChunkLimit bounds one opaque stream data | 45 | + | // broker preamble. MaxReceiveEnvelopeBytes bounds every envelope this side |
| 46 | - | // frame in either direction. | 46 | + | // accepts; StreamChunkLimit bounds one opaque stream data frame in either |
| 47 | - | type brokerCeilings struct { | 47 | + | // direction. |
| 48 | - | maxReceiveEnvelopeBytes uint64 | 48 | + | // |
| 49 | - | streamChunkLimit uint64 | 49 | + | // The preamble helpers and the connection dispatcher share this value: a |
| 50 | + | // caller that completed the preamble reads the negotiated limits from | |||
| 51 | + | // BrokerPreambleRequest or BrokerPreambleResponse and passes them to | |||
| 52 | + | // EncodeClient/DecodeClient and EncodeServer/DecodeServer. | |||
| 53 | + | type Ceilings struct { | |||
| 54 | + | MaxReceiveEnvelopeBytes uint64 | |||
| 55 | + | StreamChunkLimit uint64 | |||
| 50 | } | 56 | } | ||
| 51 | 57 | ||||
| 58 | + | // brokerCeilings is the in-package alias for Ceilings. It exists only so | |||
| 59 | + | // P3.1's tests keep their short spelling; production code uses Ceilings. | |||
| 60 | + | type brokerCeilings = Ceilings | |||
| 61 | + | ||||
| 52 | // defaultBrokerCeilings advertises the local broker receive policy: the | 62 | // defaultBrokerCeilings advertises the local broker receive policy: the | ||
| 53 | // full absolute envelope ceiling and the maximum stream chunk. | 63 | // full absolute envelope ceiling and the maximum stream chunk. | ||
| 54 | - | func defaultBrokerCeilings() brokerCeilings { | 64 | + | func defaultBrokerCeilings() Ceilings { |
| 55 | - | return brokerCeilings{ | 65 | + | return Ceilings{ |
| 56 | - | maxReceiveEnvelopeBytes: wire.AbsoluteEnvelopeLimit, | 66 | + | MaxReceiveEnvelopeBytes: wire.AbsoluteEnvelopeLimit, |
| 57 | - | streamChunkLimit: MaxStreamChunkBytes, | 67 | + | StreamChunkLimit: MaxStreamChunkBytes, |
| 58 | } | 68 | } | ||
| 59 | } | 69 | } | ||
| 60 | 70 | ||||
| 71 | + | // DefaultCeilings returns the local broker receive policy: the full absolute | |||
| 72 | + | // envelope ceiling and the maximum stream chunk. It is the offer a broker IPC | |||
| 73 | + | // client or server advertises before negotiation. | |||
| 74 | + | func DefaultCeilings() Ceilings { return defaultBrokerCeilings() } | |||
| 75 | + | ||||
| 61 | // effectiveBrokerCeilings takes the minima of both sides' advertisements. | 76 | // effectiveBrokerCeilings takes the minima of both sides' advertisements. | ||
| 62 | - | func effectiveBrokerCeilings(local, remote brokerCeilings) brokerCeilings { | 77 | + | func effectiveBrokerCeilings(local, remote Ceilings) Ceilings { |
| 63 | - | return brokerCeilings{ | 78 | + | return Ceilings{ |
| 64 | - | maxReceiveEnvelopeBytes: min(local.maxReceiveEnvelopeBytes, remote.maxReceiveEnvelopeBytes), | 79 | + | MaxReceiveEnvelopeBytes: min(local.MaxReceiveEnvelopeBytes, remote.MaxReceiveEnvelopeBytes), |
| 65 | - | streamChunkLimit: min(local.streamChunkLimit, remote.streamChunkLimit), | 80 | + | StreamChunkLimit: min(local.StreamChunkLimit, remote.StreamChunkLimit), |
| 66 | } | 81 | } | ||
| 67 | } | 82 | } | ||
| 68 | 83 | ||||
| 84 | + | // EffectiveCeilings returns the element-wise minima of the two advertised | |||
| 85 | + | // ceilings: one byte stream carries exactly the limits both peers accepted. | |||
| 86 | + | func EffectiveCeilings(local, remote Ceilings) Ceilings { | |||
| 87 | + | return effectiveBrokerCeilings(local, remote) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 69 | // checkBrokerCeilings refuses a zero, below-floor, or above-ceiling | 90 | // checkBrokerCeilings refuses a zero, below-floor, or above-ceiling | ||
| 70 | // advertisement. Envelope bounds are the 1 MiB..16 MiB advertised window; | 91 | // advertisement. Envelope bounds are the 1 MiB..16 MiB advertised window; | ||
| 71 | // chunk bounds are 1..64 KiB. | 92 | // chunk bounds are 1..64 KiB. | ||
| 72 | - | func checkBrokerCeilings(remote brokerCeilings) error { | 93 | + | func checkBrokerCeilings(remote Ceilings) error { |
| 73 | - | if remote.maxReceiveEnvelopeBytes < MinBrokerEnvelopeBytes || | 94 | + | if remote.MaxReceiveEnvelopeBytes < MinBrokerEnvelopeBytes || |
| 74 | - | remote.maxReceiveEnvelopeBytes > MaxBrokerEnvelopeBytes { | 95 | + | remote.MaxReceiveEnvelopeBytes > MaxBrokerEnvelopeBytes { |
| 75 | return ErrPreambleRejected | 96 | return ErrPreambleRejected | ||
| 76 | } | 97 | } | ||
| 77 | - | if remote.streamChunkLimit < MinStreamChunkBytes || | 98 | + | if remote.StreamChunkLimit < MinStreamChunkBytes || |
| 78 | - | remote.streamChunkLimit > MaxStreamChunkBytes { | 99 | + | remote.StreamChunkLimit > MaxStreamChunkBytes { |
| 79 | return ErrPreambleRejected | 100 | return ErrPreambleRejected | ||
| 80 | } | 101 | } | ||
| 81 | return nil | 102 | return nil | ||
| 82 | } | 103 | } | ||
| 83 | 104 | ||||
| 105 | + | // Validate reports whether one advertised ceiling is negotiable: an envelope | |||
| 106 | + | // window inside 1 MiB..16 MiB and a stream chunk inside 1..64 KiB. | |||
| 107 | + | func (c Ceilings) Validate() error { return checkBrokerCeilings(c) } | |||
| 108 | + | ||||
| 84 | // checkBrokerEnvelopeCeiling rejects one serialized broker envelope above | 109 | // checkBrokerEnvelopeCeiling rejects one serialized broker envelope above | ||
| 85 | // the negotiated ceiling. All broker envelopes are control-class: the | 110 | // the negotiated ceiling. All broker envelopes are control-class: the | ||
| 86 | // negotiated ceiling always applies in full. | 111 | // negotiated ceiling always applies in full. | ||
M internal/adapters/brokerwire/preamble.go +12 -12
| @@ -61,12 +61,12 @@ const ( | |||||
| 61 | // BrokerPreambleRequest is the validated broker client preamble: the | 61 | // BrokerPreambleRequest is the validated broker client preamble: the | ||
| 62 | // negotiated ceilings plus the raw wire message for stateless re-encode. | 62 | // negotiated ceilings plus the raw wire message for stateless re-encode. | ||
| 63 | type BrokerPreambleRequest struct { | 63 | type BrokerPreambleRequest struct { | ||
| 64 | - | Ceilings brokerCeilings | 64 | + | Ceilings Ceilings |
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | // BrokerPreambleResponse is the validated broker server preamble. | 67 | // BrokerPreambleResponse is the validated broker server preamble. | ||
| 68 | type BrokerPreambleResponse struct { | 68 | type BrokerPreambleResponse struct { | ||
| 69 | - | Ceilings brokerCeilings | 69 | + | Ceilings Ceilings |
| 70 | Accepted bool | 70 | Accepted bool | ||
| 71 | Code uint32 | 71 | Code uint32 | ||
| 72 | } | 72 | } | ||
| @@ -78,8 +78,8 @@ func EncodePreambleRequest(ceilings brokerCeilings) *wire.PreambleRequest { | |||||
| 78 | Epoch: wire.ProtocolEpoch, | 78 | Epoch: wire.ProtocolEpoch, | ||
| 79 | Version: uint32(protocol.Version), | 79 | Version: uint32(protocol.Version), | ||
| 80 | Role: &wire.PreambleRole{Role: BrokerRoleClient}, | 80 | Role: &wire.PreambleRole{Role: BrokerRoleClient}, | ||
| 81 | - | MaxReceiveEnvelopeBytes: ceilings.maxReceiveEnvelopeBytes, | 81 | + | MaxReceiveEnvelopeBytes: ceilings.MaxReceiveEnvelopeBytes, |
| 82 | - | OutputDataLimit: ceilings.streamChunkLimit, | 82 | + | OutputDataLimit: ceilings.StreamChunkLimit, |
| 83 | } | 83 | } | ||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| @@ -92,8 +92,8 @@ func EncodePreambleResponse(accepted bool, ceilings brokerCeilings, code uint32) | |||||
| 92 | Epoch: wire.ProtocolEpoch, | 92 | Epoch: wire.ProtocolEpoch, | ||
| 93 | Version: uint32(protocol.Version), | 93 | Version: uint32(protocol.Version), | ||
| 94 | Role: &wire.PreambleRole{Role: BrokerRoleServer}, | 94 | Role: &wire.PreambleRole{Role: BrokerRoleServer}, | ||
| 95 | - | MaxReceiveEnvelopeBytes: ceilings.maxReceiveEnvelopeBytes, | 95 | + | MaxReceiveEnvelopeBytes: ceilings.MaxReceiveEnvelopeBytes, |
| 96 | - | OutputDataLimit: ceilings.streamChunkLimit, | 96 | + | OutputDataLimit: ceilings.StreamChunkLimit, |
| 97 | Accepted: accepted, | 97 | Accepted: accepted, | ||
| 98 | } | 98 | } | ||
| 99 | if !accepted { | 99 | if !accepted { | ||
| @@ -125,8 +125,8 @@ func DecodePreambleRequest(message *wire.PreambleRequest) (BrokerPreambleRequest | |||||
| 125 | return BrokerPreambleRequest{}, ErrPreambleRejected | 125 | return BrokerPreambleRequest{}, ErrPreambleRejected | ||
| 126 | } | 126 | } | ||
| 127 | remote := brokerCeilings{ | 127 | remote := brokerCeilings{ | ||
| 128 | - | maxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(), | 128 | + | MaxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(), |
| 129 | - | streamChunkLimit: message.GetOutputDataLimit(), | 129 | + | StreamChunkLimit: message.GetOutputDataLimit(), |
| 130 | } | 130 | } | ||
| 131 | if err := checkBrokerCeilings(remote); err != nil { | 131 | if err := checkBrokerCeilings(remote); err != nil { | ||
| 132 | return BrokerPreambleRequest{}, err | 132 | return BrokerPreambleRequest{}, err | ||
| @@ -160,8 +160,8 @@ func DecodePreambleResponse(message *wire.PreambleResponse) (BrokerPreambleRespo | |||||
| 160 | return BrokerPreambleResponse{Code: message.GetRejection().GetCode()}, ErrPreambleRejected | 160 | return BrokerPreambleResponse{Code: message.GetRejection().GetCode()}, ErrPreambleRejected | ||
| 161 | } | 161 | } | ||
| 162 | remote := brokerCeilings{ | 162 | remote := brokerCeilings{ | ||
| 163 | - | maxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(), | 163 | + | MaxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(), |
| 164 | - | streamChunkLimit: message.GetOutputDataLimit(), | 164 | + | StreamChunkLimit: message.GetOutputDataLimit(), |
| 165 | } | 165 | } | ||
| 166 | if err := checkBrokerCeilings(remote); err != nil { | 166 | if err := checkBrokerCeilings(remote); err != nil { | ||
| 167 | return BrokerPreambleResponse{}, err | 167 | return BrokerPreambleResponse{}, err | ||
| @@ -198,8 +198,8 @@ func RejectionCodeFor(request *wire.PreambleRequest, err error) uint32 { | |||||
| 198 | return RejectionLimitRefused | 198 | return RejectionLimitRefused | ||
| 199 | } | 199 | } | ||
| 200 | remote := brokerCeilings{ | 200 | remote := brokerCeilings{ | ||
| 201 | - | maxReceiveEnvelopeBytes: request.GetMaxReceiveEnvelopeBytes(), | 201 | + | MaxReceiveEnvelopeBytes: request.GetMaxReceiveEnvelopeBytes(), |
| 202 | - | streamChunkLimit: request.GetOutputDataLimit(), | 202 | + | StreamChunkLimit: request.GetOutputDataLimit(), |
| 203 | } | 203 | } | ||
| 204 | if checkBrokerCeilings(remote) != nil { | 204 | if checkBrokerCeilings(remote) != nil { | ||
| 205 | return RejectionLimitRefused | 205 | return RejectionLimitRefused | ||
M internal/adapters/ipc/mux.go +31 -3
| @@ -68,10 +68,19 @@ var ( | |||||
| 68 | ErrMuxPeerRejected = errors.New("ipc: mux peer failed same-user credential verification") | 68 | ErrMuxPeerRejected = errors.New("ipc: mux peer failed same-user credential verification") | ||
| 69 | ) | 69 | ) | ||
| 70 | 70 | ||||
| 71 | - | // muxPeerVerifier checks one connected AF_UNIX peer's kernel credentials. It | 71 | + | // PeerVerifier checks one connected AF_UNIX peer's kernel credentials. It |
| 72 | // returns nil only for a peer proven to be the current effective user. It is a | 72 | // returns nil only for a peer proven to be the current effective user. It is a | ||
| 73 | - | // function type so a test can inject an observable refusal without root. | 73 | + | // function type so a caller (or test) can inject an observable refusal without |
| 74 | - | type muxPeerVerifier func(conn *net.UnixConn) error | 74 | + | // root; SameUserPeerVerifier returns the platform default. |
| 75 | + | type PeerVerifier func(conn *net.UnixConn) error | |||
| 76 | + | ||||
| 77 | + | // muxPeerVerifier is the in-package spelling of PeerVerifier. | |||
| 78 | + | type muxPeerVerifier = PeerVerifier | |||
| 79 | + | ||||
| 80 | + | // SameUserPeerVerifier returns this build's default same-user peer verifier: | |||
| 81 | + | // Linux SO_PEERCRED admission, or a fail-closed refusal on a platform or | |||
| 82 | + | // build without it. | |||
| 83 | + | func SameUserPeerVerifier() PeerVerifier { return verifySameUserUnixPeer } | |||
| 75 | 84 | ||||
| 76 | // MuxListener is the listener half of a private daemonmux Unix carriage. Accept | 85 | // MuxListener is the listener half of a private daemonmux Unix carriage. Accept | ||
| 77 | // returns a raw bounded transport (Send/RecvBounded/Close) ready for the | 86 | // returns a raw bounded transport (Send/RecvBounded/Close) ready for the | ||
| @@ -113,6 +122,17 @@ func ListenMux(path string, opts ...Option) (MuxListener, error) { | |||||
| 113 | return listenMux(path, verifySameUserUnixPeer, opts...) | 122 | return listenMux(path, verifySameUserUnixPeer, opts...) | ||
| 114 | } | 123 | } | ||
| 115 | 124 | ||||
| 125 | + | // ListenMuxWithPeerVerifier binds the private carriage exactly like ListenMux | |||
| 126 | + | // but admits peers with an injected verifier instead of the platform default. | |||
| 127 | + | // It exists so a caller or test can supply an explicit admission policy (or | |||
| 128 | + | // observe a deterministic refusal without root); a nil verifier falls back to | |||
| 129 | + | // the platform default. Every other security property - owner-only parent, | |||
| 130 | + | // 0600 socket, race-safe stale recovery, foreign-path refusal, inode-scoped | |||
| 131 | + | // unlink on Close - is unchanged. | |||
| 132 | + | func ListenMuxWithPeerVerifier(path string, verify PeerVerifier, opts ...Option) (MuxListener, error) { | |||
| 133 | + | return listenMux(path, verify, opts...) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 116 | // listenMux is ListenMux with an injected peer verifier, so a test can observe | 136 | // listenMux is ListenMux with an injected peer verifier, so a test can observe | ||
| 117 | // a credential refusal deterministically without root. A nil verifier falls | 137 | // a credential refusal deterministically without root. A nil verifier falls | ||
| 118 | // back to the platform default. | 138 | // back to the platform default. | ||
| @@ -168,6 +188,14 @@ func DialMuxContext(ctx context.Context, path string, opts ...Option) (wire.Boun | |||||
| 168 | return dialMux(ctx, path, verifySameUserUnixPeer, opts...) | 188 | return dialMux(ctx, path, verifySameUserUnixPeer, opts...) | ||
| 169 | } | 189 | } | ||
| 170 | 190 | ||||
| 191 | + | // DialMuxContextWithPeerVerifier connects to the private carriage exactly like | |||
| 192 | + | // DialMuxContext but verifies the accepted peer with an injected verifier | |||
| 193 | + | // instead of the platform default. A nil verifier falls back to the platform | |||
| 194 | + | // default; a peer the verifier refuses is closed without being handed out. | |||
| 195 | + | func DialMuxContextWithPeerVerifier(ctx context.Context, path string, verify PeerVerifier, opts ...Option) (wire.BoundedTransport, error) { | |||
| 196 | + | return dialMux(ctx, path, verify, opts...) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 171 | // dialMux is DialMuxContext with an injected peer verifier, so a test can | 199 | // dialMux is DialMuxContext with an injected peer verifier, so a test can | ||
| 172 | // observe a credential refusal deterministically without root. A nil verifier | 200 | // observe a credential refusal deterministically without root. A nil verifier | ||
| 173 | // falls back to the platform default. | 201 | // falls back to the platform default. | ||
M internal/adapters/ipc/mux_test.go +40 -0
| @@ -529,3 +529,43 @@ func TestListenMuxConcurrentCloseAndDial(t *testing.T) { | |||||
| 529 | } | 529 | } | ||
| 530 | } | 530 | } | ||
| 531 | } | 531 | } | ||
| 532 | + | ||||
| 533 | + | // TestMuxExportedPeerVerifierSeam proves the exported injection seams the broker | |||
| 534 | + | // IPC adapter consumes behave like the internal ones: the platform default | |||
| 535 | + | // admits a same-user peer, a nil verifier falls back to it, and an injected | |||
| 536 | + | // refusal fails closed without handing out a transport. | |||
| 537 | + | func TestMuxExportedPeerVerifierSeam(t *testing.T) { | |||
| 538 | + | path := muxTestPath(t) | |||
| 539 | + | listener, err := ListenMuxWithPeerVerifier(path, SameUserPeerVerifier()) | |||
| 540 | + | if err != nil { | |||
| 541 | + | t.Fatalf("ListenMuxWithPeerVerifier: %v", err) | |||
| 542 | + | } | |||
| 543 | + | t.Cleanup(func() { _ = listener.Close() }) | |||
| 544 | + | if SameUserPeerVerifier() == nil { | |||
| 545 | + | t.Fatal("SameUserPeerVerifier must return the platform verifier") | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 549 | + | defer cancel() | |||
| 550 | + | transport, err := DialMuxContextWithPeerVerifier(ctx, path, nil) | |||
| 551 | + | if err != nil { | |||
| 552 | + | t.Fatalf("DialMuxContextWithPeerVerifier with a nil verifier: %v", err) | |||
| 553 | + | } | |||
| 554 | + | t.Cleanup(func() { _ = transport.Close() }) | |||
| 555 | + | accepted, err := listener.Accept() | |||
| 556 | + | if err != nil { | |||
| 557 | + | t.Fatalf("Accept: %v", err) | |||
| 558 | + | } | |||
| 559 | + | t.Cleanup(func() { _ = accepted.Close() }) | |||
| 560 | + | ||||
| 561 | + | refused, err := DialMuxContextWithPeerVerifier(ctx, path, func(*net.UnixConn) error { return ErrMuxPeerRejected }) | |||
| 562 | + | if err == nil { | |||
| 563 | + | t.Fatal("an injected refusal must fail the dial") | |||
| 564 | + | } | |||
| 565 | + | if !errors.Is(err, ErrMuxPeerRejected) { | |||
| 566 | + | t.Fatalf("dial refusal = %v, want ErrMuxPeerRejected", err) | |||
| 567 | + | } | |||
| 568 | + | if refused != nil { | |||
| 569 | + | t.Fatal("a refused peer must never receive a transport") | |||
| 570 | + | } | |||
| 571 | + | } | |||