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 | 30 | byte-length bound), multipart snapshot assembly, per-connection operation | |
| 31 | 31 | and stream trackers, and the bounded admission lock. It is not activated | |
| 32 | 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 | 44 | - `internal/adapters/quic`: one-stream QUIC carriage (TLS 1.3, epoch ALPN, | |
| 34 | 45 | exact SHA-256 pin, single bidirectional stream, bounded admission) plus | |
| 35 | 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 | 192 | nonblocking drain on false precedes Reset, supporting buffered timer adapters. | |
| 182 | 193 | Policy identities have independent field-labelled validation and a named bound. | |
| 183 | 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 | 113 | code 7 (`limit refused`), and code 6 (`out of order`) is reserved for the | |
| 114 | 114 | connection dispatcher, which owns framing-order detection and never surfaces | |
| 115 | 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 | 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 | 146 | // TestBrokerPreambleRoles proves the broker preamble uses roles 3/4, the | |
| 147 | 147 | // existing magic/epoch/exact version, zero capability bits, the 4 KiB | |
| 148 | 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 | 182 | func TestBrokerPreambleRoles(t *testing.T) { | |
| 150 | 183 | t.Run("client role is 3 and server role is 4", func(t *testing.T) { | |
| 151 | 184 | request := EncodePreambleRequest(defaultBrokerCeilings()) | |
| @@ -192,7 +225,7 @@ func TestBrokerPreambleRoles(t *testing.T) { | |||
| 192 | 225 | }) | |
| 193 | 226 | t.Run("round trip negotiates minima", func(t *testing.T) { | |
| 194 | 227 | local := defaultBrokerCeilings() | |
| 195 | - | remote := brokerCeilings{maxReceiveEnvelopeBytes: testEnvelopeCeiling / 2, streamChunkLimit: 1024} | |
| 228 | + | remote := brokerCeilings{MaxReceiveEnvelopeBytes: testEnvelopeCeiling / 2, StreamChunkLimit: 1024} | |
| 196 | 229 | decoded, err := DecodePreambleRequest(EncodePreambleRequest(remote)) | |
| 197 | 230 | require.NoError(t, err) | |
| 198 | 231 | require.Equal(t, remote, decoded.Ceilings) | |
M internal/adapters/brokerwire/limits.go +45 -20
| @@ -3,7 +3,7 @@ package brokerwire | |||
| 3 | 3 | // Stateless bound refusals (P3.1). | |
| 4 | 4 | // | |
| 5 | 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 | 7 | // accepts, and StreamChunkLimit bounds one opaque ClientStreamData or | |
| 8 | 8 | // ServerStreamData frame. Envelope ceilings are fixed: ordinary envelopes | |
| 9 | 9 | // never exceed the advertised floor, and the preamble advertisement itself | |
| @@ -41,46 +41,71 @@ const ( | |||
| 41 | 41 | MaxStreamChunkBytes uint64 = 64 << 10 | |
| 42 | 42 | ) | |
| 43 | 43 | ||
| 44 | - | // brokerCeilings are the immutable per-connection ceilings negotiated once | |
| 45 | - | // by the broker preamble. StreamChunkLimit bounds one opaque stream data | |
| 46 | - | // frame in either direction. | |
| 47 | - | type brokerCeilings struct { | |
| 48 | - | maxReceiveEnvelopeBytes uint64 | |
| 49 | - | streamChunkLimit uint64 | |
| 44 | + | // Ceilings are the immutable per-connection ceilings negotiated once by the | |
| 45 | + | // broker preamble. MaxReceiveEnvelopeBytes bounds every envelope this side | |
| 46 | + | // accepts; StreamChunkLimit bounds one opaque stream data frame in either | |
| 47 | + | // direction. | |
| 48 | + | // | |
| 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 | 62 | // defaultBrokerCeilings advertises the local broker receive policy: the | |
| 53 | 63 | // full absolute envelope ceiling and the maximum stream chunk. | |
| 54 | - | func defaultBrokerCeilings() brokerCeilings { | |
| 55 | - | return brokerCeilings{ | |
| 56 | - | maxReceiveEnvelopeBytes: wire.AbsoluteEnvelopeLimit, | |
| 57 | - | streamChunkLimit: MaxStreamChunkBytes, | |
| 64 | + | func defaultBrokerCeilings() Ceilings { | |
| 65 | + | return Ceilings{ | |
| 66 | + | MaxReceiveEnvelopeBytes: wire.AbsoluteEnvelopeLimit, | |
| 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 | 76 | // effectiveBrokerCeilings takes the minima of both sides' advertisements. | |
| 62 | - | func effectiveBrokerCeilings(local, remote brokerCeilings) brokerCeilings { | |
| 63 | - | return brokerCeilings{ | |
| 64 | - | maxReceiveEnvelopeBytes: min(local.maxReceiveEnvelopeBytes, remote.maxReceiveEnvelopeBytes), | |
| 65 | - | streamChunkLimit: min(local.streamChunkLimit, remote.streamChunkLimit), | |
| 77 | + | func effectiveBrokerCeilings(local, remote Ceilings) Ceilings { | |
| 78 | + | return Ceilings{ | |
| 79 | + | MaxReceiveEnvelopeBytes: min(local.MaxReceiveEnvelopeBytes, remote.MaxReceiveEnvelopeBytes), | |
| 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 | 90 | // checkBrokerCeilings refuses a zero, below-floor, or above-ceiling | |
| 70 | 91 | // advertisement. Envelope bounds are the 1 MiB..16 MiB advertised window; | |
| 71 | 92 | // chunk bounds are 1..64 KiB. | |
| 72 | - | func checkBrokerCeilings(remote brokerCeilings) error { | |
| 73 | - | if remote.maxReceiveEnvelopeBytes < MinBrokerEnvelopeBytes || | |
| 74 | - | remote.maxReceiveEnvelopeBytes > MaxBrokerEnvelopeBytes { | |
| 93 | + | func checkBrokerCeilings(remote Ceilings) error { | |
| 94 | + | if remote.MaxReceiveEnvelopeBytes < MinBrokerEnvelopeBytes || | |
| 95 | + | remote.MaxReceiveEnvelopeBytes > MaxBrokerEnvelopeBytes { | |
| 75 | 96 | return ErrPreambleRejected | |
| 76 | 97 | } | |
| 77 | - | if remote.streamChunkLimit < MinStreamChunkBytes || | |
| 78 | - | remote.streamChunkLimit > MaxStreamChunkBytes { | |
| 98 | + | if remote.StreamChunkLimit < MinStreamChunkBytes || | |
| 99 | + | remote.StreamChunkLimit > MaxStreamChunkBytes { | |
| 79 | 100 | return ErrPreambleRejected | |
| 80 | 101 | } | |
| 81 | 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 | 109 | // checkBrokerEnvelopeCeiling rejects one serialized broker envelope above | |
| 85 | 110 | // the negotiated ceiling. All broker envelopes are control-class: the | |
| 86 | 111 | // negotiated ceiling always applies in full. | |
M internal/adapters/brokerwire/preamble.go +12 -12
| @@ -61,12 +61,12 @@ const ( | |||
| 61 | 61 | // BrokerPreambleRequest is the validated broker client preamble: the | |
| 62 | 62 | // negotiated ceilings plus the raw wire message for stateless re-encode. | |
| 63 | 63 | type BrokerPreambleRequest struct { | |
| 64 | - | Ceilings brokerCeilings | |
| 64 | + | Ceilings Ceilings | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | 67 | // BrokerPreambleResponse is the validated broker server preamble. | |
| 68 | 68 | type BrokerPreambleResponse struct { | |
| 69 | - | Ceilings brokerCeilings | |
| 69 | + | Ceilings Ceilings | |
| 70 | 70 | Accepted bool | |
| 71 | 71 | Code uint32 | |
| 72 | 72 | } | |
| @@ -78,8 +78,8 @@ func EncodePreambleRequest(ceilings brokerCeilings) *wire.PreambleRequest { | |||
| 78 | 78 | Epoch: wire.ProtocolEpoch, | |
| 79 | 79 | Version: uint32(protocol.Version), | |
| 80 | 80 | Role: &wire.PreambleRole{Role: BrokerRoleClient}, | |
| 81 | - | MaxReceiveEnvelopeBytes: ceilings.maxReceiveEnvelopeBytes, | |
| 82 | - | OutputDataLimit: ceilings.streamChunkLimit, | |
| 81 | + | MaxReceiveEnvelopeBytes: ceilings.MaxReceiveEnvelopeBytes, | |
| 82 | + | OutputDataLimit: ceilings.StreamChunkLimit, | |
| 83 | 83 | } | |
| 84 | 84 | } | |
| 85 | 85 | ||
| @@ -92,8 +92,8 @@ func EncodePreambleResponse(accepted bool, ceilings brokerCeilings, code uint32) | |||
| 92 | 92 | Epoch: wire.ProtocolEpoch, | |
| 93 | 93 | Version: uint32(protocol.Version), | |
| 94 | 94 | Role: &wire.PreambleRole{Role: BrokerRoleServer}, | |
| 95 | - | MaxReceiveEnvelopeBytes: ceilings.maxReceiveEnvelopeBytes, | |
| 96 | - | OutputDataLimit: ceilings.streamChunkLimit, | |
| 95 | + | MaxReceiveEnvelopeBytes: ceilings.MaxReceiveEnvelopeBytes, | |
| 96 | + | OutputDataLimit: ceilings.StreamChunkLimit, | |
| 97 | 97 | Accepted: accepted, | |
| 98 | 98 | } | |
| 99 | 99 | if !accepted { | |
| @@ -125,8 +125,8 @@ func DecodePreambleRequest(message *wire.PreambleRequest) (BrokerPreambleRequest | |||
| 125 | 125 | return BrokerPreambleRequest{}, ErrPreambleRejected | |
| 126 | 126 | } | |
| 127 | 127 | remote := brokerCeilings{ | |
| 128 | - | maxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(), | |
| 129 | - | streamChunkLimit: message.GetOutputDataLimit(), | |
| 128 | + | MaxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(), | |
| 129 | + | StreamChunkLimit: message.GetOutputDataLimit(), | |
| 130 | 130 | } | |
| 131 | 131 | if err := checkBrokerCeilings(remote); err != nil { | |
| 132 | 132 | return BrokerPreambleRequest{}, err | |
| @@ -160,8 +160,8 @@ func DecodePreambleResponse(message *wire.PreambleResponse) (BrokerPreambleRespo | |||
| 160 | 160 | return BrokerPreambleResponse{Code: message.GetRejection().GetCode()}, ErrPreambleRejected | |
| 161 | 161 | } | |
| 162 | 162 | remote := brokerCeilings{ | |
| 163 | - | maxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(), | |
| 164 | - | streamChunkLimit: message.GetOutputDataLimit(), | |
| 163 | + | MaxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(), | |
| 164 | + | StreamChunkLimit: message.GetOutputDataLimit(), | |
| 165 | 165 | } | |
| 166 | 166 | if err := checkBrokerCeilings(remote); err != nil { | |
| 167 | 167 | return BrokerPreambleResponse{}, err | |
| @@ -198,8 +198,8 @@ func RejectionCodeFor(request *wire.PreambleRequest, err error) uint32 { | |||
| 198 | 198 | return RejectionLimitRefused | |
| 199 | 199 | } | |
| 200 | 200 | remote := brokerCeilings{ | |
| 201 | - | maxReceiveEnvelopeBytes: request.GetMaxReceiveEnvelopeBytes(), | |
| 202 | - | streamChunkLimit: request.GetOutputDataLimit(), | |
| 201 | + | MaxReceiveEnvelopeBytes: request.GetMaxReceiveEnvelopeBytes(), | |
| 202 | + | StreamChunkLimit: request.GetOutputDataLimit(), | |
| 203 | 203 | } | |
| 204 | 204 | if checkBrokerCeilings(remote) != nil { | |
| 205 | 205 | return RejectionLimitRefused | |
M internal/adapters/ipc/mux.go +31 -3
| @@ -68,10 +68,19 @@ var ( | |||
| 68 | 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 | 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. | |
| 74 | - | type muxPeerVerifier func(conn *net.UnixConn) error | |
| 73 | + | // function type so a caller (or test) can inject an observable refusal without | |
| 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 | 85 | // MuxListener is the listener half of a private daemonmux Unix carriage. Accept | |
| 77 | 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 | 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 | 136 | // listenMux is ListenMux with an injected peer verifier, so a test can observe | |
| 117 | 137 | // a credential refusal deterministically without root. A nil verifier falls | |
| 118 | 138 | // back to the platform default. | |
| @@ -168,6 +188,14 @@ func DialMuxContext(ctx context.Context, path string, opts ...Option) (wire.Boun | |||
| 168 | 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 | 199 | // dialMux is DialMuxContext with an injected peer verifier, so a test can | |
| 172 | 200 | // observe a credential refusal deterministically without root. A nil verifier | |
| 173 | 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 | + | } | |