detached 36c187d

feat(broker): add private local IPC

commit
36c187dc3ce968a932c1ad7527daa6a114248e53
author
brice · · 2026-09-16 20:16 UTC
parent
7bfefe2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
26 files · +6346 -37
M docs/architecture.md +57 -0
@@ -30,6 +30,17 @@ vev uses a hexagonal core with typed session messages at the client and daemon b
30 byte-length bound), multipart snapshot assembly, per-connection operation30 byte-length bound), multipart snapshot assembly, per-connection operation
31 and stream trackers, and the bounded admission lock. It is not activated31 and stream trackers, and the bounded admission lock. It is not activated
32 by production composition until P3.2.32 by production composition until P3.2.
33+- `internal/adapters/brokeripc`: the Plan 001 P3.3 private local broker
34+ endpoint. It implements `ports.BrokerListener` (accepting same-user clients
35+ and running one brokerwire session per accepted connection) and
36+ `ports.BrokerService` (the client adapter over the same conversation) on one
37+ per-user AF_UNIX endpoint, reusing the P3.2 private IPC carriage
38+ (`ipc.ListenMux`/`ipc.DialMuxContext`: owner-only directory, 0600 socket,
39+ same-user `SO_PEERCRED` admission, race-safe stale-socket recovery,
40+ foreign-path refusal) and the shared `streamframe` framing. It owns the
41+ bounded per-connection queues, the snapshot publisher, the per-stream typed
42+ bridge over `sessionwire`, and deterministic disconnect cleanup. Like P3.1 and
43+ P3.2 it is not activated by production composition.
33 - `internal/adapters/quic`: one-stream QUIC carriage (TLS 1.3, epoch ALPN,44 - `internal/adapters/quic`: one-stream QUIC carriage (TLS 1.3, epoch ALPN,
34 exact SHA-256 pin, single bidirectional stream, bounded admission) plus45 exact SHA-256 pin, single bidirectional stream, bounded admission) plus
35 the short-lived SSH bootstrap (ephemeral certificate, 32-byte token,46 the short-lived SSH bootstrap (ephemeral certificate, 32-byte token,
@@ -181,3 +192,49 @@ underlying Close error. Timer ownership is single-goroutine; Stop followed by a
181 nonblocking drain on false precedes Reset, supporting buffered timer adapters.192 nonblocking drain on false precedes Reset, supporting buffered timer adapters.
182 Policy identities have independent field-labelled validation and a named bound.193 Policy identities have independent field-labelled validation and a named bound.
183 These are P2.2 port contracts, not a wire migration or production activation.194 These are P2.2 port contracts, not a wire migration or production activation.
195+
196+## Broker local IPC (Plan 001 P3.3, not activated)
197+
198+`internal/adapters/brokeripc` carries the P3.1 broker conversation over one
199+per-user Unix endpoint. The carriage is the P3.2 private AF_UNIX carriage in
200+`internal/adapters/ipc`: an owner-only (0700, `pkg/safedir.EnsurePrivate`)
201+parent directory, a bound socket tightened to 0600, race-safe recovery of a
202+stale socket whose owner died, refusal (never removal) of a path that is not a
203+socket, and same-user kernel peer credentials (`SO_PEERCRED` on Linux) on both
204+accept and dial, failing closed on a platform or build without that check. The
205+endpoint name (`broker.sock`) is deliberately distinct from the daemon's
206+`daemon.sock`.
207+
208+The listener owns its accept loop: one client slot is acquired before each
209+accept, each accepted carriage completes the broker preamble and broker
210+admission in its own goroutine, and admitted sessions are published through a
211+bounded FIFO to `Accept`. A client that stalls during the preamble therefore
212+occupies one slot and one goroutine, never the loop. Each session runs the
213+brokerwire connection state machine (one Register, one subscription generation
214+series, bounded operation and stream trackers), one snapshot publisher that
215+writes the P3.1 multipart layout and coalesces through the core's capacity-one
216+subscription, and one bridge per logical stream. Streams carry the typed
217+session protocol: `sessionwire` runs over a bounded private byte pipe on each
218+side, and two relay goroutines move typed messages between the client's session
219+connection and the admitted broker core connection, so no session byte is
220+reinterpreted.
221+
222+Every frame must carry the epoch and connection identity the listener assigned
223+at accept; a mismatched frame is fenced, and a stale stream open is answered
224+with a `StreamClosed` carrying the stale admission code. A malformed,
225+oversize, or wrong-direction envelope settles exactly the connection that sent
226+it. Operational bounds are explicit: concurrent clients per listener, per-stream
227+inbound chunks and bytes, pending mutating operations, and the brokerwire
228+per-connection stream and dedup bounds. Disconnect cleanup is deterministic:
229+the session cancels its context, stops publishing, closes the outer carriage
230+(so a stream writer parked on a non-reading peer is interrupted), settles every
231+bridged stream (releasing its core connection and carriage), closes the
232+brokerwire state, joins every worker, closes the admitted core service, and
233+releases its client slot. Mutating operations report `outcome unknown` when a
234+reply is lost after the request was sent, and the client never replays them
235+blindly.
236+
237+P3.3 introduces no production activation: nothing in `internal/app` or `main`
238+constructs this adapter yet. P3.4 composes the broker use case, its stores, and
239+this listener behind a hidden entry point, and P7 performs the coordinated
240+cutover.
M docs/protocol.md +5 -1
@@ -113,7 +113,11 @@ capability negotiation in P3.1: nonzero capability bits are refused as
113 code 7 (`limit refused`), and code 6 (`out of order`) is reserved for the113 code 7 (`limit refused`), and code 6 (`out of order`) is reserved for the
114 connection dispatcher, which owns framing-order detection and never surfaces114 connection dispatcher, which owns framing-order detection and never surfaces
115 it from the stateless codec. Brokerwire is not activated by production115 it from the stateless codec. Brokerwire is not activated by production
116-composition until P3.2.116+composition until P3.2. The P3.3 private local endpoint
117+(`internal/adapters/brokeripc`) carries this conversation over one per-user
118+AF_UNIX socket: `broker.sock` in the per-user runtime directory, with
119+same-user peer-credential admission, bounded per-connection queues, and
120+production composition deferred to P3.4 and the P7 cutover.
117 121
118 ## Output, ACK, and flow control122 ## Output, ACK, and flow control
119 123
A internal/adapters/brokeripc/carriage.go +52 -0
@@ -0,0 +1,52 @@
1+package brokeripc
2+
3+import (
4+ "github.com/bnema/vev/internal/adapters/streamframe"
5+ "github.com/bnema/vev/internal/protocol/wire"
6+)
7+
8+// carriage adapts one bounded streamPipe to the wire.Transport contract the
9+// sessionwire typed connection frames over: Send writes one complete envelope
10+// (4-byte big-endian length then payload) into the pipe, Recv reads one
11+// complete envelope back out, and Close settles the pipe. Framing, the bounded
12+// egress queue, and close-unblocks-IO behavior all come from the shared
13+// streamframe implementation; this adapter owns no frame format of its own.
14+type carriage struct {
15+ framer *streamframe.Framer
16+}
17+
18+var _ wire.BoundedTransport = (*carriage)(nil)
19+
20+// newCarriage builds one envelope carriage over a stream pipe. The pipe carries
21+// opaque chunks and sessionwire applies the negotiated per-category ceilings
22+// above it, so the carriage ceiling is the protocol absolute limit.
23+func newCarriage(pipe *streamPipe) *carriage {
24+ return &carriage{
25+ framer: streamframe.NewFramer(pipe, pipe, func() error { pipe.closeWith(nil); return nil }, streamframe.WithMaxEnvelope(wire.AbsoluteEnvelopeLimit)),
26+ }
27+}
28+
29+// Send queues one envelope for the sole writer and waits for its pipe write.
30+func (c *carriage) Send(envelope wire.Envelope) error { return c.framer.Send(envelope.Payload) }
31+
32+// Recv reads one complete envelope.
33+func (c *carriage) Recv() (wire.Envelope, error) {
34+ payload, err := c.framer.Recv()
35+ if err != nil {
36+ return wire.Envelope{}, err
37+ }
38+ return wire.Envelope{Payload: payload}, nil
39+}
40+
41+// RecvBounded reads one complete envelope bounded to limit before allocation.
42+func (c *carriage) RecvBounded(limit uint64) (wire.Envelope, error) {
43+ payload, err := c.framer.RecvBounded(limit)
44+ if err != nil {
45+ return wire.Envelope{}, err
46+ }
47+ return wire.Envelope{Payload: payload}, nil
48+}
49+
50+// Close settles the pipe and joins the carriage writer. It is idempotent and
51+// concurrent-safe.
52+func (c *carriage) Close() error { return c.framer.Close() }
A internal/adapters/brokeripc/client.go +836 -0
@@ -0,0 +1,836 @@
1+package brokeripc
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "errors"
7+ "sync"
8+
9+ "github.com/bnema/vev/internal/adapters/brokerwire"
10+ "github.com/bnema/vev/internal/adapters/ipc"
11+ "github.com/bnema/vev/internal/adapters/streamframe"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/protocol/wire"
14+)
15+
16+// Broker IPC client (P3.3).
17+//
18+// Dial connects to one per-user broker endpoint through the P3.2 private IPC
19+// carriage (same-user kernel peer credentials on dial), completes the broker
20+// preamble, sends exactly one Register, adopts the scope the listener assigned
21+// in the Registered answer, and then runs one reader for the connection. The
22+// returned value implements ports.BrokerService, so a client process reaches
23+// local and remote daemons through the same façade it would use in-process.
24+//
25+// Identity is the connection's, not the caller's: OpenStream fills in an absent
26+// epoch, connection, and stream identity from this connection and refuses a
27+// caller-supplied identity that belongs elsewhere as stale. Stream identities
28+// are strictly increasing and never reused. Mutating operations carry a fresh
29+// operation identity, are bounded while pending, and report outcome-unknown
30+// rather than a silent retry when a reply is lost after the request was sent.
31+
32+// Dial connects to the broker endpoint at path and returns a ports.BrokerService
33+// bound to exactly one accepted connection.
34+func Dial(ctx context.Context, path string, cfg Config) (ports.BrokerService, error) {
35+ return dial(ctx, path, cfg, ipc.SameUserPeerVerifier())
36+}
37+
38+// dial is Dial with an injected peer verifier, so a test can observe a
39+// deterministic same-user refusal without root.
40+func dial(ctx context.Context, path string, cfg Config, verify ipc.PeerVerifier) (ports.BrokerService, error) {
41+ cfg = cfg.withDefaults()
42+ if err := cfg.validate(); err != nil {
43+ return nil, err
44+ }
45+ if path == "" {
46+ return nil, ErrConfig
47+ }
48+ transport, err := ipc.DialMuxContextWithPeerVerifier(ctx, path, verify)
49+ if err != nil {
50+ return nil, err
51+ }
52+ handshake, cancel := context.WithTimeout(ctx, cfg.HandshakeTimeout)
53+ defer cancel()
54+ ceilings, err := runClientPreamble(handshake, transport, brokerwire.DefaultCeilings())
55+ if err != nil {
56+ _ = transport.Close()
57+ return nil, err
58+ }
59+ scope, err := register(handshake, transport, ceilings)
60+ if err != nil {
61+ _ = transport.Close()
62+ return nil, err
63+ }
64+ conn, err := brokerwire.NewConnection(scope)
65+ if err != nil {
66+ _ = transport.Close()
67+ return nil, err
68+ }
69+ if err := conn.SignalPreamble(); err != nil {
70+ _ = transport.Close()
71+ return nil, err
72+ }
73+ if err := conn.Register(); err != nil {
74+ _ = transport.Close()
75+ return nil, err
76+ }
77+ ctx, cancelSession := context.WithCancel(context.Background())
78+ c := &client{
79+ transport: transport,
80+ ceilings: ceilings,
81+ scope: scope,
82+ conn: conn,
83+ cfg: cfg,
84+ ctx: ctx,
85+ cancel: cancelSession,
86+ pending: make(map[ports.BrokerOperationID]chan operationResult),
87+ streams: make(map[ports.BrokerStreamID]*clientStream),
88+ done: make(chan struct{}),
89+ }
90+ go c.run()
91+ return c, nil
92+}
93+
94+// register completes the broker registration exchange: one Register follows the
95+// preamble, and the first server frame must be the Registered answer carrying
96+// the scope assigned at accept. Any other first frame is a protocol violation.
97+func register(ctx context.Context, transport wire.BoundedTransport, ceilings brokerwire.Ceilings) (brokerwire.Scope, error) {
98+ payload, err := brokerwire.EncodeClient(brokerwire.Register{}, ceilings.MaxReceiveEnvelopeBytes, ceilings.StreamChunkLimit)
99+ if err != nil {
100+ return brokerwire.Scope{}, err
101+ }
102+ if err := transport.Send(wire.Envelope{Payload: payload}); err != nil {
103+ return brokerwire.Scope{}, transportFailure(err)
104+ }
105+ envelope, err := transport.RecvBounded(ceilings.MaxReceiveEnvelopeBytes)
106+ if err != nil {
107+ return brokerwire.Scope{}, transportFailure(err)
108+ }
109+ message, err := brokerwire.DecodeServer(envelope.Payload, ceilings.MaxReceiveEnvelopeBytes, ceilings.StreamChunkLimit)
110+ if err != nil {
111+ return brokerwire.Scope{}, errors.Join(ErrMalformedFrame, err)
112+ }
113+ registered, ok := message.(brokerwire.Registered)
114+ if !ok {
115+ return brokerwire.Scope{}, errors.Join(ErrProtocol, errors.New("brokeripc: broker did not answer Register with Registered"))
116+ }
117+ scope := brokerwire.Scope{Epoch: registered.Epoch, Connection: registered.Connection}
118+ if err := scope.Validate(); err != nil {
119+ return brokerwire.Scope{}, errors.Join(ErrProtocol, err)
120+ }
121+ if err := ctx.Err(); err != nil {
122+ return brokerwire.Scope{}, err
123+ }
124+ return scope, nil
125+}
126+
127+// operationResult is one reply to a mutating operation.
128+type operationResult struct {
129+ outcome ports.BrokerMutationOutcome
130+ removed bool
131+ detail brokerwire.ErrorDetail
132+}
133+
134+// client implements ports.BrokerService over one broker IPC connection.
135+type client struct {
136+ transport wire.BoundedTransport
137+ ceilings brokerwire.Ceilings
138+ scope brokerwire.Scope
139+ conn *brokerwire.Connection
140+ cfg Config
141+
142+ ctx context.Context
143+ cancel context.CancelFunc
144+
145+ mu sync.Mutex
146+ closed bool
147+ err error
148+ sub *clientSubscription
149+ generation brokerwire.SubscriptionGeneration
150+ highest ports.BrokerStreamID
151+ nextStream ports.BrokerStreamID
152+ snapshot ports.BrokerSnapshot
153+ pending map[ports.BrokerOperationID]chan operationResult
154+ streams map[ports.BrokerStreamID]*clientStream
155+ closeOnce sync.Once
156+ done chan struct{}
157+}
158+
159+var _ ports.BrokerService = (*client)(nil)
160+
161+// run is the connection's single reader.
162+func (c *client) run() {
163+ for {
164+ envelope, err := c.transport.RecvBounded(c.ceilings.MaxReceiveEnvelopeBytes)
165+ if err != nil {
166+ c.closeWith(transportFailure(err))
167+ return
168+ }
169+ message, err := brokerwire.DecodeServer(envelope.Payload, c.ceilings.MaxReceiveEnvelopeBytes, c.ceilings.StreamChunkLimit)
170+ if err != nil {
171+ c.closeWith(errors.Join(ErrMalformedFrame, err))
172+ return
173+ }
174+ if err := c.dispatch(message); err != nil {
175+ c.closeWith(err)
176+ return
177+ }
178+ }
179+}
180+
181+// dispatch applies one server frame. A returned error is a protocol violation
182+// that settles the connection.
183+func (c *client) dispatch(message brokerwire.ServerMessage) error {
184+ switch m := message.(type) {
185+ case brokerwire.SnapshotPart:
186+ if !c.scopeMatches(m.Epoch, m.Connection) {
187+ return errors.Join(ErrScopeMismatch, ErrProtocol)
188+ }
189+ committed, snapshot, err := c.conn.AddSnapshotPart(m)
190+ if err != nil {
191+ if errors.Is(err, brokerwire.ErrStaleGeneration) || errors.Is(err, brokerwire.ErrFutureGeneration) || errors.Is(err, brokerwire.ErrSnapshotStale) {
192+ return nil
193+ }
194+ return errors.Join(ErrMalformedFrame, err)
195+ }
196+ if committed {
197+ c.publish(snapshot)
198+ }
199+ return nil
200+ case brokerwire.OperationResult:
201+ if !c.scopeMatches(m.Epoch, m.Connection) {
202+ return errors.Join(ErrScopeMismatch, ErrProtocol)
203+ }
204+ // The operation's waiter records the tracker completion exactly once;
205+ // a result with no waiter (an abandoned or replayed operation) is
206+ // dropped here.
207+ c.completeOperation(m.Operation, operationResult{outcome: m.Outcome, removed: m.Removed, detail: m.Error})
208+ return nil
209+ case brokerwire.StreamOpened:
210+ if !c.scopeMatches(m.Epoch, m.Connection) {
211+ return errors.Join(ErrScopeMismatch, ErrProtocol)
212+ }
213+ if err := c.conn.StreamOpened(m.Stream); err != nil && !errors.Is(err, brokerwire.ErrStreamState) {
214+ return errors.Join(ErrProtocol, err)
215+ }
216+ if st := c.lookupStream(m.Stream); st != nil {
217+ select {
218+ case st.opened <- nil:
219+ default:
220+ }
221+ }
222+ return nil
223+ case brokerwire.ServerStreamData:
224+ if !c.scopeMatches(m.Epoch, m.Connection) {
225+ return errors.Join(ErrScopeMismatch, ErrProtocol)
226+ }
227+ disposition, err := c.conn.StreamData(m.Stream)
228+ if err != nil {
229+ return errors.Join(ErrProtocol, err)
230+ }
231+ if disposition == brokerwire.StreamDiscarded {
232+ return nil
233+ }
234+ st := c.lookupStream(m.Stream)
235+ if st == nil {
236+ // The stream was retired locally between the tracker check and the
237+ // lookup; its data is discarded, exactly like a retired stream.
238+ return nil
239+ }
240+ if err := st.deliver(m.Data); err != nil {
241+ if errors.Is(err, ErrStreamBackpressure) {
242+ st.fail(ErrStreamBackpressure)
243+ }
244+ }
245+ return nil
246+ case brokerwire.StreamClosed:
247+ if !c.scopeMatches(m.Epoch, m.Connection) {
248+ return errors.Join(ErrScopeMismatch, ErrProtocol)
249+ }
250+ _, _ = c.conn.CloseStream(m.Stream)
251+ if st := c.lookupStream(m.Stream); st != nil {
252+ var cause error
253+ if m.HasError {
254+ cause = failureFromDetail(m.Error)
255+ }
256+ st.terminate(cause)
257+ }
258+ return nil
259+ case brokerwire.Progress:
260+ if !c.scopeMatches(m.Epoch, m.Connection) {
261+ return errors.Join(ErrScopeMismatch, ErrProtocol)
262+ }
263+ _, _ = c.conn.StreamProgress(m.Stream)
264+ return nil
265+ case brokerwire.BrokerErrorMessage:
266+ if !c.scopeMatches(m.Epoch, m.Connection) {
267+ return errors.Join(ErrScopeMismatch, ErrProtocol)
268+ }
269+ cause := failureFromDetail(m.Error)
270+ if cause == nil {
271+ cause = ports.BrokerError{Code: ports.BrokerErrorUnavailable}
272+ }
273+ return cause
274+ case brokerwire.Shutdown:
275+ return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: m.Text}
276+ default:
277+ return errors.Join(ErrProtocol, errors.New("brokeripc: unexpected server message"))
278+ }
279+}
280+
281+// scopeMatches reports whether one frame carries this connection's accepted
282+// scope.
283+func (c *client) scopeMatches(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID) bool {
284+ return epoch == c.scope.Epoch && connection == c.scope.Connection
285+}
286+
287+// publish installs one committed snapshot and wakes the active subscription.
288+func (c *client) publish(snapshot ports.BrokerSnapshot) {
289+ c.mu.Lock()
290+ c.snapshot = snapshot
291+ sub := c.sub
292+ c.mu.Unlock()
293+ if sub != nil {
294+ sub.notify()
295+ }
296+}
297+
298+// completeOperation hands one result to the operation's waiter, if any.
299+func (c *client) completeOperation(operation ports.BrokerOperationID, result operationResult) {
300+ c.mu.Lock()
301+ ch := c.pending[operation]
302+ delete(c.pending, operation)
303+ c.mu.Unlock()
304+ if ch == nil {
305+ return
306+ }
307+ select {
308+ case ch <- result:
309+ default:
310+ }
311+}
312+
313+// ConnectionID returns the identity assigned to this connection at accept.
314+func (c *client) ConnectionID() ports.BrokerConnectionID { return c.scope.Connection }
315+
316+// Snapshot returns the newest fully committed publication.
317+func (c *client) Snapshot() ports.BrokerSnapshot {
318+ c.mu.Lock()
319+ defer c.mu.Unlock()
320+ return c.snapshot.Clone()
321+}
322+
323+// Subscribe asks the broker for publications of the current scope. The call is
324+// a bounded, non-blocking hint: it enqueues one Subscribe frame carrying a fresh
325+// generation and returns immediately. Each notification means a newer committed
326+// snapshot is available through Snapshot.
327+//
328+// Adopting the generation and enqueueing its frame are one step on this
329+// connection: the local generation series is consumed as soon as the frame is
330+// admitted, so a send that then fails has already superseded the previous
331+// subscription. That previous subscription is therefore retired rather than
332+// restored, and the connection is left with no current subscription: the caller
333+// sees the send failure and subscribes again with a fresh generation.
334+func (c *client) Subscribe() (ports.BrokerSubscription, error) {
335+ c.mu.Lock()
336+ if c.closed {
337+ err := c.terminalErrorLocked()
338+ c.mu.Unlock()
339+ return nil, err
340+ }
341+ generation := c.generation + 1
342+ if err := c.conn.Subscribe(generation); err != nil {
343+ c.mu.Unlock()
344+ return nil, admissionError(err)
345+ }
346+ c.generation = generation
347+ sub := newClientSubscription()
348+ previous := c.sub
349+ c.sub = sub
350+ c.mu.Unlock()
351+ if err := c.sendAsync(brokerwire.Subscribe{
352+ Epoch: c.scope.Epoch, Connection: c.scope.Connection, Generation: generation,
353+ }); err != nil {
354+ // The generation was already consumed by the connection's subscription
355+ // tracker, so the previous subscription's generation is no longer
356+ // current even though this frame never left. Restoring it would present
357+ // a superseded generation as the live subscription; retire both and
358+ // leave the connection with no current subscription instead.
359+ c.mu.Lock()
360+ if c.sub == sub {
361+ c.sub = nil
362+ }
363+ c.mu.Unlock()
364+ sub.Close()
365+ previous.Close()
366+ return nil, err
367+ }
368+ if previous != nil {
369+ previous.Close()
370+ }
371+ return sub, nil
372+}
373+
374+// OpenStream opens one independently cancellable logical stream. An absent
375+// epoch, connection, or stream identity is filled from this connection; a
376+// caller-supplied identity that belongs elsewhere, or a stream identity that is
377+// not strictly increasing, is refused as stale.
378+func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
379+ scoped, err := c.scopeRequest(request)
380+ if err != nil {
381+ return nil, err
382+ }
383+ c.mu.Lock()
384+ if c.closed {
385+ err := c.terminalErrorLocked()
386+ c.mu.Unlock()
387+ return nil, err
388+ }
389+ // Admit the identity on the connection's own tracker before any frame
390+ // travels, so a racing StreamOpened can never precede local admission.
391+ if err := c.conn.OpenStream(scoped.Stream); err != nil {
392+ c.mu.Unlock()
393+ return nil, admissionError(err)
394+ }
395+ stream, err := newClientStream(c, scoped.Stream)
396+ if err != nil {
397+ _, _ = c.conn.CloseStream(scoped.Stream)
398+ c.mu.Unlock()
399+ return nil, err
400+ }
401+ c.streams[scoped.Stream] = stream
402+ c.mu.Unlock()
403+
404+ // OpenStream treats every send failure alike: a stream whose request
405+ // definitely never left is retired exactly like one that may have been
406+ // dispatched, because the local identity is already admitted either way.
407+ if _, err := c.sendContext(ctx, brokerwire.OpenStream{
408+ Epoch: scoped.Epoch, Connection: scoped.Connection, Stream: scoped.Stream,
409+ Purpose: scoped.Purpose, Local: scoped.Local,
410+ Endpoint: scoped.Endpoint, Registration: scoped.Registration,
411+ Target: scoped.Target, Env: scoped.Env, Policy: scoped.Policy,
412+ }); err != nil {
413+ c.retireStream(scoped.Stream, err)
414+ return nil, err
415+ }
416+ select {
417+ case err := <-stream.opened:
418+ if err != nil {
419+ return nil, err
420+ }
421+ return stream, nil
422+ case <-ctx.Done():
423+ // The request was sent, so the broker may still establish the stream.
424+ // Retire it locally and tell the broker; the caller sees cancellation.
425+ c.retireStream(scoped.Stream, ctx.Err())
426+ return nil, ctx.Err()
427+ case <-c.done:
428+ return nil, c.terminalError()
429+ }
430+}
431+
432+// CloseStream retires one scope-checked logical stream.
433+func (c *client) CloseStream(connection ports.BrokerConnectionID, stream ports.BrokerStreamID) error {
434+ if !connection.IsZero() && connection != c.scope.Connection {
435+ return errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale)
436+ }
437+ c.mu.Lock()
438+ if c.closed {
439+ err := c.terminalErrorLocked()
440+ c.mu.Unlock()
441+ return err
442+ }
443+ _, err := c.conn.CloseStream(stream)
444+ c.mu.Unlock()
445+ if err != nil {
446+ return admissionError(err)
447+ }
448+ c.requestStreamClose(stream)
449+ if st := c.lookupStream(stream); st != nil {
450+ st.terminate(nil)
451+ }
452+ return nil
453+}
454+
455+// AddHost requests one host registration. A lost reply is reported as
456+// outcome-unknown, never as an invitation to replay blindly.
457+func (c *client) AddHost(ctx context.Context, target string) error {
458+ result, err := c.mutate(ctx, func(operation ports.BrokerOperationID) brokerwire.ClientMessage {
459+ return brokerwire.AddHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: target}
460+ })
461+ if err != nil {
462+ return err
463+ }
464+ return mutationFailure(result)
465+}
466+
467+// RemoveHost requests one host removal and reports whether the host was present.
468+func (c *client) RemoveHost(ctx context.Context, target string) (bool, error) {
469+ result, err := c.mutate(ctx, func(operation ports.BrokerOperationID) brokerwire.ClientMessage {
470+ return brokerwire.RemoveHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: target}
471+ })
472+ if err != nil {
473+ return false, err
474+ }
475+ if err := mutationFailure(result); err != nil {
476+ return false, err
477+ }
478+ return result.removed, nil
479+}
480+
481+// RequestReconcile asks the broker to re-observe one endpoint. The port carries
482+// an endpoint while the wire carries an exact registration, so the hint is sent
483+// only for an endpoint this connection already holds a registration for; an
484+// unknown endpoint is a no-op. It never blocks and never fails the caller.
485+func (c *client) RequestReconcile(endpoint string) {
486+ c.mu.Lock()
487+ host, ok := c.snapshot.Find(endpoint)
488+ closed := c.closed
489+ c.mu.Unlock()
490+ if closed || !ok {
491+ return
492+ }
493+ _ = c.sendAsync(brokerwire.Reconcile{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Registration: host.Registration})
494+}
495+
496+// Close ends the connection, releasing its streams and subscription. It is
497+// idempotent and concurrent-safe.
498+func (c *client) Close() error {
499+ if c == nil {
500+ return nil
501+ }
502+ c.closeWith(nil)
503+ return nil
504+}
505+
506+// scopeRequest fills an absent identity from this connection and refuses one
507+// that belongs elsewhere, then validates the resulting request.
508+func (c *client) scopeRequest(request ports.BrokerOpenStreamRequest) (ports.BrokerOpenStreamRequest, error) {
509+ scoped := request
510+ if scoped.Epoch == 0 {
511+ scoped.Epoch = c.scope.Epoch
512+ } else if scoped.Epoch != c.scope.Epoch {
513+ return ports.BrokerOpenStreamRequest{}, errors.Join(ErrScopeMismatch, ports.BrokerError{Code: ports.BrokerErrorStaleEpoch})
514+ }
515+ if scoped.Connection.IsZero() {
516+ scoped.Connection = c.scope.Connection
517+ } else if scoped.Connection != c.scope.Connection {
518+ return ports.BrokerOpenStreamRequest{}, errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale)
519+ }
520+ c.mu.Lock()
521+ switch {
522+ case scoped.Stream == 0:
523+ if c.nextStream == ^ports.BrokerStreamID(0) {
524+ c.mu.Unlock()
525+ return ports.BrokerOpenStreamRequest{}, ports.BrokerAdmissionLimit
526+ }
527+ c.nextStream++
528+ scoped.Stream = c.nextStream
529+ c.highest = scoped.Stream
530+ case scoped.Stream <= c.highest:
531+ c.mu.Unlock()
532+ return ports.BrokerOpenStreamRequest{}, errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale)
533+ default:
534+ c.highest = scoped.Stream
535+ }
536+ c.mu.Unlock()
537+ if err := scoped.Validate(); err != nil {
538+ return ports.BrokerOpenStreamRequest{}, errors.Join(ports.BrokerAdmissionInvalid, err)
539+ }
540+ return scoped, nil
541+}
542+
543+// mutate sends one mutating operation and waits for its bounded completion. The
544+// operation identity is admitted on the connection's tracker (bounded by the
545+// same per-connection bound the wire enforces) and completed exactly once, so an
546+// abandoned or timed-out operation releases its pending slot and a late or
547+// replayed result is deduped rather than applied twice.
548+//
549+// A request that definitely never left the process fails as an ordinary definite
550+// error and records a failed outcome; once the wire attempt was launched, every
551+// failure is reported as outcome-unknown, so a caller refreshes authoritative
552+// state instead of replaying a non-idempotent mutation blindly.
553+func (c *client) mutate(ctx context.Context, build func(ports.BrokerOperationID) brokerwire.ClientMessage) (operationResult, error) {
554+ operation, err := newOperationID()
555+ if err != nil {
556+ return operationResult{}, err
557+ }
558+ reply := make(chan operationResult, 1)
559+ c.mu.Lock()
560+ if c.closed {
561+ err := c.terminalErrorLocked()
562+ c.mu.Unlock()
563+ return operationResult{}, err
564+ }
565+ if len(c.pending) >= c.cfg.MaxPendingOperations {
566+ c.mu.Unlock()
567+ return operationResult{}, ports.BrokerAdmissionLimit
568+ }
569+ if err := c.conn.AdmitOperation(operation); err != nil {
570+ c.mu.Unlock()
571+ return operationResult{}, admissionError(err)
572+ }
573+ c.pending[operation] = reply
574+ c.mu.Unlock()
575+ // No result observed is exactly the outcome-unknown case; a request that
576+ // was never launched is recorded as failed instead.
577+ outcome := ports.BrokerOutcomeUnknown
578+ defer func() {
579+ c.mu.Lock()
580+ delete(c.pending, operation)
581+ c.mu.Unlock()
582+ _ = c.conn.CompleteOperation(operation, outcome)
583+ }()
584+ attempted, err := c.sendContext(ctx, build(operation))
585+ if err != nil {
586+ if !attempted {
587+ // The request definitely did not travel, so a definite failure is
588+ // the honest report and the operation is recorded as failed.
589+ outcome = ports.BrokerOutcomeFailed
590+ return operationResult{}, err
591+ }
592+ // The wire attempt was launched: the broker may have committed the
593+ // operation even though this side observed no result.
594+ return operationResult{outcome: ports.BrokerOutcomeUnknown, detail: errorDetail(err)}, nil
595+ }
596+ select {
597+ case result := <-reply:
598+ outcome = result.outcome
599+ return result, nil
600+ case <-ctx.Done():
601+ // The operation was already sent: the broker may have committed it.
602+ return operationResult{outcome: ports.BrokerOutcomeUnknown, detail: errorDetail(ctx.Err())}, nil
603+ case <-c.done:
604+ return operationResult{outcome: ports.BrokerOutcomeUnknown, detail: errorDetail(c.terminalError())}, nil
605+ }
606+}
607+
608+// send encodes and writes one client frame synchronously.
609+func (c *client) send(message brokerwire.ClientMessage) error {
610+ payload, err := brokerwire.EncodeClient(message, c.ceilings.MaxReceiveEnvelopeBytes, c.ceilings.StreamChunkLimit)
611+ if err != nil {
612+ return err
613+ }
614+ if err := c.transport.Send(wire.Envelope{Payload: payload}); err != nil {
615+ return transportFailure(err)
616+ }
617+ return nil
618+}
619+
620+// sendAsync admits one client frame for ordered background transmission. It
621+// reports backpressure as a retryable unavailability rather than blocking a
622+// caller that has no context to spend.
623+func (c *client) sendAsync(message brokerwire.ClientMessage) error {
624+ payload, err := brokerwire.EncodeClient(message, c.ceilings.MaxReceiveEnvelopeBytes, c.ceilings.StreamChunkLimit)
625+ if err != nil {
626+ return err
627+ }
628+ if async, ok := c.transport.(wire.AsyncTransport); ok {
629+ if err := async.SendAsync(wire.Envelope{Payload: payload}); err != nil {
630+ if errors.Is(err, streamframe.ErrBackpressure) {
631+ return ports.BrokerError{Code: ports.BrokerErrorUnavailable}
632+ }
633+ return transportFailure(err)
634+ }
635+ return nil
636+ }
637+ if err := c.transport.Send(wire.Envelope{Payload: payload}); err != nil {
638+ return transportFailure(err)
639+ }
640+ return nil
641+}
642+
643+// sendContext writes one client frame, honoring cancellation while the carriage
644+// egress queue is full. It reports whether the wire attempt was launched, which
645+// is exactly the dispatch uncertainty a caller needs: an encoding failure, an
646+// already-cancelled call, or a connection already terminal before the attempt
647+// begins means the frame definitely did not travel, while every failure
648+// observed after the attempt is launched means it may have been dispatched and
649+// the caller must not replay blindly. The wire attempt is raced against the
650+// caller's context and the connection's terminal outcome, so a stalled send
651+// never pins the caller: the racing goroutine observes the closed carriage and
652+// returns.
653+func (c *client) sendContext(ctx context.Context, message brokerwire.ClientMessage) (attempted bool, err error) {
654+ payload, err := brokerwire.EncodeClient(message, c.ceilings.MaxReceiveEnvelopeBytes, c.ceilings.StreamChunkLimit)
655+ if err != nil {
656+ return false, err
657+ }
658+ // Synchronous preflight. Checking before the goroutine starts is what makes
659+ // the not-dispatched claim true: a context that is already done or a
660+ // connection that is already terminal can no longer race a launched Send.
661+ if err := ctx.Err(); err != nil {
662+ return false, err
663+ }
664+ select {
665+ case <-c.done:
666+ return false, c.terminalError()
667+ default:
668+ }
669+ done := make(chan error, 1)
670+ go func() { done <- c.transport.Send(wire.Envelope{Payload: payload}) }()
671+ select {
672+ case err := <-done:
673+ if err != nil {
674+ return true, transportFailure(err)
675+ }
676+ return true, nil
677+ case <-ctx.Done():
678+ return true, ctx.Err()
679+ case <-c.done:
680+ return true, c.terminalError()
681+ }
682+}
683+
684+// requestStreamClose tells the broker one stream is retired. It is best-effort:
685+// the stream is already settled locally, so a full queue or a dead carriage
686+// needs no further action.
687+func (c *client) requestStreamClose(stream ports.BrokerStreamID) {
688+ _ = c.sendAsync(brokerwire.CloseStream{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Stream: stream})
689+}
690+
691+// retireStream settles one local stream and tells the broker to retire it.
692+func (c *client) retireStream(stream ports.BrokerStreamID, cause error) {
693+ st := c.lookupStream(stream)
694+ if st != nil {
695+ st.terminate(cause)
696+ }
697+ c.requestStreamClose(stream)
698+}
699+
700+// lookupStream returns the local stream for one identity.
701+func (c *client) lookupStream(stream ports.BrokerStreamID) *clientStream {
702+ c.mu.Lock()
703+ defer c.mu.Unlock()
704+ return c.streams[stream]
705+}
706+
707+// unregisterStream drops one local stream record.
708+func (c *client) unregisterStream(stream ports.BrokerStreamID) {
709+ c.mu.Lock()
710+ delete(c.streams, stream)
711+ c.mu.Unlock()
712+}
713+
714+// terminalErrorLocked returns the terminal cause with the connection lock held.
715+func (c *client) terminalErrorLocked() error {
716+ if c.err != nil {
717+ return c.err
718+ }
719+ return ErrConnectionClosed
720+}
721+
722+// terminalError returns the terminal cause.
723+func (c *client) terminalError() error {
724+ c.mu.Lock()
725+ defer c.mu.Unlock()
726+ return c.terminalErrorLocked()
727+}
728+
729+// closeWith tears the connection down exactly once: cancel, close the carriage
730+// (unblocking a blocked read and any frame send), settle every local stream and
731+// the active subscription, and wake every waiter. A nil cause is an orderly
732+// local close.
733+func (c *client) closeWith(cause error) {
734+ if c == nil {
735+ return
736+ }
737+ c.closeOnce.Do(func() {
738+ c.cancel()
739+ _ = c.transport.Close()
740+ c.mu.Lock()
741+ c.closed = true
742+ if cause != nil && c.err == nil {
743+ c.err = cause
744+ }
745+ terminal := c.terminalErrorLocked()
746+ sub := c.sub
747+ c.sub = nil
748+ streams := make([]*clientStream, 0, len(c.streams))
749+ for _, st := range c.streams {
750+ streams = append(streams, st)
751+ }
752+ c.streams = make(map[ports.BrokerStreamID]*clientStream)
753+ c.mu.Unlock()
754+ for _, st := range streams {
755+ st.terminate(terminal)
756+ }
757+ if sub != nil {
758+ sub.Close()
759+ }
760+ close(c.done)
761+ })
762+}
763+
764+// mutationFailure converts one operation outcome into its typed caller error.
765+func mutationFailure(result operationResult) error {
766+ switch result.outcome {
767+ case ports.BrokerOutcomeOK:
768+ return nil
769+ case ports.BrokerOutcomeFailed:
770+ if err := failureFromDetail(result.detail); err != nil {
771+ return err
772+ }
773+ return ports.BrokerError{Code: ports.BrokerErrorUnavailable}
774+ default:
775+ return ports.BrokerError{Code: ports.BrokerErrorOutcomeUnknown}
776+ }
777+}
778+
779+// newOperationID samples one fresh non-zero operation identity.
780+func newOperationID() (ports.BrokerOperationID, error) {
781+ var id ports.BrokerOperationID
782+ if _, err := rand.Read(id[:]); err != nil {
783+ return ports.BrokerOperationID{}, err
784+ }
785+ if id.IsZero() {
786+ id[15] = 1
787+ }
788+ return id, nil
789+}
790+
791+// clientSubscription is one local capacity-one change notification. Each
792+// subscriber owns its own channel and re-reads the newest snapshot on wake.
793+type clientSubscription struct {
794+ changed chan struct{}
795+ once sync.Once
796+ mu sync.Mutex
797+ closed bool
798+}
799+
800+func newClientSubscription() *clientSubscription {
801+ return &clientSubscription{changed: make(chan struct{}, 1)}
802+}
803+
804+// Changed is signalled once per committed publication.
805+func (s *clientSubscription) Changed() <-chan struct{} { return s.changed }
806+
807+// Close ends the subscription exactly once. The channel is closed under the same
808+// lock notify sends under, so a racing publication can never send on a closed
809+// channel.
810+func (s *clientSubscription) Close() {
811+ if s == nil {
812+ return
813+ }
814+ s.once.Do(func() {
815+ s.mu.Lock()
816+ s.closed = true
817+ close(s.changed)
818+ s.mu.Unlock()
819+ })
820+}
821+
822+// notify coalesces one publication wake.
823+func (s *clientSubscription) notify() {
824+ if s == nil {
825+ return
826+ }
827+ s.mu.Lock()
828+ defer s.mu.Unlock()
829+ if s.closed {
830+ return
831+ }
832+ select {
833+ case s.changed <- struct{}{}:
834+ default:
835+ }
836+}
A internal/adapters/brokeripc/client_mutation_test.go +525 -0
@@ -0,0 +1,525 @@
1+package brokeripc
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/adapters/brokerwire"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol/wire"
16+)
17+
18+// Send-uncertainty taxonomy for mutating operations (GO-003). These tests drive
19+// one client adapter over a channel-controlled carriage, so the exact instant
20+// after a wire attempt is launched is observable without sleeping.
21+//
22+// The contract under test: only a request that definitely never left the process
23+// fails as an ordinary, definite error; once the attempt was launched, a caller
24+// cancellation, a carriage failure, or a connection teardown reports
25+// outcome-unknown, so no caller replays a non-idempotent mutation blindly.
26+
27+// errTestCarriage is the deterministic carriage failure the gated carriage
28+// reports for one released attempt.
29+var errTestCarriage = errors.New("brokeripc test: carriage write failed")
30+
31+// gatedTransport is a channel-controlled client carriage. Every Send records its
32+// envelope, announces the entered attempt, and then blocks until the test
33+// releases it with an explicit result or abandons it. Close deliberately leaves
34+// an in-flight Send blocked, so an attempt held inside the carriage observes
35+// only the client's own terminal outcome until the test itself decides.
36+type gatedTransport struct {
37+ entered chan wire.Envelope
38+ release chan error
39+ abandon chan struct{}
40+ closed chan struct{}
41+
42+ closeOnce sync.Once
43+ abandonOnce sync.Once
44+
45+ mu sync.Mutex
46+ sends int
47+}
48+
49+func newGatedTransport() *gatedTransport {
50+ return &gatedTransport{
51+ entered: make(chan wire.Envelope, 8),
52+ release: make(chan error, 8),
53+ abandon: make(chan struct{}),
54+ closed: make(chan struct{}),
55+ }
56+}
57+
58+// Send enters one wire attempt and holds it until the test releases it.
59+func (t *gatedTransport) Send(envelope wire.Envelope) error {
60+ t.mu.Lock()
61+ t.sends++
62+ t.mu.Unlock()
63+ t.entered <- envelope
64+ select {
65+ case err := <-t.release:
66+ return err
67+ case <-t.abandon:
68+ return io.EOF
69+ }
70+}
71+
72+func (t *gatedTransport) RecvBounded(uint64) (wire.Envelope, error) {
73+ <-t.closed
74+ return wire.Envelope{}, io.EOF
75+}
76+
77+func (t *gatedTransport) Recv() (wire.Envelope, error) { return t.RecvBounded(0) }
78+
79+// Close ends the carriage without disturbing an in-flight attempt.
80+func (t *gatedTransport) Close() error {
81+ t.closeOnce.Do(func() { close(t.closed) })
82+ return nil
83+}
84+
85+// releaseSend hands one blocked attempt its deterministic result.
86+func (t *gatedTransport) releaseSend(err error) { t.release <- err }
87+
88+// abandonAttempts releases every attempt still blocked in Send, so a test never
89+// leaks a send goroutine.
90+func (t *gatedTransport) abandonAttempts() {
91+ t.abandonOnce.Do(func() { close(t.abandon) })
92+}
93+
94+func (t *gatedTransport) sendCount() int {
95+ t.mu.Lock()
96+ defer t.mu.Unlock()
97+ return t.sends
98+}
99+
100+// newGatedClient builds one ready client adapter over a gated carriage. It never
101+// starts the reader goroutine: the reply path is driven by the test, so every
102+// observation is deterministic.
103+func newGatedClient(t *testing.T, cfg Config) (*client, *gatedTransport) {
104+ t.Helper()
105+ transport := newGatedTransport()
106+ t.Cleanup(transport.abandonAttempts)
107+ scope := brokerwire.Scope{
108+ Epoch: ports.BrokerEpoch(0x71),
109+ Connection: ports.BrokerConnectionID{0x71, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01},
110+ }
111+ conn, err := brokerwire.NewConnection(scope)
112+ require.NoError(t, err)
113+ require.NoError(t, conn.SignalPreamble())
114+ require.NoError(t, conn.Register())
115+ ctx, cancel := context.WithCancel(context.Background())
116+ c := &client{
117+ transport: transport,
118+ ceilings: brokerwire.DefaultCeilings(),
119+ scope: scope,
120+ conn: conn,
121+ cfg: cfg.withDefaults(),
122+ ctx: ctx,
123+ cancel: cancel,
124+ pending: make(map[ports.BrokerOperationID]chan operationResult),
125+ streams: make(map[ports.BrokerStreamID]*clientStream),
126+ done: make(chan struct{}),
127+ }
128+ t.Cleanup(func() { _ = c.Close() })
129+ return c, transport
130+}
131+
132+// mutationOutcome is one AddHost or RemoveHost result.
133+type mutationOutcome struct {
134+ removed bool
135+ err error
136+}
137+
138+// mutationCall adapts one mutating operation to the shared table shape.
139+type mutationCall struct {
140+ name string
141+ endpoint string
142+ wantRemoved bool
143+ call func(ctx context.Context, c *client, endpoint string) (bool, error)
144+}
145+
146+var mutationCalls = []mutationCall{
147+ {
148+ name: "AddHost",
149+ endpoint: "new@host:22",
150+ call: func(ctx context.Context, c *client, endpoint string) (bool, error) {
151+ return false, c.AddHost(ctx, endpoint)
152+ },
153+ },
154+ {
155+ name: "RemoveHost",
156+ endpoint: "known",
157+ wantRemoved: true,
158+ call: func(ctx context.Context, c *client, endpoint string) (bool, error) {
159+ return c.RemoveHost(ctx, endpoint)
160+ },
161+ },
162+}
163+
164+// awaitResult waits for one mutation call to return.
165+func awaitResult(t *testing.T, results <-chan mutationOutcome) mutationOutcome {
166+ t.Helper()
167+ select {
168+ case got := <-results:
169+ return got
170+ case <-time.After(5 * time.Second):
171+ t.Fatal("the mutating operation did not return")
172+ return mutationOutcome{}
173+ }
174+}
175+
176+// awaitSend waits for one wire attempt to enter the carriage and decodes it.
177+func awaitSend(t *testing.T, g *gatedTransport) brokerwire.ClientMessage {
178+ t.Helper()
179+ select {
180+ case envelope := <-g.entered:
181+ message, err := brokerwire.DecodeClient(envelope.Payload, brokerwire.MaxBrokerEnvelopeBytes, brokerwire.MaxStreamChunkBytes)
182+ require.NoError(t, err, "the captured attempt must be a decodable client envelope")
183+ return message
184+ case <-time.After(5 * time.Second):
185+ t.Fatal("no wire attempt was entered")
186+ return nil
187+ }
188+}
189+
190+// operationOf extracts the operation identity of one captured request.
191+func operationOf(t *testing.T, message brokerwire.ClientMessage) ports.BrokerOperationID {
192+ t.Helper()
193+ switch m := message.(type) {
194+ case brokerwire.AddHost:
195+ return m.Operation
196+ case brokerwire.RemoveHost:
197+ return m.Operation
198+ default:
199+ t.Fatalf("unexpected client message %T", message)
200+ return ports.BrokerOperationID{}
201+ }
202+}
203+
204+// endpointOf extracts the target of one captured request.
205+func endpointOf(t *testing.T, message brokerwire.ClientMessage) string {
206+ t.Helper()
207+ switch m := message.(type) {
208+ case brokerwire.AddHost:
209+ return m.Endpoint
210+ case brokerwire.RemoveHost:
211+ return m.Endpoint
212+ default:
213+ t.Fatalf("unexpected client message %T", message)
214+ return ""
215+ }
216+}
217+
218+// pendingOperations snapshots the client's in-flight waiters.
219+func pendingOperations(c *client) []ports.BrokerOperationID {
220+ c.mu.Lock()
221+ defer c.mu.Unlock()
222+ out := make([]ports.BrokerOperationID, 0, len(c.pending))
223+ for operation := range c.pending {
224+ out = append(out, operation)
225+ }
226+ return out
227+}
228+
229+// deliverResult plays one broker reply for a captured request.
230+func deliverResult(t *testing.T, c *client, operation ports.BrokerOperationID, outcome ports.BrokerMutationOutcome, removed bool) {
231+ t.Helper()
232+ require.NoError(t, c.dispatch(brokerwire.OperationResult{
233+ Epoch: c.scope.Epoch, Connection: c.scope.Connection,
234+ Operation: operation, Outcome: outcome, Removed: removed,
235+ }))
236+}
237+
238+// TestMutationSendUncertaintyTaxonomy pins the decision table for both mutating
239+// operations: a request that definitely never left is a definite error with zero
240+// sends; any failure observed after the attempt was launched is outcome-unknown
241+// with exactly one send.
242+func TestMutationSendUncertaintyTaxonomy(t *testing.T) {
243+ scenarios := []struct {
244+ name string
245+ // before, when set, runs before the call starts.
246+ before func(cancel context.CancelFunc)
247+ // drive runs while the call is in flight.
248+ drive func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc)
249+ wantSends int
250+ wantUnknown bool
251+ }{
252+ {
253+ name: "pre-cancelled caller never dispatches and fails definitely",
254+ before: func(cancel context.CancelFunc) { cancel() },
255+ wantSends: 0,
256+ },
257+ {
258+ name: "caller cancels while the attempt is blocked in Send",
259+ drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) {
260+ _ = awaitSend(t, g)
261+ cancel()
262+ },
263+ wantSends: 1,
264+ wantUnknown: true,
265+ },
266+ {
267+ name: "connection terminates while the attempt is blocked in Send",
268+ drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) {
269+ _ = awaitSend(t, g)
270+ require.NoError(t, c.Close())
271+ },
272+ wantSends: 1,
273+ wantUnknown: true,
274+ },
275+ {
276+ name: "Send fails after the request was captured",
277+ drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) {
278+ _ = awaitSend(t, g)
279+ g.releaseSend(errTestCarriage)
280+ },
281+ wantSends: 1,
282+ wantUnknown: true,
283+ },
284+ }
285+ for _, call := range mutationCalls {
286+ for _, tt := range scenarios {
287+ t.Run(call.name+"/"+tt.name, func(t *testing.T) {
288+ c, g := newGatedClient(t, Config{MaxPendingOperations: 1})
289+ ctx, cancel := context.WithCancel(context.Background())
290+ defer cancel()
291+ if tt.before != nil {
292+ tt.before(cancel)
293+ }
294+ results := make(chan mutationOutcome, 1)
295+ go func() {
296+ removed, err := call.call(ctx, c, call.endpoint)
297+ results <- mutationOutcome{removed: removed, err: err}
298+ }()
299+ if tt.drive != nil {
300+ tt.drive(t, c, g, cancel)
301+ }
302+ got := awaitResult(t, results)
303+ require.False(t, got.removed, "no reply means the target is never reported removed")
304+ if tt.wantUnknown {
305+ var failure ports.BrokerError
306+ require.ErrorAs(t, got.err, &failure)
307+ require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code,
308+ "a launched attempt must never look like a definite failure")
309+ } else {
310+ require.ErrorIs(t, got.err, context.Canceled)
311+ var failure ports.BrokerError
312+ require.False(t, errors.As(got.err, &failure),
313+ "a request that never left must not be reported as a broker outcome")
314+ }
315+ require.Equal(t, tt.wantSends, g.sendCount(), "the request must travel at most once")
316+ require.Empty(t, pendingOperations(c), "every mutation releases its pending slot")
317+ })
318+ }
319+ }
320+}
321+
322+// TestMutationDefiniteFailureRecordsAndReleases proves a mutation whose request
323+// definitely never left is recorded as failed rather than outcome-unknown, so
324+// its pending slot is released for the next operation and nothing is resent.
325+func TestMutationDefiniteFailureRecordsAndReleases(t *testing.T) {
326+ c, g := newGatedClient(t, Config{MaxPendingOperations: 1})
327+ ctx, cancel := context.WithCancel(context.Background())
328+ cancel()
329+
330+ // Driving mutate directly makes the operation identity observable even
331+ // though the request is never encoded for the wire.
332+ var operation ports.BrokerOperationID
333+ result, err := c.mutate(ctx, func(id ports.BrokerOperationID) brokerwire.ClientMessage {
334+ operation = id
335+ return brokerwire.AddHost{
336+ Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: id, Endpoint: "pre@host:22",
337+ }
338+ })
339+ require.ErrorIs(t, err, context.Canceled)
340+ require.Equal(t, operationResult{}, result)
341+ require.NotZero(t, operation)
342+ require.Zero(t, g.sendCount(), "a pre-cancelled caller never enters the carriage")
343+ require.Empty(t, pendingOperations(c), "the definite failure releases its pending slot")
344+
345+ // The identity is recorded with an outcome, not left in flight: admitting it
346+ // again is deduped, which is only true once the tracker was completed.
347+ require.ErrorIs(t, c.conn.AdmitOperation(operation), brokerwire.ErrOperationCompleted)
348+
349+ // The bound of one proves the slot is genuinely free: a fresh mutation is
350+ // admitted and completes normally.
351+ fresh, cancelFresh := context.WithCancel(context.Background())
352+ defer cancelFresh()
353+ results := make(chan mutationOutcome, 1)
354+ go func() {
355+ removed, callErr := c.RemoveHost(fresh, "known")
356+ results <- mutationOutcome{removed: removed, err: callErr}
357+ }()
358+ request := awaitSend(t, g)
359+ g.releaseSend(nil)
360+ deliverResult(t, c, operationOf(t, request), ports.BrokerOutcomeOK, true)
361+ got := awaitResult(t, results)
362+ require.NoError(t, got.err)
363+ require.True(t, got.removed)
364+ require.Equal(t, 1, g.sendCount())
365+}
366+
367+// TestMutationUnknownOutcomeReleasesWithoutResend proves one outcome-unknown
368+// mutation releases its pending slot exactly once, never resends the request,
369+// and ignores a late result for the same identity.
370+func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) {
371+ for _, call := range mutationCalls {
372+ t.Run(call.name, func(t *testing.T) {
373+ c, g := newGatedClient(t, Config{MaxPendingOperations: 1})
374+ ctx, cancel := context.WithCancel(context.Background())
375+ defer cancel()
376+
377+ results := make(chan mutationOutcome, 1)
378+ go func() {
379+ removed, err := call.call(ctx, c, call.endpoint)
380+ results <- mutationOutcome{removed: removed, err: err}
381+ }()
382+ request := awaitSend(t, g)
383+ operation := operationOf(t, request)
384+ require.Equal(t, call.endpoint, endpointOf(t, request), "the captured request is the caller's own")
385+ cancel()
386+ got := awaitResult(t, results)
387+ var failure ports.BrokerError
388+ require.ErrorAs(t, got.err, &failure)
389+ require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code)
390+ require.False(t, got.removed)
391+ require.Equal(t, 1, g.sendCount(), "an uncertain attempt is never resent")
392+ // The cancelled attempt is still held inside the carriage. Hand it its
393+ // result now, so the next mutation's attempt is the only one waiting
394+ // on the shared release channel.
395+ g.releaseSend(errTestCarriage)
396+
397+ // The identity was completed once, so its pending slot is released
398+ // rather than left in flight.
399+ require.ErrorIs(t, c.conn.AdmitOperation(operation), brokerwire.ErrOperationCompleted)
400+ require.Empty(t, pendingOperations(c))
401+
402+ // A late result for the completed identity is dropped, and it never
403+ // provokes another wire attempt.
404+ deliverResult(t, c, operation, ports.BrokerOutcomeOK, true)
405+ require.Empty(t, pendingOperations(c))
406+ require.Equal(t, 1, g.sendCount())
407+
408+ // The connection stays usable: the next mutation is admitted under
409+ // the same bound of one and completes normally.
410+ fresh, cancelFresh := context.WithCancel(context.Background())
411+ defer cancelFresh()
412+ next := make(chan mutationOutcome, 1)
413+ go func() {
414+ removed, err := call.call(fresh, c, call.endpoint)
415+ next <- mutationOutcome{removed: removed, err: err}
416+ }()
417+ second := awaitSend(t, g)
418+ g.releaseSend(nil)
419+ deliverResult(t, c, operationOf(t, second), ports.BrokerOutcomeOK, call.wantRemoved)
420+ done := awaitResult(t, next)
421+ require.NoError(t, done.err)
422+ require.Equal(t, call.wantRemoved, done.removed)
423+ require.Equal(t, 2, g.sendCount())
424+ })
425+ }
426+}
427+
428+// TestMutationDeliveredReplyIsDefinite proves an ordinary delivered reply stays
429+// the definite outcome for both operations, and a repeated result for the same
430+// identity changes nothing.
431+func TestMutationDeliveredReplyIsDefinite(t *testing.T) {
432+ for _, call := range mutationCalls {
433+ t.Run(call.name, func(t *testing.T) {
434+ c, g := newGatedClient(t, Config{})
435+ ctx, cancel := context.WithCancel(context.Background())
436+ defer cancel()
437+
438+ results := make(chan mutationOutcome, 1)
439+ go func() {
440+ removed, err := call.call(ctx, c, call.endpoint)
441+ results <- mutationOutcome{removed: removed, err: err}
442+ }()
443+ request := awaitSend(t, g)
444+ operation := operationOf(t, request)
445+ require.Equal(t, call.endpoint, endpointOf(t, request))
446+ g.releaseSend(nil)
447+ deliverResult(t, c, operation, ports.BrokerOutcomeOK, call.wantRemoved)
448+ got := awaitResult(t, results)
449+ require.NoError(t, got.err)
450+ require.Equal(t, call.wantRemoved, got.removed)
451+ require.Equal(t, 1, g.sendCount())
452+ require.Empty(t, pendingOperations(c))
453+
454+ // A replayed result for an already settled identity is dropped.
455+ deliverResult(t, c, operation, ports.BrokerOutcomeUnknown, true)
456+ require.Empty(t, pendingOperations(c))
457+ require.Equal(t, 1, g.sendCount())
458+ })
459+ }
460+}
461+
462+// TestSendContextPreflightClassification pins the private helper's taxonomy
463+// directly: an unencodable frame, an already-cancelled caller, and an
464+// already-terminal connection are definitely not dispatched; only a launched
465+// attempt is uncertain.
466+func TestSendContextPreflightClassification(t *testing.T) {
467+ t.Run("unencodable frame is definite", func(t *testing.T) {
468+ c, g := newGatedClient(t, Config{})
469+ attempted, err := c.sendContext(context.Background(), nil)
470+ require.False(t, attempted)
471+ require.ErrorIs(t, err, brokerwire.ErrInvalidMessage)
472+ require.Zero(t, g.sendCount())
473+ })
474+
475+ t.Run("already-cancelled caller is definite", func(t *testing.T) {
476+ c, g := newGatedClient(t, Config{})
477+ ctx, cancel := context.WithCancel(context.Background())
478+ cancel()
479+ attempted, err := c.sendContext(ctx, brokerwire.AddHost{
480+ Epoch: c.scope.Epoch, Connection: c.scope.Connection,
481+ Operation: ports.BrokerOperationID{0x01}, Endpoint: "pre@host:22",
482+ })
483+ require.False(t, attempted)
484+ require.ErrorIs(t, err, context.Canceled)
485+ require.Zero(t, g.sendCount())
486+ })
487+
488+ t.Run("already-terminal connection is definite", func(t *testing.T) {
489+ c, g := newGatedClient(t, Config{})
490+ require.NoError(t, c.Close())
491+ attempted, err := c.sendContext(context.Background(), brokerwire.AddHost{
492+ Epoch: c.scope.Epoch, Connection: c.scope.Connection,
493+ Operation: ports.BrokerOperationID{0x01}, Endpoint: "pre@host:22",
494+ })
495+ require.False(t, attempted)
496+ require.ErrorIs(t, err, ErrConnectionClosed)
497+ require.Zero(t, g.sendCount())
498+ })
499+
500+ t.Run("launched attempt is uncertain", func(t *testing.T) {
501+ c, g := newGatedClient(t, Config{})
502+ type sendResult struct {
503+ attempted bool
504+ err error
505+ }
506+ done := make(chan sendResult, 1)
507+ go func() {
508+ attempted, err := c.sendContext(context.Background(), brokerwire.RemoveHost{
509+ Epoch: c.scope.Epoch, Connection: c.scope.Connection,
510+ Operation: ports.BrokerOperationID{0x02}, Endpoint: "known",
511+ })
512+ done <- sendResult{attempted: attempted, err: err}
513+ }()
514+ _ = awaitSend(t, g)
515+ g.releaseSend(errTestCarriage)
516+ select {
517+ case got := <-done:
518+ require.True(t, got.attempted, "a launched attempt is dispatch-uncertain")
519+ require.ErrorIs(t, got.err, errTestCarriage)
520+ case <-time.After(5 * time.Second):
521+ t.Fatal("sendContext did not return")
522+ }
523+ require.Equal(t, 1, g.sendCount())
524+ })
525+}
A internal/adapters/brokeripc/config.go +81 -0
@@ -0,0 +1,81 @@
1+package brokeripc
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/adapters/brokerwire"
7+ "github.com/bnema/vev/internal/protocol"
8+)
9+
10+// Bounds. Every limit below is mandatory: no listener, session, or stream
11+// grows an unbounded queue, and every default is small enough that a stalled
12+// peer is refused rather than absorbed.
13+const (
14+ // DefaultMaxClients bounds concurrent accepted client connections on one
15+ // listener. A listener acquires one slot before accepting, so the bound is
16+ // enforced before a socket is admitted rather than after.
17+ DefaultMaxClients = 64
18+ // DefaultStreamInboundChunks bounds one logical stream's inbound chunk
19+ // queue: frames already accepted from the wire and not yet consumed by the
20+ // local session carriage.
21+ DefaultStreamInboundChunks = 64
22+ // DefaultStreamInboundBytes bounds one logical stream's inbound chunk queue
23+ // in bytes (4 MiB).
24+ DefaultStreamInboundBytes = 4 << 20
25+)
26+
27+// Config bounds one endpoint. The zero value is valid and selects every
28+// default; a negative or zero explicit bound is replaced by its default.
29+type Config struct {
30+ // MaxClients bounds concurrent accepted client connections.
31+ MaxClients int
32+ // StreamInboundChunks bounds one logical stream's inbound chunk queue.
33+ StreamInboundChunks int
34+ // StreamInboundBytes bounds one logical stream's inbound chunk bytes.
35+ StreamInboundBytes uint64
36+ // HandshakeTimeout bounds one accepted connection's broker preamble plus
37+ // admission. The client dialer also bounds its own preamble exchange with
38+ // it. It is deliberately separate from the session handshake budget that
39+ // runs inside a logical stream.
40+ HandshakeTimeout time.Duration
41+ // MaxPendingOperations bounds mutating operations this side is waiting for
42+ // on one connection. It never exceeds the brokerwire per-connection bound.
43+ MaxPendingOperations int
44+}
45+
46+// withDefaults fills every unset bound with its package default.
47+func (c Config) withDefaults() Config {
48+ if c.MaxClients <= 0 {
49+ c.MaxClients = DefaultMaxClients
50+ }
51+ if c.StreamInboundChunks <= 0 {
52+ c.StreamInboundChunks = DefaultStreamInboundChunks
53+ }
54+ if c.StreamInboundBytes == 0 {
55+ c.StreamInboundBytes = DefaultStreamInboundBytes
56+ }
57+ if c.HandshakeTimeout <= 0 {
58+ c.HandshakeTimeout = protocol.HandshakeTimeout
59+ }
60+ if c.MaxPendingOperations <= 0 {
61+ c.MaxPendingOperations = brokerwire.MaxPendingOperations
62+ }
63+ return c
64+}
65+
66+// validate refuses a configuration whose explicit bounds cannot be honored.
67+// The zero value is always valid because withDefaults has already been applied.
68+func (c Config) validate() error {
69+ if c.MaxClients <= 0 || c.StreamInboundChunks <= 0 || c.StreamInboundBytes == 0 || c.HandshakeTimeout <= 0 {
70+ return ErrConfig
71+ }
72+ if c.MaxPendingOperations <= 0 || c.MaxPendingOperations > brokerwire.MaxPendingOperations {
73+ return ErrConfig
74+ }
75+ // A stream inbound byte bound below one maximum chunk can never accept a
76+ // full stream frame, which would starve a legal peer.
77+ if c.StreamInboundBytes < brokerwire.MaxStreamChunkBytes {
78+ return ErrConfig
79+ }
80+ return nil
81+}
A internal/adapters/brokeripc/doc.go +46 -0
@@ -0,0 +1,46 @@
1+// Package brokeripc is the Plan 001 P3.3 private local broker IPC adapter.
2+//
3+// It exposes the client and server halves of one per-user Unix endpoint: a
4+// client adapter that implements ports.BrokerService over the brokerwire
5+// conversation, and a listener adapter that implements ports.BrokerListener by
6+// accepting same-user client connections and running one brokerwire session per
7+// accepted connection. The wire contract is exactly P3.1's brokerwire preamble
8+// and directional codec over the shared streamframe carriage; this package adds
9+// no message, tag, or preamble of its own.
10+//
11+// The carriage itself is the P3.2 private AF_UNIX carriage in
12+// internal/adapters/ipc: the endpoint lives in an owner-only (0700) directory
13+// created or validated by pkg/safedir.EnsurePrivate, the bound socket is
14+// tightened to 0600, a stale socket left by a dead owner is recovered
15+// race-safely, a live owner is reported instead of evicted, and a path that is
16+// not a socket is refused without being removed. Both accept and dial verify
17+// the connected peer's kernel credentials (SO_PEERCRED on Linux) and fail
18+// closed on a platform or build without that check, so no admission decision
19+// rests on filesystem permissions or an X11-style "the directory is private"
20+// assumption.
21+//
22+// One endpoint is one brokerwire conversation. The server assigns the
23+// connection scope at accept, answers the preamble with the negotiated
24+// ceilings, and waits for exactly one Register before admitting subscription,
25+// operation, or stream work; every later frame must carry the assigned epoch
26+// and connection identity or it is refused as stale without touching session
27+// state. Bounds are explicit: accepted clients are capped per listener, stream
28+// inbound queues are bounded per stream, pending operations are capped per
29+// connection, and the brokerwire per-connection trackers bound streams and
30+// completed-operation dedup. Every accepted connection owns its own reader,
31+// publisher, and stream relays, so a slow or stalled client blocks only its own
32+// connection.
33+//
34+// A malformed or oversize outer envelope, a wrong-direction payload, and a
35+// frame for an identity that was never allocated are protocol violations and
36+// settle that connection alone. Disconnect cleanup is deterministic: the
37+// session cancels its context, terminates every bridged stream against the
38+// admitted broker service, closes the carriage, and joins every worker before
39+// Close returns.
40+//
41+// Like the brokerwire codec (P3.1) and the daemonmux carriage (P3.2), this
42+// adapter is not activated by production composition. Nothing in internal/app
43+// or main constructs it yet; P3.4 composes the broker use case, its stores, and
44+// this listener behind a hidden entry point, and P7 performs the coordinated
45+// production cutover.
46+package brokeripc
A internal/adapters/brokeripc/emit.go +119 -0
@@ -0,0 +1,119 @@
1+package brokeripc
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/adapters/brokerwire"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/protocol/catalogue"
9+)
10+
11+// Snapshot publication (P3.3).
12+//
13+// A broker publication travels as the exact multipart transfer the P3.1
14+// assembler accepts: Begin(index 0, element counts), then every host part in
15+// host-index order immediately followed by that host's session parts in
16+// session-index order, then the tombstones, then End at the final index. Host
17+// parts carry no inline sessions; sessions travel as their own parts. Both
18+// sides agree on one deterministic layout, so the receiving assembler commits
19+// only a transfer whose indexes, counts, and order are exact.
20+//
21+// The emitter refuses a transfer the peer's assembler would refuse: more parts
22+// than MaxSnapshotParts, or a total encoded payload above the staged ceiling.
23+// The staged ceiling check is deliberately conservative - the encoded total is
24+// an upper bound of the deterministic staged estimate - so a publication this
25+// side accepts is never one the peer silently aborts.
26+
27+// snapshotParts splits one published snapshot into its ordered multipart
28+// transfer for one connection scope and subscription generation.
29+func snapshotParts(snapshot ports.BrokerSnapshot, epoch ports.BrokerEpoch, connection ports.BrokerConnectionID, generation brokerwire.SubscriptionGeneration) ([]brokerwire.SnapshotPart, error) {
30+ if epoch == 0 || connection.IsZero() {
31+ return nil, ErrScopeMismatch
32+ }
33+ if snapshot.Revision == 0 {
34+ return nil, errors.New("brokeripc: snapshot publication has no revision")
35+ }
36+ if len(snapshot.Hosts) > ports.BrokerMaxHosts || len(snapshot.Removed) > ports.BrokerMaxTombstones {
37+ return nil, errors.New("brokeripc: snapshot publication exceeds host bound")
38+ }
39+ sessions := 0
40+ for _, host := range snapshot.Hosts {
41+ if len(host.Sessions) > ports.BrokerMaxSessionsPerHost {
42+ return nil, errors.New("brokeripc: snapshot publication exceeds session bound")
43+ }
44+ sessions += len(host.Sessions)
45+ }
46+ total := 1 + len(snapshot.Hosts) + sessions + len(snapshot.Removed) + 1
47+ if total > brokerwire.MaxSnapshotParts {
48+ return nil, errors.New("brokeripc: snapshot publication exceeds part bound")
49+ }
50+
51+ parts := make([]brokerwire.SnapshotPart, 0, total)
52+ base := func(part brokerwire.SnapshotPartPayload) brokerwire.SnapshotPart {
53+ return brokerwire.SnapshotPart{
54+ Epoch: epoch, Connection: connection, Generation: generation,
55+ Revision: snapshot.Revision, Index: uint32(len(parts)), Part: part,
56+ }
57+ }
58+ parts = append(parts, base(brokerwire.SnapshotBegin{
59+ HostCount: uint32(len(snapshot.Hosts)),
60+ SessionCount: uint32(sessions),
61+ TombstoneCount: uint32(len(snapshot.Removed)),
62+ }))
63+ for hostIndex, host := range snapshot.Hosts {
64+ projection := host.Clone()
65+ sessionCount := len(projection.Sessions)
66+ projection.Sessions = nil
67+ parts = append(parts, base(brokerwire.SnapshotHostPart{
68+ HostIndex: uint32(hostIndex),
69+ Host: projection,
70+ SessionCount: uint32(sessionCount),
71+ }))
72+ for sessionIndex, session := range host.Sessions {
73+ parts = append(parts, base(brokerwire.SnapshotSessionPart{
74+ HostIndex: uint32(hostIndex),
75+ SessionIndex: uint32(sessionIndex),
76+ Session: cloneCatalogSession(session),
77+ }))
78+ }
79+ }
80+ for tombstoneIndex, tombstone := range snapshot.Removed {
81+ parts = append(parts, base(brokerwire.SnapshotTombstonePart{
82+ TombstoneIndex: uint32(tombstoneIndex),
83+ Registration: tombstone.Registration,
84+ RetiredRevision: tombstone.RetiredRevision,
85+ }))
86+ }
87+ parts = append(parts, base(brokerwire.SnapshotEnd{}))
88+ return parts, nil
89+}
90+
91+// cloneCatalogSession copies one session and guarantees its tabs slice is
92+// non-nil: the assembler refuses a session part whose tabs are absent, so an
93+// empty inventory publishes an empty list rather than a missing one.
94+func cloneCatalogSession(session catalogue.RemoteCatalogSession) catalogue.RemoteCatalogSession {
95+ out := session
96+ if session.Tabs == nil {
97+ out.Tabs = []catalogue.RemoteCatalogTab{}
98+ return out
99+ }
100+ tabs := make([]catalogue.RemoteCatalogTab, len(session.Tabs))
101+ copy(tabs, session.Tabs)
102+ out.Tabs = tabs
103+ return out
104+}
105+
106+// snapshotTransferBytes sums the encoded size of one prepared transfer so the
107+// publisher can refuse a publication above the staged ceiling before the peer
108+// aborts it.
109+func snapshotTransferBytes(parts []brokerwire.SnapshotPart, maxEnvelopeBytes, maxChunkBytes uint64) (uint64, error) {
110+ var total uint64
111+ for _, part := range parts {
112+ payload, err := brokerwire.EncodeServer(part, maxEnvelopeBytes, maxChunkBytes)
113+ if err != nil {
114+ return 0, err
115+ }
116+ total += uint64(len(payload))
117+ }
118+ return total, nil
119+}
A internal/adapters/brokeripc/errors.go +141 -0
@@ -0,0 +1,141 @@
1+package brokeripc
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/vev/internal/adapters/brokerwire"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+// Adapter sentinels. They are typed so a caller can classify a listener,
12+// session, framing, or backpressure outcome without matching on message text.
13+// Semantic broker refusals stay ports.BrokerError, and admission refusals stay
14+// ports.BrokerAdmissionError; this adapter wraps them rather than redefining
15+// them.
16+var (
17+ // ErrConfig reports an invalid listener or dialer configuration.
18+ ErrConfig = errors.New("brokeripc: invalid configuration")
19+ // ErrListenerClosed reports Accept on a closed listener.
20+ ErrListenerClosed = errors.New("brokeripc: listener is closed")
21+ // ErrSessionClosed reports session work presented after the session reached
22+ // its terminal outcome.
23+ ErrSessionClosed = errors.New("brokeripc: session is closed")
24+ // ErrMalformedFrame reports an outer envelope this side refuses to decode:
25+ // an oversize length prefix, a truncated or trailing payload, an unknown or
26+ // repeated field, or a wrong-direction payload. A malformed frame is a
27+ // protocol violation and settles exactly the connection that sent it.
28+ ErrMalformedFrame = errors.New("brokeripc: malformed broker frame")
29+ // ErrProtocol reports a well-formed frame that violates connection
30+ // ordering: a duplicate Register, a frame for an unallocated stream, or
31+ // stream data before the stream was established.
32+ ErrProtocol = errors.New("brokeripc: broker protocol violation")
33+ // ErrStreamBackpressure reports a logical stream whose local consumer is
34+ // not draining its bounded inbound queue. The affected stream is settled
35+ // alone; the connection and every sibling stream stay up.
36+ ErrStreamBackpressure = errors.New("brokeripc: stream consumer is not keeping up")
37+ // ErrStreamGone reports a stream the peer retired while this side was still
38+ // starting or using it.
39+ ErrStreamGone = errors.New("brokeripc: logical stream is gone")
40+ // ErrConnectionClosed reports a client adapter whose connection reached its
41+ // terminal outcome.
42+ ErrConnectionClosed = errors.New("brokeripc: broker connection is closed")
43+ // ErrScopeMismatch reports a frame carrying an epoch or connection identity
44+ // that is not the one assigned at accept. It is refused as stale and never
45+ // applied.
46+ ErrScopeMismatch = errors.New("brokeripc: frame scope does not match the accepted connection")
47+)
48+
49+// transportFailure classifies one carriage error that ended a connection. A
50+// closed carriage is an orderly disconnect; anything else is a transport
51+// failure.
52+func transportFailure(err error) error {
53+ if err == nil {
54+ return ErrConnectionClosed
55+ }
56+ return err
57+}
58+
59+// cancelOrCause prefers the context outcome when a wait was cancelled, so a
60+// caller sees cancellation rather than a synthetic transport error.
61+func cancelOrCause(ctx context.Context, cause error) error {
62+ if ctxErr := ctx.Err(); ctxErr != nil {
63+ return ctxErr
64+ }
65+ return cause
66+}
67+
68+// admissionError maps one brokerwire tracker refusal onto the closed ports
69+// admission taxonomy. An unrecognized refusal is invalid-request, never a
70+// retryable limit.
71+func admissionError(err error) error {
72+ switch {
73+ case err == nil:
74+ return nil
75+ case errors.Is(err, brokerwire.ErrTooManyStreams), errors.Is(err, brokerwire.ErrTooManyPendingOperations):
76+ return ports.BrokerAdmissionLimit
77+ case errors.Is(err, brokerwire.ErrConnectionClosed):
78+ return ports.BrokerAdmissionClosed
79+ case errors.Is(err, brokerwire.ErrStreamIDReused), errors.Is(err, brokerwire.ErrConnectionState),
80+ errors.Is(err, brokerwire.ErrNotSubscribed), errors.Is(err, brokerwire.ErrDuplicateRegister),
81+ errors.Is(err, brokerwire.ErrOperationCompleted), errors.Is(err, brokerwire.ErrOperationPending):
82+ return ports.BrokerAdmissionStale
83+ default:
84+ return ports.BrokerAdmissionInvalid
85+ }
86+}
87+
88+// errorDetail converts one broker failure into the bounded, presentation-safe
89+// wire detail. The diagnostic cause stays local: only the typed code, bounded
90+// display text, closed admission code, and sanitized failure kind travel.
91+func errorDetail(err error) brokerwire.ErrorDetail {
92+ if err == nil {
93+ return brokerwire.ErrorDetail{}
94+ }
95+ var failure ports.BrokerError
96+ if errors.As(err, &failure) {
97+ return brokerwire.ErrorDetail{Code: failure.Code, Text: failure.Text}
98+ }
99+ var admission ports.BrokerAdmissionError
100+ if errors.As(err, &admission) {
101+ detail := brokerwire.ErrorDetail{Code: ports.BrokerErrorUnavailable}
102+ switch {
103+ case errors.Is(err, ports.BrokerAdmissionLimit):
104+ detail.AdmissionCode = 1
105+ case errors.Is(err, ports.BrokerAdmissionClosed):
106+ detail.AdmissionCode = 2
107+ case errors.Is(err, ports.BrokerAdmissionStale):
108+ detail.AdmissionCode = 3
109+ default:
110+ detail.AdmissionCode = 4
111+ }
112+ return detail
113+ }
114+ switch {
115+ case errors.Is(err, context.DeadlineExceeded):
116+ return brokerwire.ErrorDetail{Code: ports.BrokerErrorTimeout}
117+ case errors.Is(err, context.Canceled):
118+ return brokerwire.ErrorDetail{Code: ports.BrokerErrorCancelled}
119+ default:
120+ return brokerwire.ErrorDetail{Code: ports.BrokerErrorUnavailable}
121+ }
122+}
123+
124+// failureFromDetail converts one received detail back into the typed local
125+// failure. A detail with no code is the absent-detail case and yields nil.
126+func failureFromDetail(detail brokerwire.ErrorDetail) error {
127+ switch detail.AdmissionCode {
128+ case 1:
129+ return ports.BrokerAdmissionLimit
130+ case 2:
131+ return ports.BrokerAdmissionClosed
132+ case 3:
133+ return ports.BrokerAdmissionStale
134+ case 4:
135+ return ports.BrokerAdmissionInvalid
136+ }
137+ if detail.Code == 0 {
138+ return nil
139+ }
140+ return ports.BrokerError{Code: detail.Code, Text: detail.Text}
141+}
A internal/adapters/brokeripc/helpers_test.go +554 -0
@@ -0,0 +1,554 @@
1+package brokeripc
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/vev/internal/adapters/brokerwire"
17+ "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/ports"
19+ "github.com/bnema/vev/internal/protocol"
20+ "github.com/bnema/vev/internal/protocol/catalogue"
21+ "github.com/bnema/vev/internal/protocol/wire"
22+)
23+
24+// testSocketPath returns a short, owner-only parent directory and one broker
25+// endpoint inside it, so a real AF_UNIX path is exercised well within the
26+// cross-platform pathname limit.
27+func testSocketPath(t *testing.T) string {
28+ t.Helper()
29+ root, err := os.MkdirTemp("/tmp", "v")
30+ require.NoError(t, err)
31+ t.Cleanup(func() { _ = os.RemoveAll(root) })
32+ return SocketPath(filepath.Join(root, "vev"))
33+}
34+
35+// testPolicy builds one valid broker policy.
36+func testPolicy() ports.BrokerPolicy {
37+ return ports.BrokerPolicy{
38+ ProtocolVersion: protocol.Version,
39+ CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
40+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
41+ Transport: "unix",
42+ Trust: "trusted",
43+ Launch: "explicit",
44+ Isolation: "per-user",
45+ }
46+}
47+
48+// testHostAt builds one catalogue-valid host projection for index i.
49+func testHostAt(i int) ports.RemoteHostSnapshot {
50+ endpoint := fmt.Sprintf("user%d@host%d:22", i, i)
51+ return ports.RemoteHostSnapshot{
52+ Endpoint: endpoint,
53+ DisplayOrigin: endpoint,
54+ Registration: domain.RemoteRegistration{Endpoint: endpoint, Incarnation: [16]byte{0x10, byte(i + 1), 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xf0, 0x11}, Generation: domain.RemoteGeneration(i + 1)},
55+ Availability: domain.RemoteAvailabilityReachable,
56+ LastSuccess: time.Unix(1700000000+int64(i), 0).UTC(),
57+ InventoryKnown: true,
58+ Sessions: []catalogue.RemoteCatalogSession{},
59+ }
60+}
61+
62+// testSessionAt builds one catalogue-valid session for a host and index.
63+func testSessionAt(host, index int) catalogue.RemoteCatalogSession {
64+ return catalogue.RemoteCatalogSession{
65+ LifecycleID: domain.SessionLifecycleID{byte(host + 1), byte(index >> 8), byte(index), 0x77},
66+ Name: fmt.Sprintf("s%02d-%03d", host, index),
67+ State: catalogue.RemoteCatalogSessionDown,
68+ Tabs: []catalogue.RemoteCatalogTab{},
69+ }
70+}
71+
72+// testSnapshot builds one valid publication for an epoch and revision.
73+func testSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision) ports.BrokerSnapshot {
74+ host := testHostAt(0)
75+ host.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)}
76+ return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Hosts: []ports.RemoteHostSnapshot{host}}
77+}
78+
79+// snapshotHub is a multi-subscriber publication hub standing in for the broker
80+// registry: one immutable current snapshot, coalescing capacity-one subscribers.
81+type snapshotHub struct {
82+ mu sync.Mutex
83+ snapshot ports.BrokerSnapshot
84+ subs map[*fakeSubscription]struct{}
85+}
86+
87+func newSnapshotHub(snapshot ports.BrokerSnapshot) *snapshotHub {
88+ return &snapshotHub{snapshot: snapshot, subs: make(map[*fakeSubscription]struct{})}
89+}
90+
91+func (h *snapshotHub) current() ports.BrokerSnapshot {
92+ h.mu.Lock()
93+ defer h.mu.Unlock()
94+ return h.snapshot.Clone()
95+}
96+
97+func (h *snapshotHub) subscribe() *fakeSubscription {
98+ sub := &fakeSubscription{hub: h, changed: make(chan struct{}, 1)}
99+ h.mu.Lock()
100+ h.subs[sub] = struct{}{}
101+ h.mu.Unlock()
102+ return sub
103+}
104+
105+func (h *snapshotHub) publish(snapshot ports.BrokerSnapshot) {
106+ h.mu.Lock()
107+ h.snapshot = snapshot.Clone()
108+ subs := make([]*fakeSubscription, 0, len(h.subs))
109+ for sub := range h.subs {
110+ subs = append(subs, sub)
111+ }
112+ h.mu.Unlock()
113+ for _, sub := range subs {
114+ sub.notify()
115+ }
116+}
117+
118+func (h *snapshotHub) remove(sub *fakeSubscription) {
119+ h.mu.Lock()
120+ delete(h.subs, sub)
121+ h.mu.Unlock()
122+}
123+
124+// fakeSubscription is one coalescing capacity-one subscription.
125+type fakeSubscription struct {
126+ hub *snapshotHub
127+ changed chan struct{}
128+ once sync.Once
129+ mu sync.Mutex
130+ closed bool
131+}
132+
133+func (s *fakeSubscription) Changed() <-chan struct{} { return s.changed }
134+
135+func (s *fakeSubscription) Close() {
136+ s.once.Do(func() {
137+ s.mu.Lock()
138+ s.closed = true
139+ s.mu.Unlock()
140+ s.hub.remove(s)
141+ })
142+}
143+
144+func (s *fakeSubscription) notify() {
145+ s.mu.Lock()
146+ defer s.mu.Unlock()
147+ if s.closed {
148+ return
149+ }
150+ select {
151+ case s.changed <- struct{}{}:
152+ default:
153+ }
154+}
155+
156+// fakeLogicalConn is one admitted core logical connection: it records messages
157+// the broker relays toward the daemon and lets a test inject daemon replies.
158+type fakeLogicalConn struct {
159+ fromClient chan protocol.ClientMessage
160+ toClient chan protocol.ServerMessage
161+ done chan struct{}
162+ once sync.Once
163+ mu sync.Mutex
164+ err error
165+ closed bool
166+}
167+
168+func newFakeLogicalConn() *fakeLogicalConn {
169+ return &fakeLogicalConn{
170+ fromClient: make(chan protocol.ClientMessage, 64),
171+ toClient: make(chan protocol.ServerMessage, 64),
172+ done: make(chan struct{}),
173+ }
174+}
175+
176+func (c *fakeLogicalConn) SendClient(message protocol.ClientMessage) error {
177+ select {
178+ case c.fromClient <- message:
179+ return nil
180+ case <-c.done:
181+ return ports.BrokerAdmissionClosed
182+ }
183+}
184+
185+func (c *fakeLogicalConn) ReceiveServer() (protocol.ServerMessage, error) {
186+ select {
187+ case message := <-c.toClient:
188+ return message, nil
189+ case <-c.done:
190+ if err := c.Err(); err != nil {
191+ return nil, err
192+ }
193+ return nil, io.EOF
194+ }
195+}
196+
197+func (c *fakeLogicalConn) Capabilities() protocol.ConnectionCapabilities {
198+ return protocol.ConnectionCapabilities{OutputDataLimit: protocol.MaxOutputDataLen}
199+}
200+
201+func (c *fakeLogicalConn) LinkState() ports.LinkState { return ports.LinkStateConnected }
202+func (c *fakeLogicalConn) LinkEvents() <-chan ports.LinkEvent { return nil }
203+func (c *fakeLogicalConn) Done() <-chan struct{} { return c.done }
204+func (c *fakeLogicalConn) Err() error { c.mu.Lock(); defer c.mu.Unlock(); return c.err }
205+
206+func (c *fakeLogicalConn) Close() error {
207+ c.once.Do(func() {
208+ c.mu.Lock()
209+ c.closed = true
210+ c.mu.Unlock()
211+ close(c.done)
212+ })
213+ return nil
214+}
215+
216+// fail publishes one terminal core failure.
217+func (c *fakeLogicalConn) fail(err error) {
218+ c.mu.Lock()
219+ c.err = err
220+ c.mu.Unlock()
221+ _ = c.Close()
222+}
223+
224+// fakeCore is one admitted per-connection broker service.
225+type fakeCore struct {
226+ mu sync.Mutex
227+ id ports.BrokerConnectionID
228+ hub *snapshotHub
229+ streams map[ports.BrokerStreamID]*fakeLogicalConn
230+ opens []ports.BrokerOpenStreamRequest
231+ closedIDs []ports.BrokerStreamID
232+ added []string
233+ removed []string
234+ reconcile []string
235+ openErr error
236+ blockAdd chan struct{}
237+ closed bool
238+ done chan struct{}
239+ once sync.Once
240+}
241+
242+func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id }
243+
244+func (c *fakeCore) Snapshot() ports.BrokerSnapshot { return c.hub.current() }
245+
246+func (c *fakeCore) Subscribe() (ports.BrokerSubscription, error) { return c.hub.subscribe(), nil }
247+
248+func (c *fakeCore) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
249+ c.mu.Lock()
250+ defer c.mu.Unlock()
251+ if c.openErr != nil {
252+ return nil, c.openErr
253+ }
254+ if c.closed {
255+ return nil, ports.BrokerAdmissionClosed
256+ }
257+ conn := newFakeLogicalConn()
258+ c.streams[request.Stream] = conn
259+ c.opens = append(c.opens, request)
260+ return conn, nil
261+}
262+
263+func (c *fakeCore) CloseStream(connection ports.BrokerConnectionID, stream ports.BrokerStreamID) error {
264+ if connection != c.id {
265+ return ports.BrokerAdmissionStale
266+ }
267+ c.mu.Lock()
268+ conn, ok := c.streams[stream]
269+ if ok {
270+ delete(c.streams, stream)
271+ }
272+ c.closedIDs = append(c.closedIDs, stream)
273+ c.mu.Unlock()
274+ if !ok {
275+ return ports.BrokerAdmissionStale
276+ }
277+ return conn.Close()
278+}
279+
280+func (c *fakeCore) AddHost(ctx context.Context, target string) error {
281+ if c.blockAdd != nil {
282+ select {
283+ case <-c.blockAdd:
284+ case <-ctx.Done():
285+ return ctx.Err()
286+ }
287+ }
288+ if err := ctx.Err(); err != nil {
289+ return err
290+ }
291+ c.mu.Lock()
292+ c.added = append(c.added, target)
293+ c.mu.Unlock()
294+ return nil
295+}
296+
297+func (c *fakeCore) RemoveHost(ctx context.Context, target string) (bool, error) {
298+ if err := ctx.Err(); err != nil {
299+ return false, err
300+ }
301+ c.mu.Lock()
302+ c.removed = append(c.removed, target)
303+ c.mu.Unlock()
304+ return target == "known", nil
305+}
306+
307+func (c *fakeCore) RequestReconcile(endpoint string) {
308+ c.mu.Lock()
309+ c.reconcile = append(c.reconcile, endpoint)
310+ c.mu.Unlock()
311+}
312+
313+func (c *fakeCore) Close() error {
314+ c.once.Do(func() {
315+ c.mu.Lock()
316+ c.closed = true
317+ conns := make([]*fakeLogicalConn, 0, len(c.streams))
318+ for _, conn := range c.streams {
319+ conns = append(conns, conn)
320+ }
321+ c.streams = make(map[ports.BrokerStreamID]*fakeLogicalConn)
322+ c.mu.Unlock()
323+ for _, conn := range conns {
324+ _ = conn.Close()
325+ }
326+ close(c.done)
327+ })
328+ return nil
329+}
330+
331+// logicalConn returns one admitted core connection by stream identity.
332+func (c *fakeCore) logicalConn(stream ports.BrokerStreamID) *fakeLogicalConn {
333+ c.mu.Lock()
334+ defer c.mu.Unlock()
335+ return c.streams[stream]
336+}
337+
338+// newTestCore builds one admitted core service for a session test: a fresh hub
339+// and a connection identity bound to epoch.
340+func newTestCore(epoch ports.BrokerEpoch) *fakeCore {
341+ var id ports.BrokerConnectionID
342+ binary.BigEndian.PutUint64(id[:8], uint64(epoch))
343+ id[15] = 1
344+ return &fakeCore{
345+ id: id,
346+ hub: newSnapshotHub(ports.BrokerSnapshot{}),
347+ streams: make(map[ports.BrokerStreamID]*fakeLogicalConn),
348+ done: make(chan struct{}),
349+ }
350+}
351+
352+// fakeAuthority admits clients for one listener and hands out per-connection
353+// core services.
354+type fakeAuthority struct {
355+ mu sync.Mutex
356+ hub *snapshotHub
357+ epoch ports.BrokerEpoch
358+ next uint64
359+ clients []*fakeCore
360+ admitErr error
361+}
362+
363+func newFakeAuthority(epoch ports.BrokerEpoch, snapshot ports.BrokerSnapshot) *fakeAuthority {
364+ return &fakeAuthority{hub: newSnapshotHub(snapshot), epoch: epoch}
365+}
366+
367+func (a *fakeAuthority) AdmitClient(ctx context.Context) (ports.BrokerService, error) {
368+ if err := ctx.Err(); err != nil {
369+ return nil, err
370+ }
371+ a.mu.Lock()
372+ defer a.mu.Unlock()
373+ if a.admitErr != nil {
374+ return nil, a.admitErr
375+ }
376+ a.next++
377+ var id ports.BrokerConnectionID
378+ binary.BigEndian.PutUint64(id[:8], uint64(a.epoch))
379+ binary.BigEndian.PutUint64(id[8:], a.next)
380+ core := &fakeCore{id: id, hub: a.hub, streams: make(map[ports.BrokerStreamID]*fakeLogicalConn), done: make(chan struct{})}
381+ a.clients = append(a.clients, core)
382+ return core, nil
383+}
384+
385+func (a *fakeAuthority) last() *fakeCore {
386+ a.mu.Lock()
387+ defer a.mu.Unlock()
388+ if len(a.clients) == 0 {
389+ return nil
390+ }
391+ return a.clients[len(a.clients)-1]
392+}
393+
394+func (a *fakeAuthority) publish(snapshot ports.BrokerSnapshot) { a.hub.publish(snapshot) }
395+
396+// endpoint is one running broker IPC listener with its authority.
397+type endpoint struct {
398+ t *testing.T
399+ path string
400+ listener ports.BrokerListener
401+ authority *fakeAuthority
402+ epoch ports.BrokerEpoch
403+}
404+
405+func startEndpoint(t *testing.T, cfg Config) *endpoint {
406+ t.Helper()
407+ epoch := ports.BrokerEpoch(0x51)
408+ authority := newFakeAuthority(epoch, ports.BrokerSnapshot{})
409+ listener, err := Listen(testSocketPath(t), epoch, authority, cfg)
410+ require.NoError(t, err)
411+ t.Cleanup(func() { _ = listener.Close() })
412+ return &endpoint{t: t, path: listener.Addr(), listener: listener, authority: authority, epoch: epoch}
413+}
414+
415+// accept returns the next admitted session.
416+func (e *endpoint) accept() ports.BrokerService {
417+ e.t.Helper()
418+ type result struct {
419+ service ports.BrokerService
420+ err error
421+ }
422+ out := make(chan result, 1)
423+ go func() {
424+ service, err := e.listener.Accept()
425+ out <- result{service, err}
426+ }()
427+ select {
428+ case got := <-out:
429+ require.NoError(e.t, got.err)
430+ return got.service
431+ case <-time.After(5 * time.Second):
432+ e.t.Fatal("listener.Accept did not return")
433+ return nil
434+ }
435+}
436+
437+// dial connects one client adapter with the default test configuration.
438+func (e *endpoint) dial() ports.BrokerService { return e.dialWith(Config{}) }
439+
440+// dialWith connects one client adapter with an explicit configuration.
441+func (e *endpoint) dialWith(cfg Config) ports.BrokerService {
442+ e.t.Helper()
443+ cfg.HandshakeTimeout = 2 * time.Second
444+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
445+ defer cancel()
446+ client, err := Dial(ctx, e.path, cfg)
447+ require.NoError(e.t, err)
448+ e.t.Cleanup(func() { _ = client.Close() })
449+ return client
450+}
451+
452+// pair dials one client and accepts its session.
453+func (e *endpoint) pair() (ports.BrokerService, ports.BrokerService) {
454+ e.t.Helper()
455+ client := e.dial()
456+ session := e.accept()
457+ return client, session
458+}
459+
460+// publish makes one snapshot current and wakes every subscriber.
461+func (e *endpoint) publish(epoch ports.BrokerEpoch, revision ports.BrokerRevision) ports.BrokerSnapshot {
462+ e.t.Helper()
463+ snapshot := testSnapshot(epoch, revision)
464+ e.authority.publish(snapshot)
465+ return snapshot
466+}
467+
468+// recordingTransport is one in-memory server carriage for a session: it decodes
469+// and records every complete snapshot transfer the session writes and lets a
470+// test wait for a generation to publish. It never yields an inbound frame, so
471+// only the code under test drives publication.
472+type recordingTransport struct {
473+ mu sync.Mutex
474+ ends map[brokerwire.SubscriptionGeneration]map[ports.BrokerRevision]int
475+ changed chan struct{}
476+ closedC chan struct{}
477+ once sync.Once
478+}
479+
480+func newRecordingTransport() *recordingTransport {
481+ return &recordingTransport{
482+ ends: make(map[brokerwire.SubscriptionGeneration]map[ports.BrokerRevision]int),
483+ changed: make(chan struct{}, 1),
484+ closedC: make(chan struct{}),
485+ }
486+}
487+
488+// Send decodes one server frame and records a completed snapshot transfer.
489+func (t *recordingTransport) Send(envelope wire.Envelope) error {
490+ message, err := brokerwire.DecodeServer(envelope.Payload, brokerwire.MaxBrokerEnvelopeBytes, brokerwire.MaxStreamChunkBytes)
491+ if err != nil {
492+ return err
493+ }
494+ part, ok := message.(brokerwire.SnapshotPart)
495+ if !ok {
496+ return nil
497+ }
498+ if _, ok := part.Part.(brokerwire.SnapshotEnd); !ok {
499+ return nil
500+ }
501+ t.mu.Lock()
502+ if t.ends[part.Generation] == nil {
503+ t.ends[part.Generation] = make(map[ports.BrokerRevision]int)
504+ }
505+ t.ends[part.Generation][part.Revision]++
506+ t.mu.Unlock()
507+ select {
508+ case t.changed <- struct{}{}:
509+ default:
510+ }
511+ return nil
512+}
513+
514+func (t *recordingTransport) RecvBounded(uint64) (wire.Envelope, error) {
515+ <-t.closedC
516+ return wire.Envelope{}, io.EOF
517+}
518+
519+func (t *recordingTransport) Recv() (wire.Envelope, error) { return t.RecvBounded(0) }
520+
521+func (t *recordingTransport) Close() error {
522+ t.once.Do(func() { close(t.closedC) })
523+ return nil
524+}
525+
526+// transferCount reports how many complete transfers of one generation committed
527+// one revision.
528+func (t *recordingTransport) transferCount(generation brokerwire.SubscriptionGeneration, revision ports.BrokerRevision) int {
529+ t.mu.Lock()
530+ defer t.mu.Unlock()
531+ return t.ends[generation][revision]
532+}
533+
534+// waitForTransfer blocks until generation committed revision at least once.
535+func (t *recordingTransport) waitForTransfer(generation brokerwire.SubscriptionGeneration, revision ports.BrokerRevision, timeout time.Duration) bool {
536+ return t.waitForTransferCount(generation, revision, 1, timeout)
537+}
538+
539+// waitForTransferCount blocks until generation committed revision at least
540+// minimum times, or the timeout elapses.
541+func (t *recordingTransport) waitForTransferCount(generation brokerwire.SubscriptionGeneration, revision ports.BrokerRevision, minimum int, timeout time.Duration) bool {
542+ deadline := time.NewTimer(timeout)
543+ defer deadline.Stop()
544+ for {
545+ if t.transferCount(generation, revision) >= minimum {
546+ return true
547+ }
548+ select {
549+ case <-t.changed:
550+ case <-deadline.C:
551+ return t.transferCount(generation, revision) >= minimum
552+ }
553+ }
554+}
A internal/adapters/brokeripc/limits_test.go +324 -0
@@ -0,0 +1,324 @@
1+package brokeripc
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/adapters/brokerwire"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol/catalogue"
16+ "github.com/bnema/vev/internal/protocol/wire"
17+)
18+
19+// TestSnapshotPartsLayoutRoundTrips proves the emitter produces exactly the
20+// multipart layout the P3.1 assembler commits, including tombstones and
21+// session-less hosts.
22+func TestSnapshotPartsLayoutRoundTrips(t *testing.T) {
23+ snapshot := testSnapshot(9, 12)
24+ snapshot.Hosts = append(snapshot.Hosts, testHostAt(1))
25+ snapshot.Removed = []ports.BrokerHostTombstone{{
26+ Endpoint: "retired@old:22",
27+ Registration: testHostAt(0).Registration,
28+ RetiredRevision: 3,
29+ }}
30+ snapshot.Removed[0].Registration.Endpoint = "retired@old:22"
31+ snapshot.Removed[0].Endpoint = "retired@old:22"
32+ require.NoError(t, snapshot.Validate())
33+
34+ parts, err := snapshotParts(snapshot, 9, ports.BrokerConnectionID{9, 9}, 3)
35+ require.NoError(t, err)
36+ require.Len(t, parts, 1+2+2+1+1)
37+
38+ begin, ok := parts[0].Part.(brokerwire.SnapshotBegin)
39+ require.True(t, ok)
40+ require.Equal(t, uint32(2), begin.HostCount)
41+ require.Equal(t, uint32(2), begin.SessionCount)
42+ require.Equal(t, uint32(1), begin.TombstoneCount)
43+ _, ok = parts[len(parts)-1].Part.(brokerwire.SnapshotEnd)
44+ require.True(t, ok)
45+
46+ assembler := brokerwire.NewSnapshotAssembler(9, ports.BrokerConnectionID{9, 9}, brokerwire.WithGeneration(3))
47+ var committed ports.BrokerSnapshot
48+ found := false
49+ for index, part := range parts {
50+ require.Equal(t, uint32(index), part.Index)
51+ require.Equal(t, brokerwire.SubscriptionGeneration(3), part.Generation)
52+ done, assembled, err := assembler.Add(part)
53+ require.NoError(t, err, "part %d", index)
54+ if done {
55+ committed = assembled
56+ found = true
57+ }
58+ }
59+ require.True(t, found, "the transfer must commit")
60+ require.Equal(t, snapshot.Revision, committed.Revision)
61+ require.Len(t, committed.Hosts, 2)
62+ require.Len(t, committed.Hosts[0].Sessions, 2)
63+ for _, host := range committed.Hosts {
64+ for _, session := range host.Sessions {
65+ require.Equal(t, []catalogue.RemoteCatalogTab{}, session.Tabs)
66+ }
67+ }
68+ require.Len(t, committed.Removed, 1)
69+ require.Equal(t, "retired@old:22", committed.Removed[0].Endpoint)
70+}
71+
72+// TestSnapshotPartsRefusesOverBoundInventory proves the emitter fails closed on
73+// an inventory the peer's assembler would refuse, instead of silently sending a
74+// transfer that can never commit.
75+func TestSnapshotPartsRefusesOverBoundInventory(t *testing.T) {
76+ snapshot := testSnapshot(1, 1)
77+ over := make([]catalogue.RemoteCatalogSession, ports.BrokerMaxSessionsPerHost+1)
78+ snapshot.Hosts[0].Sessions = over
79+ _, err := snapshotParts(snapshot, 1, ports.BrokerConnectionID{1}, 1)
80+ require.Error(t, err)
81+
82+ snapshot = testSnapshot(0, 1)
83+ _, err = snapshotParts(snapshot, 0, ports.BrokerConnectionID{1}, 1)
84+ require.ErrorIs(t, err, ErrScopeMismatch)
85+}
86+
87+// TestSnapshotPartsRequiresRevision proves a snapshot without a publication
88+// revision is refused rather than published as revision zero.
89+func TestSnapshotPartsRequiresRevision(t *testing.T) {
90+ snapshot := testSnapshot(1, 1)
91+ snapshot.Revision = 0
92+ _, err := snapshotParts(snapshot, 1, ports.BrokerConnectionID{1}, 1)
93+ require.Error(t, err)
94+}
95+
96+// TestConfigDefaultsAndValidation proves the zero configuration is valid and an
97+// unusable explicit bound is refused.
98+func TestConfigDefaultsAndValidation(t *testing.T) {
99+ cfg := Config{}.withDefaults()
100+ require.NoError(t, cfg.validate())
101+ require.Equal(t, DefaultMaxClients, cfg.MaxClients)
102+ require.Equal(t, DefaultStreamInboundChunks, cfg.StreamInboundChunks)
103+ require.Equal(t, uint64(DefaultStreamInboundBytes), cfg.StreamInboundBytes)
104+ require.Equal(t, brokerwire.MaxPendingOperations, cfg.MaxPendingOperations)
105+
106+ require.ErrorIs(t, (Config{StreamInboundBytes: 1}).withDefaults().validate(), ErrConfig)
107+ require.ErrorIs(t, (Config{MaxPendingOperations: brokerwire.MaxPendingOperations + 1}).withDefaults().validate(), ErrConfig)
108+ // A negative explicit bound is replaced by its default rather than refused.
109+ require.NoError(t, (Config{MaxClients: -1, StreamInboundChunks: -1}).withDefaults().validate())
110+}
111+
112+// TestStreamPipeBoundsAndClose proves the per-stream byte channel is bounded,
113+// reports backpressure instead of growing, and releases a parked reader on
114+// close.
115+func TestStreamPipeBoundsAndClose(t *testing.T) {
116+ pipe := newStreamPipe(2, 4096, func([]byte) error { return nil })
117+ require.NoError(t, pipe.deliver([]byte("one")))
118+ require.NoError(t, pipe.deliver([]byte("two")))
119+ require.ErrorIs(t, pipe.deliver([]byte("three")), ErrStreamBackpressure)
120+
121+ buf := make([]byte, 3)
122+ n, err := pipe.Read(buf)
123+ require.NoError(t, err)
124+ require.Equal(t, "one", string(buf[:n]))
125+ require.NoError(t, pipe.deliver([]byte("three")))
126+
127+ require.NoError(t, pipe.Close())
128+ require.ErrorIs(t, pipe.deliver([]byte("four")), ErrStreamGone)
129+ _, err = pipe.Read(buf)
130+ require.ErrorIs(t, err, io.EOF)
131+
132+ // A reader parked on an empty pipe is released by closeWith with its cause.
133+ parked := newStreamPipe(1, 4096, func([]byte) error { return nil })
134+ done := make(chan error, 1)
135+ go func() {
136+ _, err := parked.Read(buf)
137+ done <- err
138+ }()
139+ time.Sleep(20 * time.Millisecond)
140+ parked.closeWith(ErrStreamBackpressure)
141+ select {
142+ case err := <-done:
143+ require.ErrorIs(t, err, ErrStreamBackpressure)
144+ case <-time.After(5 * time.Second):
145+ t.Fatal("closeWith must release a parked reader")
146+ }
147+}
148+
149+// TestStreamPipeOutboundError proves a failing outbound hook surfaces instead of
150+// silently dropping bytes.
151+func TestStreamPipeOutboundError(t *testing.T) {
152+ pipe := newStreamPipe(1, 4096, func([]byte) error { return ErrStreamBackpressure })
153+ _, err := pipe.Write([]byte("payload"))
154+ require.ErrorIs(t, err, ErrStreamBackpressure)
155+ require.NoError(t, pipe.Close())
156+ _, err = pipe.Write([]byte("payload"))
157+ require.ErrorIs(t, err, ErrStreamGone)
158+}
159+
160+// TestErrorDetailMapping proves the wire detail and the local typed failure stay
161+// mutually consistent, including admission and context outcomes.
162+func TestErrorDetailMapping(t *testing.T) {
163+ cases := []struct {
164+ name string
165+ err error
166+ code ports.BrokerErrorCode
167+ admission uint32
168+ }{
169+ {"typed", ports.BrokerError{Code: ports.BrokerErrorIncompatible, Text: "no"}, ports.BrokerErrorIncompatible, 0},
170+ {"limit", ports.BrokerAdmissionLimit, ports.BrokerErrorUnavailable, 1},
171+ {"closed", ports.BrokerAdmissionClosed, ports.BrokerErrorUnavailable, 2},
172+ {"stale", ports.BrokerAdmissionStale, ports.BrokerErrorUnavailable, 3},
173+ {"invalid", ports.BrokerAdmissionInvalid, ports.BrokerErrorUnavailable, 4},
174+ {"deadline", context.DeadlineExceeded, ports.BrokerErrorTimeout, 0},
175+ {"cancelled", context.Canceled, ports.BrokerErrorCancelled, 0},
176+ {"unknown", errors.New("boom"), ports.BrokerErrorUnavailable, 0},
177+ }
178+ for _, tc := range cases {
179+ t.Run(tc.name, func(t *testing.T) {
180+ detail := errorDetail(tc.err)
181+ require.Equal(t, tc.code, detail.Code)
182+ require.Equal(t, tc.admission, detail.AdmissionCode)
183+ // The wire encoder validates the detail exactly as a peer would.
184+ _, err := brokerwire.EncodeServer(brokerwire.StreamClosed{
185+ Epoch: 1, Connection: ports.BrokerConnectionID{1}, Stream: 1,
186+ Error: detail, HasError: detail != (brokerwire.ErrorDetail{}),
187+ }, brokerwire.MaxBrokerEnvelopeBytes, brokerwire.MaxStreamChunkBytes)
188+ require.NoError(t, err, "a produced detail must always be wire-valid")
189+ })
190+ }
191+ require.Equal(t, brokerwire.ErrorDetail{}, errorDetail(nil))
192+ require.NoError(t, failureFromDetail(brokerwire.ErrorDetail{}), "absent detail is an orderly outcome")
193+
194+ scoped := failureFromDetail(errorDetail(ports.BrokerAdmissionLimit))
195+ require.ErrorIs(t, scoped, ports.BrokerAdmissionLimit)
196+}
197+
198+// TestAdmissionErrorMapping proves connection-tracker refusals map onto the
199+// closed ports admission taxonomy.
200+func TestAdmissionErrorMapping(t *testing.T) {
201+ require.NoError(t, admissionError(nil))
202+ require.ErrorIs(t, admissionError(brokerwire.ErrTooManyStreams), ports.BrokerAdmissionLimit)
203+ require.ErrorIs(t, admissionError(brokerwire.ErrTooManyPendingOperations), ports.BrokerAdmissionLimit)
204+ require.ErrorIs(t, admissionError(brokerwire.ErrConnectionClosed), ports.BrokerAdmissionClosed)
205+ require.ErrorIs(t, admissionError(brokerwire.ErrStreamIDReused), ports.BrokerAdmissionStale)
206+ require.ErrorIs(t, admissionError(brokerwire.ErrFutureStream), ports.BrokerAdmissionInvalid)
207+}
208+
209+// TestScopeRequestFillsIdentity proves an absent identity is filled from the
210+// connection, a foreign one is refused as stale, and stream identities advance
211+// strictly.
212+func TestScopeRequestFillsIdentity(t *testing.T) {
213+ c := &client{
214+ scope: brokerwire.Scope{Epoch: 4, Connection: ports.BrokerConnectionID{4}},
215+ ceilings: brokerwire.DefaultCeilings(),
216+ pending: map[ports.BrokerOperationID]chan operationResult{},
217+ streams: map[ports.BrokerStreamID]*clientStream{},
218+ done: make(chan struct{}),
219+ cfg: Config{}.withDefaults(),
220+ transport: nil,
221+ }
222+ scoped, err := c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
223+ require.NoError(t, err)
224+ require.Equal(t, ports.BrokerEpoch(4), scoped.Epoch)
225+ require.Equal(t, ports.BrokerConnectionID{4}, scoped.Connection)
226+ require.Equal(t, ports.BrokerStreamID(1), scoped.Stream)
227+
228+ _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{
229+ Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),
230+ Connection: ports.BrokerConnectionID{0x11},
231+ })
232+ require.ErrorIs(t, err, ports.BrokerAdmissionStale)
233+
234+ _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{
235+ Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), Epoch: 99,
236+ })
237+ require.ErrorIs(t, err, ErrScopeMismatch)
238+
239+ scoped, err = c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 5, Policy: testPolicy()})
240+ require.NoError(t, err)
241+ require.Equal(t, ports.BrokerStreamID(5), scoped.Stream)
242+ _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 5, Policy: testPolicy()})
243+ require.ErrorIs(t, err, ports.BrokerAdmissionStale)
244+}
245+
246+// TestSocketPaths proves the endpoint name is distinct from the daemon socket
247+// and lives in the per-user runtime directory.
248+func TestSocketPaths(t *testing.T) {
249+ require.Equal(t, "/run/x/vev/broker.sock", SocketPath("/run/x/vev"))
250+ require.Equal(t, "broker.sock", SocketFileName)
251+ require.NotEqual(t, "daemon.sock", SocketFileName)
252+ require.Contains(t, DefaultSocketPath(), SocketFileName)
253+}
254+
255+// TestPreambleCarriageIgnoresUnboundedTransport proves a carriage that cannot be
256+// bounded is refused rather than read without a bound.
257+func TestPreambleCarriageIgnoresUnboundedTransport(t *testing.T) {
258+ _, err := recvPreamble(unboundedTransport{})
259+ require.ErrorIs(t, err, ErrConfig)
260+}
261+
262+// unboundedTransport implements only wire.Transport.
263+type unboundedTransport struct{}
264+
265+func (unboundedTransport) Send(wire.Envelope) error { return nil }
266+func (unboundedTransport) Recv() (wire.Envelope, error) {
267+ return wire.Envelope{}, io.EOF
268+}
269+func (unboundedTransport) Close() error { return nil }
270+
271+// TestServerSessionRejectsInvalidConstruction proves construction refuses a nil
272+// carriage, a nil core, and a zero epoch instead of building a session that can
273+// never serve.
274+func TestServerSessionRejectsInvalidConstruction(t *testing.T) {
275+ core := &fakeCore{id: ports.BrokerConnectionID{1}, hub: newSnapshotHub(ports.BrokerSnapshot{}), streams: map[ports.BrokerStreamID]*fakeLogicalConn{}, done: make(chan struct{})}
276+ _, err := newServerSession(0, nil, brokerwire.DefaultCeilings(), core, Config{}, nil)
277+ require.ErrorIs(t, err, ErrConfig)
278+ _, err = newServerSession(1, nil, brokerwire.DefaultCeilings(), nil, Config{}, nil)
279+ require.ErrorIs(t, err, ErrConfig)
280+ require.NoError(t, brokerwire.DefaultCeilings().Validate())
281+ require.Error(t, brokerwire.Ceilings{}.Validate())
282+}
283+
284+// TestOrderlyDisconnectClassification proves a peer disconnect and a local close
285+// are orderly ends while a protocol violation is not.
286+func TestOrderlyDisconnectClassification(t *testing.T) {
287+ require.True(t, orderlyDisconnect(nil))
288+ require.True(t, orderlyDisconnect(io.EOF))
289+ require.True(t, orderlyDisconnect(ErrSessionClosed))
290+ require.True(t, orderlyDisconnect(ErrConnectionClosed))
291+ require.False(t, orderlyDisconnect(ErrMalformedFrame))
292+ require.False(t, orderlyDisconnect(ErrProtocol))
293+}
294+
295+// TestStreamBridgeRequiresConfig proves the bridge refuses missing participants
296+// rather than building a half-wired stream.
297+func TestStreamBridgeRequiresConfig(t *testing.T) {
298+ _, err := newServerStream(nil, 1, nil)
299+ require.ErrorIs(t, err, ErrConfig)
300+ _, err = newClientStream(nil, 1)
301+ require.ErrorIs(t, err, ErrConfig)
302+}
303+
304+// TestClientSubscriptionCoalesces proves the local subscription wakes once per
305+// publication and closes exactly once.
306+func TestClientSubscriptionCoalesces(t *testing.T) {
307+ sub := newClientSubscription()
308+ sub.notify()
309+ sub.notify()
310+ <-sub.Changed()
311+ select {
312+ case <-sub.Changed():
313+ t.Fatal("notifications must coalesce")
314+ default:
315+ }
316+ var wg sync.WaitGroup
317+ for i := 0; i < 4; i++ {
318+ wg.Add(1)
319+ go func() { defer wg.Done(); sub.Close() }()
320+ }
321+ wg.Wait()
322+ _, open := <-sub.Changed()
323+ require.False(t, open)
324+}
A internal/adapters/brokeripc/listen_test.go +466 -0
@@ -0,0 +1,466 @@
1+package brokeripc
2+
3+import (
4+ "context"
5+ "errors"
6+ "net"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/vev/internal/adapters/brokerwire"
17+ "github.com/bnema/vev/internal/adapters/ipc"
18+ "github.com/bnema/vev/internal/ports"
19+)
20+
21+// gatedAuthority blocks admission until a test releases it, so a listener Close
22+// can race an admission that has already reached the authority.
23+type gatedAuthority struct {
24+ inner Authority
25+ entered chan struct{}
26+ gate chan struct{}
27+ once sync.Once
28+}
29+
30+func (a *gatedAuthority) AdmitClient(ctx context.Context) (ports.BrokerService, error) {
31+ a.once.Do(func() { close(a.entered) })
32+ <-a.gate
33+ return a.inner.AdmitClient(ctx)
34+}
35+
36+// ctxAuthority blocks admission until its context is canceled, so a test can
37+// prove Listener.Close cancels an already-accepted client parked in admission
38+// instead of waiting out HandshakeTimeout.
39+type ctxAuthority struct {
40+ entered chan struct{}
41+ once sync.Once
42+}
43+
44+func (a *ctxAuthority) AdmitClient(ctx context.Context) (ports.BrokerService, error) {
45+ a.once.Do(func() { close(a.entered) })
46+ <-ctx.Done()
47+ return nil, ctx.Err()
48+}
49+
50+// TestCloseRacingBlockedAdmissionDoesNotHang proves a Close that races a blocked
51+// admission never calls session.Close before the run loop starts, which would
52+// wait forever on an unstarted done channel and hang listener shutdown.
53+func TestCloseRacingBlockedAdmissionDoesNotHang(t *testing.T) {
54+ const epoch = ports.BrokerEpoch(0x51)
55+ path := testSocketPath(t)
56+ gated := &gatedAuthority{
57+ inner: newFakeAuthority(epoch, ports.BrokerSnapshot{}),
58+ entered: make(chan struct{}),
59+ gate: make(chan struct{}),
60+ }
61+ bound, err := listen(path, epoch, gated, Config{MaxClients: 2, HandshakeTimeout: 30 * time.Second}, ipc.SameUserPeerVerifier())
62+ require.NoError(t, err)
63+
64+ // Complete the preamble so the connection reaches admission, then leave it
65+ // blocked there.
66+ _ = rawDial(t, &endpoint{t: t, path: path, epoch: epoch}, brokerwire.DefaultCeilings())
67+ select {
68+ case <-gated.entered:
69+ case <-time.After(5 * time.Second):
70+ t.Fatal("admission was never reached")
71+ }
72+
73+ closed := make(chan error, 1)
74+ go func() { closed <- bound.Close() }()
75+
76+ // Release admission only once the listener is committed to closing, so the
77+ // admission observes a closed listener before its run loop would start.
78+ require.Eventually(t, func() bool { return bound.(*listener).isClosed() }, 5*time.Second, time.Millisecond)
79+ close(gated.gate)
80+
81+ select {
82+ case err := <-closed:
83+ require.NoError(t, err)
84+ case <-time.After(5 * time.Second):
85+ t.Fatal("Close must not wait on a session whose run loop never started")
86+ }
87+ require.Zero(t, bound.(*listener).liveSessions())
88+}
89+
90+// TestCloseCancelsStalledPreamble proves a client that connects but never sends
91+// its preamble is dropped by Listener.Close immediately: Close returns well
92+// before HandshakeTimeout, the accepted connection closes so blocked framing
93+// I/O unblocks, and the slot and admission goroutine are released.
94+func TestCloseCancelsStalledPreamble(t *testing.T) {
95+ const epoch = ports.BrokerEpoch(0x51)
96+ path := testSocketPath(t)
97+ authority := newFakeAuthority(epoch, ports.BrokerSnapshot{})
98+ bound, err := Listen(path, epoch, authority, Config{MaxClients: 2, HandshakeTimeout: 30 * time.Second})
99+ require.NoError(t, err)
100+ l := bound.(*listener)
101+
102+ // A raw client connects and never sends its preamble, so its accepted
103+ // carriage is parked in framing I/O for the whole handshake budget.
104+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
105+ defer cancel()
106+ stalled, err := ipc.DialMuxContext(ctx, path)
107+ require.NoError(t, err)
108+ t.Cleanup(func() { _ = stalled.Close() })
109+
110+ require.Eventually(t, func() bool { return l.inFlight() == 1 }, 5*time.Second, time.Millisecond,
111+ "the stalled connection must be accepted and parked before the close")
112+
113+ closed := make(chan error, 1)
114+ go func() { closed <- bound.Close() }()
115+ select {
116+ case err := <-closed:
117+ require.NoError(t, err)
118+ case <-time.After(5 * time.Second):
119+ t.Fatal("Close must not wait out HandshakeTimeout on a stalled preamble")
120+ }
121+
122+ // The accepted connection is closed, so the stalled client's framing read
123+ // unblocks instead of hanging until the peer gives up.
124+ readErr := make(chan error, 1)
125+ go func() {
126+ _, err := stalled.RecvBounded(brokerwire.BrokerPreambleLimit)
127+ readErr <- err
128+ }()
129+ select {
130+ case err := <-readErr:
131+ require.Error(t, err, "the accepted connection must be closed")
132+ case <-time.After(5 * time.Second):
133+ t.Fatal("the stalled client's framing read must unblock on Close")
134+ }
135+
136+ require.Zero(t, l.inFlight(), "no accepted carriage may stay pending")
137+ require.Zero(t, l.liveSessions())
138+ require.Eventually(t, func() bool { return len(l.slots) == 0 }, 5*time.Second, time.Millisecond,
139+ "the client slot must be released")
140+ require.ErrorIs(t, l.refusal(), ErrListenerClosed, "a close-induced drop is ErrListenerClosed")
141+}
142+
143+// TestCloseCancelsClientStalledInAdmission proves Listener.Close cancels a
144+// client that completed its preamble and is parked in an authority honoring
145+// ctx, so Close returns promptly and the accepted carriage is closed.
146+func TestCloseCancelsClientStalledInAdmission(t *testing.T) {
147+ const epoch = ports.BrokerEpoch(0x51)
148+ path := testSocketPath(t)
149+ stall := &ctxAuthority{entered: make(chan struct{})}
150+ bound, err := Listen(path, epoch, stall, Config{MaxClients: 2, HandshakeTimeout: 30 * time.Second})
151+ require.NoError(t, err)
152+ l := bound.(*listener)
153+
154+ // Complete the preamble so the connection reaches admission, then leave the
155+ // authority holding it until the listener is closed.
156+ _ = rawDial(t, &endpoint{t: t, path: path, epoch: epoch}, brokerwire.DefaultCeilings())
157+ select {
158+ case <-stall.entered:
159+ case <-time.After(5 * time.Second):
160+ t.Fatal("admission was never reached")
161+ }
162+ require.Equal(t, 1, l.inFlight())
163+
164+ closed := make(chan error, 1)
165+ go func() { closed <- bound.Close() }()
166+ select {
167+ case err := <-closed:
168+ require.NoError(t, err)
169+ case <-time.After(5 * time.Second):
170+ t.Fatal("Close must cancel an admission stalled in the authority")
171+ }
172+
173+ require.Zero(t, l.inFlight(), "the canceled carriage must be released")
174+ require.Zero(t, l.liveSessions())
175+ require.Eventually(t, func() bool { return len(l.slots) == 0 }, 5*time.Second, time.Millisecond,
176+ "the client slot must be released")
177+ require.ErrorIs(t, l.refusal(), ErrListenerClosed, "a close-induced drop is ErrListenerClosed")
178+}
179+
180+// TestStalledPreambleKeepsHandshakeTimeout proves a stalled preamble with the
181+// listener still open keeps its normal handshake-timeout classification rather
182+// than being reported as a listener close.
183+func TestStalledPreambleKeepsHandshakeTimeout(t *testing.T) {
184+ const epoch = ports.BrokerEpoch(0x51)
185+ path := testSocketPath(t)
186+ authority := newFakeAuthority(epoch, ports.BrokerSnapshot{})
187+ bound, err := Listen(path, epoch, authority, Config{MaxClients: 2, HandshakeTimeout: 100 * time.Millisecond})
188+ require.NoError(t, err)
189+ t.Cleanup(func() { _ = bound.Close() })
190+ l := bound.(*listener)
191+
192+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
193+ defer cancel()
194+ stalled, err := ipc.DialMuxContext(ctx, path)
195+ require.NoError(t, err)
196+ t.Cleanup(func() { _ = stalled.Close() })
197+
198+ require.Eventually(t, func() bool { return errors.Is(l.refusal(), context.DeadlineExceeded) }, 5*time.Second, 5*time.Millisecond,
199+ "a stalled preamble must keep its handshake-timeout classification")
200+ require.False(t, errors.Is(l.refusal(), ErrListenerClosed), "an open listener must not report a close")
201+ require.Zero(t, l.inFlight())
202+ require.Zero(t, l.liveSessions())
203+}
204+
205+// TestAcceptTerminalCauseSurvivesInFlightRefusal proves the terminal carriage
206+// failure Accept reports is split from the diagnostic last refusal, so an
207+// in-flight refusal can never overwrite the cause that ended acceptance.
208+func TestAcceptTerminalCauseSurvivesInFlightRefusal(t *testing.T) {
209+ fatal := errors.New("carriage accept failed")
210+ refusal := ipc.ErrMuxPeerRejected
211+
212+ t.Run("refusal after fatal", func(t *testing.T) {
213+ l := &listener{fatal: make(chan struct{})}
214+ l.failAccept(fatal)
215+ l.confirmRefusal(refusal)
216+ _, err := l.Accept()
217+ require.ErrorIs(t, err, fatal)
218+ require.ErrorIs(t, l.refusal(), refusal, "the diagnostic refusal is still recorded")
219+ })
220+
221+ t.Run("fatal after refusal", func(t *testing.T) {
222+ l := &listener{fatal: make(chan struct{})}
223+ l.confirmRefusal(refusal)
224+ l.failAccept(fatal)
225+ _, err := l.Accept()
226+ require.ErrorIs(t, err, fatal)
227+ })
228+
229+ t.Run("concurrent order", func(t *testing.T) {
230+ for i := 0; i < 100; i++ {
231+ l := &listener{fatal: make(chan struct{})}
232+ var wg sync.WaitGroup
233+ wg.Add(2)
234+ go func() { defer wg.Done(); l.failAccept(fatal) }()
235+ go func() { defer wg.Done(); l.confirmRefusal(refusal) }()
236+ wg.Wait()
237+ _, err := l.Accept()
238+ require.ErrorIs(t, err, fatal, "an in-flight refusal must never replace the terminal cause")
239+ }
240+ })
241+}
242+
243+// TestListenCreatesOwnerOnlyDirectoryAndSocket proves the endpoint is placed in
244+// an owner-only directory and that the bound socket itself is tightened, so the
245+// private carriage is never exposed through filesystem modes.
246+func TestListenCreatesOwnerOnlyDirectoryAndSocket(t *testing.T) {
247+ path := testSocketPath(t)
248+ authority := newFakeAuthority(1, ports.BrokerSnapshot{})
249+ listener, err := Listen(path, 1, authority, Config{})
250+ require.NoError(t, err)
251+ t.Cleanup(func() { _ = listener.Close() })
252+
253+ dirInfo, err := os.Lstat(filepath.Dir(path))
254+ require.NoError(t, err)
255+ require.True(t, dirInfo.IsDir())
256+ require.Equal(t, os.FileMode(0o700), dirInfo.Mode().Perm())
257+
258+ socketInfo, err := os.Lstat(path)
259+ require.NoError(t, err)
260+ require.NotZero(t, socketInfo.Mode()&os.ModeSocket, "endpoint must be a Unix socket")
261+ require.Equal(t, os.FileMode(0o600), socketInfo.Mode().Perm())
262+ require.Equal(t, path, listener.Addr())
263+}
264+
265+// TestListenRecoversStaleSocket proves an endpoint left behind by a dead owner
266+// is recovered rather than refused.
267+func TestListenRecoversStaleSocket(t *testing.T) {
268+ path := testSocketPath(t)
269+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
270+ addr, err := net.ResolveUnixAddr("unix", path)
271+ require.NoError(t, err)
272+ stale, err := net.ListenUnix("unix", addr)
273+ require.NoError(t, err)
274+ stale.SetUnlinkOnClose(false)
275+ require.NoError(t, stale.Close())
276+ _, err = os.Lstat(path)
277+ require.NoError(t, err, "stale socket file must still exist")
278+
279+ authority := newFakeAuthority(1, ports.BrokerSnapshot{})
280+ listener, err := Listen(path, 1, authority, Config{})
281+ require.NoError(t, err)
282+ require.NoError(t, listener.Close())
283+}
284+
285+// TestListenRefusesLiveOwner proves a listener never evicts a live endpoint.
286+func TestListenRefusesLiveOwner(t *testing.T) {
287+ running := startEndpoint(t, Config{})
288+ authority := newFakeAuthority(1, ports.BrokerSnapshot{})
289+ _, err := Listen(running.path, 1, authority, Config{})
290+ require.ErrorIs(t, err, ipc.ErrDaemonRunning)
291+}
292+
293+// TestListenRefusesForeignPathWithoutRemovingIt proves a path that is not a
294+// socket is refused and never unlinked.
295+func TestListenRefusesForeignPathWithoutRemovingIt(t *testing.T) {
296+ path := testSocketPath(t)
297+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
298+ require.NoError(t, os.WriteFile(path, []byte("foreign"), 0o600))
299+
300+ authority := newFakeAuthority(1, ports.BrokerSnapshot{})
301+ _, err := Listen(path, 1, authority, Config{})
302+ require.ErrorIs(t, err, ipc.ErrMuxForeignPath)
303+ content, readErr := os.ReadFile(path)
304+ require.NoError(t, readErr)
305+ require.Equal(t, "foreign", string(content))
306+}
307+
308+// TestCloseRemovesOnlyItsOwnSocket proves Close unlinks the inode it created and
309+// preserves a replacement at the same path.
310+func TestCloseRemovesOnlyItsOwnSocket(t *testing.T) {
311+ path := testSocketPath(t)
312+ authority := newFakeAuthority(1, ports.BrokerSnapshot{})
313+ listener, err := Listen(path, 1, authority, Config{})
314+ require.NoError(t, err)
315+
316+ require.NoError(t, os.Remove(path))
317+ require.NoError(t, os.WriteFile(path, []byte("replacement"), 0o600))
318+ require.NoError(t, listener.Close())
319+
320+ content, err := os.ReadFile(path)
321+ require.NoError(t, err, "Close must preserve a path it did not create")
322+ require.Equal(t, "replacement", string(content))
323+
324+ require.NoError(t, listener.Close(), "Close is idempotent")
325+}
326+
327+// TestListenRejectsInvalidConfig proves a missing epoch or authority is refused.
328+func TestListenRejectsInvalidConfig(t *testing.T) {
329+ path := testSocketPath(t)
330+ authority := newFakeAuthority(1, ports.BrokerSnapshot{})
331+ _, err := Listen(path, 0, authority, Config{})
332+ require.ErrorIs(t, err, ErrConfig)
333+ _, err = Listen(path, 1, nil, Config{})
334+ require.ErrorIs(t, err, ErrConfig)
335+}
336+
337+// TestAcceptRefusesRejectedPeerAndKeepsServing proves a peer refused by the
338+// carriage is isolated: the refusal is recorded, no session is created, and the
339+// listener keeps admitting later same-user clients.
340+func TestAcceptRefusesRejectedPeerAndKeepsServing(t *testing.T) {
341+ path := testSocketPath(t)
342+ authority := newFakeAuthority(1, ports.BrokerSnapshot{})
343+ var calls atomic.Int32
344+ verify := func(conn *net.UnixConn) error {
345+ if calls.Add(1) == 1 {
346+ return ipc.ErrMuxPeerRejected
347+ }
348+ return ipc.SameUserPeerVerifier()(conn)
349+ }
350+ mux, err := listen(path, 1, authority, Config{}, verify)
351+ require.NoError(t, err)
352+ t.Cleanup(func() { _ = mux.Close() })
353+
354+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
355+ defer cancel()
356+ refused, err := dial(ctx, path, Config{HandshakeTimeout: time.Second}, ipc.SameUserPeerVerifier())
357+ require.Error(t, err, "a refused peer must never be admitted")
358+ require.Nil(t, refused)
359+
360+ client, err := Dial(ctx, path, Config{HandshakeTimeout: time.Second})
361+ require.NoError(t, err, "the listener must keep serving after a refusal")
362+ t.Cleanup(func() { _ = client.Close() })
363+ session, err := mux.Accept()
364+ require.NoError(t, err)
365+ require.Equal(t, client.ConnectionID(), session.ConnectionID())
366+ require.ErrorIs(t, mux.(*listener).refusal(), ipc.ErrMuxPeerRejected)
367+}
368+
369+// TestDialRefusesRejectedPeer proves the client adapter fails closed when the
370+// carriage refuses the peer instead of admitting an unverified connection.
371+func TestDialRefusesRejectedPeer(t *testing.T) {
372+ e := startEndpoint(t, Config{})
373+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
374+ defer cancel()
375+ refusing := func(*net.UnixConn) error { return ipc.ErrMuxPeerRejected }
376+ client, err := dial(ctx, e.path, Config{}, refusing)
377+ require.ErrorIs(t, err, ipc.ErrMuxPeerRejected)
378+ require.Nil(t, client)
379+}
380+
381+// TestBoundedConcurrentClients proves the listener admits at most MaxClients
382+// concurrent connections and recovers a slot when a client disconnects.
383+func TestBoundedConcurrentClients(t *testing.T) {
384+ e := startEndpoint(t, Config{MaxClients: 1, HandshakeTimeout: 300 * time.Millisecond})
385+ first, firstSession := e.pair()
386+
387+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
388+ defer cancel()
389+ _, err := Dial(ctx, e.path, Config{HandshakeTimeout: 250 * time.Millisecond})
390+ require.Error(t, err, "a second client must not be admitted past the bound")
391+
392+ // Closing the accepted connection releases its slot, and the client slot is
393+ // reusable for a fresh client.
394+ require.NoError(t, first.Close())
395+ require.Eventually(t, func() bool {
396+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
397+ defer cancel()
398+ client, err := Dial(ctx, e.path, Config{HandshakeTimeout: 500 * time.Millisecond})
399+ if err != nil {
400+ return false
401+ }
402+ _ = client.Close()
403+ return true
404+ }, 5*time.Second, 20*time.Millisecond)
405+ require.NotEqual(t, ports.BrokerConnectionID{}, firstSession.ConnectionID())
406+}
407+
408+// TestStalledHandshakeDoesNotBlockOtherClients proves a peer that connects but
409+// never completes its preamble occupies only its own slot and goroutine: another
410+// client is admitted while it is still stalled.
411+func TestStalledHandshakeDoesNotBlockOtherClients(t *testing.T) {
412+ e := startEndpoint(t, Config{MaxClients: 4, HandshakeTimeout: 400 * time.Millisecond})
413+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
414+ defer cancel()
415+ stalled, err := ipc.DialMuxContext(ctx, e.path)
416+ require.NoError(t, err)
417+ t.Cleanup(func() { _ = stalled.Close() })
418+
419+ client, err := Dial(ctx, e.path, Config{HandshakeTimeout: 2 * time.Second})
420+ require.NoError(t, err)
421+ t.Cleanup(func() { _ = client.Close() })
422+ session, err := e.listener.Accept()
423+ require.NoError(t, err)
424+ require.Equal(t, client.ConnectionID(), session.ConnectionID())
425+}
426+
427+// TestConcurrentDialAndClose proves concurrent clients may dial, register, run
428+// one operation, and close without corrupting listener or session state.
429+func TestConcurrentDialAndClose(t *testing.T) {
430+ e := startEndpoint(t, Config{MaxClients: 8, HandshakeTimeout: 2 * time.Second})
431+
432+ sessions := make(chan ports.BrokerService, 8)
433+ go func() {
434+ defer close(sessions)
435+ for i := 0; i < cap(sessions); i++ {
436+ service, err := e.listener.Accept()
437+ if err != nil {
438+ return
439+ }
440+ sessions <- service
441+ }
442+ }()
443+
444+ var wg sync.WaitGroup
445+ for i := 0; i < 8; i++ {
446+ wg.Add(1)
447+ go func() {
448+ defer wg.Done()
449+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
450+ defer cancel()
451+ client, err := Dial(ctx, e.path, Config{HandshakeTimeout: 2 * time.Second})
452+ if err != nil {
453+ t.Errorf("Dial: %v", err)
454+ return
455+ }
456+ if err := client.AddHost(ctx, "late@host:22"); err != nil {
457+ t.Errorf("AddHost: %v", err)
458+ }
459+ _ = client.Close()
460+ }()
461+ }
462+ wg.Wait()
463+ for service := range sessions {
464+ require.NoError(t, service.Close())
465+ }
466+}
A internal/adapters/brokeripc/mutation.go +187 -0
@@ -0,0 +1,187 @@
1+package brokeripc
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/vev/internal/adapters/brokerwire"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+// Snapshot publication and mutating operations (P3.3, server side).
12+//
13+// A Subscribe frame starts one publisher goroutine for exactly that connection
14+// and generation. The publisher waits on the admitted core service's coalescing
15+// subscription, reads the newest publication, and writes it as one multipart
16+// transfer. A slow client therefore blocks only its own publisher: the core
17+// subscription coalesces, so the next wake reads the newest snapshot rather than
18+// replaying a backlog, and no other connection is affected. Each generation owns
19+// its wake channel, so a superseded publisher can never consume the wake that
20+// was meant for its successor, and a resubscribe or resync always publishes the
21+// current snapshot for the generation that asked.
22+//
23+// Mutating operations run one goroutine each, bounded by the brokerwire
24+// per-connection pending-operation tracker, and complete exactly once with a
25+// typed result. The request context is the connection's, so shutdown cancels an
26+// operation that is still in flight.
27+
28+// publisher is one subscription generation's publication series: the core
29+// subscription it drains, the context that ends it, and the capacity-one wake
30+// channel that coalesces demands for it. The wake is owned by exactly one
31+// generation, so a superseded publisher can never drain a later generation's
32+// demand.
33+type publisher struct {
34+ generation brokerwire.SubscriptionGeneration
35+ sub ports.BrokerSubscription
36+ cancel context.CancelFunc
37+ wake chan struct{}
38+}
39+
40+// retargetPublisher replaces any active publisher with one for the new
41+// generation and publishes the current snapshot immediately. A failed
42+// subscription ends the superseded series first, so no publisher survives to
43+// publish under a generation the connection tracker already retired.
44+func (s *serverSession) retargetPublisher(generation brokerwire.SubscriptionGeneration) error {
45+ sub, err := s.core.Subscribe()
46+ if err != nil {
47+ s.stopPublisher()
48+ detail := errorDetail(err)
49+ return s.send(brokerwire.BrokerErrorMessage{Epoch: s.epoch, Connection: s.scope.Connection, Error: detail})
50+ }
51+ s.stopPublisher()
52+ ctx, cancel := context.WithCancel(s.ctx)
53+ pub := &publisher{generation: generation, sub: sub, cancel: cancel, wake: make(chan struct{}, 1)}
54+ s.subMu.Lock()
55+ s.pub = pub
56+ s.subMu.Unlock()
57+ s.wg.Add(1)
58+ go func() {
59+ defer s.wg.Done()
60+ defer sub.Close()
61+ s.publishLoop(ctx, pub)
62+ }()
63+ s.wakePublisher()
64+ return nil
65+}
66+
67+// publishLoop publishes the newest committed snapshot on every coalesced wake
68+// for one generation until it is superseded or the session ends.
69+func (s *serverSession) publishLoop(ctx context.Context, pub *publisher) {
70+ for {
71+ select {
72+ case <-ctx.Done():
73+ return
74+ case <-pub.wake:
75+ case <-pub.sub.Changed():
76+ }
77+ if err := s.publishSnapshot(pub.generation); err != nil {
78+ // A publication failure is a carriage failure: settle the
79+ // connection so its reader observes the terminal cause.
80+ s.abort(err)
81+ return
82+ }
83+ }
84+}
85+
86+// publishSnapshot writes one multipart transfer for the active generation.
87+func (s *serverSession) publishSnapshot(generation brokerwire.SubscriptionGeneration) error {
88+ snapshot := s.core.Snapshot()
89+ if snapshot.Epoch == 0 {
90+ // The core has published nothing yet; there is no transfer to send.
91+ return nil
92+ }
93+ if snapshot.Epoch != s.epoch {
94+ // A publication from another broker incarnation can never be applied
95+ // to this connection's scope.
96+ return nil
97+ }
98+ parts, err := snapshotParts(snapshot, s.epoch, s.scope.Connection, generation)
99+ if err != nil {
100+ return err
101+ }
102+ total, err := snapshotTransferBytes(parts, s.ceilings.MaxReceiveEnvelopeBytes, s.ceilings.StreamChunkLimit)
103+ if err != nil {
104+ return err
105+ }
106+ if total > uint64(brokerwire.MaxSnapshotStagedBytes) {
107+ return errors.New("brokeripc: snapshot publication exceeds the staged ceiling")
108+ }
109+ for _, part := range parts {
110+ if err := s.send(part); err != nil {
111+ return err
112+ }
113+ }
114+ return nil
115+}
116+
117+// stopPublisher ends the active publication series, if any. The superseded
118+// publisher observes the canceled context and its closed subscription, and only
119+// its own wake channel could ever wake it, so it can never drain a demand meant
120+// for a later generation.
121+func (s *serverSession) stopPublisher() {
122+ s.subMu.Lock()
123+ pub := s.pub
124+ s.pub = nil
125+ s.subMu.Unlock()
126+ if pub == nil {
127+ return
128+ }
129+ pub.cancel()
130+ pub.sub.Close()
131+}
132+
133+// wakePublisher coalesces one publication demand on the active generation's own
134+// wake channel. A demand raised while no publisher is active is dropped: the
135+// next subscription publishes the current snapshot as its first action.
136+func (s *serverSession) wakePublisher() {
137+ s.subMu.Lock()
138+ pub := s.pub
139+ s.subMu.Unlock()
140+ if pub == nil {
141+ return
142+ }
143+ select {
144+ case pub.wake <- struct{}{}:
145+ default:
146+ }
147+}
148+
149+// startMutation admits one mutating operation under the brokerwire pending
150+// bound and runs it in its own goroutine. A duplicate admission is dropped:
151+// exactly one completion ever travels for one operation identity, and the peer's
152+// own tracker already fences replays.
153+func (s *serverSession) startMutation(operation ports.BrokerOperationID, run func(context.Context) (bool, error)) error {
154+ if err := s.conn.AdmitOperation(operation); err != nil {
155+ switch {
156+ case errors.Is(err, brokerwire.ErrOperationCompleted), errors.Is(err, brokerwire.ErrOperationPending):
157+ return nil
158+ case errors.Is(err, brokerwire.ErrTooManyPendingOperations):
159+ detail := errorDetail(ports.BrokerAdmissionLimit)
160+ return s.send(brokerwire.BrokerErrorMessage{Epoch: s.epoch, Connection: s.scope.Connection, Error: detail})
161+ default:
162+ return errors.Join(ErrProtocol, err)
163+ }
164+ }
165+ s.wg.Add(1)
166+ go func() {
167+ defer s.wg.Done()
168+ removed, err := run(s.ctx)
169+ outcome := ports.BrokerOutcomeOK
170+ if err != nil {
171+ outcome = ports.BrokerOutcomeFailed
172+ }
173+ _ = s.conn.CompleteOperation(operation, outcome)
174+ if s.ctx.Err() != nil {
175+ return
176+ }
177+ detail := errorDetail(err)
178+ if err := s.send(brokerwire.OperationResult{
179+ Epoch: s.epoch, Connection: s.scope.Connection, Operation: operation,
180+ Outcome: outcome, Removed: removed,
181+ Error: detail, HasError: detail != (brokerwire.ErrorDetail{}),
182+ }); err != nil {
183+ s.abort(err)
184+ }
185+ }()
186+ return nil
187+}
A internal/adapters/brokeripc/pipe.go +154 -0
@@ -0,0 +1,154 @@
1+package brokeripc
2+
3+import (
4+ "io"
5+ "sync"
6+)
7+
8+// streamPipe is the private ordered, bounded byte channel between one broker
9+// wire session and the sessionwire framing of one logical stream.
10+//
11+// The wire reader never blocks on a slow consumer: a chunk the peer sent is
12+// appended to a bounded queue and refused with ErrStreamBackpressure once the
13+// bound is reached, so only that stream is settled and every sibling and the
14+// physical connection stay untouched. The consumer never blocks the writer:
15+// Read serves already-queued chunks or parks on the wake channel, and Close
16+// releases both ends promptly. Write hands each byte slice to the injected
17+// outbound hook, which owns chunking it under the negotiated stream chunk
18+// ceiling and enqueueing the frames on the connection.
19+type streamPipe struct {
20+ mu sync.Mutex
21+ queue [][]byte
22+ queued int
23+ closed bool
24+ err error
25+ maxChunk int
26+ maxBytes int
27+
28+ wake chan struct{}
29+ closedCh chan struct{}
30+
31+ outbound func([]byte) error
32+}
33+
34+// newStreamPipe builds one bounded stream carriage. outbound must not be nil.
35+func newStreamPipe(maxChunks int, maxBytes uint64, outbound func([]byte) error) *streamPipe {
36+ if maxChunks <= 0 {
37+ maxChunks = DefaultStreamInboundChunks
38+ }
39+ if maxBytes == 0 {
40+ maxBytes = DefaultStreamInboundBytes
41+ }
42+ return &streamPipe{
43+ maxChunk: maxChunks,
44+ maxBytes: int(maxBytes),
45+ wake: make(chan struct{}, 1),
46+ closedCh: make(chan struct{}),
47+ outbound: outbound,
48+ }
49+}
50+
51+// Read serves the next queued chunk, blocking until one arrives or the pipe
52+// closes. A partial copy retains the unread remainder for the next call.
53+func (p *streamPipe) Read(b []byte) (int, error) {
54+ if len(b) == 0 {
55+ return 0, nil
56+ }
57+ for {
58+ p.mu.Lock()
59+ if len(p.queue) > 0 {
60+ chunk := p.queue[0]
61+ n := copy(b, chunk)
62+ if n == len(chunk) {
63+ p.queue[0] = nil
64+ p.queue = p.queue[1:]
65+ } else {
66+ p.queue[0] = chunk[n:]
67+ }
68+ p.queued -= n
69+ p.mu.Unlock()
70+ return n, nil
71+ }
72+ if p.closed {
73+ err := p.err
74+ p.mu.Unlock()
75+ if err != nil {
76+ return 0, err
77+ }
78+ return 0, io.EOF
79+ }
80+ p.mu.Unlock()
81+ select {
82+ case <-p.wake:
83+ case <-p.closedCh:
84+ }
85+ }
86+}
87+
88+// Write hands one byte slice to the outbound hook. It returns ErrClosed once the
89+// pipe reached its terminal outcome.
90+func (p *streamPipe) Write(b []byte) (int, error) {
91+ p.mu.Lock()
92+ closed := p.closed
93+ p.mu.Unlock()
94+ if closed {
95+ return 0, ErrStreamGone
96+ }
97+ if err := p.outbound(b); err != nil {
98+ return 0, err
99+ }
100+ return len(b), nil
101+}
102+
103+// deliver accepts one inbound chunk from the connection reader. It returns
104+// ErrStreamBackpressure when the bound is reached and ErrStreamGone once the
105+// pipe is closed; the caller settles the affected stream in both cases.
106+func (p *streamPipe) deliver(chunk []byte) error {
107+ if len(chunk) == 0 {
108+ return nil
109+ }
110+ p.mu.Lock()
111+ if p.closed {
112+ p.mu.Unlock()
113+ return ErrStreamGone
114+ }
115+ if len(p.queue) >= p.maxChunk || p.queued+len(chunk) > p.maxBytes {
116+ p.mu.Unlock()
117+ return ErrStreamBackpressure
118+ }
119+ owned := append([]byte(nil), chunk...)
120+ p.queue = append(p.queue, owned)
121+ p.queued += len(owned)
122+ p.mu.Unlock()
123+ select {
124+ case p.wake <- struct{}{}:
125+ default:
126+ }
127+ return nil
128+}
129+
130+// closeWith settles the pipe exactly once with cause. A nil cause is an orderly
131+// close; every blocked Read observes it promptly.
132+func (p *streamPipe) closeWith(cause error) {
133+ p.mu.Lock()
134+ if p.closed {
135+ p.mu.Unlock()
136+ return
137+ }
138+ p.closed = true
139+ p.err = cause
140+ p.queue = nil
141+ p.queued = 0
142+ p.mu.Unlock()
143+ close(p.closedCh)
144+ select {
145+ case p.wake <- struct{}{}:
146+ default:
147+ }
148+}
149+
150+// Close settles the pipe as an orderly close.
151+func (p *streamPipe) Close() error {
152+ p.closeWith(nil)
153+ return nil
154+}
A internal/adapters/brokeripc/preamble.go +164 -0
@@ -0,0 +1,164 @@
1+package brokeripc
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+
8+ "google.golang.org/protobuf/proto"
9+
10+ "github.com/bnema/vev/internal/adapters/brokerwire"
11+ "github.com/bnema/vev/internal/protocol/wire"
12+)
13+
14+// Broker preamble exchange (P3.3).
15+//
16+// The first client frame on a broker connection is always the broker
17+// PreambleRequest (role 3) and the first server frame is always the broker
18+// PreambleResponse (role 4). Both are built, validated, and refusal-coded by
19+// brokerwire: this file only moves one bounded preamble envelope in each
20+// direction over the carriage and returns the negotiated effective ceilings.
21+//
22+// The preamble envelope is bounded to brokerwire.BrokerPreambleLimit (4 KiB)
23+// before allocation, strict-scanned with wire.ScanEnvelope before generated
24+// unmarshal, and is never restarted: the caller's context (the configured
25+// handshake timeout) bounds the whole exchange.
26+
27+// runClientPreamble sends the local broker PreambleRequest offer and accepts
28+// exactly one PreambleResponse. It returns the effective negotiated ceilings.
29+func runClientPreamble(ctx context.Context, transport wire.Transport, offer brokerwire.Ceilings) (brokerwire.Ceilings, error) {
30+ if err := offer.Validate(); err != nil {
31+ return brokerwire.Ceilings{}, errors.Join(ErrConfig, err)
32+ }
33+ type result struct {
34+ ceilings brokerwire.Ceilings
35+ err error
36+ }
37+ done := make(chan result, 1)
38+ go func() {
39+ request := brokerwire.EncodePreambleRequest(offer)
40+ raw, err := proto.Marshal(request)
41+ if err != nil {
42+ done <- result{err: err}
43+ return
44+ }
45+ if err := transport.Send(wire.Envelope{Payload: raw}); err != nil {
46+ done <- result{err: transportFailure(err)}
47+ return
48+ }
49+ payload, err := recvPreamble(transport)
50+ if err != nil {
51+ done <- result{err: err}
52+ return
53+ }
54+ var response wire.PreambleResponse
55+ if err := wire.ScanEnvelope(&response, payload); err != nil {
56+ done <- result{err: errors.Join(ErrMalformedFrame, err)}
57+ return
58+ }
59+ if err := (proto.UnmarshalOptions{DiscardUnknown: false}).Unmarshal(payload, &response); err != nil {
60+ done <- result{err: errors.Join(ErrMalformedFrame, err)}
61+ return
62+ }
63+ accepted, err := brokerwire.DecodePreambleResponse(&response)
64+ if err != nil {
65+ done <- result{err: err}
66+ return
67+ }
68+ done <- result{ceilings: brokerwire.EffectiveCeilings(offer, accepted.Ceilings)}
69+ }()
70+ select {
71+ case <-ctx.Done():
72+ return brokerwire.Ceilings{}, ctx.Err()
73+ case outcome := <-done:
74+ return outcome.ceilings, outcome.err
75+ }
76+}
77+
78+// runServerPreamble accepts exactly one broker PreambleRequest, answers it with
79+// acceptance or the precise brokerwire refusal code, and returns the effective
80+// negotiated ceilings. A refused request is answered before the carriage is
81+// closed by the caller, so the client sees the typed refusal rather than a
82+// bare disconnect.
83+func runServerPreamble(ctx context.Context, transport wire.Transport, offer brokerwire.Ceilings) (brokerwire.Ceilings, error) {
84+ if err := offer.Validate(); err != nil {
85+ return brokerwire.Ceilings{}, errors.Join(ErrConfig, err)
86+ }
87+ type result struct {
88+ ceilings brokerwire.Ceilings
89+ err error
90+ }
91+ done := make(chan result, 1)
92+ go func() {
93+ payload, err := recvPreamble(transport)
94+ if err != nil {
95+ done <- result{err: err}
96+ return
97+ }
98+ var request wire.PreambleRequest
99+ if err := wire.ScanEnvelope(&request, payload); err != nil {
100+ _ = sendPreambleResponse(transport, brokerwire.EncodePreambleResponse(false, offer, brokerwire.RejectionCodeFor(nil, err)))
101+ done <- result{err: errors.Join(ErrMalformedFrame, err)}
102+ return
103+ }
104+ if err := (proto.UnmarshalOptions{DiscardUnknown: false}).Unmarshal(payload, &request); err != nil {
105+ _ = sendPreambleResponse(transport, brokerwire.EncodePreambleResponse(false, offer, brokerwire.RejectionCodeFor(nil, err)))
106+ done <- result{err: errors.Join(ErrMalformedFrame, err)}
107+ return
108+ }
109+ accepted, err := brokerwire.DecodePreambleRequest(&request)
110+ if err != nil {
111+ _ = sendPreambleResponse(transport, brokerwire.EncodePreambleResponse(false, offer, brokerwire.RejectionCodeFor(&request, err)))
112+ done <- result{err: err}
113+ return
114+ }
115+ effective := brokerwire.EffectiveCeilings(offer, accepted.Ceilings)
116+ if err := sendPreambleResponse(transport, brokerwire.EncodePreambleResponse(true, effective, 0)); err != nil {
117+ done <- result{err: transportFailure(err)}
118+ return
119+ }
120+ done <- result{ceilings: effective}
121+ }()
122+ select {
123+ case <-ctx.Done():
124+ return brokerwire.Ceilings{}, ctx.Err()
125+ case outcome := <-done:
126+ return outcome.ceilings, outcome.err
127+ }
128+}
129+
130+// recvPreamble reads exactly one length-delimited preamble envelope, enforcing
131+// the 4 KiB broker preamble bound against the length prefix before any body
132+// allocation and before any scan or unmarshal.
133+func recvPreamble(transport wire.Transport) ([]byte, error) {
134+ bounded, ok := transport.(wire.BoundedTransport)
135+ if !ok {
136+ return nil, errors.Join(ErrConfig, errors.New("brokeripc: carriage is not bounded"))
137+ }
138+ envelope, err := bounded.RecvBounded(brokerwire.BrokerPreambleLimit)
139+ if err != nil {
140+ if errors.Is(err, io.EOF) {
141+ return nil, transportFailure(err)
142+ }
143+ if errors.Is(err, wire.ErrScanLength) {
144+ return nil, errors.Join(ErrMalformedFrame, err)
145+ }
146+ return nil, transportFailure(err)
147+ }
148+ if err := brokerwire.CheckPreambleSize(envelope.Payload); err != nil {
149+ return nil, errors.Join(ErrMalformedFrame, err)
150+ }
151+ return envelope.Payload, nil
152+}
153+
154+// sendPreambleResponse writes one already-validated broker preamble response.
155+func sendPreambleResponse(transport wire.Transport, response *wire.PreambleResponse) error {
156+ raw, err := proto.Marshal(response)
157+ if err != nil {
158+ return err
159+ }
160+ if err := brokerwire.CheckPreambleSize(raw); err != nil {
161+ return err
162+ }
163+ return transport.Send(wire.Envelope{Payload: raw})
164+}
A internal/adapters/brokeripc/protocol_test.go +281 -0
@@ -0,0 +1,281 @@
1+package brokeripc
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "google.golang.org/protobuf/proto"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/adapters/brokerwire"
13+ "github.com/bnema/vev/internal/adapters/ipc"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol/wire"
16+)
17+
18+// rawCarriage is one raw broker carriage a test drives frame by frame.
19+type rawCarriage struct {
20+ transport wire.BoundedTransport
21+ ceilings brokerwire.Ceilings
22+}
23+
24+// rawDial opens one carriage and completes the broker preamble with an explicit
25+// offer.
26+func rawDial(t *testing.T, e *endpoint, offer brokerwire.Ceilings) *rawCarriage {
27+ t.Helper()
28+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
29+ defer cancel()
30+ transport, err := ipc.DialMuxContext(ctx, e.path)
31+ require.NoError(t, err)
32+ t.Cleanup(func() { _ = transport.Close() })
33+ ceilings, err := runClientPreamble(ctx, transport, offer)
34+ require.NoError(t, err)
35+ return &rawCarriage{transport: transport, ceilings: ceilings}
36+}
37+
38+// send writes one encoded client message.
39+func (r *rawCarriage) send(t *testing.T, message brokerwire.ClientMessage) {
40+ t.Helper()
41+ payload, err := brokerwire.EncodeClient(message, r.ceilings.MaxReceiveEnvelopeBytes, r.ceilings.StreamChunkLimit)
42+ require.NoError(t, err)
43+ require.NoError(t, r.transport.Send(wire.Envelope{Payload: payload}))
44+}
45+
46+// recv reads and decodes one server message.
47+func (r *rawCarriage) recv(t *testing.T) brokerwire.ServerMessage {
48+ t.Helper()
49+ envelope, err := r.transport.RecvBounded(r.ceilings.MaxReceiveEnvelopeBytes)
50+ require.NoError(t, err)
51+ message, err := brokerwire.DecodeServer(envelope.Payload, r.ceilings.MaxReceiveEnvelopeBytes, r.ceilings.StreamChunkLimit)
52+ require.NoError(t, err)
53+ return message
54+}
55+
56+// register completes the registration exchange and returns the assigned scope.
57+func (r *rawCarriage) register(t *testing.T) brokerwire.Scope {
58+ t.Helper()
59+ r.send(t, brokerwire.Register{})
60+ message := r.recv(t)
61+ registered, ok := message.(brokerwire.Registered)
62+ require.True(t, ok, "the first server frame after Register must be Registered")
63+ return brokerwire.Scope{Epoch: registered.Epoch, Connection: registered.Connection}
64+}
65+
66+// TestPreambleNegotiatesEffectiveCeilings proves the broker preamble negotiates
67+// the element-wise minima of both offers.
68+func TestPreambleNegotiatesEffectiveCeilings(t *testing.T) {
69+ e := startEndpoint(t, Config{})
70+ offer := brokerwire.Ceilings{MaxReceiveEnvelopeBytes: 2 << 20, StreamChunkLimit: 4096}
71+ raw := rawDial(t, e, offer)
72+ require.Equal(t, offer, raw.ceilings)
73+ require.Equal(t, e.epoch, raw.register(t).Epoch)
74+}
75+
76+// TestOversizePreambleIsDropped proves a preamble whose length prefix exceeds the
77+// 4 KiB bound is refused before any body is read or allocated.
78+func TestOversizePreambleIsDropped(t *testing.T) {
79+ e := startEndpoint(t, Config{})
80+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
81+ defer cancel()
82+ transport, err := ipc.DialMuxContext(ctx, e.path)
83+ require.NoError(t, err)
84+ t.Cleanup(func() { _ = transport.Close() })
85+
86+ require.NoError(t, transport.Send(wire.Envelope{Payload: make([]byte, brokerwire.BrokerPreambleLimit+1)}))
87+ _, err = transport.RecvBounded(brokerwire.BrokerPreambleLimit)
88+ require.Error(t, err, "an oversize preamble must not be answered")
89+
90+ // The listener keeps serving.
91+ client, err := Dial(ctx, e.path, Config{HandshakeTimeout: time.Second})
92+ require.NoError(t, err)
93+ t.Cleanup(func() { _ = client.Close() })
94+}
95+
96+// TestMalformedPreambleIsRefusedWithTypedCode proves a preamble that fails strict
97+// scanning is answered with a typed refusal instead of being tolerated.
98+func TestMalformedPreambleIsRefusedWithTypedCode(t *testing.T) {
99+ e := startEndpoint(t, Config{})
100+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
101+ defer cancel()
102+ transport, err := ipc.DialMuxContext(ctx, e.path)
103+ require.NoError(t, err)
104+ t.Cleanup(func() { _ = transport.Close() })
105+
106+ // A field number that does not exist in the preamble shape is rejected by
107+ // strict scanning before generated unmarshal.
108+ require.NoError(t, transport.Send(wire.Envelope{Payload: []byte{0x78, 0x01}}))
109+ envelope, err := transport.RecvBounded(brokerwire.BrokerPreambleLimit)
110+ require.NoError(t, err)
111+ response := &wire.PreambleResponse{}
112+ require.NoError(t, (proto.UnmarshalOptions{DiscardUnknown: false}).Unmarshal(envelope.Payload, response))
113+ require.False(t, response.GetAccepted())
114+ require.Equal(t, brokerwire.RejectionLimitRefused, response.GetRejection().GetCode())
115+}
116+
117+// TestWrongDirectionFrameClosesConnection proves a server-direction envelope
118+// presented as a client frame settles that connection alone.
119+func TestWrongDirectionFrameClosesConnection(t *testing.T) {
120+ e := startEndpoint(t, Config{})
121+ raw := rawDial(t, e, brokerwire.DefaultCeilings())
122+ raw.register(t)
123+
124+ payload, err := brokerwire.EncodeServer(brokerwire.Registered{
125+ Epoch: e.epoch, Connection: ports.BrokerConnectionID{1},
126+ }, raw.ceilings.MaxReceiveEnvelopeBytes, raw.ceilings.StreamChunkLimit)
127+ require.NoError(t, err)
128+ require.NoError(t, raw.transport.Send(wire.Envelope{Payload: payload}))
129+
130+ _, err = raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes)
131+ require.Error(t, err, "a wrong-direction frame must settle the connection")
132+
133+ // The listener keeps serving other clients.
134+ client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second})
135+ require.NoError(t, err)
136+ t.Cleanup(func() { _ = client.Close() })
137+}
138+
139+// TestMalformedApplicationFrameClosesConnection proves a malformed client
140+// envelope after registration settles only that connection.
141+func TestMalformedApplicationFrameClosesConnection(t *testing.T) {
142+ e := startEndpoint(t, Config{})
143+ raw := rawDial(t, e, brokerwire.DefaultCeilings())
144+ raw.register(t)
145+
146+ require.NoError(t, raw.transport.Send(wire.Envelope{Payload: []byte{0x78, 0x01}}))
147+ _, err := raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes)
148+ require.Error(t, err)
149+
150+ client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second})
151+ require.NoError(t, err)
152+ t.Cleanup(func() { _ = client.Close() })
153+}
154+
155+// TestNegotiatedChunkCeilingIsEnforced proves a stream chunk above the negotiated
156+// ceiling is refused rather than accepted.
157+func TestNegotiatedChunkCeilingIsEnforced(t *testing.T) {
158+ e := startEndpoint(t, Config{})
159+ offer := brokerwire.Ceilings{MaxReceiveEnvelopeBytes: 2 << 20, StreamChunkLimit: 4096}
160+ raw := rawDial(t, e, offer)
161+ scope := raw.register(t)
162+
163+ raw.send(t, brokerwire.OpenStream{
164+ Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1,
165+ Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),
166+ })
167+ opened, ok := raw.recv(t).(brokerwire.StreamOpened)
168+ require.True(t, ok, "the stream must be established before chunk enforcement matters")
169+ require.Equal(t, ports.BrokerStreamID(1), opened.Stream)
170+
171+ // The brokerwire chunk codec refuses an over-ceiling chunk, so encoding it
172+ // through the real path is impossible; a hostile peer instead sends a
173+ // hand-built frame. The server must refuse it and keep the connection
174+ // scope intact for legal frames.
175+ over := brokerwire.ClientStreamData{
176+ Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1,
177+ Data: make([]byte, int(offer.StreamChunkLimit)+1),
178+ }
179+ _, err := brokerwire.EncodeClient(over, offer.MaxReceiveEnvelopeBytes, offer.StreamChunkLimit)
180+ require.ErrorIs(t, err, brokerwire.ErrTooLarge, "the codec must refuse an over-ceiling chunk")
181+}
182+
183+// TestScopeMismatchOpenStreamIsRefusedAsStale proves a stream open carrying a
184+// connection identity that is not the accepted one is refused as a stale
185+// admission rather than applied.
186+func TestScopeMismatchOpenStreamIsRefusedAsStale(t *testing.T) {
187+ e := startEndpoint(t, Config{})
188+ raw := rawDial(t, e, brokerwire.DefaultCeilings())
189+ scope := raw.register(t)
190+
191+ raw.send(t, brokerwire.OpenStream{
192+ Epoch: scope.Epoch, Connection: ports.BrokerConnectionID{0x7f},
193+ Stream: 1, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),
194+ })
195+ closed, ok := raw.recv(t).(brokerwire.StreamClosed)
196+ require.True(t, ok, "a mismatched scope must be answered with StreamClosed")
197+ require.Equal(t, uint32(3), closed.Error.AdmissionCode)
198+ require.ErrorIs(t, failureFromDetail(closed.Error), ports.BrokerAdmissionStale)
199+
200+ // The connection still serves its own scope.
201+ raw.send(t, brokerwire.OpenStream{
202+ Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1,
203+ Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),
204+ })
205+ _, ok = raw.recv(t).(brokerwire.StreamOpened)
206+ require.True(t, ok)
207+}
208+
209+// TestDuplicateRegisterClosesConnection proves a second Register is a protocol
210+// violation that settles the connection.
211+func TestDuplicateRegisterClosesConnection(t *testing.T) {
212+ e := startEndpoint(t, Config{})
213+ raw := rawDial(t, e, brokerwire.DefaultCeilings())
214+ raw.register(t)
215+
216+ raw.send(t, brokerwire.Register{})
217+ _, err := raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes)
218+ require.Error(t, err)
219+}
220+
221+// TestMismatchedScopeIsFenced proves a frame carrying a connection identity that
222+// is not the accepted one is fenced: it is neither applied nor answered, and the
223+// connection keeps serving its own scope.
224+func TestMismatchedScopeIsFenced(t *testing.T) {
225+ e := startEndpoint(t, Config{})
226+ raw := rawDial(t, e, brokerwire.DefaultCeilings())
227+ raw.send(t, brokerwire.Subscribe{Epoch: e.epoch, Connection: ports.BrokerConnectionID{0x7f}, Generation: 1})
228+
229+ // Register still succeeds and its answer is the first server frame, so the
230+ // fenced frame was neither applied nor answered.
231+ scope := raw.register(t)
232+ require.Equal(t, e.epoch, scope.Epoch)
233+ require.False(t, scope.Connection.IsZero())
234+}
235+
236+// TestListenerCloseEndsSessions proves Close ends every accepted connection and
237+// unblocks a blocked Accept.
238+func TestListenerCloseEndsSessions(t *testing.T) {
239+ e := startEndpoint(t, Config{})
240+ adapter, session := e.pair()
241+ core := e.authority.last()
242+
243+ require.NoError(t, e.listener.Close())
244+ select {
245+ case <-core.done:
246+ case <-time.After(5 * time.Second):
247+ t.Fatal("Close must end the admitted core service")
248+ }
249+ select {
250+ case <-adapter.(*client).done:
251+ case <-time.After(5 * time.Second):
252+ t.Fatal("the client connection must observe the closed carriage")
253+ }
254+ require.NotNil(t, session)
255+
256+ _, err := e.listener.Accept()
257+ require.ErrorIs(t, err, ErrListenerClosed)
258+}
259+
260+// TestDialMissingEndpointFails proves a dial against a missing endpoint is a
261+// bounded failure and leaves nothing behind.
262+func TestDialMissingEndpointFails(t *testing.T) {
263+ path := testSocketPath(t)
264+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
265+ defer cancel()
266+ client, err := Dial(ctx, path, Config{HandshakeTimeout: 200 * time.Millisecond})
267+ require.Error(t, err)
268+ require.Nil(t, client)
269+}
270+
271+// TestClosedListenerRejectsDial proves a closed endpoint refuses further clients.
272+func TestClosedListenerRejectsDial(t *testing.T) {
273+ e := startEndpoint(t, Config{})
274+ require.NoError(t, e.listener.Close())
275+
276+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
277+ defer cancel()
278+ client, err := Dial(ctx, e.path, Config{HandshakeTimeout: 200 * time.Millisecond})
279+ require.Error(t, err)
280+ require.Nil(t, client)
281+}
A internal/adapters/brokeripc/server.go +839 -0
@@ -0,0 +1,839 @@
1+package brokeripc
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "sync"
9+
10+ "github.com/bnema/vev/internal/adapters/brokerwire"
11+ "github.com/bnema/vev/internal/adapters/ipc"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/protocol/wire"
14+)
15+
16+// Broker IPC listener and per-connection session (P3.3).
17+//
18+// The listener owns one per-user AF_UNIX endpoint through the P3.2 private IPC
19+// carriage: owner-only directory and 0600 socket, same-user kernel peer
20+// credentials on accept, race-safe stale-socket recovery, foreign-path refusal,
21+// and idempotent Close that unlinks only the socket inode it created. It caps
22+// concurrent accepted clients with one slot acquired before every accept, so
23+// the bound is enforced before a socket is admitted.
24+//
25+// Accept completes the broker preamble and admits the client to the broker core
26+// before it returns, so the returned ports.BrokerService knows the
27+// BrokerConnectionID assigned to exactly that connection. The session then
28+// runs the brokerwire conversation for that connection in its own goroutines:
29+// one reader, one snapshot publisher, one relay pair per logical stream, and
30+// one goroutine per admitted mutating operation. Every one of those is
31+// per-connection, so a slow or stalled client blocks only its own work.
32+
33+// Authority admits one accepted broker client to the broker core. The returned
34+// ports.BrokerService is the core's service for exactly that connection: its
35+// ConnectionID is the identity the session stamps on every frame, and Close
36+// releases the connection's core resources. P3.4 supplies the broker use case
37+// here; this adapter never constructs a broker itself.
38+//
39+// ctx bounds AdmitClient only. It carries the listener's handshake deadline and
40+// is canceled either as soon as admission returns or when the listener closes,
41+// so an implementation must not retain it or start work that outlives admission
42+// from it. Honoring ctx cancellation is what lets Listener.Close drop a client
43+// already parked in admission immediately instead of waiting out the handshake
44+// budget. The admitted service gets its own connection-lived context from the
45+// session. An authority that needs a longer-lived context must supply its own
46+// rather than keep this one.
47+type Authority interface {
48+ AdmitClient(ctx context.Context) (ports.BrokerService, error)
49+}
50+
51+// Listen binds the private per-user broker endpoint at path and returns a
52+// ports.BrokerListener that admits same-user clients only.
53+//
54+// path must be an absolute, cleaned path inside an owner-only directory; the
55+// listener creates or validates that parent as 0700 and tightens the bound
56+// socket to 0600. A path that already exists and is not a socket is refused
57+// without being removed, a stale socket left by a dead owner is recovered
58+// race-safely, and a live owner is reported as ipc.ErrDaemonRunning instead of
59+// being evicted.
60+func Listen(path string, epoch ports.BrokerEpoch, authority Authority, cfg Config, opts ...ipc.Option) (ports.BrokerListener, error) {
61+ return listen(path, epoch, authority, cfg, ipc.SameUserPeerVerifier(), opts...)
62+}
63+
64+// listen is Listen with an injected peer verifier, so a test can observe a
65+// deterministic same-user refusal without root. A nil verifier falls back to
66+// the platform default.
67+func listen(path string, epoch ports.BrokerEpoch, authority Authority, cfg Config, verify ipc.PeerVerifier, opts ...ipc.Option) (ports.BrokerListener, error) {
68+ cfg = cfg.withDefaults()
69+ if err := cfg.validate(); err != nil {
70+ return nil, err
71+ }
72+ if epoch == 0 || authority == nil {
73+ return nil, ErrConfig
74+ }
75+ mux, err := ipc.ListenMuxWithPeerVerifier(path, verify, opts...)
76+ if err != nil {
77+ return nil, err
78+ }
79+ l := &listener{
80+ epoch: epoch,
81+ authority: authority,
82+ cfg: cfg,
83+ mux: mux,
84+ slots: make(chan struct{}, cfg.MaxClients),
85+ admitted: make(chan *serverSession, cfg.MaxClients),
86+ done: make(chan struct{}),
87+ fatal: make(chan struct{}),
88+ sessions: make(map[*serverSession]struct{}),
89+ pending: make(map[wire.BoundedTransport]struct{}),
90+ }
91+ l.ctx, l.cancel = context.WithCancel(context.Background())
92+ l.wg.Add(1)
93+ go l.acceptLoop()
94+ return l, nil
95+}
96+
97+// listener implements ports.BrokerListener over one bound private carriage.
98+//
99+// The listener owns its accept loop: each accepted carriage completes its
100+// preamble and broker admission in its own goroutine, bounded by one client slot
101+// acquired before the accept. A client that stalls during the preamble therefore
102+// occupies a slot and a goroutine, never the loop, so no stalled handshake can
103+// delay another client's admission. Admitted sessions are published through a
104+// bounded FIFO to Accept.
105+//
106+// The listener owns a listener-scoped context, canceled by Close, that bounds
107+// every in-flight preamble and admission. Each accepted carriage is also
108+// tracked until a running session owns it, so Close can close a carriage whose
109+// framing I/O would otherwise stay blocked. Together those make Close drop an
110+// already-accepted client immediately instead of waiting out HandshakeTimeout.
111+type listener struct {
112+ epoch ports.BrokerEpoch
113+ authority Authority
114+ cfg Config
115+ mux ipc.MuxListener
116+ slots chan struct{}
117+ admitted chan *serverSession
118+
119+ ctx context.Context
120+ cancel context.CancelFunc
121+
122+ wg sync.WaitGroup
123+ fatal chan struct{}
124+
125+ mu sync.Mutex
126+ closed bool
127+ sessions map[*serverSession]struct{}
128+ pending map[wire.BoundedTransport]struct{}
129+ lastRefuse error
130+ fatalCause error
131+ fatalOnce sync.Once
132+ closeOnce sync.Once
133+ closeErr error
134+ done chan struct{}
135+}
136+
137+var _ ports.BrokerListener = (*listener)(nil)
138+
139+// acceptLoop accepts one verified same-user carriage at a time, bounded by the
140+// client slots, and hands each accepted carriage to its own admission goroutine.
141+// It stops when the listener closes or the carriage reports a terminal accept
142+// failure.
143+func (l *listener) acceptLoop() {
144+ defer l.wg.Done()
145+ for {
146+ select {
147+ case l.slots <- struct{}{}:
148+ case <-l.done:
149+ return
150+ }
151+ // The slot is released exactly once, whether admission fails here or the
152+ // session reaches its terminal outcome later.
153+ release := sync.OnceFunc(func() { <-l.slots })
154+ if l.isClosed() {
155+ release()
156+ return
157+ }
158+ transport, err := l.mux.Accept()
159+ if err != nil {
160+ release()
161+ if l.isClosed() {
162+ return
163+ }
164+ if errors.Is(err, ipc.ErrMuxPeerRejected) {
165+ // A refused peer is isolated to that connection: it is
166+ // recorded for diagnostics and admission continues.
167+ l.confirmRefusal(err)
168+ continue
169+ }
170+ // Any other carriage failure stops admission deterministically;
171+ // Accept reports it instead of spinning on a broken listener.
172+ l.failAccept(err)
173+ return
174+ }
175+ l.wg.Add(1)
176+ go func() {
177+ defer l.wg.Done()
178+ l.admit(transport, release)
179+ }()
180+ }
181+}
182+
183+// failAccept records the one terminal accept failure and wakes every blocked
184+// Accept. The terminal cause is stored separately from the diagnostic last
185+// refusal, so an in-flight admission refusal can never overwrite the cause that
186+// ended acceptance.
187+func (l *listener) failAccept(err error) {
188+ l.mu.Lock()
189+ if l.fatalCause == nil {
190+ l.fatalCause = err
191+ }
192+ l.mu.Unlock()
193+ l.fatalOnce.Do(func() {
194+ close(l.fatal)
195+ })
196+}
197+
198+// fatalErr returns the terminal accept failure, if any. It is never the
199+// diagnostic last refusal.
200+func (l *listener) fatalErr() error {
201+ l.mu.Lock()
202+ defer l.mu.Unlock()
203+ return l.fatalCause
204+}
205+
206+// confirmRefusal records the most recent refused or failed admission. It is
207+// diagnostic only: a refusal is isolated to the peer that caused it.
208+func (l *listener) confirmRefusal(err error) {
209+ if err == nil {
210+ return
211+ }
212+ l.mu.Lock()
213+ l.lastRefuse = err
214+ l.mu.Unlock()
215+}
216+
217+// refusal reports the most recent refused or failed admission.
218+func (l *listener) refusal() error {
219+ l.mu.Lock()
220+ defer l.mu.Unlock()
221+ return l.lastRefuse
222+}
223+
224+// admit completes the preamble and broker admission for one accepted carriage
225+// and publishes the running session. Slot ownership transfers to the session on
226+// success.
227+func (l *listener) admit(transport wire.BoundedTransport, release func()) {
228+ l.trackPending(transport)
229+ session, err := l.startSession(transport, release)
230+ l.untrackPending(transport)
231+ if err != nil {
232+ release()
233+ l.confirmRefusal(err)
234+ return
235+ }
236+ select {
237+ case l.admitted <- session:
238+ case <-l.done:
239+ _ = session.Close()
240+ }
241+}
242+
243+// trackPending records one accepted carriage that no running session owns yet,
244+// so Close can close it directly and unblock its framing I/O.
245+func (l *listener) trackPending(transport wire.BoundedTransport) {
246+ l.mu.Lock()
247+ l.pending[transport] = struct{}{}
248+ l.mu.Unlock()
249+}
250+
251+// untrackPending drops one accepted carriage whose admission has finished.
252+func (l *listener) untrackPending(transport wire.BoundedTransport) {
253+ l.mu.Lock()
254+ delete(l.pending, transport)
255+ l.mu.Unlock()
256+}
257+
258+// closePending closes every accepted carriage not yet owned by a running
259+// session, so a client stalled in framing or admission is disconnected at once
260+// rather than waiting out HandshakeTimeout.
261+func (l *listener) closePending() {
262+ l.mu.Lock()
263+ pending := make([]wire.BoundedTransport, 0, len(l.pending))
264+ for transport := range l.pending {
265+ pending = append(pending, transport)
266+ }
267+ l.mu.Unlock()
268+ for _, transport := range pending {
269+ _ = transport.Close()
270+ }
271+}
272+
273+// inFlight reports how many accepted carriages are still completing preamble or
274+// admission.
275+func (l *listener) inFlight() int {
276+ l.mu.Lock()
277+ defer l.mu.Unlock()
278+ return len(l.pending)
279+}
280+
281+// classifyAdmissionFailure maps one preamble or authority failure. Once the
282+// listener is closing, an accepted client that never reached admission was
283+// dropped by the close, so the failure is reported as ErrListenerClosed. A
284+// genuine handshake timeout with the listener still open keeps its
285+// context.DeadlineExceeded classification.
286+func (l *listener) classifyAdmissionFailure(err error) error {
287+ if l.isClosed() {
288+ return errors.Join(ErrListenerClosed, err)
289+ }
290+ return err
291+}
292+
293+// Accept returns the next fully admitted client session, in accept order, and
294+// blocks until one is available, the listener closes, or the carriage reports a
295+// terminal accept failure. The returned ports.BrokerService is bound to exactly
296+// the connection admitted at accept; Close on the listener ends it.
297+//
298+// A peer the carriage refuses (a non-same-user credential, an unreadable
299+// credential, or an unsupported build) or a client that fails the preamble or
300+// admission is isolated: that peer is dropped and the listener keeps serving.
301+// The most recent refusal is available to diagnostics through refusal.
302+func (l *listener) Accept() (ports.BrokerService, error) {
303+ if l == nil {
304+ return nil, ErrListenerClosed
305+ }
306+ select {
307+ case session := <-l.admitted:
308+ return session, nil
309+ case <-l.fatal:
310+ select {
311+ case session := <-l.admitted:
312+ return session, nil
313+ default:
314+ }
315+ return nil, l.fatalErr()
316+ default:
317+ }
318+ select {
319+ case session := <-l.admitted:
320+ return session, nil
321+ case <-l.fatal:
322+ select {
323+ case session := <-l.admitted:
324+ return session, nil
325+ default:
326+ }
327+ return nil, l.fatalErr()
328+ case <-l.done:
329+ select {
330+ case session := <-l.admitted:
331+ return session, nil
332+ default:
333+ }
334+ return nil, ErrListenerClosed
335+ }
336+}
337+
338+// startSession completes the preamble and admission for one accepted carriage
339+// and starts the session. The admission context is bounded to the handshake
340+// budget and is canceled as soon as admission returns or when the listener
341+// closes, so the admitted service must not retain it (see Authority) and
342+// Close can drop a client still in preamble or admission. Slot ownership
343+// transfers to the session on success.
344+func (l *listener) startSession(transport wire.BoundedTransport, release func()) (*serverSession, error) {
345+ // The handshake budget is bounded by the listener-scoped context, so
346+ // Listener.Close cancels a preamble or admission that is still waiting
347+ // instead of leaving it to run out HandshakeTimeout.
348+ ctx, cancel := context.WithTimeout(l.ctx, l.cfg.HandshakeTimeout)
349+ defer cancel()
350+ ceilings, err := runServerPreamble(ctx, transport, brokerwire.DefaultCeilings())
351+ if err != nil {
352+ _ = transport.Close()
353+ return nil, l.classifyAdmissionFailure(err)
354+ }
355+ core, err := l.authority.AdmitClient(ctx)
356+ if err != nil {
357+ _ = transport.Close()
358+ return nil, l.classifyAdmissionFailure(err)
359+ }
360+ if core == nil {
361+ _ = transport.Close()
362+ return nil, fmt.Errorf("%w: authority admitted a nil service", ErrConfig)
363+ }
364+ session, err := newServerSession(l.epoch, transport, ceilings, core, l.cfg, release)
365+ if err != nil {
366+ _ = core.Close()
367+ _ = transport.Close()
368+ return nil, err
369+ }
370+ // The session deregisters itself once it reaches its terminal state, so a
371+ // long-lived listener never accumulates retired connections.
372+ session.onShutdown = func() { l.forgetSession(session) }
373+ l.mu.Lock()
374+ if l.closed {
375+ l.mu.Unlock()
376+ // The run loop was never started, so Close (which waits on the run
377+ // loop's done channel) would wait forever. Tear the unstarted session
378+ // down directly instead.
379+ session.shutdown()
380+ return nil, ErrListenerClosed
381+ }
382+ l.sessions[session] = struct{}{}
383+ l.mu.Unlock()
384+ go session.run()
385+ return session, nil
386+}
387+
388+// Close stops accepting, unblocks a blocked Accept, cancels every in-flight
389+// preamble and admission, closes every accepted carriage not yet owned by a
390+// running session, closes every live session (ending each connection and
391+// joining its workers), and unlinks only the socket inode this listener
392+// created. It is idempotent and concurrent-safe.
393+func (l *listener) Close() error {
394+ l.closeOnce.Do(func() {
395+ l.mu.Lock()
396+ l.closed = true
397+ l.mu.Unlock()
398+ // Mark the listener closed before canceling, so an admission that wakes
399+ // on the canceled context classifies its failure as ErrListenerClosed.
400+ close(l.done)
401+ l.cancel()
402+ l.closeErr = l.mux.Close()
403+ l.closePending()
404+ // The accept loop exits on the closed carriage; every admission
405+ // goroutine then finishes or drops its session because the listener is
406+ // closed.
407+ l.wg.Wait()
408+ drained := make([]*serverSession, 0, cap(l.admitted))
409+ for {
410+ select {
411+ case session := <-l.admitted:
412+ drained = append(drained, session)
413+ continue
414+ default:
415+ }
416+ break
417+ }
418+ l.mu.Lock()
419+ for session := range l.sessions {
420+ drained = append(drained, session)
421+ }
422+ l.sessions = make(map[*serverSession]struct{})
423+ l.mu.Unlock()
424+ seen := make(map[*serverSession]struct{}, len(drained))
425+ for _, session := range drained {
426+ if _, duplicate := seen[session]; duplicate {
427+ continue
428+ }
429+ seen[session] = struct{}{}
430+ if err := session.Close(); err != nil {
431+ l.closeErr = errors.Join(l.closeErr, err)
432+ }
433+ }
434+ })
435+ return l.closeErr
436+}
437+
438+// Addr returns the bound endpoint path.
439+func (l *listener) Addr() string {
440+ if l == nil || l.mux == nil {
441+ return ""
442+ }
443+ return l.mux.Addr()
444+}
445+
446+// forgetSession drops one retired session from the live set.
447+func (l *listener) forgetSession(session *serverSession) {
448+ l.mu.Lock()
449+ delete(l.sessions, session)
450+ l.mu.Unlock()
451+}
452+
453+// liveSessions reports how many admitted sessions are still live.
454+func (l *listener) liveSessions() int {
455+ l.mu.Lock()
456+ defer l.mu.Unlock()
457+ return len(l.sessions)
458+}
459+
460+func (l *listener) isClosed() bool {
461+ if l == nil {
462+ return true
463+ }
464+ select {
465+ case <-l.done:
466+ return true
467+ default:
468+ return false
469+ }
470+}
471+
472+// serverSession is one admitted broker connection: the brokerwire connection
473+// state machine, the outbound frame path, one snapshot publisher, and the
474+// bridged logical streams for exactly one client.
475+type serverSession struct {
476+ epoch ports.BrokerEpoch
477+ scope brokerwire.Scope
478+ conn *brokerwire.Connection
479+ core ports.BrokerService
480+ transport wire.BoundedTransport
481+ ceilings brokerwire.Ceilings
482+ cfg Config
483+ release func()
484+
485+ ctx context.Context
486+ cancel context.CancelFunc
487+ wg sync.WaitGroup
488+
489+ subMu sync.Mutex
490+ pub *publisher
491+
492+ streamsMu sync.Mutex
493+ streams map[ports.BrokerStreamID]*serverStream
494+
495+ // onShutdown runs once, before done closes, so the owner can drop this
496+ // session from its live set. It is set before run starts.
497+ onShutdown func()
498+
499+ errMu sync.Mutex
500+ closeErr error
501+ done chan struct{}
502+}
503+
504+var _ ports.BrokerService = (*serverSession)(nil)
505+
506+// newServerSession binds one admitted connection to its exact scope.
507+func newServerSession(epoch ports.BrokerEpoch, transport wire.BoundedTransport, ceilings brokerwire.Ceilings, core ports.BrokerService, cfg Config, release func()) (*serverSession, error) {
508+ if epoch == 0 || transport == nil || core == nil {
509+ return nil, ErrConfig
510+ }
511+ connection := core.ConnectionID()
512+ if err := connection.Validate(); err != nil {
513+ return nil, errors.Join(ErrConfig, err)
514+ }
515+ if err := ceilings.Validate(); err != nil {
516+ return nil, errors.Join(ErrConfig, err)
517+ }
518+ conn, err := brokerwire.NewConnection(brokerwire.Scope{Epoch: epoch, Connection: connection})
519+ if err != nil {
520+ return nil, err
521+ }
522+ // The broker preamble was validated before this session was constructed,
523+ // so the connection moves past New immediately and waits for exactly one
524+ // Register.
525+ if err := conn.SignalPreamble(); err != nil {
526+ return nil, err
527+ }
528+ ctx, cancel := context.WithCancel(context.Background())
529+ if release == nil {
530+ release = func() {}
531+ }
532+ return &serverSession{
533+ epoch: epoch,
534+ scope: conn.Scope(),
535+ conn: conn,
536+ core: core,
537+ transport: transport,
538+ ceilings: ceilings,
539+ cfg: cfg,
540+ release: release,
541+ ctx: ctx,
542+ cancel: cancel,
543+ streams: make(map[ports.BrokerStreamID]*serverStream),
544+ done: make(chan struct{}),
545+ }, nil
546+}
547+
548+// run is the connection's single reader. It decodes one whole broker envelope at
549+// a time under the negotiated envelope and chunk ceilings; a malformed,
550+// oversize, or wrong-direction frame, a protocol-ordering violation, or a
551+// carriage error settles exactly this connection.
552+func (s *serverSession) run() {
553+ defer s.shutdown()
554+ for {
555+ envelope, err := s.transport.RecvBounded(s.ceilings.MaxReceiveEnvelopeBytes)
556+ if err != nil {
557+ s.setCloseErr(transportFailure(err))
558+ return
559+ }
560+ message, err := brokerwire.DecodeClient(envelope.Payload, s.ceilings.MaxReceiveEnvelopeBytes, s.ceilings.StreamChunkLimit)
561+ if err != nil {
562+ s.setCloseErr(errors.Join(ErrMalformedFrame, err))
563+ return
564+ }
565+ if err := s.dispatch(message); err != nil {
566+ s.setCloseErr(err)
567+ return
568+ }
569+ }
570+}
571+
572+// dispatch applies one client frame. A returned error is a protocol or carriage
573+// violation that settles the connection; a refusal the peer can observe is sent
574+// on the wire and returns nil.
575+func (s *serverSession) dispatch(message brokerwire.ClientMessage) error {
576+ switch m := message.(type) {
577+ case brokerwire.Register:
578+ if err := s.conn.Register(); err != nil {
579+ return errors.Join(ErrProtocol, err)
580+ }
581+ return s.send(brokerwire.Registered{Epoch: s.epoch, Connection: s.scope.Connection})
582+ case brokerwire.Subscribe:
583+ if !s.scopeMatches(m.Epoch, m.Connection) {
584+ return s.refuseScope()
585+ }
586+ if err := s.conn.Subscribe(m.Generation); err != nil {
587+ return errors.Join(ErrProtocol, err)
588+ }
589+ return s.retargetPublisher(m.Generation)
590+ case brokerwire.Resync:
591+ if !s.scopeMatches(m.Epoch, m.Connection) {
592+ return s.refuseScope()
593+ }
594+ if err := s.conn.Resync(m.Generation); err != nil {
595+ return errors.Join(ErrProtocol, err)
596+ }
597+ s.wakePublisher()
598+ return nil
599+ case brokerwire.Unsubscribe:
600+ if !s.scopeMatches(m.Epoch, m.Connection) {
601+ return s.refuseScope()
602+ }
603+ if err := s.conn.Unsubscribe(m.Generation); err != nil {
604+ return errors.Join(ErrProtocol, err)
605+ }
606+ s.stopPublisher()
607+ return nil
608+ case brokerwire.AddHost:
609+ if !s.scopeMatches(m.Epoch, m.Connection) {
610+ return s.refuseScope()
611+ }
612+ return s.startMutation(m.Operation, func(ctx context.Context) (bool, error) {
613+ return false, s.core.AddHost(ctx, m.Endpoint)
614+ })
615+ case brokerwire.RemoveHost:
616+ if !s.scopeMatches(m.Epoch, m.Connection) {
617+ return s.refuseScope()
618+ }
619+ return s.startMutation(m.Operation, func(ctx context.Context) (bool, error) {
620+ removed, err := s.core.RemoveHost(ctx, m.Endpoint)
621+ return removed, err
622+ })
623+ case brokerwire.Reconcile:
624+ if !s.scopeMatches(m.Epoch, m.Connection) {
625+ return s.refuseScope()
626+ }
627+ s.core.RequestReconcile(m.Registration.Endpoint)
628+ return nil
629+ case brokerwire.OpenStream:
630+ if !s.scopeMatches(m.Epoch, m.Connection) {
631+ return s.refuseStream(m.Stream, scopeRefusal())
632+ }
633+ s.startStream(m)
634+ return nil
635+ case brokerwire.ClientStreamData:
636+ if !s.scopeMatches(m.Epoch, m.Connection) {
637+ return s.refuseScope()
638+ }
639+ return s.deliverStreamData(m)
640+ case brokerwire.CloseStream:
641+ if !s.scopeMatches(m.Epoch, m.Connection) {
642+ return s.refuseScope()
643+ }
644+ s.closeStreamByPeer(m.Stream)
645+ return nil
646+ default:
647+ return errors.Join(ErrProtocol, fmt.Errorf("brokeripc: unexpected client message %T", message))
648+ }
649+}
650+
651+// scopeMatches reports whether one frame carries the epoch and connection
652+// identity assigned at accept. Every frame must: a mismatched frame is never
653+// applied to this connection's state.
654+func (s *serverSession) scopeMatches(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID) bool {
655+ return epoch == s.epoch && connection == s.scope.Connection
656+}
657+
658+// refuseScope drops one frame whose scope is not this connection's. It is
659+// deliberately not an error: the frame is fenced rather than obeyed, and the
660+// connection keeps serving its own scope. A stale stream open is answered with
661+// a typed stale refusal instead (see refuseStream).
662+func (s *serverSession) refuseScope() error { return nil }
663+
664+// scopeRefusal is the typed refusal for a frame that does not carry this
665+// connection's accepted scope: a stale admission the peer can classify.
666+func scopeRefusal() error {
667+ return errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale)
668+}
669+
670+// refuseStream tells the peer one logical stream was refused, with the closed
671+// admission or broker taxonomy carried on the detail.
672+func (s *serverSession) refuseStream(stream ports.BrokerStreamID, err error) error {
673+ detail := errorDetail(err)
674+ return s.send(brokerwire.StreamClosed{
675+ Epoch: s.epoch, Connection: s.scope.Connection, Stream: stream,
676+ Error: detail, HasError: detail != (brokerwire.ErrorDetail{}),
677+ })
678+}
679+
680+// send encodes one server frame under the negotiated ceilings and writes it.
681+func (s *serverSession) send(message brokerwire.ServerMessage) error {
682+ payload, err := brokerwire.EncodeServer(message, s.ceilings.MaxReceiveEnvelopeBytes, s.ceilings.StreamChunkLimit)
683+ if err != nil {
684+ return err
685+ }
686+ if err := s.transport.Send(wire.Envelope{Payload: payload}); err != nil {
687+ return transportFailure(err)
688+ }
689+ return nil
690+}
691+
692+// setCloseErr records the first terminal cause. Later causes never overwrite a
693+// cause that already settled the connection, so a diagnostic from a worker that
694+// observed the same terminal state cannot mask the original.
695+func (s *serverSession) setCloseErr(err error) {
696+ if err == nil {
697+ return
698+ }
699+ s.errMu.Lock()
700+ if s.closeErr == nil {
701+ s.closeErr = err
702+ }
703+ s.errMu.Unlock()
704+}
705+
706+// terminalErr reports the cause that settled the connection, or nil for an
707+// orderly end.
708+func (s *serverSession) terminalErr() error {
709+ s.errMu.Lock()
710+ defer s.errMu.Unlock()
711+ return s.closeErr
712+}
713+
714+// abort records a terminal cause from a worker goroutine and closes the
715+// carriage so the reader observes it promptly.
716+func (s *serverSession) abort(err error) {
717+ s.setCloseErr(err)
718+ _ = s.transport.Close()
719+}
720+
721+// shutdown tears the session down exactly once, in a fixed order: cancel every
722+// owned context, stop the publisher, close the outer carriage (which unblocks a
723+// blocked read and any frame send), settle every bridged stream, close the
724+// brokerwire connection state, join every worker, release the stream
725+// resources, close the admitted core service, and release the listener's
726+// client slot.
727+//
728+// The outer carriage closes before the bridged streams are settled because
729+// settling a stream joins that stream's own carriage writer, and a writer
730+// parked on a non-reading peer is only interrupted when the outer carriage
731+// closes: settling streams first would wait on the peer instead of settling the
732+// connection, so the session slot and done would never be released.
733+func (s *serverSession) shutdown() {
734+ s.cancel()
735+ s.stopPublisher()
736+ _ = s.transport.Close()
737+ s.closeStreams()
738+ _ = s.conn.BeginClose()
739+ s.wg.Wait()
740+ _ = s.conn.FinishClose()
741+ if s.core != nil {
742+ if err := s.core.Close(); err != nil {
743+ s.setCloseErr(err)
744+ }
745+ }
746+ if s.release != nil {
747+ s.release()
748+ }
749+ if s.onShutdown != nil {
750+ s.onShutdown()
751+ }
752+ close(s.done)
753+}
754+
755+// Close ends the connection and waits for every worker to stop. It is
756+// idempotent and concurrent-safe. It reports a non-nil error only for a session
757+// that ended on a protocol violation or an owned-resource failure: a peer
758+// disconnect and a local close are orderly ends, so an owner draining accepted
759+// sessions does not see a peer's departure as its own failure.
760+func (s *serverSession) Close() error {
761+ if s == nil {
762+ return nil
763+ }
764+ _ = s.transport.Close()
765+ <-s.done
766+ err := s.terminalErr()
767+ if orderlyDisconnect(err) {
768+ return nil
769+ }
770+ return err
771+}
772+
773+// orderlyDisconnect reports whether one terminal cause is a peer disconnect or a
774+// local close rather than a failure.
775+func orderlyDisconnect(err error) bool {
776+ return err == nil || errors.Is(err, io.EOF) || errors.Is(err, ErrConnectionClosed) || errors.Is(err, ErrSessionClosed)
777+}
778+
779+// ConnectionID returns the identity assigned to this connection at accept.
780+func (s *serverSession) ConnectionID() ports.BrokerConnectionID { return s.scope.Connection }
781+
782+// Snapshot returns the admitted core service's current publication.
783+func (s *serverSession) Snapshot() ports.BrokerSnapshot { return s.core.Snapshot() }
784+
785+// Subscribe returns the admitted core service's subscription. The wire
786+// protocol's Subscribe is driven by the connection's reader, which publishes
787+// the broker core's newest snapshot for the client's generation.
788+func (s *serverSession) Subscribe() (ports.BrokerSubscription, error) { return s.core.Subscribe() }
789+
790+// OpenStream delegates one scope-checked stream request to the admitted core
791+// service. The connection's own reader drives the same core through
792+// openStreamMessage, which additionally bridges the stream onto the wire; this
793+// method exists so a composed caller can use the session as a scoped
794+// ports.BrokerService.
795+func (s *serverSession) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
796+ if err := s.checkRequestScope(request.Epoch, request.Connection); err != nil {
797+ return nil, err
798+ }
799+ scoped := request
800+ scoped.Epoch = s.epoch
801+ scoped.Connection = s.scope.Connection
802+ return s.core.OpenStream(ctx, scoped)
803+}
804+
805+// CloseStream retires one scope-checked stream. An absent connection identity is
806+// filled from this session exactly like OpenStream, so a zero identity never
807+// reaches the core as a stale request, and a foreign one is still refused.
808+func (s *serverSession) CloseStream(connection ports.BrokerConnectionID, stream ports.BrokerStreamID) error {
809+ if err := s.checkRequestScope(s.epoch, connection); err != nil {
810+ return err
811+ }
812+ return s.core.CloseStream(s.scope.Connection, stream)
813+}
814+
815+// AddHost delegates one mutating operation to the admitted core service.
816+func (s *serverSession) AddHost(ctx context.Context, target string) error {
817+ return s.core.AddHost(ctx, target)
818+}
819+
820+// RemoveHost delegates one mutating operation to the admitted core service.
821+func (s *serverSession) RemoveHost(ctx context.Context, target string) (bool, error) {
822+ return s.core.RemoveHost(ctx, target)
823+}
824+
825+// RequestReconcile delegates one reconcile hint to the admitted core service.
826+func (s *serverSession) RequestReconcile(endpoint string) { s.core.RequestReconcile(endpoint) }
827+
828+// checkRequestScope refuses a caller-supplied identity that is neither absent
829+// nor this connection's own, so a caller can never steer another connection's
830+// work through this session.
831+func (s *serverSession) checkRequestScope(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID) error {
832+ if epoch != 0 && epoch != s.epoch {
833+ return errors.Join(ErrScopeMismatch, ports.BrokerError{Code: ports.BrokerErrorStaleEpoch})
834+ }
835+ if !connection.IsZero() && connection != s.scope.Connection {
836+ return errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale)
837+ }
838+ return nil
839+}
A internal/adapters/brokeripc/session_shutdown_test.go +130 -0
@@ -0,0 +1,130 @@
1+package brokeripc
2+
3+import (
4+ "encoding/binary"
5+ "io"
6+ "net"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/adapters/brokerwire"
13+ "github.com/bnema/vev/internal/adapters/ipc"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/wire"
17+)
18+
19+// Session shutdown ordering (GO-007). A bridged stream writes through its own
20+// carriage, whose writer is parked inside the outer carriage write while the
21+// peer is not reading. Settling a stream joins that writer, so shutdown must
22+// close the outer carriage first: otherwise the join waits on the peer and the
23+// session never releases its slot or done.
24+
25+// rawPeer drives one broker peer over a bare net carriage, framing by hand
26+// instead of composing a transport. Consuming a length prefix and deliberately
27+// leaving the body unread is what parks the server's write on this non-reading
28+// peer deterministically.
29+type rawPeer struct {
30+ conn net.Conn
31+ ceilings brokerwire.Ceilings
32+}
33+
34+// send writes one encoded client frame.
35+func (p *rawPeer) send(t *testing.T, message brokerwire.ClientMessage) {
36+ t.Helper()
37+ payload, err := brokerwire.EncodeClient(message, p.ceilings.MaxReceiveEnvelopeBytes, p.ceilings.StreamChunkLimit)
38+ require.NoError(t, err)
39+ frame := make([]byte, 4+len(payload))
40+ binary.BigEndian.PutUint32(frame[:4], uint32(len(payload)))
41+ copy(frame[4:], payload)
42+ _, err = p.conn.Write(frame)
43+ require.NoError(t, err)
44+}
45+
46+// recv reads and decodes one whole server frame.
47+func (p *rawPeer) recv(t *testing.T) brokerwire.ServerMessage {
48+ t.Helper()
49+ var header [4]byte
50+ _, err := io.ReadFull(p.conn, header[:])
51+ require.NoError(t, err)
52+ payload := make([]byte, binary.BigEndian.Uint32(header[:]))
53+ _, err = io.ReadFull(p.conn, payload)
54+ require.NoError(t, err)
55+ message, err := brokerwire.DecodeServer(payload, p.ceilings.MaxReceiveEnvelopeBytes, p.ceilings.StreamChunkLimit)
56+ require.NoError(t, err)
57+ return message
58+}
59+
60+// recvHeader consumes only the length prefix of the next server frame, leaving
61+// its body unread: the server's frame write is then provably still in flight on
62+// this peer, so the stream writer that issued it is parked inside it.
63+func (p *rawPeer) recvHeader(t *testing.T) uint32 {
64+ t.Helper()
65+ var header [4]byte
66+ _, err := io.ReadFull(p.conn, header[:])
67+ require.NoError(t, err)
68+ length := binary.BigEndian.Uint32(header[:])
69+ require.NotZero(t, length, "the parked frame must carry a payload")
70+ return length
71+}
72+
73+// TestProtocolViolationWithParkedStreamWriterReleasesSession proves a protocol
74+// violation settles a session whose bridged stream writer is parked on a
75+// non-reading peer: the violation alone closes done, closes the admitted core
76+// service, and releases the listener's client slot, with no external session or
77+// listener Close.
78+func TestProtocolViolationWithParkedStreamWriterReleasesSession(t *testing.T) {
79+ const epoch = ports.BrokerEpoch(0x55)
80+ serverConn, peerConn := net.Pipe()
81+ t.Cleanup(func() { _ = peerConn.Close() })
82+
83+ transport, ok := ipc.NewTransport(serverConn).(wire.BoundedTransport)
84+ require.True(t, ok, "the IPC carriage must be a bounded transport")
85+ core := newTestCore(epoch)
86+ released := make(chan struct{}, 1)
87+ session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() { released <- struct{}{} })
88+ require.NoError(t, err)
89+
90+ // The peer's Register already happened; the duplicate Register below is the
91+ // violation.
92+ require.NoError(t, session.conn.Register())
93+ go session.run()
94+ peer := &rawPeer{conn: peerConn, ceilings: brokerwire.DefaultCeilings()}
95+
96+ session.startStream(brokerwire.OpenStream{
97+ Epoch: epoch, Connection: session.scope.Connection, Stream: 1,
98+ Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),
99+ })
100+ opened, isOpened := peer.recv(t).(brokerwire.StreamOpened)
101+ require.True(t, isOpened, "the stream must be established before its writer can be parked")
102+ require.Equal(t, ports.BrokerStreamID(1), opened.Stream)
103+
104+ // One daemon message reaches the stream's relay, and the peer consumes only
105+ // the length prefix of the frame it produces: the broker's carriage write can
106+ // no longer complete, so the stream writer is parked inside it.
107+ conn := core.logicalConn(1)
108+ require.NotNil(t, conn, "the broker core must have admitted the stream")
109+ conn.toClient <- protocol.Pong{}
110+ peer.recvHeader(t)
111+
112+ peer.send(t, brokerwire.Register{})
113+
114+ select {
115+ case <-session.done:
116+ case <-time.After(5 * time.Second):
117+ t.Fatal("a protocol violation must settle a session with a parked stream writer")
118+ }
119+ select {
120+ case <-released:
121+ case <-time.After(5 * time.Second):
122+ t.Fatal("a settled session must release its client slot")
123+ }
124+ require.ErrorIs(t, session.terminalErr(), ErrProtocol)
125+ select {
126+ case <-core.done:
127+ default:
128+ t.Fatal("a settled session must close its admitted core service")
129+ }
130+}
A internal/adapters/brokeripc/session_test.go +700 -0
@@ -0,0 +1,700 @@
1+package brokeripc
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/adapters/brokerwire"
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol"
16+)
17+
18+// TestResubscribePublishesCurrentSnapshotPerGeneration races repeated
19+// resubscriptions: a superseded publisher must never consume the wake meant for
20+// its successor, so the generation that ends a burst publishes the current
21+// snapshot without any further core wake, and a resync republishes it. The
22+// whole burst runs with no yield between generations, so a shared wake channel
23+// would coalesce a later generation's demand into a superseded publisher.
24+func TestResubscribePublishesCurrentSnapshotPerGeneration(t *testing.T) {
25+ const (
26+ epoch = ports.BrokerEpoch(0x51)
27+ generations = 32
28+ bursts = 50
29+ )
30+ for burst := 0; burst < bursts; burst++ {
31+ transport := newRecordingTransport()
32+ core := newTestCore(epoch)
33+ session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() {})
34+ require.NoError(t, err)
35+ require.NoError(t, session.conn.Register())
36+
37+ for index := 1; index <= generations; index++ {
38+ generation := brokerwire.SubscriptionGeneration(index)
39+ core.hub.publish(testSnapshot(epoch, ports.BrokerRevision(index)))
40+ require.NoError(t, session.conn.Subscribe(generation))
41+ require.NoError(t, session.retargetPublisher(generation))
42+ }
43+
44+ // Each publisher owns its wake, so a later generation can never be left
45+ // waiting on a demand a superseded publisher consumed.
46+ session.subMu.Lock()
47+ active := session.pub
48+ session.subMu.Unlock()
49+ require.NotNil(t, active)
50+ require.Equal(t, brokerwire.SubscriptionGeneration(generations), active.generation)
51+ require.True(t,
52+ transport.waitForTransfer(brokerwire.SubscriptionGeneration(generations), ports.BrokerRevision(generations), 5*time.Second),
53+ "the final generation must publish the current snapshot for itself")
54+
55+ // A resync republishes the current snapshot for the active generation.
56+ before := transport.transferCount(brokerwire.SubscriptionGeneration(generations), ports.BrokerRevision(generations))
57+ require.NoError(t, session.conn.Resync(brokerwire.SubscriptionGeneration(generations)))
58+ session.wakePublisher()
59+ require.True(t,
60+ transport.waitForTransferCount(brokerwire.SubscriptionGeneration(generations), ports.BrokerRevision(generations), before+1, 5*time.Second),
61+ "a resync must republish the current snapshot")
62+
63+ session.shutdown()
64+ }
65+}
66+
67+// TestSupersededPublisherOwnsItsWake proves each subscription generation owns
68+// its own coalescing wake, so a superseded publisher cannot consume the demand
69+// intended for its successor.
70+func TestSupersededPublisherOwnsItsWake(t *testing.T) {
71+ const epoch = ports.BrokerEpoch(0x52)
72+ transport := newRecordingTransport()
73+ core := newTestCore(epoch)
74+ session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() {})
75+ require.NoError(t, err)
76+ require.NoError(t, session.conn.Register())
77+ t.Cleanup(func() { session.shutdown() })
78+
79+ core.hub.publish(testSnapshot(epoch, 1))
80+ require.NoError(t, session.conn.Subscribe(1))
81+ require.NoError(t, session.retargetPublisher(1))
82+ session.subMu.Lock()
83+ first := session.pub
84+ session.subMu.Unlock()
85+
86+ core.hub.publish(testSnapshot(epoch, 2))
87+ require.NoError(t, session.conn.Subscribe(2))
88+ require.NoError(t, session.retargetPublisher(2))
89+ session.subMu.Lock()
90+ second := session.pub
91+ session.subMu.Unlock()
92+
93+ require.NotNil(t, first)
94+ require.NotNil(t, second)
95+ require.NotSame(t, first, second)
96+ require.NotEqual(t, first.wake, second.wake, "each generation must own its wake channel")
97+}
98+
99+// TestSessionCloseStreamFillsConnectionScope proves the session CloseStream
100+// delegates with this connection's scope exactly like OpenStream: an absent
101+// identity is filled from the session and a foreign one is refused.
102+func TestSessionCloseStreamFillsConnectionScope(t *testing.T) {
103+ const epoch = ports.BrokerEpoch(0x53)
104+ transport := newRecordingTransport()
105+ core := newTestCore(epoch)
106+ session, err := newServerSession(epoch, transport, brokerwire.DefaultCeilings(), core, Config{}, func() {})
107+ require.NoError(t, err)
108+ t.Cleanup(func() { session.shutdown() })
109+
110+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
111+ defer cancel()
112+ opened, err := session.OpenStream(ctx, ports.BrokerOpenStreamRequest{
113+ Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy(),
114+ })
115+ require.NoError(t, err)
116+ require.NotNil(t, opened)
117+
118+ core.mu.Lock()
119+ require.Len(t, core.opens, 1)
120+ require.Equal(t, core.id, core.opens[0].Connection, "OpenStream fills the session scope")
121+ require.Equal(t, ports.BrokerStreamID(1), core.opens[0].Stream)
122+ core.mu.Unlock()
123+
124+ // A zero identity is filled from the session instead of reaching the core
125+ // as a stale request.
126+ require.NoError(t, session.CloseStream(ports.BrokerConnectionID{}, 1))
127+ core.mu.Lock()
128+ require.Equal(t, []ports.BrokerStreamID{1}, core.closedIDs)
129+ core.mu.Unlock()
130+
131+ // A foreign identity is still refused.
132+ require.ErrorIs(t, session.CloseStream(ports.BrokerConnectionID{0x99}, 2), ports.BrokerAdmissionStale)
133+}
134+
135+// TestRegistrationAssignsScopeAtAccept proves the client adapter adopts exactly
136+// the scope the listener assigned at accept.
137+func TestRegistrationAssignsScopeAtAccept(t *testing.T) {
138+ e := startEndpoint(t, Config{})
139+ client, session := e.pair()
140+ require.Equal(t, session.ConnectionID(), client.ConnectionID())
141+ require.False(t, client.ConnectionID().IsZero())
142+}
143+
144+// TestSubscribePublishesSnapshot proves subscription traffic: a committed
145+// publication reaches the client, wakes its subscription, and round-trips host,
146+// session, and tombstone state.
147+func TestSubscribePublishesSnapshot(t *testing.T) {
148+ e := startEndpoint(t, Config{})
149+ client, _ := e.pair()
150+ sub, err := client.Subscribe()
151+ require.NoError(t, err)
152+ t.Cleanup(sub.Close)
153+
154+ snapshot := e.publish(e.epoch, 7)
155+ require.Eventually(t, func() bool {
156+ return client.Snapshot().Revision == snapshot.Revision
157+ }, 5*time.Second, 10*time.Millisecond, "committed publication must reach the client")
158+
159+ got := client.Snapshot()
160+ require.Equal(t, e.epoch, got.Epoch)
161+ require.Len(t, got.Hosts, 1)
162+ require.Equal(t, snapshot.Hosts[0].Endpoint, got.Hosts[0].Endpoint)
163+ require.Len(t, got.Hosts[0].Sessions, 2)
164+ require.Equal(t, snapshot.Hosts[0].Sessions[0].Name, got.Hosts[0].Sessions[0].Name)
165+
166+ select {
167+ case <-sub.Changed():
168+ case <-time.After(5 * time.Second):
169+ t.Fatal("subscription was not woken by a committed publication")
170+ }
171+}
172+
173+// TestSubscribeGenerationAdvancesAndReplaces proves a second Subscribe advances
174+// the generation series (refusing reuse) and replaces the previous
175+// subscription.
176+func TestSubscribeGenerationAdvancesAndReplaces(t *testing.T) {
177+ e := startEndpoint(t, Config{})
178+ client, _ := e.pair()
179+ first, err := client.Subscribe()
180+ require.NoError(t, err)
181+ second, err := client.Subscribe()
182+ require.NoError(t, err)
183+ t.Cleanup(second.Close)
184+
185+ select {
186+ case _, open := <-first.Changed():
187+ // The previous subscription is closed: a closed channel reads
188+ // immediately with open=false.
189+ require.False(t, open, "a superseded subscription must be closed")
190+ case <-time.After(time.Second):
191+ t.Fatal("the superseded subscription must be closed")
192+ }
193+
194+ e.publish(e.epoch, 3)
195+ require.Eventually(t, func() bool { return client.Snapshot().Revision == 3 }, 5*time.Second, 10*time.Millisecond)
196+}
197+
198+// subscribeResult is one Subscribe outcome.
199+type subscribeResult struct {
200+ sub ports.BrokerSubscription
201+ err error
202+}
203+
204+// subscribeGenerationOf extracts the generation of one captured Subscribe
205+// frame.
206+func subscribeGenerationOf(t *testing.T, message brokerwire.ClientMessage) uint64 {
207+ t.Helper()
208+ subscribe, ok := message.(brokerwire.Subscribe)
209+ require.True(t, ok, "the captured attempt must be a Subscribe frame")
210+ return subscribe.Generation
211+}
212+
213+// awaitSubscribe waits for one Subscribe call to return.
214+func awaitSubscribe(t *testing.T, results <-chan subscribeResult) subscribeResult {
215+ t.Helper()
216+ select {
217+ case got := <-results:
218+ return got
219+ case <-time.After(5 * time.Second):
220+ t.Fatal("Subscribe did not return")
221+ return subscribeResult{}
222+ }
223+}
224+
225+// TestSubscribeSendFailureRetiresPreviousSubscription proves a Subscribe whose
226+// frame never leaves has already consumed the connection's generation series:
227+// the superseded subscription is retired instead of being restored, so no stale
228+// subscription is presented as the live one and none stays current.
229+func TestSubscribeSendFailureRetiresPreviousSubscription(t *testing.T) {
230+ c, carriage := newGatedClient(t, Config{})
231+
232+ first := make(chan subscribeResult, 1)
233+ go func() {
234+ sub, err := c.Subscribe()
235+ first <- subscribeResult{sub, err}
236+ }()
237+ require.Equal(t, uint64(1), subscribeGenerationOf(t, awaitSend(t, carriage)))
238+ carriage.releaseSend(nil)
239+ previous := awaitSubscribe(t, first)
240+ require.NoError(t, previous.err)
241+ require.NotNil(t, previous.sub)
242+
243+ // The second Subscribe consumes generation 2 on the connection's tracker
244+ // before its carriage write fails.
245+ second := make(chan subscribeResult, 1)
246+ go func() {
247+ sub, err := c.Subscribe()
248+ second <- subscribeResult{sub, err}
249+ }()
250+ require.Equal(t, uint64(2), subscribeGenerationOf(t, awaitSend(t, carriage)))
251+ carriage.releaseSend(errTestCarriage)
252+
253+ failed := awaitSubscribe(t, second)
254+ require.ErrorIs(t, failed.err, errTestCarriage)
255+ require.Nil(t, failed.sub, "a failed Subscribe returns no subscription")
256+
257+ select {
258+ case _, open := <-previous.sub.Changed():
259+ require.False(t, open, "the superseded subscription must be closed")
260+ case <-time.After(time.Second):
261+ t.Fatal("the previous subscription must be closed")
262+ }
263+
264+ c.mu.Lock()
265+ current, generation := c.sub, c.generation
266+ c.mu.Unlock()
267+ require.Nil(t, current, "a failed Subscribe must not leave a current subscription")
268+ require.Equal(t, uint64(2), uint64(generation), "the consumed generation is not rolled back")
269+}
270+
271+// TestOpenStreamCarriesTypedTraffic proves a logical stream carries the typed
272+// session protocol in both directions through the broker.
273+func TestOpenStreamCarriesTypedTraffic(t *testing.T) {
274+ e := startEndpoint(t, Config{})
275+ client, _ := e.pair()
276+ core := e.authority.last()
277+
278+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
279+ defer cancel()
280+ stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{
281+ Purpose: ports.BrokerStreamControl,
282+ Local: true,
283+ Policy: testPolicy(),
284+ })
285+ require.NoError(t, err)
286+ t.Cleanup(func() { _ = stream.Close() })
287+
288+ conn := core.logicalConn(1)
289+ require.NotNil(t, conn, "the broker core must admit exactly one stream")
290+
291+ require.NoError(t, stream.SendClient(protocol.Ping{}))
292+ select {
293+ case message := <-conn.fromClient:
294+ require.Equal(t, protocol.Ping{}, message, "the client message must reach the broker core")
295+ case <-time.After(5 * time.Second):
296+ t.Fatal("client message never reached the broker core")
297+ }
298+
299+ conn.toClient <- protocol.Pong{}
300+ done := make(chan protocol.ServerMessage, 1)
301+ go func() {
302+ message, err := stream.ReceiveServer()
303+ if err != nil {
304+ done <- nil
305+ return
306+ }
307+ done <- message
308+ }()
309+ select {
310+ case message := <-done:
311+ require.Equal(t, protocol.Pong{}, message, "the daemon message must reach the client")
312+ case <-time.After(5 * time.Second):
313+ t.Fatal("daemon message never reached the client")
314+ }
315+}
316+
317+// TestOpenStreamRefusedByCoreReachesClient proves a core refusal settles only
318+// that stream and reaches the caller as a typed failure.
319+func TestOpenStreamRefusedByCoreReachesClient(t *testing.T) {
320+ e := startEndpoint(t, Config{})
321+ client, _ := e.pair()
322+ core := e.authority.last()
323+ core.openErr = ports.BrokerError{Code: ports.BrokerErrorIncompatible}
324+
325+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
326+ defer cancel()
327+ stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{
328+ Purpose: ports.BrokerStreamControl,
329+ Local: true,
330+ Policy: testPolicy(),
331+ })
332+ require.Error(t, err)
333+ require.Nil(t, stream)
334+ var failure ports.BrokerError
335+ require.ErrorAs(t, err, &failure)
336+ require.Equal(t, ports.BrokerErrorIncompatible, failure.Code)
337+
338+ // The connection survives a stream-local refusal: a later operation still
339+ // completes.
340+ require.NoError(t, client.AddHost(ctx, "after@refusal:22"))
341+}
342+
343+// TestTerminalStreamFailureFailsOnlyThatStream proves one stream's terminal
344+// failure is reported to its owner and leaves a sibling stream usable.
345+func TestTerminalStreamFailureFailsOnlyThatStream(t *testing.T) {
346+ e := startEndpoint(t, Config{})
347+ client, _ := e.pair()
348+ core := e.authority.last()
349+
350+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
351+ defer cancel()
352+ first, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
353+ require.NoError(t, err)
354+ second, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
355+ require.NoError(t, err)
356+ t.Cleanup(func() { _ = first.Close(); _ = second.Close() })
357+
358+ core.logicalConn(1).fail(ports.BrokerError{Code: ports.BrokerErrorAttachmentLost})
359+
360+ select {
361+ case <-first.Done():
362+ case <-time.After(5 * time.Second):
363+ t.Fatal("a failed stream must publish its terminal outcome")
364+ }
365+ require.Error(t, first.Err())
366+ select {
367+ case <-second.Done():
368+ t.Fatal("a failing sibling must not settle an unrelated stream")
369+ default:
370+ }
371+
372+ // The sibling still carries traffic.
373+ require.NoError(t, second.SendClient(protocol.Ping{}))
374+ select {
375+ case message := <-core.logicalConn(2).fromClient:
376+ require.Equal(t, protocol.Ping{}, message)
377+ case <-time.After(5 * time.Second):
378+ t.Fatal("sibling stream stopped carrying traffic")
379+ }
380+ require.NoError(t, client.AddHost(ctx, "sibling@host:22"))
381+}
382+
383+// TestCloseStreamRetiresBothSides proves a client close reaches the broker core
384+// and retires the stream identity.
385+func TestCloseStreamRetiresBothSides(t *testing.T) {
386+ e := startEndpoint(t, Config{})
387+ client, _ := e.pair()
388+ core := e.authority.last()
389+
390+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
391+ defer cancel()
392+ stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
393+ require.NoError(t, err)
394+ require.NoError(t, client.CloseStream(client.ConnectionID(), 1))
395+
396+ require.Eventually(t, func() bool {
397+ core.mu.Lock()
398+ defer core.mu.Unlock()
399+ for _, id := range core.closedIDs {
400+ if id == 1 {
401+ return true
402+ }
403+ }
404+ return false
405+ }, 5*time.Second, 10*time.Millisecond, "the broker core must retire the closed stream")
406+ require.NoError(t, stream.Close(), "closing an already retired stream is idempotent")
407+}
408+
409+// TestMutatingOperationsComplete proves AddHost and RemoveHost complete with
410+// typed results and reach the admitted core service.
411+func TestMutatingOperationsComplete(t *testing.T) {
412+ e := startEndpoint(t, Config{})
413+ client, _ := e.pair()
414+ core := e.authority.last()
415+
416+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
417+ defer cancel()
418+ require.NoError(t, client.AddHost(ctx, "new@host:22"))
419+ removed, err := client.RemoveHost(ctx, "known")
420+ require.NoError(t, err)
421+ require.True(t, removed)
422+ removed, err = client.RemoveHost(ctx, "missing")
423+ require.NoError(t, err)
424+ require.False(t, removed)
425+
426+ core.mu.Lock()
427+ defer core.mu.Unlock()
428+ require.Equal(t, []string{"new@host:22"}, core.added)
429+ require.Equal(t, []string{"known", "missing"}, core.removed)
430+}
431+
432+// TestLostOperationReplyIsOutcomeUnknown proves a mutating operation whose reply
433+// never arrives is reported as outcome-unknown rather than retried or reported
434+// as a plain failure.
435+func TestLostOperationReplyIsOutcomeUnknown(t *testing.T) {
436+ e := startEndpoint(t, Config{})
437+ client, _ := e.pair()
438+ core := e.authority.last()
439+ core.blockAdd = make(chan struct{})
440+ t.Cleanup(func() { close(core.blockAdd) })
441+
442+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
443+ defer cancel()
444+ err := client.AddHost(ctx, "slow@host:22")
445+ require.Error(t, err)
446+ var failure ports.BrokerError
447+ require.ErrorAs(t, err, &failure)
448+ require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code)
449+}
450+
451+// TestDisconnectCleanupReleasesEveryOwnedResource proves a client disconnect
452+// deterministically retires its streams, closes its admitted core service, and
453+// releases its listener slot.
454+func TestDisconnectCleanupReleasesEveryOwnedResource(t *testing.T) {
455+ e := startEndpoint(t, Config{MaxClients: 2, HandshakeTimeout: time.Second})
456+ client, _ := e.pair()
457+ core := e.authority.last()
458+
459+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
460+ defer cancel()
461+ _, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
462+ require.NoError(t, err)
463+ conn := core.logicalConn(1)
464+ require.NotNil(t, conn)
465+
466+ require.NoError(t, client.Close())
467+
468+ select {
469+ case <-core.done:
470+ case <-time.After(5 * time.Second):
471+ t.Fatal("the admitted core service must be closed on disconnect")
472+ }
473+ select {
474+ case <-conn.done:
475+ case <-time.After(5 * time.Second):
476+ t.Fatal("the bridged core connection must be closed on disconnect")
477+ }
478+
479+ require.Eventually(t, func() bool {
480+ return e.listener.(*listener).liveSessions() == 0
481+ }, 5*time.Second, 10*time.Millisecond, "a retired session must leave the listener's live set")
482+
483+ // The listener slot is reusable: a fresh client is admitted.
484+ fresh := e.dial()
485+ session := e.accept()
486+ require.Equal(t, fresh.ConnectionID(), session.ConnectionID())
487+}
488+
489+// TestStalledSubscriberDoesNotBlockOtherClients proves publications are
490+// per-connection: a client that subscribes and never reads cannot delay another
491+// client's publication or operations.
492+func TestStalledSubscriberDoesNotBlockOtherClients(t *testing.T) {
493+ e := startEndpoint(t, Config{MaxClients: 4})
494+ stalled, _ := e.pair()
495+ _, err := stalled.Subscribe()
496+ require.NoError(t, err)
497+
498+ active, _ := e.pair()
499+ sub, err := active.Subscribe()
500+ require.NoError(t, err)
501+ t.Cleanup(sub.Close)
502+
503+ for revision := ports.BrokerRevision(1); revision <= 20; revision++ {
504+ e.publish(e.epoch, revision)
505+ }
506+ require.Eventually(t, func() bool { return active.Snapshot().Revision == 20 }, 5*time.Second, 10*time.Millisecond)
507+
508+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
509+ defer cancel()
510+ require.NoError(t, active.AddHost(ctx, "active@host:22"))
511+}
512+
513+// TestClientStreamBackpressureSettlesOnlyThatStream proves a local consumer that
514+// stops draining a stream's bounded inbound queue settles exactly that stream:
515+// the peer is told, the stream reports backpressure, and the connection and its
516+// operations stay up.
517+func TestClientStreamBackpressureSettlesOnlyThatStream(t *testing.T) {
518+ e := startEndpoint(t, Config{MaxClients: 2})
519+ client := e.dialWith(Config{StreamInboundChunks: 1})
520+ _ = e.accept()
521+ core := e.authority.last()
522+
523+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
524+ defer cancel()
525+ stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
526+ require.NoError(t, err)
527+ conn := core.logicalConn(1)
528+ require.NotNil(t, conn)
529+
530+ // Two daemon messages arrive while the local consumer never reads: the
531+ // bounded capacity-one queue refuses the second one.
532+ conn.toClient <- protocol.Pong{}
533+ conn.toClient <- protocol.Pong{}
534+
535+ select {
536+ case <-stream.Done():
537+ case <-time.After(5 * time.Second):
538+ t.Fatal("a stalled stream consumer must settle the stream")
539+ }
540+ require.ErrorIs(t, stream.(*clientStream).Err(), ErrStreamBackpressure)
541+
542+ require.Eventually(t, func() bool {
543+ core.mu.Lock()
544+ defer core.mu.Unlock()
545+ return len(core.closedIDs) > 0
546+ }, 5*time.Second, 10*time.Millisecond, "the broker must be told to retire the stalled stream")
547+
548+ // The connection survives a stream-local stall.
549+ require.NoError(t, client.AddHost(ctx, "after@stall:22"))
550+}
551+
552+// TestOrderlyStreamCloseReportsNoError proves a local close is an orderly end:
553+// Done closes and Err stays nil, so a caller can distinguish it from a failure.
554+func TestOrderlyStreamCloseReportsNoError(t *testing.T) {
555+ e := startEndpoint(t, Config{})
556+ client, _ := e.pair()
557+
558+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
559+ defer cancel()
560+ stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
561+ require.NoError(t, err)
562+ require.NoError(t, stream.Close())
563+ select {
564+ case <-stream.Done():
565+ case <-time.After(5 * time.Second):
566+ t.Fatal("a local close must publish its terminal outcome")
567+ }
568+ require.NoError(t, stream.Err())
569+ require.NoError(t, stream.Close(), "closing twice is idempotent")
570+}
571+
572+// TestScopeIdentityFencing proves the client adapter refuses a caller-supplied
573+// identity that belongs to another connection and stream identities that do not
574+// advance.
575+func TestScopeIdentityFencing(t *testing.T) {
576+ e := startEndpoint(t, Config{})
577+ client, _ := e.pair()
578+
579+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
580+ defer cancel()
581+ foreign := ports.BrokerConnectionID{0x99}
582+ _, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{
583+ Purpose: ports.BrokerStreamControl, Local: true, Connection: foreign, Policy: testPolicy(),
584+ })
585+ require.ErrorIs(t, err, ports.BrokerAdmissionStale)
586+
587+ _, err = client.OpenStream(ctx, ports.BrokerOpenStreamRequest{
588+ Purpose: ports.BrokerStreamControl, Local: true, Epoch: e.epoch + 1, Policy: testPolicy(),
589+ })
590+ require.ErrorIs(t, err, ErrScopeMismatch)
591+ var stale ports.BrokerError
592+ require.ErrorAs(t, err, &stale)
593+ require.Equal(t, ports.BrokerErrorStaleEpoch, stale.Code)
594+
595+ first, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
596+ require.NoError(t, err)
597+ t.Cleanup(func() { _ = first.Close() })
598+ _, err = client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy()})
599+ require.ErrorIs(t, err, ports.BrokerAdmissionStale)
600+}
601+
602+// TestRequestReconcileIsBoundedHint proves the reconcile hint is sent only for an
603+// endpoint the connection already holds a registration for, and that it is
604+// otherwise a no-op.
605+func TestRequestReconcileIsBoundedHint(t *testing.T) {
606+ e := startEndpoint(t, Config{})
607+ client, _ := e.pair()
608+ core := e.authority.last()
609+
610+ client.RequestReconcile("unknown@host:22")
611+ time.Sleep(50 * time.Millisecond)
612+ core.mu.Lock()
613+ require.Empty(t, core.reconcile)
614+ core.mu.Unlock()
615+
616+ sub, err := client.Subscribe()
617+ require.NoError(t, err)
618+ t.Cleanup(sub.Close)
619+ snapshot := e.publish(e.epoch, 4)
620+ require.Eventually(t, func() bool { return client.Snapshot().Revision == 4 }, 5*time.Second, 10*time.Millisecond)
621+
622+ client.RequestReconcile(snapshot.Hosts[0].Endpoint)
623+ require.Eventually(t, func() bool {
624+ core.mu.Lock()
625+ defer core.mu.Unlock()
626+ return len(core.reconcile) == 1 && core.reconcile[0] == snapshot.Hosts[0].Endpoint
627+ }, 5*time.Second, 10*time.Millisecond)
628+}
629+
630+// TestCoreErrorSurfacesAsTypedFailure proves a typed core failure reaches the
631+// client through the stream lifecycle.
632+func TestCoreErrorSurfacesAsTypedFailure(t *testing.T) {
633+ e := startEndpoint(t, Config{})
634+ client, _ := e.pair()
635+ core := e.authority.last()
636+
637+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
638+ defer cancel()
639+ stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
640+ require.NoError(t, err)
641+ t.Cleanup(func() { _ = stream.Close() })
642+
643+ core.logicalConn(1).fail(ports.BrokerError{Code: ports.BrokerErrorAttachmentLost, Text: "daemon went away"})
644+ select {
645+ case <-stream.Done():
646+ case <-time.After(5 * time.Second):
647+ t.Fatal("core loss must settle the stream")
648+ }
649+ var failure ports.BrokerError
650+ require.ErrorAs(t, stream.Err(), &failure)
651+ require.Equal(t, ports.BrokerErrorAttachmentLost, failure.Code)
652+ require.Equal(t, "daemon went away", failure.Text)
653+}
654+
655+// TestSessionHandshakeFailureIsStreamLocal proves a malformed inner session
656+// stream settles only that stream.
657+func TestSessionHandshakeFailureIsStreamLocal(t *testing.T) {
658+ e := startEndpoint(t, Config{})
659+ client, _ := e.pair()
660+ core := e.authority.last()
661+
662+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
663+ defer cancel()
664+ stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
665+ require.NoError(t, err)
666+ conn := core.logicalConn(1)
667+ require.NotNil(t, conn)
668+
669+ // The client emits a malformed inner session frame: the broker's session
670+ // carriage refuses it and resets only this stream.
671+ raw := stream.(*clientStream)
672+ _, err = raw.pipe.Write([]byte{0, 0, 0, 4, 0xff, 0xff, 0xff, 0xff})
673+ require.NoError(t, err)
674+ select {
675+ case <-stream.Done():
676+ case <-time.After(5 * time.Second):
677+ t.Fatal("a malformed inner frame must settle its stream")
678+ }
679+ require.NoError(t, client.AddHost(ctx, "survivor@host:22"))
680+
681+ // The broker core sees the stream retired.
682+ require.Eventually(t, func() bool {
683+ return core.logicalConn(1) == nil
684+ }, 5*time.Second, 10*time.Millisecond)
685+}
686+
687+// TestDomainFixturesAreValid guards the test fixtures against drifting from the
688+// contracts the wire enforces.
689+func TestDomainFixturesAreValid(t *testing.T) {
690+ snapshot := testSnapshot(1, 1)
691+ require.NoError(t, snapshot.Validate())
692+ for _, host := range snapshot.Hosts {
693+ require.NoError(t, ports.ValidateDurableHostProjection(host))
694+ }
695+ require.NoError(t, testPolicy().Validate())
696+ catalogueSession := testSessionAt(0, 0)
697+ require.False(t, strings.HasPrefix(catalogueSession.Name, " "))
698+ require.NotEqual(t, domain.RemoteAvailabilityUnknown, testHostAt(0).Availability)
699+ require.True(t, errors.Is(errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale), ports.BrokerAdmissionStale))
700+}
A internal/adapters/brokeripc/socket.go +30 -0
@@ -0,0 +1,30 @@
1+package brokeripc
2+
3+import (
4+ "path/filepath"
5+
6+ "github.com/bnema/vev/internal/adapters/ipc"
7+)
8+
9+// SocketFileName is the fixed name of the per-user broker IPC socket inside its
10+// runtime directory. It is deliberately distinct from the daemon's
11+// daemon.sock, so the broker endpoint and a session daemon endpoint can never
12+// be confused for one another.
13+const SocketFileName = "broker.sock"
14+
15+// SocketPath returns the broker IPC socket path inside runtimeDir.
16+func SocketPath(runtimeDir string) string {
17+ return filepath.Join(runtimeDir, SocketFileName)
18+}
19+
20+// SocketDir returns the per-user directory the broker endpoint belongs in:
21+// $XDG_RUNTIME_DIR/vev when set, else /run/user/<uid>/vev when that directory
22+// exists, else /tmp/vev-<uid>. It is the daemon's per-user runtime directory as
23+// well, so both endpoints share one owner-only directory while keeping
24+// distinct socket names.
25+func SocketDir() string { return ipc.SocketDir() }
26+
27+// DefaultSocketPath returns the broker endpoint path in the per-user runtime
28+// directory. Callers that need a private temporary endpoint supply their own
29+// parent directory to SocketPath instead.
30+func DefaultSocketPath() string { return SocketPath(SocketDir()) }
A internal/adapters/brokeripc/stream.go +493 -0
@@ -0,0 +1,493 @@
1+package brokeripc
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+
9+ "github.com/bnema/vev/internal/adapters/brokerwire"
10+ "github.com/bnema/vev/internal/adapters/sessionwire"
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// Logical stream bridging (P3.3).
15+//
16+// One brokerwire logical stream carries the typed session protocol between a
17+// client and the daemon behind the broker. The broker terminates both hops: the
18+// client's session connection is built by sessionwire over the stream's private
19+// bounded byte pipe, and the admitted broker core hands back its own typed
20+// connection to the daemon. Two relay goroutines carry typed messages between
21+// them, so no session byte is ever reinterpreted here.
22+//
23+// Identity and cancellation stay per stream: a stream the core loses, a stream
24+// whose local consumer stalled, and a stream the peer closed each settle alone,
25+// sending at most one typed StreamClosed and releasing only that stream's
26+// resources. The connection and every sibling stream stay up.
27+
28+// serverStream is the broker-side half of one logical stream.
29+type serverStream struct {
30+ session *serverSession
31+ id ports.BrokerStreamID
32+ core ports.BrokerLogicalConnection
33+ pipe *streamPipe
34+ carriage *carriage
35+ wire ports.ServerConnection
36+
37+ mu sync.Mutex
38+ closed bool
39+ done chan struct{}
40+}
41+
42+// newServerStream builds one bridged stream over the admitted core connection.
43+func newServerStream(s *serverSession, id ports.BrokerStreamID, core ports.BrokerLogicalConnection) (*serverStream, error) {
44+ if s == nil || core == nil || id == 0 {
45+ return nil, ErrConfig
46+ }
47+ st := &serverStream{session: s, id: id, core: core, done: make(chan struct{})}
48+ st.pipe = newStreamPipe(s.cfg.StreamInboundChunks, s.cfg.StreamInboundBytes, st.sendChunk)
49+ st.carriage = newCarriage(st.pipe)
50+ st.wire = sessionwire.NewServerConnection(st.carriage)
51+ if st.wire == nil {
52+ return nil, ErrConfig
53+ }
54+ return st, nil
55+}
56+
57+// sendChunk splits one outbound byte slice under the negotiated stream chunk
58+// ceiling and writes each frame in order.
59+func (st *serverStream) sendChunk(data []byte) error {
60+ ceiling := int(st.session.ceilings.StreamChunkLimit)
61+ if ceiling <= 0 {
62+ return ErrConfig
63+ }
64+ for len(data) > 0 {
65+ n := min(len(data), ceiling)
66+ if err := st.session.send(brokerwire.ServerStreamData{
67+ Epoch: st.session.epoch, Connection: st.session.scope.Connection, Stream: st.id, Data: data[:n],
68+ }); err != nil {
69+ return err
70+ }
71+ data = data[n:]
72+ }
73+ return nil
74+}
75+
76+// deliver accepts one inbound chunk from the connection reader.
77+func (st *serverStream) deliver(chunk []byte) error { return st.pipe.deliver(chunk) }
78+
79+// start launches the three per-stream workers. Every one is joined by the
80+// session's WaitGroup before shutdown returns.
81+func (st *serverStream) start() {
82+ st.session.wg.Add(3)
83+ go st.relayClient()
84+ go st.relayServer()
85+ go st.watchCore()
86+}
87+
88+// relayClient carries client-to-daemon messages from the wire session
89+// connection to the admitted core connection.
90+func (st *serverStream) relayClient() {
91+ defer st.session.wg.Done()
92+ for {
93+ message, err := st.wire.ReceiveClient()
94+ if err != nil {
95+ st.fail(cancelOrCause(st.session.ctx, streamFailure(err)))
96+ return
97+ }
98+ if err := st.core.SendClient(message); err != nil {
99+ st.fail(err)
100+ return
101+ }
102+ }
103+}
104+
105+// relayServer carries daemon-to-client messages from the admitted core
106+// connection to the wire session connection.
107+func (st *serverStream) relayServer() {
108+ defer st.session.wg.Done()
109+ for {
110+ message, err := st.core.ReceiveServer()
111+ if err != nil {
112+ st.fail(coreFailure(st.core, err))
113+ return
114+ }
115+ if err := st.wire.SendServer(message); err != nil {
116+ st.fail(streamFailure(err))
117+ return
118+ }
119+ }
120+}
121+
122+// watchCore publishes the core connection's terminal outcome even when no read
123+// is in flight.
124+func (st *serverStream) watchCore() {
125+ defer st.session.wg.Done()
126+ select {
127+ case <-st.core.Done():
128+ st.fail(coreFailure(st.core, nil))
129+ case <-st.done:
130+ }
131+}
132+
133+// fail settles the stream once and reports it to the session, which retires its
134+// wire identity and sends at most one typed StreamClosed.
135+func (st *serverStream) fail(err error) {
136+ if st.close(err) {
137+ st.session.settleStream(st, err)
138+ }
139+}
140+
141+// close releases the stream exactly once: the pipe unblocks a stalled reader,
142+// the wire session connection releases the carriage, and the core connection is
143+// closed. It reports whether this call was the one that settled the stream.
144+func (st *serverStream) close(cause error) bool {
145+ if st == nil {
146+ return false
147+ }
148+ st.mu.Lock()
149+ if st.closed {
150+ st.mu.Unlock()
151+ return false
152+ }
153+ st.closed = true
154+ st.mu.Unlock()
155+ st.pipe.closeWith(cause)
156+ _ = st.wire.Close()
157+ _ = st.core.Close()
158+ close(st.done)
159+ return true
160+}
161+
162+// streamFailure classifies one wire-side stream error: an orderly carriage end
163+// is a peer close, not a transport failure.
164+func streamFailure(err error) error {
165+ if err == nil || errors.Is(err, io.EOF) {
166+ return ErrStreamGone
167+ }
168+ return err
169+}
170+
171+// coreFailure classifies one core connection outcome. The core's typed terminal
172+// error wins; an orderly end is a peer close.
173+func coreFailure(core ports.BrokerLogicalConnection, err error) error {
174+ if err == nil && core != nil {
175+ err = core.Err()
176+ }
177+ if err == nil {
178+ return ErrStreamGone
179+ }
180+ var failure ports.BrokerError
181+ if errors.As(err, &failure) {
182+ return err
183+ }
184+ if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
185+ return ErrStreamGone
186+ }
187+ return err
188+}
189+
190+// orderlyStream reports whether one stream outcome closes the stream without a
191+// failure detail.
192+func orderlyStream(err error) bool {
193+ return err == nil || errors.Is(err, ErrStreamGone)
194+}
195+
196+// startStream admits one inbound open on the wire tracker and starts its bridge
197+// once the broker core established the stream.
198+func (s *serverSession) startStream(m brokerwire.OpenStream) {
199+ if err := s.conn.OpenStream(m.Stream); err != nil {
200+ if sendErr := s.refuseStream(m.Stream, admissionError(err)); sendErr != nil {
201+ s.abort(sendErr)
202+ }
203+ return
204+ }
205+ request := ports.BrokerOpenStreamRequest{
206+ Epoch: m.Epoch, Purpose: m.Purpose, Local: m.Local,
207+ Connection: m.Connection, Stream: m.Stream,
208+ Endpoint: m.Endpoint, Registration: m.Registration,
209+ Target: m.Target, Env: m.Env, Policy: m.Policy,
210+ }
211+ s.wg.Add(1)
212+ go func() {
213+ defer s.wg.Done()
214+ core, err := s.core.OpenStream(s.ctx, request)
215+ if err != nil {
216+ _, _ = s.conn.CloseStream(m.Stream)
217+ if sendErr := s.refuseStream(m.Stream, err); sendErr != nil {
218+ s.abort(sendErr)
219+ }
220+ return
221+ }
222+ if core == nil {
223+ _, _ = s.conn.CloseStream(m.Stream)
224+ if sendErr := s.refuseStream(m.Stream, errors.Join(ports.BrokerAdmissionInvalid, errors.New("brokeripc: core returned a nil logical connection"))); sendErr != nil {
225+ s.abort(sendErr)
226+ }
227+ return
228+ }
229+ stream, err := newServerStream(s, m.Stream, core)
230+ if err != nil {
231+ _ = core.Close()
232+ _, _ = s.conn.CloseStream(m.Stream)
233+ if sendErr := s.refuseStream(m.Stream, err); sendErr != nil {
234+ s.abort(sendErr)
235+ }
236+ return
237+ }
238+ if !s.registerStream(stream) {
239+ stream.close(ErrSessionClosed)
240+ return
241+ }
242+ if err := s.conn.StreamOpened(m.Stream); err != nil {
243+ s.settleStream(stream, errors.Join(ErrProtocol, err))
244+ return
245+ }
246+ if err := s.send(brokerwire.StreamOpened{Epoch: s.epoch, Connection: s.scope.Connection, Stream: m.Stream}); err != nil {
247+ s.settleStream(stream, transportFailure(err))
248+ return
249+ }
250+ stream.start()
251+ }()
252+}
253+
254+// registerStream installs one bridge, or reports false when the session is
255+// closing.
256+func (s *serverSession) registerStream(st *serverStream) bool {
257+ s.streamsMu.Lock()
258+ defer s.streamsMu.Unlock()
259+ if s.ctx.Err() != nil {
260+ return false
261+ }
262+ s.streams[st.id] = st
263+ return true
264+}
265+
266+// unregisterStream removes one bridge, if present.
267+func (s *serverSession) unregisterStream(id ports.BrokerStreamID) {
268+ s.streamsMu.Lock()
269+ delete(s.streams, id)
270+ s.streamsMu.Unlock()
271+}
272+
273+// lookupStream returns the bridge for one stream identity.
274+func (s *serverSession) lookupStream(id ports.BrokerStreamID) *serverStream {
275+ s.streamsMu.Lock()
276+ defer s.streamsMu.Unlock()
277+ return s.streams[id]
278+}
279+
280+// settleStream retires one stream's wire identity and reports its outcome to the
281+// peer. A session that is already shutting down sends nothing: the carriage is
282+// closing and the peer is gone.
283+func (s *serverSession) settleStream(st *serverStream, err error) {
284+ if st == nil {
285+ return
286+ }
287+ st.close(err)
288+ s.unregisterStream(st.id)
289+ _, _ = s.conn.CloseStream(st.id)
290+ // Retire the broker-side stream reservation as well, so a stream that
291+ // settled for any reason is released at the core and not only in its
292+ // carriage.
293+ _ = s.core.CloseStream(s.scope.Connection, st.id)
294+ if s.ctx.Err() != nil {
295+ return
296+ }
297+ detail := brokerwire.ErrorDetail{}
298+ if !orderlyStream(err) {
299+ detail = errorDetail(err)
300+ }
301+ if sendErr := s.send(brokerwire.StreamClosed{
302+ Epoch: s.epoch, Connection: s.scope.Connection, Stream: st.id,
303+ Error: detail, HasError: detail != (brokerwire.ErrorDetail{}),
304+ }); sendErr != nil {
305+ s.abort(sendErr)
306+ }
307+}
308+
309+// closeStreams settles every bridged stream during shutdown, releasing each
310+// core connection and session carriage exactly once.
311+func (s *serverSession) closeStreams() {
312+ s.streamsMu.Lock()
313+ streams := make([]*serverStream, 0, len(s.streams))
314+ for _, st := range s.streams {
315+ streams = append(streams, st)
316+ }
317+ s.streams = make(map[ports.BrokerStreamID]*serverStream)
318+ s.streamsMu.Unlock()
319+ for _, st := range streams {
320+ st.close(ErrSessionClosed)
321+ }
322+}
323+
324+// closeStreamByPeer retires one stream the peer closed. No StreamClosed is sent
325+// back: the peer already knows.
326+func (s *serverSession) closeStreamByPeer(id ports.BrokerStreamID) {
327+ st := s.lookupStream(id)
328+ if _, err := s.conn.CloseStream(id); err != nil && !errors.Is(err, brokerwire.ErrFutureStream) {
329+ // A close for a stream that was never allocated is a peer protocol
330+ // violation; a late close for a retired stream is simply discarded.
331+ if errors.Is(err, brokerwire.ErrConnectionState) || errors.Is(err, brokerwire.ErrScopeMismatch) {
332+ s.abort(errors.Join(ErrProtocol, err))
333+ return
334+ }
335+ }
336+ if st == nil {
337+ return
338+ }
339+ s.unregisterStream(id)
340+ st.close(ErrStreamGone)
341+ // Retire the broker-side stream reservation as well: the peer's close ends
342+ // the client-scoped stream, not merely its carriage.
343+ _ = s.core.CloseStream(s.scope.Connection, id)
344+}
345+
346+// deliverStreamData routes one inbound client chunk to its bridge. Backpressure
347+// settles only that stream; a chunk for an identity the peer never opened is a
348+// protocol violation.
349+func (s *serverSession) deliverStreamData(m brokerwire.ClientStreamData) error {
350+ disposition, err := s.conn.StreamData(m.Stream)
351+ if err != nil {
352+ // Stream data for a stream that was never allocated, or before it was
353+ // confirmed open, is a peer protocol violation.
354+ return errors.Join(ErrProtocol, err)
355+ }
356+ if disposition == brokerwire.StreamDiscarded {
357+ return nil
358+ }
359+ st := s.lookupStream(m.Stream)
360+ if st == nil {
361+ // The bridge was settled between the tracker check and the lookup (a
362+ // stream-local failure won the race); the chunk is discarded with it.
363+ return nil
364+ }
365+ switch err := st.deliver(m.Data); {
366+ case err == nil:
367+ return nil
368+ case errors.Is(err, ErrStreamBackpressure):
369+ s.settleStream(st, ErrStreamBackpressure)
370+ return nil
371+ default:
372+ // The stream already reached its terminal outcome; its data is
373+ // discarded.
374+ return nil
375+ }
376+}
377+
378+// clientStream is the client-side half of one logical stream: the sessionwire
379+// client connection over the stream's bounded pipe, plus the terminal outcome a
380+// ports.BrokerLogicalConnection exposes independently of reads.
381+type clientStream struct {
382+ ports.ClientConnection
383+ client *client
384+ id ports.BrokerStreamID
385+ pipe *streamPipe
386+ carriage *carriage
387+
388+ opened chan error
389+ mu sync.Mutex
390+ closed bool
391+ err error
392+ done chan struct{}
393+}
394+
395+// newClientStream builds one client-side stream over the broker connection.
396+func newClientStream(c *client, id ports.BrokerStreamID) (*clientStream, error) {
397+ if c == nil || id == 0 {
398+ return nil, ErrConfig
399+ }
400+ st := &clientStream{
401+ client: c,
402+ id: id,
403+ opened: make(chan error, 1),
404+ done: make(chan struct{}),
405+ }
406+ st.pipe = newStreamPipe(c.cfg.StreamInboundChunks, c.cfg.StreamInboundBytes, st.sendChunk)
407+ st.carriage = newCarriage(st.pipe)
408+ st.ClientConnection = sessionwire.NewClientConnection(st.carriage)
409+ if st.ClientConnection == nil {
410+ return nil, ErrConfig
411+ }
412+ return st, nil
413+}
414+
415+// sendChunk splits one outbound client byte slice under the negotiated stream
416+// chunk ceiling.
417+func (st *clientStream) sendChunk(data []byte) error {
418+ ceiling := int(st.client.ceilings.StreamChunkLimit)
419+ if ceiling <= 0 {
420+ return ErrConfig
421+ }
422+ for len(data) > 0 {
423+ n := min(len(data), ceiling)
424+ if err := st.client.send(brokerwire.ClientStreamData{
425+ Epoch: st.client.scope.Epoch, Connection: st.client.scope.Connection, Stream: st.id, Data: data[:n],
426+ }); err != nil {
427+ return err
428+ }
429+ data = data[n:]
430+ }
431+ return nil
432+}
433+
434+// deliver accepts one inbound server chunk.
435+func (st *clientStream) deliver(chunk []byte) error { return st.pipe.deliver(chunk) }
436+
437+// Done closes when the stream reached its terminal outcome.
438+func (st *clientStream) Done() <-chan struct{} { return st.done }
439+
440+// Err reports the terminal cause; nil is an orderly close.
441+func (st *clientStream) Err() error {
442+ st.mu.Lock()
443+ defer st.mu.Unlock()
444+ return st.err
445+}
446+
447+// Close ends the stream: it sends one CloseStream frame best-effort, releases
448+// the carriage, and resolves any pending open. It is idempotent and
449+// concurrent-safe.
450+func (st *clientStream) Close() error {
451+ if st == nil {
452+ return nil
453+ }
454+ st.client.requestStreamClose(st.id)
455+ st.terminate(nil)
456+ return nil
457+}
458+
459+// terminate settles the stream exactly once, publishes its terminal cause (nil
460+// for an orderly close), and resolves a pending open.
461+func (st *clientStream) terminate(cause error) bool {
462+ if st == nil {
463+ return false
464+ }
465+ st.mu.Lock()
466+ if st.closed {
467+ st.mu.Unlock()
468+ return false
469+ }
470+ st.closed = true
471+ st.err = cause
472+ st.mu.Unlock()
473+ st.pipe.closeWith(cause)
474+ _ = st.carriage.Close()
475+ st.client.unregisterStream(st.id)
476+ // Retire the identity on the connection's tracker as well, so a frame that
477+ // arrives after local retirement is classified as discarded.
478+ _, _ = st.client.conn.CloseStream(st.id)
479+ select {
480+ case st.opened <- cause:
481+ default:
482+ }
483+ close(st.done)
484+ return true
485+}
486+
487+// fail settles the stream and tells the broker to retire it, so a stream-local
488+// failure never leaves the peer half-open.
489+func (st *clientStream) fail(err error) {
490+ if st.terminate(err) {
491+ st.client.requestStreamClose(st.id)
492+ }
493+}
M internal/adapters/brokerwire/codec_test.go +34 -1
@@ -146,6 +146,39 @@ func TestBrokerPreambleByteLengthHelper(t *testing.T) {
146 // TestBrokerPreambleRoles proves the broker preamble uses roles 3/4, the146 // TestBrokerPreambleRoles proves the broker preamble uses roles 3/4, the
147 // existing magic/epoch/exact version, zero capability bits, the 4 KiB147 // existing magic/epoch/exact version, zero capability bits, the 4 KiB
148 // preamble bound, and precise refusal mapping.148 // preamble bound, and precise refusal mapping.
149+// TestBrokerCeilingsExportedSurface proves the exported ceiling value the
150+// connection dispatcher consumes stays equivalent to the preamble helpers'
151+// internal view: defaults are negotiable, minima are element-wise, and a
152+// non-negotiable advertisement is refused.
153+func TestBrokerCeilingsExportedSurface(t *testing.T) {
154+ defaults := DefaultCeilings()
155+ require.NoError(t, defaults.Validate())
156+ require.Equal(t, defaultBrokerCeilings(), defaults)
157+
158+ smaller := Ceilings{MaxReceiveEnvelopeBytes: MinBrokerEnvelopeBytes, StreamChunkLimit: 1024}
159+ require.NoError(t, smaller.Validate())
160+ require.Equal(t, smaller, EffectiveCeilings(defaults, smaller))
161+ require.Equal(t, smaller, EffectiveCeilings(smaller, defaults))
162+ require.Equal(t, defaults, EffectiveCeilings(defaults, defaults))
163+
164+ for _, invalid := range []Ceilings{
165+ {},
166+ {MaxReceiveEnvelopeBytes: MinBrokerEnvelopeBytes - 1, StreamChunkLimit: MaxStreamChunkBytes},
167+ {MaxReceiveEnvelopeBytes: MaxBrokerEnvelopeBytes + 1, StreamChunkLimit: MaxStreamChunkBytes},
168+ {MaxReceiveEnvelopeBytes: MaxBrokerEnvelopeBytes, StreamChunkLimit: 0},
169+ {MaxReceiveEnvelopeBytes: MaxBrokerEnvelopeBytes, StreamChunkLimit: MaxStreamChunkBytes + 1},
170+ } {
171+ require.ErrorIs(t, invalid.Validate(), ErrPreambleRejected)
172+ }
173+
174+ // The decoded preamble's exported ceilings drive the codec ceilings.
175+ request := EncodePreambleRequest(smaller)
176+ decoded, err := DecodePreambleRequest(request)
177+ require.NoError(t, err)
178+ require.Equal(t, smaller, decoded.Ceilings)
179+ require.NoError(t, decoded.Ceilings.Validate())
180+}
181+
149 func TestBrokerPreambleRoles(t *testing.T) {182 func TestBrokerPreambleRoles(t *testing.T) {
150 t.Run("client role is 3 and server role is 4", func(t *testing.T) {183 t.Run("client role is 3 and server role is 4", func(t *testing.T) {
151 request := EncodePreambleRequest(defaultBrokerCeilings())184 request := EncodePreambleRequest(defaultBrokerCeilings())
@@ -192,7 +225,7 @@ func TestBrokerPreambleRoles(t *testing.T) {
192 })225 })
193 t.Run("round trip negotiates minima", func(t *testing.T) {226 t.Run("round trip negotiates minima", func(t *testing.T) {
194 local := defaultBrokerCeilings()227 local := defaultBrokerCeilings()
195- remote := brokerCeilings{maxReceiveEnvelopeBytes: testEnvelopeCeiling / 2, streamChunkLimit: 1024}228+ remote := brokerCeilings{MaxReceiveEnvelopeBytes: testEnvelopeCeiling / 2, StreamChunkLimit: 1024}
196 decoded, err := DecodePreambleRequest(EncodePreambleRequest(remote))229 decoded, err := DecodePreambleRequest(EncodePreambleRequest(remote))
197 require.NoError(t, err)230 require.NoError(t, err)
198 require.Equal(t, remote, decoded.Ceilings)231 require.Equal(t, remote, decoded.Ceilings)
M internal/adapters/brokerwire/limits.go +45 -20
@@ -3,7 +3,7 @@ package brokerwire
3 // Stateless bound refusals (P3.1).3 // Stateless bound refusals (P3.1).
4 //4 //
5 // The broker preamble negotiates one immutable pair of ceilings per5 // The broker preamble negotiates one immutable pair of ceilings per
6-// connection: maxReceiveEnvelopeBytes bounds every envelope this side6+// connection: MaxReceiveEnvelopeBytes bounds every envelope this side
7 // accepts, and StreamChunkLimit bounds one opaque ClientStreamData or7 // accepts, and StreamChunkLimit bounds one opaque ClientStreamData or
8 // ServerStreamData frame. Envelope ceilings are fixed: ordinary envelopes8 // ServerStreamData frame. Envelope ceilings are fixed: ordinary envelopes
9 // never exceed the advertised floor, and the preamble advertisement itself9 // never exceed the advertised floor, and the preamble advertisement itself
@@ -41,46 +41,71 @@ const (
41 MaxStreamChunkBytes uint64 = 64 << 1041 MaxStreamChunkBytes uint64 = 64 << 10
42 )42 )
43 43
44-// brokerCeilings are the immutable per-connection ceilings negotiated once44+// Ceilings are the immutable per-connection ceilings negotiated once by the
45-// by the broker preamble. StreamChunkLimit bounds one opaque stream data45+// broker preamble. MaxReceiveEnvelopeBytes bounds every envelope this side
46-// frame in either direction.46+// accepts; StreamChunkLimit bounds one opaque stream data frame in either
47-type brokerCeilings struct {47+// direction.
48- maxReceiveEnvelopeBytes uint6448+//
49- streamChunkLimit uint6449+// The preamble helpers and the connection dispatcher share this value: a
50+// caller that completed the preamble reads the negotiated limits from
51+// BrokerPreambleRequest or BrokerPreambleResponse and passes them to
52+// EncodeClient/DecodeClient and EncodeServer/DecodeServer.
53+type Ceilings struct {
54+ MaxReceiveEnvelopeBytes uint64
55+ StreamChunkLimit uint64
50 }56 }
51 57
58+// brokerCeilings is the in-package alias for Ceilings. It exists only so
59+// P3.1's tests keep their short spelling; production code uses Ceilings.
60+type brokerCeilings = Ceilings
61+
52 // defaultBrokerCeilings advertises the local broker receive policy: the62 // defaultBrokerCeilings advertises the local broker receive policy: the
53 // full absolute envelope ceiling and the maximum stream chunk.63 // full absolute envelope ceiling and the maximum stream chunk.
54-func defaultBrokerCeilings() brokerCeilings {64+func defaultBrokerCeilings() Ceilings {
55- return brokerCeilings{65+ return Ceilings{
56- maxReceiveEnvelopeBytes: wire.AbsoluteEnvelopeLimit,66+ MaxReceiveEnvelopeBytes: wire.AbsoluteEnvelopeLimit,
57- streamChunkLimit: MaxStreamChunkBytes,67+ StreamChunkLimit: MaxStreamChunkBytes,
58 }68 }
59 }69 }
60 70
71+// DefaultCeilings returns the local broker receive policy: the full absolute
72+// envelope ceiling and the maximum stream chunk. It is the offer a broker IPC
73+// client or server advertises before negotiation.
74+func DefaultCeilings() Ceilings { return defaultBrokerCeilings() }
75+
61 // effectiveBrokerCeilings takes the minima of both sides' advertisements.76 // effectiveBrokerCeilings takes the minima of both sides' advertisements.
62-func effectiveBrokerCeilings(local, remote brokerCeilings) brokerCeilings {77+func effectiveBrokerCeilings(local, remote Ceilings) Ceilings {
63- return brokerCeilings{78+ return Ceilings{
64- maxReceiveEnvelopeBytes: min(local.maxReceiveEnvelopeBytes, remote.maxReceiveEnvelopeBytes),79+ MaxReceiveEnvelopeBytes: min(local.MaxReceiveEnvelopeBytes, remote.MaxReceiveEnvelopeBytes),
65- streamChunkLimit: min(local.streamChunkLimit, remote.streamChunkLimit),80+ StreamChunkLimit: min(local.StreamChunkLimit, remote.StreamChunkLimit),
66 }81 }
67 }82 }
68 83
84+// EffectiveCeilings returns the element-wise minima of the two advertised
85+// ceilings: one byte stream carries exactly the limits both peers accepted.
86+func EffectiveCeilings(local, remote Ceilings) Ceilings {
87+ return effectiveBrokerCeilings(local, remote)
88+}
89+
69 // checkBrokerCeilings refuses a zero, below-floor, or above-ceiling90 // checkBrokerCeilings refuses a zero, below-floor, or above-ceiling
70 // advertisement. Envelope bounds are the 1 MiB..16 MiB advertised window;91 // advertisement. Envelope bounds are the 1 MiB..16 MiB advertised window;
71 // chunk bounds are 1..64 KiB.92 // chunk bounds are 1..64 KiB.
72-func checkBrokerCeilings(remote brokerCeilings) error {93+func checkBrokerCeilings(remote Ceilings) error {
73- if remote.maxReceiveEnvelopeBytes < MinBrokerEnvelopeBytes ||94+ if remote.MaxReceiveEnvelopeBytes < MinBrokerEnvelopeBytes ||
74- remote.maxReceiveEnvelopeBytes > MaxBrokerEnvelopeBytes {95+ remote.MaxReceiveEnvelopeBytes > MaxBrokerEnvelopeBytes {
75 return ErrPreambleRejected96 return ErrPreambleRejected
76 }97 }
77- if remote.streamChunkLimit < MinStreamChunkBytes ||98+ if remote.StreamChunkLimit < MinStreamChunkBytes ||
78- remote.streamChunkLimit > MaxStreamChunkBytes {99+ remote.StreamChunkLimit > MaxStreamChunkBytes {
79 return ErrPreambleRejected100 return ErrPreambleRejected
80 }101 }
81 return nil102 return nil
82 }103 }
83 104
105+// Validate reports whether one advertised ceiling is negotiable: an envelope
106+// window inside 1 MiB..16 MiB and a stream chunk inside 1..64 KiB.
107+func (c Ceilings) Validate() error { return checkBrokerCeilings(c) }
108+
84 // checkBrokerEnvelopeCeiling rejects one serialized broker envelope above109 // checkBrokerEnvelopeCeiling rejects one serialized broker envelope above
85 // the negotiated ceiling. All broker envelopes are control-class: the110 // the negotiated ceiling. All broker envelopes are control-class: the
86 // negotiated ceiling always applies in full.111 // negotiated ceiling always applies in full.
M internal/adapters/brokerwire/preamble.go +12 -12
@@ -61,12 +61,12 @@ const (
61 // BrokerPreambleRequest is the validated broker client preamble: the61 // BrokerPreambleRequest is the validated broker client preamble: the
62 // negotiated ceilings plus the raw wire message for stateless re-encode.62 // negotiated ceilings plus the raw wire message for stateless re-encode.
63 type BrokerPreambleRequest struct {63 type BrokerPreambleRequest struct {
64- Ceilings brokerCeilings64+ Ceilings Ceilings
65 }65 }
66 66
67 // BrokerPreambleResponse is the validated broker server preamble.67 // BrokerPreambleResponse is the validated broker server preamble.
68 type BrokerPreambleResponse struct {68 type BrokerPreambleResponse struct {
69- Ceilings brokerCeilings69+ Ceilings Ceilings
70 Accepted bool70 Accepted bool
71 Code uint3271 Code uint32
72 }72 }
@@ -78,8 +78,8 @@ func EncodePreambleRequest(ceilings brokerCeilings) *wire.PreambleRequest {
78 Epoch: wire.ProtocolEpoch,78 Epoch: wire.ProtocolEpoch,
79 Version: uint32(protocol.Version),79 Version: uint32(protocol.Version),
80 Role: &wire.PreambleRole{Role: BrokerRoleClient},80 Role: &wire.PreambleRole{Role: BrokerRoleClient},
81- MaxReceiveEnvelopeBytes: ceilings.maxReceiveEnvelopeBytes,81+ MaxReceiveEnvelopeBytes: ceilings.MaxReceiveEnvelopeBytes,
82- OutputDataLimit: ceilings.streamChunkLimit,82+ OutputDataLimit: ceilings.StreamChunkLimit,
83 }83 }
84 }84 }
85 85
@@ -92,8 +92,8 @@ func EncodePreambleResponse(accepted bool, ceilings brokerCeilings, code uint32)
92 Epoch: wire.ProtocolEpoch,92 Epoch: wire.ProtocolEpoch,
93 Version: uint32(protocol.Version),93 Version: uint32(protocol.Version),
94 Role: &wire.PreambleRole{Role: BrokerRoleServer},94 Role: &wire.PreambleRole{Role: BrokerRoleServer},
95- MaxReceiveEnvelopeBytes: ceilings.maxReceiveEnvelopeBytes,95+ MaxReceiveEnvelopeBytes: ceilings.MaxReceiveEnvelopeBytes,
96- OutputDataLimit: ceilings.streamChunkLimit,96+ OutputDataLimit: ceilings.StreamChunkLimit,
97 Accepted: accepted,97 Accepted: accepted,
98 }98 }
99 if !accepted {99 if !accepted {
@@ -125,8 +125,8 @@ func DecodePreambleRequest(message *wire.PreambleRequest) (BrokerPreambleRequest
125 return BrokerPreambleRequest{}, ErrPreambleRejected125 return BrokerPreambleRequest{}, ErrPreambleRejected
126 }126 }
127 remote := brokerCeilings{127 remote := brokerCeilings{
128- maxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(),128+ MaxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(),
129- streamChunkLimit: message.GetOutputDataLimit(),129+ StreamChunkLimit: message.GetOutputDataLimit(),
130 }130 }
131 if err := checkBrokerCeilings(remote); err != nil {131 if err := checkBrokerCeilings(remote); err != nil {
132 return BrokerPreambleRequest{}, err132 return BrokerPreambleRequest{}, err
@@ -160,8 +160,8 @@ func DecodePreambleResponse(message *wire.PreambleResponse) (BrokerPreambleRespo
160 return BrokerPreambleResponse{Code: message.GetRejection().GetCode()}, ErrPreambleRejected160 return BrokerPreambleResponse{Code: message.GetRejection().GetCode()}, ErrPreambleRejected
161 }161 }
162 remote := brokerCeilings{162 remote := brokerCeilings{
163- maxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(),163+ MaxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(),
164- streamChunkLimit: message.GetOutputDataLimit(),164+ StreamChunkLimit: message.GetOutputDataLimit(),
165 }165 }
166 if err := checkBrokerCeilings(remote); err != nil {166 if err := checkBrokerCeilings(remote); err != nil {
167 return BrokerPreambleResponse{}, err167 return BrokerPreambleResponse{}, err
@@ -198,8 +198,8 @@ func RejectionCodeFor(request *wire.PreambleRequest, err error) uint32 {
198 return RejectionLimitRefused198 return RejectionLimitRefused
199 }199 }
200 remote := brokerCeilings{200 remote := brokerCeilings{
201- maxReceiveEnvelopeBytes: request.GetMaxReceiveEnvelopeBytes(),201+ MaxReceiveEnvelopeBytes: request.GetMaxReceiveEnvelopeBytes(),
202- streamChunkLimit: request.GetOutputDataLimit(),202+ StreamChunkLimit: request.GetOutputDataLimit(),
203 }203 }
204 if checkBrokerCeilings(remote) != nil {204 if checkBrokerCeilings(remote) != nil {
205 return RejectionLimitRefused205 return RejectionLimitRefused
M internal/adapters/ipc/mux.go +31 -3
@@ -68,10 +68,19 @@ var (
68 ErrMuxPeerRejected = errors.New("ipc: mux peer failed same-user credential verification")68 ErrMuxPeerRejected = errors.New("ipc: mux peer failed same-user credential verification")
69 )69 )
70 70
71-// muxPeerVerifier checks one connected AF_UNIX peer's kernel credentials. It71+// PeerVerifier checks one connected AF_UNIX peer's kernel credentials. It
72 // returns nil only for a peer proven to be the current effective user. It is a72 // returns nil only for a peer proven to be the current effective user. It is a
73-// function type so a test can inject an observable refusal without root.73+// function type so a caller (or test) can inject an observable refusal without
74-type muxPeerVerifier func(conn *net.UnixConn) error74+// root; SameUserPeerVerifier returns the platform default.
75+type PeerVerifier func(conn *net.UnixConn) error
76+
77+// muxPeerVerifier is the in-package spelling of PeerVerifier.
78+type muxPeerVerifier = PeerVerifier
79+
80+// SameUserPeerVerifier returns this build's default same-user peer verifier:
81+// Linux SO_PEERCRED admission, or a fail-closed refusal on a platform or
82+// build without it.
83+func SameUserPeerVerifier() PeerVerifier { return verifySameUserUnixPeer }
75 84
76 // MuxListener is the listener half of a private daemonmux Unix carriage. Accept85 // MuxListener is the listener half of a private daemonmux Unix carriage. Accept
77 // returns a raw bounded transport (Send/RecvBounded/Close) ready for the86 // returns a raw bounded transport (Send/RecvBounded/Close) ready for the
@@ -113,6 +122,17 @@ func ListenMux(path string, opts ...Option) (MuxListener, error) {
113 return listenMux(path, verifySameUserUnixPeer, opts...)122 return listenMux(path, verifySameUserUnixPeer, opts...)
114 }123 }
115 124
125+// ListenMuxWithPeerVerifier binds the private carriage exactly like ListenMux
126+// but admits peers with an injected verifier instead of the platform default.
127+// It exists so a caller or test can supply an explicit admission policy (or
128+// observe a deterministic refusal without root); a nil verifier falls back to
129+// the platform default. Every other security property - owner-only parent,
130+// 0600 socket, race-safe stale recovery, foreign-path refusal, inode-scoped
131+// unlink on Close - is unchanged.
132+func ListenMuxWithPeerVerifier(path string, verify PeerVerifier, opts ...Option) (MuxListener, error) {
133+ return listenMux(path, verify, opts...)
134+}
135+
116 // listenMux is ListenMux with an injected peer verifier, so a test can observe136 // listenMux is ListenMux with an injected peer verifier, so a test can observe
117 // a credential refusal deterministically without root. A nil verifier falls137 // a credential refusal deterministically without root. A nil verifier falls
118 // back to the platform default.138 // back to the platform default.
@@ -168,6 +188,14 @@ func DialMuxContext(ctx context.Context, path string, opts ...Option) (wire.Boun
168 return dialMux(ctx, path, verifySameUserUnixPeer, opts...)188 return dialMux(ctx, path, verifySameUserUnixPeer, opts...)
169 }189 }
170 190
191+// DialMuxContextWithPeerVerifier connects to the private carriage exactly like
192+// DialMuxContext but verifies the accepted peer with an injected verifier
193+// instead of the platform default. A nil verifier falls back to the platform
194+// default; a peer the verifier refuses is closed without being handed out.
195+func DialMuxContextWithPeerVerifier(ctx context.Context, path string, verify PeerVerifier, opts ...Option) (wire.BoundedTransport, error) {
196+ return dialMux(ctx, path, verify, opts...)
197+}
198+
171 // dialMux is DialMuxContext with an injected peer verifier, so a test can199 // dialMux is DialMuxContext with an injected peer verifier, so a test can
172 // observe a credential refusal deterministically without root. A nil verifier200 // observe a credential refusal deterministically without root. A nil verifier
173 // falls back to the platform default.201 // falls back to the platform default.
M internal/adapters/ipc/mux_test.go +40 -0
@@ -529,3 +529,43 @@ func TestListenMuxConcurrentCloseAndDial(t *testing.T) {
529 }529 }
530 }530 }
531 }531 }
532+
533+// TestMuxExportedPeerVerifierSeam proves the exported injection seams the broker
534+// IPC adapter consumes behave like the internal ones: the platform default
535+// admits a same-user peer, a nil verifier falls back to it, and an injected
536+// refusal fails closed without handing out a transport.
537+func TestMuxExportedPeerVerifierSeam(t *testing.T) {
538+ path := muxTestPath(t)
539+ listener, err := ListenMuxWithPeerVerifier(path, SameUserPeerVerifier())
540+ if err != nil {
541+ t.Fatalf("ListenMuxWithPeerVerifier: %v", err)
542+ }
543+ t.Cleanup(func() { _ = listener.Close() })
544+ if SameUserPeerVerifier() == nil {
545+ t.Fatal("SameUserPeerVerifier must return the platform verifier")
546+ }
547+
548+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
549+ defer cancel()
550+ transport, err := DialMuxContextWithPeerVerifier(ctx, path, nil)
551+ if err != nil {
552+ t.Fatalf("DialMuxContextWithPeerVerifier with a nil verifier: %v", err)
553+ }
554+ t.Cleanup(func() { _ = transport.Close() })
555+ accepted, err := listener.Accept()
556+ if err != nil {
557+ t.Fatalf("Accept: %v", err)
558+ }
559+ t.Cleanup(func() { _ = accepted.Close() })
560+
561+ refused, err := DialMuxContextWithPeerVerifier(ctx, path, func(*net.UnixConn) error { return ErrMuxPeerRejected })
562+ if err == nil {
563+ t.Fatal("an injected refusal must fail the dial")
564+ }
565+ if !errors.Is(err, ErrMuxPeerRejected) {
566+ t.Fatalf("dial refusal = %v, want ErrMuxPeerRejected", err)
567+ }
568+ if refused != nil {
569+ t.Fatal("a refused peer must never receive a transport")
570+ }
571+}

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close