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
3030 byte-length bound), multipart snapshot assembly, per-connection operation
3131 and stream trackers, and the bounded admission lock. It is not activated
3232 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.
3344 - `internal/adapters/quic`: one-stream QUIC carriage (TLS 1.3, epoch ALPN,
3445 exact SHA-256 pin, single bidirectional stream, bounded admission) plus
3546 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
181192 nonblocking drain on false precedes Reset, supporting buffered timer adapters.
182193 Policy identities have independent field-labelled validation and a named bound.
183194 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
113113 code 7 (`limit refused`), and code 6 (`out of order`) is reserved for the
114114 connection dispatcher, which owns framing-order detection and never surfaces
115115 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.
117121
118122 ## Output, ACK, and flow control
119123
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) {
146146 // TestBrokerPreambleRoles proves the broker preamble uses roles 3/4, the
147147 // existing magic/epoch/exact version, zero capability bits, the 4 KiB
148148 // 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+
149182 func TestBrokerPreambleRoles(t *testing.T) {
150183 t.Run("client role is 3 and server role is 4", func(t *testing.T) {
151184 request := EncodePreambleRequest(defaultBrokerCeilings())
@@ -192,7 +225,7 @@ func TestBrokerPreambleRoles(t *testing.T) {
192225 })
193226 t.Run("round trip negotiates minima", func(t *testing.T) {
194227 local := defaultBrokerCeilings()
195- remote := brokerCeilings{maxReceiveEnvelopeBytes: testEnvelopeCeiling / 2, streamChunkLimit: 1024}
228+ remote := brokerCeilings{MaxReceiveEnvelopeBytes: testEnvelopeCeiling / 2, StreamChunkLimit: 1024}
196229 decoded, err := DecodePreambleRequest(EncodePreambleRequest(remote))
197230 require.NoError(t, err)
198231 require.Equal(t, remote, decoded.Ceilings)
M internal/adapters/brokerwire/limits.go +45 -20
@@ -3,7 +3,7 @@ package brokerwire
33 // Stateless bound refusals (P3.1).
44 //
55 // The broker preamble negotiates one immutable pair of ceilings per
6-// connection: maxReceiveEnvelopeBytes bounds every envelope this side
6+// connection: MaxReceiveEnvelopeBytes bounds every envelope this side
77 // accepts, and StreamChunkLimit bounds one opaque ClientStreamData or
88 // ServerStreamData frame. Envelope ceilings are fixed: ordinary envelopes
99 // never exceed the advertised floor, and the preamble advertisement itself
@@ -41,46 +41,71 @@ const (
4141 MaxStreamChunkBytes uint64 = 64 << 10
4242 )
4343
44-// brokerCeilings are the immutable per-connection ceilings negotiated once
45-// by the broker preamble. StreamChunkLimit bounds one opaque stream data
46-// frame in either direction.
47-type brokerCeilings struct {
48- maxReceiveEnvelopeBytes uint64
49- streamChunkLimit uint64
44+// Ceilings are the immutable per-connection ceilings negotiated once by the
45+// broker preamble. MaxReceiveEnvelopeBytes bounds every envelope this side
46+// accepts; StreamChunkLimit bounds one opaque stream data frame in either
47+// direction.
48+//
49+// The preamble helpers and the connection dispatcher share this value: a
50+// caller that completed the preamble reads the negotiated limits from
51+// BrokerPreambleRequest or BrokerPreambleResponse and passes them to
52+// EncodeClient/DecodeClient and EncodeServer/DecodeServer.
53+type Ceilings struct {
54+ MaxReceiveEnvelopeBytes uint64
55+ StreamChunkLimit uint64
5056 }
5157
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+
5262 // defaultBrokerCeilings advertises the local broker receive policy: the
5363 // full absolute envelope ceiling and the maximum stream chunk.
54-func defaultBrokerCeilings() brokerCeilings {
55- return brokerCeilings{
56- maxReceiveEnvelopeBytes: wire.AbsoluteEnvelopeLimit,
57- streamChunkLimit: MaxStreamChunkBytes,
64+func defaultBrokerCeilings() Ceilings {
65+ return Ceilings{
66+ MaxReceiveEnvelopeBytes: wire.AbsoluteEnvelopeLimit,
67+ StreamChunkLimit: MaxStreamChunkBytes,
5868 }
5969 }
6070
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+
6176 // effectiveBrokerCeilings takes the minima of both sides' advertisements.
62-func effectiveBrokerCeilings(local, remote brokerCeilings) brokerCeilings {
63- return brokerCeilings{
64- maxReceiveEnvelopeBytes: min(local.maxReceiveEnvelopeBytes, remote.maxReceiveEnvelopeBytes),
65- streamChunkLimit: min(local.streamChunkLimit, remote.streamChunkLimit),
77+func effectiveBrokerCeilings(local, remote Ceilings) Ceilings {
78+ return Ceilings{
79+ MaxReceiveEnvelopeBytes: min(local.MaxReceiveEnvelopeBytes, remote.MaxReceiveEnvelopeBytes),
80+ StreamChunkLimit: min(local.StreamChunkLimit, remote.StreamChunkLimit),
6681 }
6782 }
6883
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+
6990 // checkBrokerCeilings refuses a zero, below-floor, or above-ceiling
7091 // advertisement. Envelope bounds are the 1 MiB..16 MiB advertised window;
7192 // chunk bounds are 1..64 KiB.
72-func checkBrokerCeilings(remote brokerCeilings) error {
73- if remote.maxReceiveEnvelopeBytes < MinBrokerEnvelopeBytes ||
74- remote.maxReceiveEnvelopeBytes > MaxBrokerEnvelopeBytes {
93+func checkBrokerCeilings(remote Ceilings) error {
94+ if remote.MaxReceiveEnvelopeBytes < MinBrokerEnvelopeBytes ||
95+ remote.MaxReceiveEnvelopeBytes > MaxBrokerEnvelopeBytes {
7596 return ErrPreambleRejected
7697 }
77- if remote.streamChunkLimit < MinStreamChunkBytes ||
78- remote.streamChunkLimit > MaxStreamChunkBytes {
98+ if remote.StreamChunkLimit < MinStreamChunkBytes ||
99+ remote.StreamChunkLimit > MaxStreamChunkBytes {
79100 return ErrPreambleRejected
80101 }
81102 return nil
82103 }
83104
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+
84109 // checkBrokerEnvelopeCeiling rejects one serialized broker envelope above
85110 // the negotiated ceiling. All broker envelopes are control-class: the
86111 // negotiated ceiling always applies in full.
M internal/adapters/brokerwire/preamble.go +12 -12
@@ -61,12 +61,12 @@ const (
6161 // BrokerPreambleRequest is the validated broker client preamble: the
6262 // negotiated ceilings plus the raw wire message for stateless re-encode.
6363 type BrokerPreambleRequest struct {
64- Ceilings brokerCeilings
64+ Ceilings Ceilings
6565 }
6666
6767 // BrokerPreambleResponse is the validated broker server preamble.
6868 type BrokerPreambleResponse struct {
69- Ceilings brokerCeilings
69+ Ceilings Ceilings
7070 Accepted bool
7171 Code uint32
7272 }
@@ -78,8 +78,8 @@ func EncodePreambleRequest(ceilings brokerCeilings) *wire.PreambleRequest {
7878 Epoch: wire.ProtocolEpoch,
7979 Version: uint32(protocol.Version),
8080 Role: &wire.PreambleRole{Role: BrokerRoleClient},
81- MaxReceiveEnvelopeBytes: ceilings.maxReceiveEnvelopeBytes,
82- OutputDataLimit: ceilings.streamChunkLimit,
81+ MaxReceiveEnvelopeBytes: ceilings.MaxReceiveEnvelopeBytes,
82+ OutputDataLimit: ceilings.StreamChunkLimit,
8383 }
8484 }
8585
@@ -92,8 +92,8 @@ func EncodePreambleResponse(accepted bool, ceilings brokerCeilings, code uint32)
9292 Epoch: wire.ProtocolEpoch,
9393 Version: uint32(protocol.Version),
9494 Role: &wire.PreambleRole{Role: BrokerRoleServer},
95- MaxReceiveEnvelopeBytes: ceilings.maxReceiveEnvelopeBytes,
96- OutputDataLimit: ceilings.streamChunkLimit,
95+ MaxReceiveEnvelopeBytes: ceilings.MaxReceiveEnvelopeBytes,
96+ OutputDataLimit: ceilings.StreamChunkLimit,
9797 Accepted: accepted,
9898 }
9999 if !accepted {
@@ -125,8 +125,8 @@ func DecodePreambleRequest(message *wire.PreambleRequest) (BrokerPreambleRequest
125125 return BrokerPreambleRequest{}, ErrPreambleRejected
126126 }
127127 remote := brokerCeilings{
128- maxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(),
129- streamChunkLimit: message.GetOutputDataLimit(),
128+ MaxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(),
129+ StreamChunkLimit: message.GetOutputDataLimit(),
130130 }
131131 if err := checkBrokerCeilings(remote); err != nil {
132132 return BrokerPreambleRequest{}, err
@@ -160,8 +160,8 @@ func DecodePreambleResponse(message *wire.PreambleResponse) (BrokerPreambleRespo
160160 return BrokerPreambleResponse{Code: message.GetRejection().GetCode()}, ErrPreambleRejected
161161 }
162162 remote := brokerCeilings{
163- maxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(),
164- streamChunkLimit: message.GetOutputDataLimit(),
163+ MaxReceiveEnvelopeBytes: message.GetMaxReceiveEnvelopeBytes(),
164+ StreamChunkLimit: message.GetOutputDataLimit(),
165165 }
166166 if err := checkBrokerCeilings(remote); err != nil {
167167 return BrokerPreambleResponse{}, err
@@ -198,8 +198,8 @@ func RejectionCodeFor(request *wire.PreambleRequest, err error) uint32 {
198198 return RejectionLimitRefused
199199 }
200200 remote := brokerCeilings{
201- maxReceiveEnvelopeBytes: request.GetMaxReceiveEnvelopeBytes(),
202- streamChunkLimit: request.GetOutputDataLimit(),
201+ MaxReceiveEnvelopeBytes: request.GetMaxReceiveEnvelopeBytes(),
202+ StreamChunkLimit: request.GetOutputDataLimit(),
203203 }
204204 if checkBrokerCeilings(remote) != nil {
205205 return RejectionLimitRefused
M internal/adapters/ipc/mux.go +31 -3
@@ -68,10 +68,19 @@ var (
6868 ErrMuxPeerRejected = errors.New("ipc: mux peer failed same-user credential verification")
6969 )
7070
71-// muxPeerVerifier checks one connected AF_UNIX peer's kernel credentials. It
71+// PeerVerifier checks one connected AF_UNIX peer's kernel credentials. It
7272 // returns nil only for a peer proven to be the current effective user. It is a
73-// function type so a test can inject an observable refusal without root.
74-type muxPeerVerifier func(conn *net.UnixConn) error
73+// function type so a caller (or test) can inject an observable refusal without
74+// root; SameUserPeerVerifier returns the platform default.
75+type PeerVerifier func(conn *net.UnixConn) error
76+
77+// muxPeerVerifier is the in-package spelling of PeerVerifier.
78+type muxPeerVerifier = PeerVerifier
79+
80+// SameUserPeerVerifier returns this build's default same-user peer verifier:
81+// Linux SO_PEERCRED admission, or a fail-closed refusal on a platform or
82+// build without it.
83+func SameUserPeerVerifier() PeerVerifier { return verifySameUserUnixPeer }
7584
7685 // MuxListener is the listener half of a private daemonmux Unix carriage. Accept
7786 // returns a raw bounded transport (Send/RecvBounded/Close) ready for the
@@ -113,6 +122,17 @@ func ListenMux(path string, opts ...Option) (MuxListener, error) {
113122 return listenMux(path, verifySameUserUnixPeer, opts...)
114123 }
115124
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+
116136 // listenMux is ListenMux with an injected peer verifier, so a test can observe
117137 // a credential refusal deterministically without root. A nil verifier falls
118138 // back to the platform default.
@@ -168,6 +188,14 @@ func DialMuxContext(ctx context.Context, path string, opts ...Option) (wire.Boun
168188 return dialMux(ctx, path, verifySameUserUnixPeer, opts...)
169189 }
170190
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+
171199 // dialMux is DialMuxContext with an injected peer verifier, so a test can
172200 // observe a credential refusal deterministically without root. A nil verifier
173201 // falls back to the platform default.
M internal/adapters/ipc/mux_test.go +40 -0
@@ -529,3 +529,43 @@ func TestListenMuxConcurrentCloseAndDial(t *testing.T) {
529529 }
530530 }
531531 }
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