detached 7655e2e

feat(client): attach over broker logical streams

A committed picker selection now opens one broker logical stream and runs the
typed session protocol on it through the supervisor's existing foreground
grant. Offline and unactivated: nothing in production composes this path.

- The supervisor resolves the committed key to one exact
  ports.BrokerOpenStreamRequest, opens it on the broker service, and hands the
  returned ports.BrokerLogicalConnection to the attachment worker. The picker
  never opens, writes, or retargets a session, and the supervisor keeps the
  terminal, the single input lifetime, and the close authority.
- One absolute deadline bounds stream open, the preamble, Hello/Welcome, and
  the committed initial publication. Folding in the admitted stream's accepted
  deadline keeps the earlier of the two, so queue time is consumed rather than
  repaid with a second budget, and the deadline is retired at the committed
  publication: attached always implies the handshake budget can no longer
  cancel the run. Expiry is lock-free, so retiring a timer can never deadlock
  against it.
- Attached means committed publication: the initial frame is validated as a
  full first frame with a real context publication, written, flushed, and
  committed through the UI output transaction, and only then is the attached
  transition recorded. Welcome alone never attaches. The UI observation channel
  is not the commit authority, so an unavailable or closed capture does not
  fail a frame that was written, while any other publication error does.
- The committed identity is revalidated against the resolved request before
  anything is written or marked attached, so the peer is never the only
  authority on which session the user committed.
- EOF, refusal, typed post-Welcome errors, deadline expiry, and stream loss
  each end the attempt with a typed event before or after attachment, and
  always return to the picker in the same process. Cancelling a run retires its
  generation: a retired worker cannot publish output, mark attached, or
  consume input.
- Hello carries the terminal environment supplied by composition
  (SupervisorConfig.AttachmentEnvironment); the use case reads no environment.
commit
7655e2e5efc3ba76221a2a5ada68cda6971f1de6
author
brice · · 2026-09-19 10:35 UTC
parent
5d9061b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +2472 -38
A internal/usecase/client/attachment_session.go +380 -0
@@ -0,0 +1,380 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+)
13+
14+// Real attachment worker (Plan 001 P5.3b, offline and unactivated).
15+//
16+// sessionAttachmentWorker drives the existing typed session protocol on the
17+// broker logical stream the supervisor opened for one resolved request. It
18+// sends Hello, awaits Welcome, then awaits and commits the initial full output
19+// publication. Welcome alone is never attachment: the worker marks attached
20+// only after the validated frame was written, flushed, and committed through
21+// the foreground's UI output transaction. After attachment it keeps consuming
22+// output until the stream ends.
23+//
24+// The worker owns no endpoint, route, raw mode, terminal writer, or second
25+// reader. It reaches the terminal only through the supervisor-granted
26+// AttachmentForeground. Terminal input pumping into the session and UI action
27+// admission are deliberately left to P5.4, exactly as the P5.1b host documents,
28+// so this slice adds no path from picker input to a session.
29+
30+var (
31+ // errAttachmentDeadline is the stable local sentinel of an expired
32+ // attachment budget. The supervisor wraps it in a typed timeout.
33+ errAttachmentDeadline = errors.New("client: attachment deadline elapsed")
34+ // errAttachmentNoStream reports a foreground granted without a stream.
35+ errAttachmentNoStream = errors.New("client: attachment stream is missing")
36+ // errAttachmentPublication reports a publication frame that violates the
37+ // output contract before it was written.
38+ errAttachmentPublication = errors.New("client: invalid initial publication")
39+)
40+
41+// AttachmentIdentityError reports a server commitment that disagrees with the
42+// broker request resolved from the selected picker row.
43+type AttachmentIdentityError struct {
44+ Stage string
45+ Want protocol.ExactSessionTarget
46+ Got protocol.ExactSessionTarget
47+ Name string
48+}
49+
50+func (e *AttachmentIdentityError) Error() string {
51+ if e.Name != "" {
52+ return fmt.Sprintf("client: %s committed session name %q, want %q", e.Stage, e.Got.SessionName, e.Name)
53+ }
54+ return fmt.Sprintf("client: %s committed target %+v, want %+v", e.Stage, e.Got, e.Want)
55+}
56+
57+// sessionAttachmentConfig supplies the typed-session inputs of one worker run.
58+// The request is the exact request the supervisor opened the stream with; the
59+// rest is client-local presentation data the worker mirrors into Hello.
60+type sessionAttachmentConfig struct {
61+ Request ports.BrokerOpenStreamRequest
62+ ClientID [16]byte
63+ Geometry domain.Geometry
64+ TermEnv string
65+ Cwd string
66+ TrueColor bool
67+ // BeforeAttached runs after the initial frame commits but before attached
68+ // presentation becomes observable. A failure keeps the run unattached.
69+ BeforeAttached func() error
70+}
71+
72+// sessionAttachmentWorker implements AttachmentWorker for one broker logical
73+// stream. It holds no stream or terminal of its own: everything arrives through
74+// Run's foreground for the duration of one run.
75+type sessionAttachmentWorker struct {
76+ cfg sessionAttachmentConfig
77+}
78+
79+// newSessionAttachmentWorker builds the real typed-session attachment worker.
80+func newSessionAttachmentWorker(cfg sessionAttachmentConfig) *sessionAttachmentWorker {
81+ return &sessionAttachmentWorker{cfg: cfg}
82+}
83+
84+// serverReceive is one server message or the terminal receive error.
85+type serverReceive struct {
86+ message protocol.ServerMessage
87+ err error
88+}
89+
90+// startServerReceive owns the single reader of the logical stream. It is
91+// bounded: it buffers one message and blocks on the next read, and the stream's
92+// Close contract unblocks the read. The returned stop channel releases the
93+// reader when the run ends, so a message the worker no longer reads can never
94+// pin it.
95+func startServerReceive(stream ports.BrokerLogicalConnection) (<-chan serverReceive, chan struct{}) {
96+ out := make(chan serverReceive, 1)
97+ stop := make(chan struct{})
98+ go func() {
99+ for {
100+ message, err := stream.ReceiveServer()
101+ select {
102+ case out <- serverReceive{message: message, err: err}:
103+ case <-stop:
104+ return
105+ }
106+ if err != nil {
107+ return
108+ }
109+ }
110+ }()
111+ return out, stop
112+}
113+
114+// Run drives Hello -> Welcome -> committed initial publication -> attached
115+// output until the stream ends or the context/deadline, the supervisor finalize
116+// signal, or the stream itself stops it. Every return path is a typed event;
117+// the supervisor stamps the granted token onto it.
118+func (w *sessionAttachmentWorker) Run(ctx context.Context, fg AttachmentForeground) AttachmentEvent {
119+ token := fg.Token()
120+ if ctx == nil {
121+ ctx = context.Background()
122+ }
123+ stream := fg.Stream()
124+ if supervisorNil(stream) {
125+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentNoStream}
126+ }
127+ incoming, stopReceive := startServerReceive(stream)
128+ defer close(stopReceive)
129+
130+ if err := w.sendHello(ctx, fg, stream); err != nil {
131+ return w.settle(ctx, fg, stream, token, err)
132+ }
133+
134+ welcome, err := w.awaitServer(ctx, fg, incoming)
135+ if err != nil {
136+ return w.settle(ctx, fg, stream, token, err)
137+ }
138+ switch message := welcome.(type) {
139+ case protocol.Welcome:
140+ // Welcome is a promise, not attachment: the daemon still owes the
141+ // committed initial publication.
142+ if err := w.validateWelcome(message); err != nil {
143+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}
144+ }
145+ case protocol.ErrorMsg:
146+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: message.Code, Text: message.Text}}
147+ default:
148+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: unexpected %T before welcome", welcome)}
149+ }
150+
151+ state, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming)
152+ if event != nil {
153+ return *event
154+ }
155+
156+ return w.pumpAttached(ctx, fg, stream, token, state, incoming)
157+}
158+
159+// awaitInitialPublication consumes server messages until the first valid full
160+// output frame is committed. It returns the output state to continue with, or
161+// a typed terminal event.
162+func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, incoming <-chan serverReceive) (outputApplyState, *AttachmentEvent) {
163+ state := outputApplyState{}
164+ for {
165+ message, err := w.awaitServer(ctx, fg, incoming)
166+ if err != nil {
167+ event := w.settle(ctx, fg, stream, token, err)
168+ return state, &event
169+ }
170+ switch typed := message.(type) {
171+ case protocol.Output:
172+ next, ok := state.next(typed)
173+ if !ok {
174+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentPublication}
175+ return state, &event
176+ }
177+ if err := w.validatePublication(typed); err != nil {
178+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}
179+ return state, &event
180+ }
181+ state = next
182+ if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusTransitioning), typed.Data); err != nil {
183+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing initial output: %w", err)}
184+ return state, &event
185+ }
186+ // Attachment is the committed publication. Complete the deadline
187+ // boundary before presenting attached, so attached always implies the
188+ // handshake budget can no longer cancel the run.
189+ if w.cfg.BeforeAttached != nil {
190+ if err := w.cfg.BeforeAttached(); err != nil {
191+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}
192+ return state, &event
193+ }
194+ }
195+ // A refused marker means the grant was revoked underneath us, so the
196+ // run ends as a failure.
197+ if !fg.MarkAttached() {
198+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentForegroundRevoked}
199+ return state, &event
200+ }
201+ return state, nil
202+ case protocol.ErrorMsg:
203+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}
204+ return state, &event
205+ case protocol.Detached:
206+ // The daemon ended the attempt before publishing anything: it
207+ // never attached.
208+ event := AttachmentEvent{Token: token, Kind: AttachmentEventEnded}
209+ return state, &event
210+ default:
211+ // Everything else the daemon may publish before the initial frame
212+ // stays outside this slice's scope and is ignored.
213+ }
214+ }
215+}
216+
217+// pumpAttached publishes accepted output after attachment until the stream
218+// ends. It never unmarks attachment: a stream failure ends the run as
219+// lost/ended, so the supervisor returns to the picker and never keeps the
220+// attached presentation showing.
221+func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent {
222+ for {
223+ message, err := w.awaitServer(ctx, fg, incoming)
224+ if err != nil {
225+ return w.settle(ctx, fg, stream, token, err)
226+ }
227+ switch typed := message.(type) {
228+ case protocol.Output:
229+ next, ok := state.next(typed)
230+ if !ok {
231+ // A frame outside the output chain after attachment is ignored
232+ // rather than replaying stale cells.
233+ continue
234+ }
235+ state = next
236+ if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusAttached), typed.Data); err != nil {
237+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
238+ }
239+ case protocol.ErrorMsg:
240+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}
241+ case protocol.Detached:
242+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded}
243+ default:
244+ // Messages outside the attached output flow are consumed and
245+ // ignored until the stream ends; this slice adds no other handler.
246+ }
247+ }
248+}
249+
250+func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) error {
251+ if welcome.CommittedIdentity != nil {
252+ return w.validateIdentity("welcome", welcome.CommittedIdentity.Target)
253+ }
254+ if w.cfg.Request.Admission == ports.BrokerAdmissionCreateNamed && welcome.SessionName != w.cfg.Request.Name {
255+ return &AttachmentIdentityError{Stage: "welcome", Got: protocol.ExactSessionTarget{SessionName: welcome.SessionName}, Name: w.cfg.Request.Name}
256+ }
257+ return nil
258+}
259+
260+func (w *sessionAttachmentWorker) validatePublication(output protocol.Output) error {
261+ if output.Context == nil {
262+ return errAttachmentPublication
263+ }
264+ return w.validateIdentity("initial publication", output.Context.Route.Target)
265+}
266+
267+func (w *sessionAttachmentWorker) validateIdentity(stage string, got protocol.ExactSessionTarget) error {
268+ request := w.cfg.Request
269+ switch request.Admission {
270+ case ports.BrokerAdmissionExact:
271+ if got != request.Target {
272+ return &AttachmentIdentityError{Stage: stage, Want: request.Target, Got: got}
273+ }
274+ case ports.BrokerAdmissionCreateNamed:
275+ if got.SessionName != request.Name {
276+ return &AttachmentIdentityError{Stage: stage, Got: got, Name: request.Name}
277+ }
278+ }
279+ return nil
280+}
281+
282+// sendHello sends the typed Hello derived from the exact opened request.
283+func (w *sessionAttachmentWorker) sendHello(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection) error {
284+ return w.send(ctx, fg, stream, w.hello(stream))
285+}
286+
287+// hello builds the Hello the daemon expects for exactly one opened request. No
288+// endpoint, route, or resume token is invented: an exact request attaches, a
289+// named request creates, and an ephemeral request is ephemeral.
290+func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) protocol.Hello {
291+ request := w.cfg.Request
292+ geometry := w.cfg.Geometry.NormalizePixels()
293+ hello := protocol.Hello{
294+ Version: protocol.Version,
295+ Intent: protocol.IntentAttach,
296+ ClientID: w.cfg.ClientID,
297+ Size: geometry.Size,
298+ PixelWidth: geometry.PixelWidth,
299+ PixelHeight: geometry.PixelHeight,
300+ TermEnv: w.cfg.TermEnv,
301+ Cwd: w.cfg.Cwd,
302+ TrueColor: w.cfg.TrueColor,
303+ MaxOutputInFlight: requestedOutputWindow(stream),
304+ Env: append([]string(nil), request.Env...),
305+ EnvironmentPolicy: request.Policy.EnvironmentPolicy,
306+ Remote: !request.Local,
307+ }
308+ switch request.Admission {
309+ case ports.BrokerAdmissionExact:
310+ target := request.Target
311+ hello.Intent = protocol.IntentAttach
312+ hello.ExactTarget = &target
313+ case ports.BrokerAdmissionCreateNamed:
314+ hello.Intent = protocol.IntentNew
315+ hello.Name = request.Name
316+ case ports.BrokerAdmissionCreateEphemeral:
317+ hello.Intent = protocol.IntentEphemeral
318+ }
319+ return hello
320+}
321+
322+// send sends one client message while the run is still live. The send runs on
323+// its own goroutine so a cancellation or finalize wakes the worker without
324+// waiting for the stream's Close, which is the supervisor's close authority.
325+func (w *sessionAttachmentWorker) send(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, message protocol.ClientMessage) error {
326+ sent := make(chan error, 1)
327+ go func() { sent <- stream.SendClient(message) }()
328+ select {
329+ case err := <-sent:
330+ return err
331+ case <-ctx.Done():
332+ return ctx.Err()
333+ case <-fg.Done():
334+ return errAttachmentForegroundRevoked
335+ }
336+}
337+
338+// awaitServer waits for the next server message while the run is live.
339+func (w *sessionAttachmentWorker) awaitServer(ctx context.Context, fg AttachmentForeground, incoming <-chan serverReceive) (protocol.ServerMessage, error) {
340+ select {
341+ case result := <-incoming:
342+ if result.err != nil {
343+ return nil, result.err
344+ }
345+ return result.message, nil
346+ case <-ctx.Done():
347+ return nil, ctx.Err()
348+ case <-fg.Done():
349+ return nil, errAttachmentForegroundRevoked
350+ }
351+}
352+
353+// settle classifies a stopping cause into the correct typed event. A cancelled
354+// context is the supervisor's own deadline/teardown, a published stream cause
355+// is a loss, a bare EOF is an orderly end, and anything else is a protocol or
356+// application failure on the stream.
357+func (w *sessionAttachmentWorker) settle(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, err error) AttachmentEvent {
358+ if ctxErr := ctx.Err(); ctxErr != nil {
359+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errors.Join(ctxErr, err)}
360+ }
361+ if cause := streamErr(stream); cause != nil {
362+ return AttachmentEvent{Token: token, Kind: AttachmentEventLost, Err: cause}
363+ }
364+ if errors.Is(err, io.EOF) {
365+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: err}
366+ }
367+ if err != nil {
368+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}
369+ }
370+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded}
371+}
372+
373+// streamErr reads the logical stream's stable terminal cause without treating a
374+// nil stream as a failure.
375+func streamErr(stream ports.BrokerLogicalConnection) error {
376+ if supervisorNil(stream) {
377+ return nil
378+ }
379+ return stream.Err()
380+}
@@ -0,0 +1,380 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+)
13+
14+// Real attachment worker (Plan 001 P5.3b, offline and unactivated).
15+//
16+// sessionAttachmentWorker drives the existing typed session protocol on the
17+// broker logical stream the supervisor opened for one resolved request. It
18+// sends Hello, awaits Welcome, then awaits and commits the initial full output
19+// publication. Welcome alone is never attachment: the worker marks attached
20+// only after the validated frame was written, flushed, and committed through
21+// the foreground's UI output transaction. After attachment it keeps consuming
22+// output until the stream ends.
23+//
24+// The worker owns no endpoint, route, raw mode, terminal writer, or second
25+// reader. It reaches the terminal only through the supervisor-granted
26+// AttachmentForeground. Terminal input pumping into the session and UI action
27+// admission are deliberately left to P5.4, exactly as the P5.1b host documents,
28+// so this slice adds no path from picker input to a session.
29+
30+var (
31+ // errAttachmentDeadline is the stable local sentinel of an expired
32+ // attachment budget. The supervisor wraps it in a typed timeout.
33+ errAttachmentDeadline = errors.New("client: attachment deadline elapsed")
34+ // errAttachmentNoStream reports a foreground granted without a stream.
35+ errAttachmentNoStream = errors.New("client: attachment stream is missing")
36+ // errAttachmentPublication reports a publication frame that violates the
37+ // output contract before it was written.
38+ errAttachmentPublication = errors.New("client: invalid initial publication")
39+)
40+
41+// AttachmentIdentityError reports a server commitment that disagrees with the
42+// broker request resolved from the selected picker row.
43+type AttachmentIdentityError struct {
44+ Stage string
45+ Want protocol.ExactSessionTarget
46+ Got protocol.ExactSessionTarget
47+ Name string
48+}
49+
50+func (e *AttachmentIdentityError) Error() string {
51+ if e.Name != "" {
52+ return fmt.Sprintf("client: %s committed session name %q, want %q", e.Stage, e.Got.SessionName, e.Name)
53+ }
54+ return fmt.Sprintf("client: %s committed target %+v, want %+v", e.Stage, e.Got, e.Want)
55+}
56+
57+// sessionAttachmentConfig supplies the typed-session inputs of one worker run.
58+// The request is the exact request the supervisor opened the stream with; the
59+// rest is client-local presentation data the worker mirrors into Hello.
60+type sessionAttachmentConfig struct {
61+ Request ports.BrokerOpenStreamRequest
62+ ClientID [16]byte
63+ Geometry domain.Geometry
64+ TermEnv string
65+ Cwd string
66+ TrueColor bool
67+ // BeforeAttached runs after the initial frame commits but before attached
68+ // presentation becomes observable. A failure keeps the run unattached.
69+ BeforeAttached func() error
70+}
71+
72+// sessionAttachmentWorker implements AttachmentWorker for one broker logical
73+// stream. It holds no stream or terminal of its own: everything arrives through
74+// Run's foreground for the duration of one run.
75+type sessionAttachmentWorker struct {
76+ cfg sessionAttachmentConfig
77+}
78+
79+// newSessionAttachmentWorker builds the real typed-session attachment worker.
80+func newSessionAttachmentWorker(cfg sessionAttachmentConfig) *sessionAttachmentWorker {
81+ return &sessionAttachmentWorker{cfg: cfg}
82+}
83+
84+// serverReceive is one server message or the terminal receive error.
85+type serverReceive struct {
86+ message protocol.ServerMessage
87+ err error
88+}
89+
90+// startServerReceive owns the single reader of the logical stream. It is
91+// bounded: it buffers one message and blocks on the next read, and the stream's
92+// Close contract unblocks the read. The returned stop channel releases the
93+// reader when the run ends, so a message the worker no longer reads can never
94+// pin it.
95+func startServerReceive(stream ports.BrokerLogicalConnection) (<-chan serverReceive, chan struct{}) {
96+ out := make(chan serverReceive, 1)
97+ stop := make(chan struct{})
98+ go func() {
99+ for {
100+ message, err := stream.ReceiveServer()
101+ select {
102+ case out <- serverReceive{message: message, err: err}:
103+ case <-stop:
104+ return
105+ }
106+ if err != nil {
107+ return
108+ }
109+ }
110+ }()
111+ return out, stop
112+}
113+
114+// Run drives Hello -> Welcome -> committed initial publication -> attached
115+// output until the stream ends or the context/deadline, the supervisor finalize
116+// signal, or the stream itself stops it. Every return path is a typed event;
117+// the supervisor stamps the granted token onto it.
118+func (w *sessionAttachmentWorker) Run(ctx context.Context, fg AttachmentForeground) AttachmentEvent {
119+ token := fg.Token()
120+ if ctx == nil {
121+ ctx = context.Background()
122+ }
123+ stream := fg.Stream()
124+ if supervisorNil(stream) {
125+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentNoStream}
126+ }
127+ incoming, stopReceive := startServerReceive(stream)
128+ defer close(stopReceive)
129+
130+ if err := w.sendHello(ctx, fg, stream); err != nil {
131+ return w.settle(ctx, fg, stream, token, err)
132+ }
133+
134+ welcome, err := w.awaitServer(ctx, fg, incoming)
135+ if err != nil {
136+ return w.settle(ctx, fg, stream, token, err)
137+ }
138+ switch message := welcome.(type) {
139+ case protocol.Welcome:
140+ // Welcome is a promise, not attachment: the daemon still owes the
141+ // committed initial publication.
142+ if err := w.validateWelcome(message); err != nil {
143+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}
144+ }
145+ case protocol.ErrorMsg:
146+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: message.Code, Text: message.Text}}
147+ default:
148+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: unexpected %T before welcome", welcome)}
149+ }
150+
151+ state, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming)
152+ if event != nil {
153+ return *event
154+ }
155+
156+ return w.pumpAttached(ctx, fg, stream, token, state, incoming)
157+}
158+
159+// awaitInitialPublication consumes server messages until the first valid full
160+// output frame is committed. It returns the output state to continue with, or
161+// a typed terminal event.
162+func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, incoming <-chan serverReceive) (outputApplyState, *AttachmentEvent) {
163+ state := outputApplyState{}
164+ for {
165+ message, err := w.awaitServer(ctx, fg, incoming)
166+ if err != nil {
167+ event := w.settle(ctx, fg, stream, token, err)
168+ return state, &event
169+ }
170+ switch typed := message.(type) {
171+ case protocol.Output:
172+ next, ok := state.next(typed)
173+ if !ok {
174+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentPublication}
175+ return state, &event
176+ }
177+ if err := w.validatePublication(typed); err != nil {
178+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}
179+ return state, &event
180+ }
181+ state = next
182+ if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusTransitioning), typed.Data); err != nil {
183+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing initial output: %w", err)}
184+ return state, &event
185+ }
186+ // Attachment is the committed publication. Complete the deadline
187+ // boundary before presenting attached, so attached always implies the
188+ // handshake budget can no longer cancel the run.
189+ if w.cfg.BeforeAttached != nil {
190+ if err := w.cfg.BeforeAttached(); err != nil {
191+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}
192+ return state, &event
193+ }
194+ }
195+ // A refused marker means the grant was revoked underneath us, so the
196+ // run ends as a failure.
197+ if !fg.MarkAttached() {
198+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errAttachmentForegroundRevoked}
199+ return state, &event
200+ }
201+ return state, nil
202+ case protocol.ErrorMsg:
203+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}
204+ return state, &event
205+ case protocol.Detached:
206+ // The daemon ended the attempt before publishing anything: it
207+ // never attached.
208+ event := AttachmentEvent{Token: token, Kind: AttachmentEventEnded}
209+ return state, &event
210+ default:
211+ // Everything else the daemon may publish before the initial frame
212+ // stays outside this slice's scope and is ignored.
213+ }
214+ }
215+}
216+
217+// pumpAttached publishes accepted output after attachment until the stream
218+// ends. It never unmarks attachment: a stream failure ends the run as
219+// lost/ended, so the supervisor returns to the picker and never keeps the
220+// attached presentation showing.
221+func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent {
222+ for {
223+ message, err := w.awaitServer(ctx, fg, incoming)
224+ if err != nil {
225+ return w.settle(ctx, fg, stream, token, err)
226+ }
227+ switch typed := message.(type) {
228+ case protocol.Output:
229+ next, ok := state.next(typed)
230+ if !ok {
231+ // A frame outside the output chain after attachment is ignored
232+ // rather than replaying stale cells.
233+ continue
234+ }
235+ state = next
236+ if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusAttached), typed.Data); err != nil {
237+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
238+ }
239+ case protocol.ErrorMsg:
240+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}
241+ case protocol.Detached:
242+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded}
243+ default:
244+ // Messages outside the attached output flow are consumed and
245+ // ignored until the stream ends; this slice adds no other handler.
246+ }
247+ }
248+}
249+
250+func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) error {
251+ if welcome.CommittedIdentity != nil {
252+ return w.validateIdentity("welcome", welcome.CommittedIdentity.Target)
253+ }
254+ if w.cfg.Request.Admission == ports.BrokerAdmissionCreateNamed && welcome.SessionName != w.cfg.Request.Name {
255+ return &AttachmentIdentityError{Stage: "welcome", Got: protocol.ExactSessionTarget{SessionName: welcome.SessionName}, Name: w.cfg.Request.Name}
256+ }
257+ return nil
258+}
259+
260+func (w *sessionAttachmentWorker) validatePublication(output protocol.Output) error {
261+ if output.Context == nil {
262+ return errAttachmentPublication
263+ }
264+ return w.validateIdentity("initial publication", output.Context.Route.Target)
265+}
266+
267+func (w *sessionAttachmentWorker) validateIdentity(stage string, got protocol.ExactSessionTarget) error {
268+ request := w.cfg.Request
269+ switch request.Admission {
270+ case ports.BrokerAdmissionExact:
271+ if got != request.Target {
272+ return &AttachmentIdentityError{Stage: stage, Want: request.Target, Got: got}
273+ }
274+ case ports.BrokerAdmissionCreateNamed:
275+ if got.SessionName != request.Name {
276+ return &AttachmentIdentityError{Stage: stage, Got: got, Name: request.Name}
277+ }
278+ }
279+ return nil
280+}
281+
282+// sendHello sends the typed Hello derived from the exact opened request.
283+func (w *sessionAttachmentWorker) sendHello(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection) error {
284+ return w.send(ctx, fg, stream, w.hello(stream))
285+}
286+
287+// hello builds the Hello the daemon expects for exactly one opened request. No
288+// endpoint, route, or resume token is invented: an exact request attaches, a
289+// named request creates, and an ephemeral request is ephemeral.
290+func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) protocol.Hello {
291+ request := w.cfg.Request
292+ geometry := w.cfg.Geometry.NormalizePixels()
293+ hello := protocol.Hello{
294+ Version: protocol.Version,
295+ Intent: protocol.IntentAttach,
296+ ClientID: w.cfg.ClientID,
297+ Size: geometry.Size,
298+ PixelWidth: geometry.PixelWidth,
299+ PixelHeight: geometry.PixelHeight,
300+ TermEnv: w.cfg.TermEnv,
301+ Cwd: w.cfg.Cwd,
302+ TrueColor: w.cfg.TrueColor,
303+ MaxOutputInFlight: requestedOutputWindow(stream),
304+ Env: append([]string(nil), request.Env...),
305+ EnvironmentPolicy: request.Policy.EnvironmentPolicy,
306+ Remote: !request.Local,
307+ }
308+ switch request.Admission {
309+ case ports.BrokerAdmissionExact:
310+ target := request.Target
311+ hello.Intent = protocol.IntentAttach
312+ hello.ExactTarget = &target
313+ case ports.BrokerAdmissionCreateNamed:
314+ hello.Intent = protocol.IntentNew
315+ hello.Name = request.Name
316+ case ports.BrokerAdmissionCreateEphemeral:
317+ hello.Intent = protocol.IntentEphemeral
318+ }
319+ return hello
320+}
321+
322+// send sends one client message while the run is still live. The send runs on
323+// its own goroutine so a cancellation or finalize wakes the worker without
324+// waiting for the stream's Close, which is the supervisor's close authority.
325+func (w *sessionAttachmentWorker) send(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, message protocol.ClientMessage) error {
326+ sent := make(chan error, 1)
327+ go func() { sent <- stream.SendClient(message) }()
328+ select {
329+ case err := <-sent:
330+ return err
331+ case <-ctx.Done():
332+ return ctx.Err()
333+ case <-fg.Done():
334+ return errAttachmentForegroundRevoked
335+ }
336+}
337+
338+// awaitServer waits for the next server message while the run is live.
339+func (w *sessionAttachmentWorker) awaitServer(ctx context.Context, fg AttachmentForeground, incoming <-chan serverReceive) (protocol.ServerMessage, error) {
340+ select {
341+ case result := <-incoming:
342+ if result.err != nil {
343+ return nil, result.err
344+ }
345+ return result.message, nil
346+ case <-ctx.Done():
347+ return nil, ctx.Err()
348+ case <-fg.Done():
349+ return nil, errAttachmentForegroundRevoked
350+ }
351+}
352+
353+// settle classifies a stopping cause into the correct typed event. A cancelled
354+// context is the supervisor's own deadline/teardown, a published stream cause
355+// is a loss, a bare EOF is an orderly end, and anything else is a protocol or
356+// application failure on the stream.
357+func (w *sessionAttachmentWorker) settle(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, err error) AttachmentEvent {
358+ if ctxErr := ctx.Err(); ctxErr != nil {
359+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: errors.Join(ctxErr, err)}
360+ }
361+ if cause := streamErr(stream); cause != nil {
362+ return AttachmentEvent{Token: token, Kind: AttachmentEventLost, Err: cause}
363+ }
364+ if errors.Is(err, io.EOF) {
365+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: err}
366+ }
367+ if err != nil {
368+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: err}
369+ }
370+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded}
371+}
372+
373+// streamErr reads the logical stream's stable terminal cause without treating a
374+// nil stream as a failure.
375+func streamErr(stream ports.BrokerLogicalConnection) error {
376+ if supervisorNil(stream) {
377+ return nil
378+ }
379+ return stream.Err()
380+}
A internal/usecase/client/attachment_session_test.go +608 -0
@@ -0,0 +1,608 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/protocol"
17+)
18+
19+// Real attachment worker tests (Plan 001 P5.3b). They are deterministic and
20+// drive the typed session protocol directly: no wall-clock sleeps, no real
21+// terminal, and no broker process.
22+
23+var errSessionTestClosed = errors.New("session test stream is closed")
24+
25+// sessionTestStream is one scripted broker logical connection. It records every
26+// typed client message, delivers scripted server messages, and unblocks
27+// ReceiveServer on Close exactly as the ports.BrokerLogicalConnection contract
28+// requires.
29+type sessionTestStream struct {
30+ mu sync.Mutex
31+ sent []protocol.ClientMessage
32+ incoming chan protocol.ServerMessage
33+ closed chan struct{}
34+ closeOnce sync.Once
35+ err error
36+}
37+
38+func newSessionTestStream() *sessionTestStream {
39+ return &sessionTestStream{
40+ incoming: make(chan protocol.ServerMessage, 8),
41+ closed: make(chan struct{}),
42+ }
43+}
44+
45+func (s *sessionTestStream) SendClient(message protocol.ClientMessage) error {
46+ select {
47+ case <-s.closed:
48+ return errSessionTestClosed
49+ default:
50+ }
51+ s.mu.Lock()
52+ s.sent = append(s.sent, message)
53+ s.mu.Unlock()
54+ return nil
55+}
56+
57+func (s *sessionTestStream) ReceiveServer() (protocol.ServerMessage, error) {
58+ select {
59+ case message := <-s.incoming:
60+ return message, nil
61+ case <-s.closed:
62+ s.mu.Lock()
63+ err := s.err
64+ s.mu.Unlock()
65+ if err != nil {
66+ return nil, errors.Join(io.EOF, err)
67+ }
68+ return nil, io.EOF
69+ }
70+}
71+
72+func (s *sessionTestStream) Capabilities() protocol.ConnectionCapabilities {
73+ return protocol.ConnectionCapabilities{}
74+}
75+
76+func (s *sessionTestStream) LinkState() ports.LinkState { return ports.LinkStateConnected }
77+
78+func (s *sessionTestStream) LinkEvents() <-chan ports.LinkEvent { return nil }
79+
80+func (s *sessionTestStream) Close() error {
81+ s.closeOnce.Do(func() { close(s.closed) })
82+ return nil
83+}
84+
85+func (s *sessionTestStream) Done() <-chan struct{} { return s.closed }
86+
87+func (s *sessionTestStream) Err() error {
88+ s.mu.Lock()
89+ defer s.mu.Unlock()
90+ return s.err
91+}
92+
93+func (s *sessionTestStream) deliver(message protocol.ServerMessage) {
94+ select {
95+ case s.incoming <- message:
96+ case <-s.closed:
97+ }
98+}
99+
100+// fail publishes the stable stream cause and closes, exactly as a transported
101+// logical stream does on loss.
102+func (s *sessionTestStream) fail(err error) {
103+ s.mu.Lock()
104+ if s.err == nil {
105+ s.err = err
106+ }
107+ s.mu.Unlock()
108+ _ = s.Close()
109+}
110+
111+func (s *sessionTestStream) closedNow() bool {
112+ select {
113+ case <-s.closed:
114+ return true
115+ default:
116+ return false
117+ }
118+}
119+
120+func (s *sessionTestStream) messages() []protocol.ClientMessage {
121+ s.mu.Lock()
122+ defer s.mu.Unlock()
123+ return append([]protocol.ClientMessage(nil), s.sent...)
124+}
125+
126+// sessionTestStreamWithDeadline additionally implements
127+// ports.HandshakeDeadlineProvider, so the supervisor-level deadline tests can
128+// prove an accepted absolute deadline is adopted verbatim.
129+type sessionTestStreamWithDeadline struct {
130+ *sessionTestStream
131+ deadline time.Time
132+}
133+
134+func (s *sessionTestStreamWithDeadline) HandshakeDeadline() time.Time { return s.deadline }
135+
136+func sessionTestOutput(publication uint64, data string) protocol.Output {
137+ return protocol.Output{
138+ Epoch: 1, New: 1, Full: true,
139+ Size: domain.Size{Cols: 80, Rows: 24},
140+ Context: &protocol.ViewContext{
141+ Publication: publication,
142+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{
143+ LifecycleID: domain.SessionLifecycleID{1},
144+ SessionName: "alpha",
145+ }},
146+ TabID: "t_abc123",
147+ FocusedPaneID: "p_def456",
148+ },
149+ Data: []byte(data),
150+ }
151+}
152+
153+func sessionTestRequest(local bool) ports.BrokerOpenStreamRequest {
154+ request := ports.BrokerOpenStreamRequest{
155+ Epoch: 3,
156+ Purpose: ports.BrokerStreamAttachment,
157+ Admission: ports.BrokerAdmissionExact,
158+ Local: local,
159+ Target: protocol.ExactSessionTarget{
160+ LifecycleID: domain.SessionLifecycleID{1},
161+ SessionName: "alpha",
162+ },
163+ Policy: ports.BrokerPolicy{
164+ ProtocolVersion: protocol.Version,
165+ CatalogSchemaVersion: 1,
166+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
167+ Transport: "transport",
168+ Trust: "trust",
169+ Launch: "launch",
170+ Isolation: "isolation",
171+ },
172+ }
173+ if !local {
174+ request.Endpoint = "user@example"
175+ request.Registration = domain.RemoteRegistration{
176+ Endpoint: "user@example",
177+ Incarnation: [16]byte{1},
178+ Generation: 1,
179+ }
180+ }
181+ return request
182+}
183+
184+func sessionTestWorkerConfig(request ports.BrokerOpenStreamRequest) sessionAttachmentConfig {
185+ return sessionAttachmentConfig{
186+ Request: request,
187+ ClientID: [16]byte{0xAB},
188+ Geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}},
189+ }
190+}
191+
192+// sessionTestAttachments records every successful MarkAttached without
193+// draining: a wait observes the transition but never consumes it, so the test
194+// can assert attachment after a script already synchronized on it.
195+type sessionTestAttachments struct {
196+ count atomic.Int64
197+ tokens chan AttachmentToken
198+}
199+
200+func newSessionTestAttachments() *sessionTestAttachments {
201+ return &sessionTestAttachments{tokens: make(chan AttachmentToken, 8)}
202+}
203+
204+func (a *sessionTestAttachments) record(token AttachmentToken) {
205+ a.count.Add(1)
206+ select {
207+ case a.tokens <- token:
208+ default:
209+ }
210+}
211+
212+func (a *sessionTestAttachments) attached() bool { return a.count.Load() > 0 }
213+
214+func (a *sessionTestAttachments) token(t *testing.T) AttachmentToken {
215+ t.Helper()
216+ select {
217+ case token := <-a.tokens:
218+ return token
219+ case <-time.After(5 * time.Second):
220+ t.Fatal("the worker never attached")
221+ return AttachmentToken{}
222+ }
223+}
224+
225+// sessionTestRun boots one worker through the real foreground host.
226+func sessionTestRun(t *testing.T, stream ports.BrokerLogicalConnection, terminal *workerTestTerminal, cfg sessionAttachmentConfig, attached *sessionTestAttachments) <-chan AttachmentEvent {
227+ t.Helper()
228+ host := newWorkerTestHost(terminal, nil, attached.record)
229+ worker := newSessionAttachmentWorker(cfg)
230+ events := make(chan AttachmentEvent, 1)
231+ go func() {
232+ event, _ := host.Run(context.Background(), AttachmentToken{Generation: 4, Attempt: 1}, worker, stream)
233+ events <- event
234+ }()
235+ return events
236+}
237+
238+// awaitHello blocks until the worker sent its Hello.
239+func awaitHello(t *testing.T, stream *sessionTestStream) protocol.Hello {
240+ t.Helper()
241+ deadline := time.After(5 * time.Second)
242+ for {
243+ for _, message := range stream.messages() {
244+ if hello, ok := message.(protocol.Hello); ok {
245+ return hello
246+ }
247+ }
248+ select {
249+ case <-deadline:
250+ t.Fatal("the worker never sent Hello")
251+ return protocol.Hello{}
252+ case <-time.After(time.Millisecond):
253+ }
254+ }
255+}
256+
257+// TestSessionAttachmentWorkerStaysConnectingUntilPublication proves Welcome is
258+// never attachment: only a validated, committed initial publication marks
259+// attached, and the frame is written and flushed through the UI transaction.
260+func TestSessionAttachmentWorkerStaysConnectingUntilPublication(t *testing.T) {
261+ stream := newSessionTestStream()
262+ terminal := newWorkerTestTerminal()
263+ attached := newSessionTestAttachments()
264+ events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attached)
265+
266+ hello := awaitHello(t, stream)
267+ require.Equal(t, protocol.Version, hello.Version)
268+ require.Equal(t, protocol.IntentAttach, hello.Intent)
269+ require.NotNil(t, hello.ExactTarget)
270+ require.False(t, hello.Remote)
271+
272+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
273+ // Welcome alone must not attach, write, or publish.
274+ require.Never(t, attached.attached, 20*time.Millisecond, 2*time.Millisecond, "Welcome alone must never attach")
275+ require.Zero(t, terminal.flushCount(), "Welcome alone writes nothing")
276+ require.Empty(t, terminal.publications(), "Welcome alone publishes nothing")
277+
278+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
279+ require.Equal(t, AttachmentToken{Generation: 4, Attempt: 1}, attached.token(t))
280+ require.Equal(t, "\x1b[Hready", terminal.written())
281+ require.Equal(t, 1, terminal.flushCount())
282+ require.Len(t, terminal.publications(), 1)
283+ require.Equal(t, ports.UIStatusTransitioning, terminal.publications()[0].Status)
284+
285+ require.NoError(t, stream.Close())
286+ select {
287+ case event := <-events:
288+ require.Equal(t, AttachmentEventEnded, event.Kind)
289+ require.Equal(t, AttachmentToken{Generation: 4, Attempt: 1}, event.Token)
290+ case <-time.After(5 * time.Second):
291+ t.Fatal("the worker never settled after the stream closed")
292+ }
293+}
294+
295+// TestSessionAttachmentWorkerFailureStages is the EOF/refusal coverage table:
296+// before Welcome, after Welcome but before publication, and after attachment,
297+// plus a protocol refusal and a publication failure.
298+func TestSessionAttachmentWorkerFailureStages(t *testing.T) {
299+ lossErr := ports.BrokerStreamLost{
300+ Connection: ports.BrokerConnectionID{1},
301+ Stream: 1,
302+ Epoch: 3,
303+ Cause: domain.RemoteFailureTransport,
304+ Err: errors.New("transport lost"),
305+ }
306+ publishErr := errors.New("ui publication failed")
307+
308+ tests := []struct {
309+ name string
310+ script func(stream *sessionTestStream, terminal *workerTestTerminal, attached *sessionTestAttachments)
311+ wantKind AttachmentEventKind
312+ wantAttached bool
313+ wantErr error
314+ wantProtocolCode uint16
315+ }{
316+ {
317+ name: "eof_before_welcome",
318+ script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) { _ = stream.Close() },
319+ wantKind: AttachmentEventEnded,
320+ },
321+ {
322+ name: "refusal_before_welcome",
323+ script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) {
324+ stream.deliver(protocol.ErrorMsg{Code: protocol.ErrNoSuchSession, Text: "no such session"})
325+ },
326+ wantKind: AttachmentEventFailed,
327+ wantProtocolCode: protocol.ErrNoSuchSession,
328+ },
329+ {
330+ name: "unexpected_before_welcome",
331+ script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) {
332+ stream.deliver(protocol.Sessions{})
333+ },
334+ wantKind: AttachmentEventFailed,
335+ },
336+ {
337+ name: "eof_after_welcome_before_publication",
338+ script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) {
339+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
340+ _ = stream.Close()
341+ },
342+ wantKind: AttachmentEventEnded,
343+ },
344+ {
345+ name: "invalid_initial_publication",
346+ script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) {
347+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
348+ // A full frame with no context never commits.
349+ stream.deliver(protocol.Output{Epoch: 1, New: 1, Full: true})
350+ },
351+ wantKind: AttachmentEventFailed,
352+ wantErr: errAttachmentPublication,
353+ },
354+ {
355+ name: "publication_failure_before_attachment",
356+ script: func(stream *sessionTestStream, terminal *workerTestTerminal, _ *sessionTestAttachments) {
357+ terminal.publishErr = publishErr
358+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
359+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
360+ },
361+ wantKind: AttachmentEventFailed,
362+ wantErr: publishErr,
363+ },
364+ {
365+ name: "unavailable_observation_commits_attachment",
366+ script: func(stream *sessionTestStream, terminal *workerTestTerminal, attached *sessionTestAttachments) {
367+ terminal.publishErr = ports.ErrUIUnavailable
368+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
369+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
370+ attached.token(t)
371+ _ = stream.Close()
372+ },
373+ wantKind: AttachmentEventEnded,
374+ wantAttached: true,
375+ },
376+ {
377+ name: "eof_after_attachment",
378+ script: func(stream *sessionTestStream, _ *workerTestTerminal, attached *sessionTestAttachments) {
379+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
380+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
381+ attached.token(t)
382+ _ = stream.Close()
383+ },
384+ wantKind: AttachmentEventEnded,
385+ wantAttached: true,
386+ },
387+ {
388+ name: "loss_after_attachment",
389+ script: func(stream *sessionTestStream, _ *workerTestTerminal, attached *sessionTestAttachments) {
390+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
391+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
392+ attached.token(t)
393+ stream.fail(lossErr)
394+ },
395+ wantKind: AttachmentEventLost,
396+ wantAttached: true,
397+ wantErr: lossErr,
398+ },
399+ }
400+
401+ for _, tt := range tests {
402+ t.Run(tt.name, func(t *testing.T) {
403+ stream := newSessionTestStream()
404+ terminal := newWorkerTestTerminal()
405+ attached := newSessionTestAttachments()
406+ events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attached)
407+ awaitHello(t, stream)
408+
409+ tt.script(stream, terminal, attached)
410+
411+ var event AttachmentEvent
412+ select {
413+ case event = <-events:
414+ case <-time.After(5 * time.Second):
415+ t.Fatal("the worker never settled")
416+ }
417+ require.Equal(t, AttachmentToken{Generation: 4, Attempt: 1}, event.Token)
418+ require.Equal(t, tt.wantKind, event.Kind, "event error: %v", event.Err)
419+ if tt.wantErr != nil {
420+ require.ErrorIs(t, event.Err, tt.wantErr)
421+ }
422+ if tt.wantProtocolCode != 0 {
423+ var protocolErr *ProtocolError
424+ require.ErrorAs(t, event.Err, &protocolErr)
425+ require.Equal(t, tt.wantProtocolCode, protocolErr.Code)
426+ }
427+ require.Equal(t, tt.wantAttached, attached.attached())
428+ if !tt.wantAttached {
429+ // A frame that never committed was never written or flushed; the
430+ // UI transaction still observed its own boundary.
431+ require.Empty(t, terminal.written(), "an uncommitted frame must not be written")
432+ require.Zero(t, terminal.flushCount(), "an uncommitted frame must not be flushed")
433+ }
434+ })
435+ }
436+}
437+
438+func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
439+ tests := []struct {
440+ name string
441+ env AttachmentEnvironment
442+ }{
443+ {name: "unset"},
444+ {name: "supplied", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", TrueColor: true}},
445+ }
446+ for _, tt := range tests {
447+ t.Run(tt.name, func(t *testing.T) {
448+ stream := newSessionTestStream()
449+ terminal := newWorkerTestTerminal()
450+ attached := newSessionTestAttachments()
451+ supervisor := &Supervisor{cfg: SupervisorConfig{Terminal: terminal, AttachmentEnvironment: tt.env}, clientID: [16]byte{1}}
452+ worker, err := supervisor.newAttachmentWorker(sessionTestRequest(true), nil)
453+ require.NoError(t, err)
454+ events := make(chan AttachmentEvent, 1)
455+ host := newWorkerTestHost(terminal, nil, attached.record)
456+ go func() {
457+ event, _ := host.Run(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, worker, stream)
458+ events <- event
459+ }()
460+
461+ hello := awaitHello(t, stream)
462+ require.Equal(t, tt.env.TermEnv, hello.TermEnv)
463+ require.Equal(t, tt.env.Cwd, hello.Cwd)
464+ require.Equal(t, tt.env.TrueColor, hello.TrueColor)
465+ require.NoError(t, stream.Close())
466+ <-events
467+ })
468+ }
469+}
470+
471+func TestSessionAttachmentWorkerHandlesProtocolErrorAfterWelcome(t *testing.T) {
472+ tests := []struct {
473+ name string
474+ attached bool
475+ }{
476+ {name: "awaiting initial publication"},
477+ {name: "attached pump", attached: true},
478+ }
479+ for _, tt := range tests {
480+ t.Run(tt.name, func(t *testing.T) {
481+ stream := newSessionTestStream()
482+ terminal := newWorkerTestTerminal()
483+ attachments := newSessionTestAttachments()
484+ events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attachments)
485+ awaitHello(t, stream)
486+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
487+ if tt.attached {
488+ stream.deliver(sessionTestOutput(1, "initial"))
489+ require.Eventually(t, attachments.attached, time.Second, time.Millisecond)
490+ }
491+ stream.deliver(protocol.ErrorMsg{Code: 42, Text: "refused"})
492+ event := <-events
493+ var protocolErr *ProtocolError
494+ require.ErrorAs(t, event.Err, &protocolErr)
495+ require.Equal(t, uint16(42), protocolErr.Code)
496+ require.Equal(t, "refused", protocolErr.Text)
497+ })
498+ }
499+}
500+
501+func TestSessionAttachmentWorkerValidatesIdentityByAdmission(t *testing.T) {
502+ namedRequest := sessionTestRequest(true)
503+ namedRequest.Admission, namedRequest.Name, namedRequest.Target = ports.BrokerAdmissionCreateNamed, "alpha", protocol.ExactSessionTarget{}
504+ ephemeralRequest := sessionTestRequest(true)
505+ ephemeralRequest.Admission, ephemeralRequest.Target = ports.BrokerAdmissionCreateEphemeral, protocol.ExactSessionTarget{}
506+ tests := []struct {
507+ name string
508+ request ports.BrokerOpenStreamRequest
509+ welcome protocol.Welcome
510+ output protocol.Output
511+ wantErr bool
512+ }{
513+ {name: "exact welcome mismatch", request: sessionTestRequest(true), welcome: protocol.Welcome{CommittedIdentity: &protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "alpha"}}}, wantErr: true},
514+ {name: "exact publication mismatch", request: sessionTestRequest(true), welcome: protocol.Welcome{SessionName: "alpha"}, output: func() protocol.Output {
515+ o := sessionTestOutput(1, "wrong")
516+ o.Context.Route.Target.SessionName = "beta"
517+ return o
518+ }(), wantErr: true},
519+ {name: "exact match", request: sessionTestRequest(true), welcome: protocol.Welcome{CommittedIdentity: &protocol.CommittedRouteIdentity{Target: sessionTestRequest(true).Target}}, output: sessionTestOutput(1, "ok")},
520+ {name: "create named welcome mismatch", request: namedRequest, welcome: protocol.Welcome{SessionName: "beta"}, wantErr: true},
521+ {name: "create named publication mismatch", request: namedRequest, welcome: protocol.Welcome{SessionName: "alpha"}, output: func() protocol.Output {
522+ o := sessionTestOutput(1, "wrong")
523+ o.Context.Route.Target.SessionName = "beta"
524+ return o
525+ }(), wantErr: true},
526+ {name: "create named match", request: namedRequest, welcome: protocol.Welcome{SessionName: "alpha"}, output: sessionTestOutput(1, "ok")},
527+ {name: "create ephemeral accepts committed identity", request: ephemeralRequest, welcome: protocol.Welcome{CommittedIdentity: &protocol.CommittedRouteIdentity{Target: sessionTestOutput(1, "").Context.Route.Target}}, output: sessionTestOutput(1, "ok")},
528+ }
529+ for _, tt := range tests {
530+ t.Run(tt.name, func(t *testing.T) {
531+ stream := newSessionTestStream()
532+ terminal := newWorkerTestTerminal()
533+ attached := newSessionTestAttachments()
534+ events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(tt.request), attached)
535+ awaitHello(t, stream)
536+ stream.deliver(tt.welcome)
537+ if tt.output.Context != nil {
538+ stream.deliver(tt.output)
539+ }
540+ if tt.wantErr {
541+ event := <-events
542+ var identityErr *AttachmentIdentityError
543+ require.ErrorAs(t, event.Err, &identityErr)
544+ require.False(t, attached.attached())
545+ require.Empty(t, terminal.written())
546+ return
547+ }
548+ require.Eventually(t, attached.attached, time.Second, time.Millisecond)
549+ require.Equal(t, "ok", terminal.written())
550+ require.NoError(t, stream.Close())
551+ <-events
552+ })
553+ }
554+}
555+
556+// TestSessionAttachmentWorkerRemoteHello proves the same worker derives a
557+// remote Hello from a remote request: the typed-preamble rules stay per
558+// logical stream, and no endpoint is rewritten.
559+func TestSessionAttachmentWorkerRemoteHello(t *testing.T) {
560+ stream := newSessionTestStream()
561+ terminal := newWorkerTestTerminal()
562+ attached := newSessionTestAttachments()
563+ request := sessionTestRequest(false)
564+ events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(request), attached)
565+
566+ hello := awaitHello(t, stream)
567+ require.True(t, hello.Remote)
568+ require.Equal(t, protocol.EnvironmentPolicyClientOwned, hello.EnvironmentPolicy)
569+ require.Equal(t, protocol.IntentAttach, hello.Intent)
570+ require.NotNil(t, hello.ExactTarget)
571+ require.Equal(t, "alpha", hello.ExactTarget.SessionName)
572+
573+ require.NoError(t, stream.Close())
574+ select {
575+ case event := <-events:
576+ require.Equal(t, AttachmentEventEnded, event.Kind)
577+ case <-time.After(5 * time.Second):
578+ t.Fatal("the worker never settled")
579+ }
580+}
581+
582+// TestSessionAttachmentWorkerNeverReadsTerminalInput proves the worker has no
583+// path to terminal input: it consumes no bytes and sends no Input frame while
584+// the foreground owns no input source.
585+func TestSessionAttachmentWorkerNeverReadsTerminalInput(t *testing.T) {
586+ stream := newSessionTestStream()
587+ terminal := newWorkerTestTerminal()
588+ attached := newSessionTestAttachments()
589+ events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attached)
590+
591+ awaitHello(t, stream)
592+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
593+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
594+ _ = attached.token(t)
595+
596+ for _, message := range stream.messages() {
597+ _, isInput := message.(protocol.Input)
598+ require.False(t, isInput, "the worker must never send terminal input in P5.3b")
599+ }
600+ require.Equal(t, "\x1b[Hready", terminal.written(), "only session output reaches the terminal")
601+
602+ require.NoError(t, stream.Close())
603+ select {
604+ case <-events:
605+ case <-time.After(5 * time.Second):
606+ t.Fatal("the worker never settled")
607+ }
608+}
@@ -0,0 +1,608 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/protocol"
17+)
18+
19+// Real attachment worker tests (Plan 001 P5.3b). They are deterministic and
20+// drive the typed session protocol directly: no wall-clock sleeps, no real
21+// terminal, and no broker process.
22+
23+var errSessionTestClosed = errors.New("session test stream is closed")
24+
25+// sessionTestStream is one scripted broker logical connection. It records every
26+// typed client message, delivers scripted server messages, and unblocks
27+// ReceiveServer on Close exactly as the ports.BrokerLogicalConnection contract
28+// requires.
29+type sessionTestStream struct {
30+ mu sync.Mutex
31+ sent []protocol.ClientMessage
32+ incoming chan protocol.ServerMessage
33+ closed chan struct{}
34+ closeOnce sync.Once
35+ err error
36+}
37+
38+func newSessionTestStream() *sessionTestStream {
39+ return &sessionTestStream{
40+ incoming: make(chan protocol.ServerMessage, 8),
41+ closed: make(chan struct{}),
42+ }
43+}
44+
45+func (s *sessionTestStream) SendClient(message protocol.ClientMessage) error {
46+ select {
47+ case <-s.closed:
48+ return errSessionTestClosed
49+ default:
50+ }
51+ s.mu.Lock()
52+ s.sent = append(s.sent, message)
53+ s.mu.Unlock()
54+ return nil
55+}
56+
57+func (s *sessionTestStream) ReceiveServer() (protocol.ServerMessage, error) {
58+ select {
59+ case message := <-s.incoming:
60+ return message, nil
61+ case <-s.closed:
62+ s.mu.Lock()
63+ err := s.err
64+ s.mu.Unlock()
65+ if err != nil {
66+ return nil, errors.Join(io.EOF, err)
67+ }
68+ return nil, io.EOF
69+ }
70+}
71+
72+func (s *sessionTestStream) Capabilities() protocol.ConnectionCapabilities {
73+ return protocol.ConnectionCapabilities{}
74+}
75+
76+func (s *sessionTestStream) LinkState() ports.LinkState { return ports.LinkStateConnected }
77+
78+func (s *sessionTestStream) LinkEvents() <-chan ports.LinkEvent { return nil }
79+
80+func (s *sessionTestStream) Close() error {
81+ s.closeOnce.Do(func() { close(s.closed) })
82+ return nil
83+}
84+
85+func (s *sessionTestStream) Done() <-chan struct{} { return s.closed }
86+
87+func (s *sessionTestStream) Err() error {
88+ s.mu.Lock()
89+ defer s.mu.Unlock()
90+ return s.err
91+}
92+
93+func (s *sessionTestStream) deliver(message protocol.ServerMessage) {
94+ select {
95+ case s.incoming <- message:
96+ case <-s.closed:
97+ }
98+}
99+
100+// fail publishes the stable stream cause and closes, exactly as a transported
101+// logical stream does on loss.
102+func (s *sessionTestStream) fail(err error) {
103+ s.mu.Lock()
104+ if s.err == nil {
105+ s.err = err
106+ }
107+ s.mu.Unlock()
108+ _ = s.Close()
109+}
110+
111+func (s *sessionTestStream) closedNow() bool {
112+ select {
113+ case <-s.closed:
114+ return true
115+ default:
116+ return false
117+ }
118+}
119+
120+func (s *sessionTestStream) messages() []protocol.ClientMessage {
121+ s.mu.Lock()
122+ defer s.mu.Unlock()
123+ return append([]protocol.ClientMessage(nil), s.sent...)
124+}
125+
126+// sessionTestStreamWithDeadline additionally implements
127+// ports.HandshakeDeadlineProvider, so the supervisor-level deadline tests can
128+// prove an accepted absolute deadline is adopted verbatim.
129+type sessionTestStreamWithDeadline struct {
130+ *sessionTestStream
131+ deadline time.Time
132+}
133+
134+func (s *sessionTestStreamWithDeadline) HandshakeDeadline() time.Time { return s.deadline }
135+
136+func sessionTestOutput(publication uint64, data string) protocol.Output {
137+ return protocol.Output{
138+ Epoch: 1, New: 1, Full: true,
139+ Size: domain.Size{Cols: 80, Rows: 24},
140+ Context: &protocol.ViewContext{
141+ Publication: publication,
142+ Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{
143+ LifecycleID: domain.SessionLifecycleID{1},
144+ SessionName: "alpha",
145+ }},
146+ TabID: "t_abc123",
147+ FocusedPaneID: "p_def456",
148+ },
149+ Data: []byte(data),
150+ }
151+}
152+
153+func sessionTestRequest(local bool) ports.BrokerOpenStreamRequest {
154+ request := ports.BrokerOpenStreamRequest{
155+ Epoch: 3,
156+ Purpose: ports.BrokerStreamAttachment,
157+ Admission: ports.BrokerAdmissionExact,
158+ Local: local,
159+ Target: protocol.ExactSessionTarget{
160+ LifecycleID: domain.SessionLifecycleID{1},
161+ SessionName: "alpha",
162+ },
163+ Policy: ports.BrokerPolicy{
164+ ProtocolVersion: protocol.Version,
165+ CatalogSchemaVersion: 1,
166+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
167+ Transport: "transport",
168+ Trust: "trust",
169+ Launch: "launch",
170+ Isolation: "isolation",
171+ },
172+ }
173+ if !local {
174+ request.Endpoint = "user@example"
175+ request.Registration = domain.RemoteRegistration{
176+ Endpoint: "user@example",
177+ Incarnation: [16]byte{1},
178+ Generation: 1,
179+ }
180+ }
181+ return request
182+}
183+
184+func sessionTestWorkerConfig(request ports.BrokerOpenStreamRequest) sessionAttachmentConfig {
185+ return sessionAttachmentConfig{
186+ Request: request,
187+ ClientID: [16]byte{0xAB},
188+ Geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}},
189+ }
190+}
191+
192+// sessionTestAttachments records every successful MarkAttached without
193+// draining: a wait observes the transition but never consumes it, so the test
194+// can assert attachment after a script already synchronized on it.
195+type sessionTestAttachments struct {
196+ count atomic.Int64
197+ tokens chan AttachmentToken
198+}
199+
200+func newSessionTestAttachments() *sessionTestAttachments {
201+ return &sessionTestAttachments{tokens: make(chan AttachmentToken, 8)}
202+}
203+
204+func (a *sessionTestAttachments) record(token AttachmentToken) {
205+ a.count.Add(1)
206+ select {
207+ case a.tokens <- token:
208+ default:
209+ }
210+}
211+
212+func (a *sessionTestAttachments) attached() bool { return a.count.Load() > 0 }
213+
214+func (a *sessionTestAttachments) token(t *testing.T) AttachmentToken {
215+ t.Helper()
216+ select {
217+ case token := <-a.tokens:
218+ return token
219+ case <-time.After(5 * time.Second):
220+ t.Fatal("the worker never attached")
221+ return AttachmentToken{}
222+ }
223+}
224+
225+// sessionTestRun boots one worker through the real foreground host.
226+func sessionTestRun(t *testing.T, stream ports.BrokerLogicalConnection, terminal *workerTestTerminal, cfg sessionAttachmentConfig, attached *sessionTestAttachments) <-chan AttachmentEvent {
227+ t.Helper()
228+ host := newWorkerTestHost(terminal, nil, attached.record)
229+ worker := newSessionAttachmentWorker(cfg)
230+ events := make(chan AttachmentEvent, 1)
231+ go func() {
232+ event, _ := host.Run(context.Background(), AttachmentToken{Generation: 4, Attempt: 1}, worker, stream)
233+ events <- event
234+ }()
235+ return events
236+}
237+
238+// awaitHello blocks until the worker sent its Hello.
239+func awaitHello(t *testing.T, stream *sessionTestStream) protocol.Hello {
240+ t.Helper()
241+ deadline := time.After(5 * time.Second)
242+ for {
243+ for _, message := range stream.messages() {
244+ if hello, ok := message.(protocol.Hello); ok {
245+ return hello
246+ }
247+ }
248+ select {
249+ case <-deadline:
250+ t.Fatal("the worker never sent Hello")
251+ return protocol.Hello{}
252+ case <-time.After(time.Millisecond):
253+ }
254+ }
255+}
256+
257+// TestSessionAttachmentWorkerStaysConnectingUntilPublication proves Welcome is
258+// never attachment: only a validated, committed initial publication marks
259+// attached, and the frame is written and flushed through the UI transaction.
260+func TestSessionAttachmentWorkerStaysConnectingUntilPublication(t *testing.T) {
261+ stream := newSessionTestStream()
262+ terminal := newWorkerTestTerminal()
263+ attached := newSessionTestAttachments()
264+ events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attached)
265+
266+ hello := awaitHello(t, stream)
267+ require.Equal(t, protocol.Version, hello.Version)
268+ require.Equal(t, protocol.IntentAttach, hello.Intent)
269+ require.NotNil(t, hello.ExactTarget)
270+ require.False(t, hello.Remote)
271+
272+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
273+ // Welcome alone must not attach, write, or publish.
274+ require.Never(t, attached.attached, 20*time.Millisecond, 2*time.Millisecond, "Welcome alone must never attach")
275+ require.Zero(t, terminal.flushCount(), "Welcome alone writes nothing")
276+ require.Empty(t, terminal.publications(), "Welcome alone publishes nothing")
277+
278+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
279+ require.Equal(t, AttachmentToken{Generation: 4, Attempt: 1}, attached.token(t))
280+ require.Equal(t, "\x1b[Hready", terminal.written())
281+ require.Equal(t, 1, terminal.flushCount())
282+ require.Len(t, terminal.publications(), 1)
283+ require.Equal(t, ports.UIStatusTransitioning, terminal.publications()[0].Status)
284+
285+ require.NoError(t, stream.Close())
286+ select {
287+ case event := <-events:
288+ require.Equal(t, AttachmentEventEnded, event.Kind)
289+ require.Equal(t, AttachmentToken{Generation: 4, Attempt: 1}, event.Token)
290+ case <-time.After(5 * time.Second):
291+ t.Fatal("the worker never settled after the stream closed")
292+ }
293+}
294+
295+// TestSessionAttachmentWorkerFailureStages is the EOF/refusal coverage table:
296+// before Welcome, after Welcome but before publication, and after attachment,
297+// plus a protocol refusal and a publication failure.
298+func TestSessionAttachmentWorkerFailureStages(t *testing.T) {
299+ lossErr := ports.BrokerStreamLost{
300+ Connection: ports.BrokerConnectionID{1},
301+ Stream: 1,
302+ Epoch: 3,
303+ Cause: domain.RemoteFailureTransport,
304+ Err: errors.New("transport lost"),
305+ }
306+ publishErr := errors.New("ui publication failed")
307+
308+ tests := []struct {
309+ name string
310+ script func(stream *sessionTestStream, terminal *workerTestTerminal, attached *sessionTestAttachments)
311+ wantKind AttachmentEventKind
312+ wantAttached bool
313+ wantErr error
314+ wantProtocolCode uint16
315+ }{
316+ {
317+ name: "eof_before_welcome",
318+ script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) { _ = stream.Close() },
319+ wantKind: AttachmentEventEnded,
320+ },
321+ {
322+ name: "refusal_before_welcome",
323+ script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) {
324+ stream.deliver(protocol.ErrorMsg{Code: protocol.ErrNoSuchSession, Text: "no such session"})
325+ },
326+ wantKind: AttachmentEventFailed,
327+ wantProtocolCode: protocol.ErrNoSuchSession,
328+ },
329+ {
330+ name: "unexpected_before_welcome",
331+ script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) {
332+ stream.deliver(protocol.Sessions{})
333+ },
334+ wantKind: AttachmentEventFailed,
335+ },
336+ {
337+ name: "eof_after_welcome_before_publication",
338+ script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) {
339+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
340+ _ = stream.Close()
341+ },
342+ wantKind: AttachmentEventEnded,
343+ },
344+ {
345+ name: "invalid_initial_publication",
346+ script: func(stream *sessionTestStream, _ *workerTestTerminal, _ *sessionTestAttachments) {
347+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
348+ // A full frame with no context never commits.
349+ stream.deliver(protocol.Output{Epoch: 1, New: 1, Full: true})
350+ },
351+ wantKind: AttachmentEventFailed,
352+ wantErr: errAttachmentPublication,
353+ },
354+ {
355+ name: "publication_failure_before_attachment",
356+ script: func(stream *sessionTestStream, terminal *workerTestTerminal, _ *sessionTestAttachments) {
357+ terminal.publishErr = publishErr
358+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
359+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
360+ },
361+ wantKind: AttachmentEventFailed,
362+ wantErr: publishErr,
363+ },
364+ {
365+ name: "unavailable_observation_commits_attachment",
366+ script: func(stream *sessionTestStream, terminal *workerTestTerminal, attached *sessionTestAttachments) {
367+ terminal.publishErr = ports.ErrUIUnavailable
368+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
369+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
370+ attached.token(t)
371+ _ = stream.Close()
372+ },
373+ wantKind: AttachmentEventEnded,
374+ wantAttached: true,
375+ },
376+ {
377+ name: "eof_after_attachment",
378+ script: func(stream *sessionTestStream, _ *workerTestTerminal, attached *sessionTestAttachments) {
379+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
380+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
381+ attached.token(t)
382+ _ = stream.Close()
383+ },
384+ wantKind: AttachmentEventEnded,
385+ wantAttached: true,
386+ },
387+ {
388+ name: "loss_after_attachment",
389+ script: func(stream *sessionTestStream, _ *workerTestTerminal, attached *sessionTestAttachments) {
390+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
391+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
392+ attached.token(t)
393+ stream.fail(lossErr)
394+ },
395+ wantKind: AttachmentEventLost,
396+ wantAttached: true,
397+ wantErr: lossErr,
398+ },
399+ }
400+
401+ for _, tt := range tests {
402+ t.Run(tt.name, func(t *testing.T) {
403+ stream := newSessionTestStream()
404+ terminal := newWorkerTestTerminal()
405+ attached := newSessionTestAttachments()
406+ events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attached)
407+ awaitHello(t, stream)
408+
409+ tt.script(stream, terminal, attached)
410+
411+ var event AttachmentEvent
412+ select {
413+ case event = <-events:
414+ case <-time.After(5 * time.Second):
415+ t.Fatal("the worker never settled")
416+ }
417+ require.Equal(t, AttachmentToken{Generation: 4, Attempt: 1}, event.Token)
418+ require.Equal(t, tt.wantKind, event.Kind, "event error: %v", event.Err)
419+ if tt.wantErr != nil {
420+ require.ErrorIs(t, event.Err, tt.wantErr)
421+ }
422+ if tt.wantProtocolCode != 0 {
423+ var protocolErr *ProtocolError
424+ require.ErrorAs(t, event.Err, &protocolErr)
425+ require.Equal(t, tt.wantProtocolCode, protocolErr.Code)
426+ }
427+ require.Equal(t, tt.wantAttached, attached.attached())
428+ if !tt.wantAttached {
429+ // A frame that never committed was never written or flushed; the
430+ // UI transaction still observed its own boundary.
431+ require.Empty(t, terminal.written(), "an uncommitted frame must not be written")
432+ require.Zero(t, terminal.flushCount(), "an uncommitted frame must not be flushed")
433+ }
434+ })
435+ }
436+}
437+
438+func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
439+ tests := []struct {
440+ name string
441+ env AttachmentEnvironment
442+ }{
443+ {name: "unset"},
444+ {name: "supplied", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", TrueColor: true}},
445+ }
446+ for _, tt := range tests {
447+ t.Run(tt.name, func(t *testing.T) {
448+ stream := newSessionTestStream()
449+ terminal := newWorkerTestTerminal()
450+ attached := newSessionTestAttachments()
451+ supervisor := &Supervisor{cfg: SupervisorConfig{Terminal: terminal, AttachmentEnvironment: tt.env}, clientID: [16]byte{1}}
452+ worker, err := supervisor.newAttachmentWorker(sessionTestRequest(true), nil)
453+ require.NoError(t, err)
454+ events := make(chan AttachmentEvent, 1)
455+ host := newWorkerTestHost(terminal, nil, attached.record)
456+ go func() {
457+ event, _ := host.Run(context.Background(), AttachmentToken{Generation: 1, Attempt: 1}, worker, stream)
458+ events <- event
459+ }()
460+
461+ hello := awaitHello(t, stream)
462+ require.Equal(t, tt.env.TermEnv, hello.TermEnv)
463+ require.Equal(t, tt.env.Cwd, hello.Cwd)
464+ require.Equal(t, tt.env.TrueColor, hello.TrueColor)
465+ require.NoError(t, stream.Close())
466+ <-events
467+ })
468+ }
469+}
470+
471+func TestSessionAttachmentWorkerHandlesProtocolErrorAfterWelcome(t *testing.T) {
472+ tests := []struct {
473+ name string
474+ attached bool
475+ }{
476+ {name: "awaiting initial publication"},
477+ {name: "attached pump", attached: true},
478+ }
479+ for _, tt := range tests {
480+ t.Run(tt.name, func(t *testing.T) {
481+ stream := newSessionTestStream()
482+ terminal := newWorkerTestTerminal()
483+ attachments := newSessionTestAttachments()
484+ events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attachments)
485+ awaitHello(t, stream)
486+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
487+ if tt.attached {
488+ stream.deliver(sessionTestOutput(1, "initial"))
489+ require.Eventually(t, attachments.attached, time.Second, time.Millisecond)
490+ }
491+ stream.deliver(protocol.ErrorMsg{Code: 42, Text: "refused"})
492+ event := <-events
493+ var protocolErr *ProtocolError
494+ require.ErrorAs(t, event.Err, &protocolErr)
495+ require.Equal(t, uint16(42), protocolErr.Code)
496+ require.Equal(t, "refused", protocolErr.Text)
497+ })
498+ }
499+}
500+
501+func TestSessionAttachmentWorkerValidatesIdentityByAdmission(t *testing.T) {
502+ namedRequest := sessionTestRequest(true)
503+ namedRequest.Admission, namedRequest.Name, namedRequest.Target = ports.BrokerAdmissionCreateNamed, "alpha", protocol.ExactSessionTarget{}
504+ ephemeralRequest := sessionTestRequest(true)
505+ ephemeralRequest.Admission, ephemeralRequest.Target = ports.BrokerAdmissionCreateEphemeral, protocol.ExactSessionTarget{}
506+ tests := []struct {
507+ name string
508+ request ports.BrokerOpenStreamRequest
509+ welcome protocol.Welcome
510+ output protocol.Output
511+ wantErr bool
512+ }{
513+ {name: "exact welcome mismatch", request: sessionTestRequest(true), welcome: protocol.Welcome{CommittedIdentity: &protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "alpha"}}}, wantErr: true},
514+ {name: "exact publication mismatch", request: sessionTestRequest(true), welcome: protocol.Welcome{SessionName: "alpha"}, output: func() protocol.Output {
515+ o := sessionTestOutput(1, "wrong")
516+ o.Context.Route.Target.SessionName = "beta"
517+ return o
518+ }(), wantErr: true},
519+ {name: "exact match", request: sessionTestRequest(true), welcome: protocol.Welcome{CommittedIdentity: &protocol.CommittedRouteIdentity{Target: sessionTestRequest(true).Target}}, output: sessionTestOutput(1, "ok")},
520+ {name: "create named welcome mismatch", request: namedRequest, welcome: protocol.Welcome{SessionName: "beta"}, wantErr: true},
521+ {name: "create named publication mismatch", request: namedRequest, welcome: protocol.Welcome{SessionName: "alpha"}, output: func() protocol.Output {
522+ o := sessionTestOutput(1, "wrong")
523+ o.Context.Route.Target.SessionName = "beta"
524+ return o
525+ }(), wantErr: true},
526+ {name: "create named match", request: namedRequest, welcome: protocol.Welcome{SessionName: "alpha"}, output: sessionTestOutput(1, "ok")},
527+ {name: "create ephemeral accepts committed identity", request: ephemeralRequest, welcome: protocol.Welcome{CommittedIdentity: &protocol.CommittedRouteIdentity{Target: sessionTestOutput(1, "").Context.Route.Target}}, output: sessionTestOutput(1, "ok")},
528+ }
529+ for _, tt := range tests {
530+ t.Run(tt.name, func(t *testing.T) {
531+ stream := newSessionTestStream()
532+ terminal := newWorkerTestTerminal()
533+ attached := newSessionTestAttachments()
534+ events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(tt.request), attached)
535+ awaitHello(t, stream)
536+ stream.deliver(tt.welcome)
537+ if tt.output.Context != nil {
538+ stream.deliver(tt.output)
539+ }
540+ if tt.wantErr {
541+ event := <-events
542+ var identityErr *AttachmentIdentityError
543+ require.ErrorAs(t, event.Err, &identityErr)
544+ require.False(t, attached.attached())
545+ require.Empty(t, terminal.written())
546+ return
547+ }
548+ require.Eventually(t, attached.attached, time.Second, time.Millisecond)
549+ require.Equal(t, "ok", terminal.written())
550+ require.NoError(t, stream.Close())
551+ <-events
552+ })
553+ }
554+}
555+
556+// TestSessionAttachmentWorkerRemoteHello proves the same worker derives a
557+// remote Hello from a remote request: the typed-preamble rules stay per
558+// logical stream, and no endpoint is rewritten.
559+func TestSessionAttachmentWorkerRemoteHello(t *testing.T) {
560+ stream := newSessionTestStream()
561+ terminal := newWorkerTestTerminal()
562+ attached := newSessionTestAttachments()
563+ request := sessionTestRequest(false)
564+ events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(request), attached)
565+
566+ hello := awaitHello(t, stream)
567+ require.True(t, hello.Remote)
568+ require.Equal(t, protocol.EnvironmentPolicyClientOwned, hello.EnvironmentPolicy)
569+ require.Equal(t, protocol.IntentAttach, hello.Intent)
570+ require.NotNil(t, hello.ExactTarget)
571+ require.Equal(t, "alpha", hello.ExactTarget.SessionName)
572+
573+ require.NoError(t, stream.Close())
574+ select {
575+ case event := <-events:
576+ require.Equal(t, AttachmentEventEnded, event.Kind)
577+ case <-time.After(5 * time.Second):
578+ t.Fatal("the worker never settled")
579+ }
580+}
581+
582+// TestSessionAttachmentWorkerNeverReadsTerminalInput proves the worker has no
583+// path to terminal input: it consumes no bytes and sends no Input frame while
584+// the foreground owns no input source.
585+func TestSessionAttachmentWorkerNeverReadsTerminalInput(t *testing.T) {
586+ stream := newSessionTestStream()
587+ terminal := newWorkerTestTerminal()
588+ attached := newSessionTestAttachments()
589+ events := sessionTestRun(t, stream, terminal, sessionTestWorkerConfig(sessionTestRequest(true)), attached)
590+
591+ awaitHello(t, stream)
592+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
593+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
594+ _ = attached.token(t)
595+
596+ for _, message := range stream.messages() {
597+ _, isInput := message.(protocol.Input)
598+ require.False(t, isInput, "the worker must never send terminal input in P5.3b")
599+ }
600+ require.Equal(t, "\x1b[Hready", terminal.written(), "only session output reaches the terminal")
601+
602+ require.NoError(t, stream.Close())
603+ select {
604+ case <-events:
605+ case <-time.After(5 * time.Second):
606+ t.Fatal("the worker never settled")
607+ }
608+}
M internal/usecase/client/attachment_worker.go +8 -8
@@ -745,11 +745,11 @@ func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, boo
745745 }
746746
747747 // Output holds the same send lease across the entire UI observation boundary.
748-// It mirrors the legacy attach output path: a UI capture that is merely
749-// unavailable (ports.ErrUIUnavailable) never stops the frame, so the write and
750-// flush still proceed; any other publication failure aborts. EndOutput success
751-// follows the frame write and flush, so a failed publication, short write, or
752-// failed write or flush leaves EndOutput(false).
748+// UIOutputTransaction is an observation channel, not attachment commit
749+// authority: an unavailable or closed observation channel does not stop the
750+// terminal frame. Other publication failures abort. Successful terminal write,
751+// flush, and EndOutput(true) commit the frame; any failed or short write or
752+// failed flush leaves EndOutput(false).
753753 func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error {
754754 if f == nil {
755755 return errAttachmentForegroundRevoked
@@ -772,9 +772,9 @@ func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) er
772772 f.ui.BeginOutput(uiContext)
773773 success := false
774774 defer func() { f.ui.EndOutput(success) }()
775- // An unavailable UI capture is tolerated, exactly like the legacy attach
776- // path: the frame is still written and flushed. Any other publication
777- // error aborts before the frame is written.
775+ // An unavailable capture means the optional UI observation channel is
776+ // disabled or closed, so the terminal frame still writes and flushes.
777+ // Any other publication error aborts before the frame is written.
778778 if err := f.ui.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
779779 outputErr = err
780780 return true
@@ -745,11 +745,11 @@ func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, boo
745 }745 }
746 746
747 // Output holds the same send lease across the entire UI observation boundary.747 // Output holds the same send lease across the entire UI observation boundary.
748-// It mirrors the legacy attach output path: a UI capture that is merely748+// UIOutputTransaction is an observation channel, not attachment commit
749-// unavailable (ports.ErrUIUnavailable) never stops the frame, so the write and749+// authority: an unavailable or closed observation channel does not stop the
750-// flush still proceed; any other publication failure aborts. EndOutput success750+// terminal frame. Other publication failures abort. Successful terminal write,
751-// follows the frame write and flush, so a failed publication, short write, or751+// flush, and EndOutput(true) commit the frame; any failed or short write or
752-// failed write or flush leaves EndOutput(false).752+// failed flush leaves EndOutput(false).
753 func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error {753 func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error {
754 if f == nil {754 if f == nil {
755 return errAttachmentForegroundRevoked755 return errAttachmentForegroundRevoked
@@ -772,9 +772,9 @@ func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) er
772 f.ui.BeginOutput(uiContext)772 f.ui.BeginOutput(uiContext)
773 success := false773 success := false
774 defer func() { f.ui.EndOutput(success) }()774 defer func() { f.ui.EndOutput(success) }()
775- // An unavailable UI capture is tolerated, exactly like the legacy attach775+ // An unavailable capture means the optional UI observation channel is
776- // path: the frame is still written and flushed. Any other publication776+ // disabled or closed, so the terminal frame still writes and flushes.
777- // error aborts before the frame is written.777+ // Any other publication error aborts before the frame is written.
778 if err := f.ui.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {778 if err := f.ui.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
779 outputErr = err779 outputErr = err
780 return true780 return true
M internal/usecase/client/picker_catalogue.go +9 -10
@@ -799,12 +799,11 @@ func pickerMergeAuthority(current, incoming ports.BrokerDaemonObservation) ports
799799 return merged
800800 }
801801
802-// pickerObservationStatus classifies one observation into a row badge. It
803-// mirrors the legacy daemon-served classifier (directorySessionReason):
804-// availability is decided first, so an unobserved (Availability Unknown) or
805-// unreachable daemon is never reported as a version mismatch. A version
806-// mismatch is derived only for a reachable observation whose observed
807-// ProtocolVersion differs from its policy's required version.
802+// pickerObservationStatus classifies one observation into a row badge using
803+// the shared domain.RemoteReason taxonomy: availability is decided first, so
804+// an unobserved (Availability Unknown) or unreachable daemon is never reported
805+// as a version mismatch. A version mismatch is derived only for a reachable
806+// observation whose observed ProtocolVersion differs from policy.
808807 func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bool) protocol.PickerLineStatus {
809808 switch observation.Availability {
810809 case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed:
@@ -829,10 +828,10 @@ func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bo
829828 }
830829
831830 // pickerObservationReason classifies one observation into a bounded display
832-// reason. As with the badge, availability is decided first; the version branch
833-// applies only to a reachable observation whose observed version differs from
834-// policy. Unobserved and unknown availability render as refreshing, matching
835-// the legacy daemon-served classifier.
831+// reason from the shared domain.RemoteReason taxonomy. Availability is decided
832+// first; the version branch applies only to a reachable observation whose
833+// observed version differs from policy. Unobserved and unknown availability
834+// render as refreshing.
836835 func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bool) string {
837836 switch observation.Availability {
838837 case domain.RemoteAvailabilityUnreachable:
@@ -799,12 +799,11 @@ func pickerMergeAuthority(current, incoming ports.BrokerDaemonObservation) ports
799 return merged799 return merged
800 }800 }
801 801
802-// pickerObservationStatus classifies one observation into a row badge. It802+// pickerObservationStatus classifies one observation into a row badge using
803-// mirrors the legacy daemon-served classifier (directorySessionReason):803+// the shared domain.RemoteReason taxonomy: availability is decided first, so
804-// availability is decided first, so an unobserved (Availability Unknown) or804+// an unobserved (Availability Unknown) or unreachable daemon is never reported
805-// unreachable daemon is never reported as a version mismatch. A version805+// as a version mismatch. A version mismatch is derived only for a reachable
806-// mismatch is derived only for a reachable observation whose observed806+// observation whose observed ProtocolVersion differs from policy.
807-// ProtocolVersion differs from its policy's required version.
808 func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bool) protocol.PickerLineStatus {807 func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bool) protocol.PickerLineStatus {
809 switch observation.Availability {808 switch observation.Availability {
810 case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed:809 case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed:
@@ -829,10 +828,10 @@ func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bo
829 }828 }
830 829
831 // pickerObservationReason classifies one observation into a bounded display830 // pickerObservationReason classifies one observation into a bounded display
832-// reason. As with the badge, availability is decided first; the version branch831+// reason from the shared domain.RemoteReason taxonomy. Availability is decided
833-// applies only to a reachable observation whose observed version differs from832+// first; the version branch applies only to a reachable observation whose
834-// policy. Unobserved and unknown availability render as refreshing, matching833+// observed version differs from policy. Unobserved and unknown availability
835-// the legacy daemon-served classifier.834+// render as refreshing.
836 func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bool) string {835 func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bool) string {
837 switch observation.Availability {836 switch observation.Availability {
838 case domain.RemoteAvailabilityUnreachable:837 case domain.RemoteAvailabilityUnreachable:
M internal/usecase/client/picker_controller.go +40 -0
@@ -37,11 +37,31 @@ import (
3737 // pickerHost is the supervisor's view of the client-owned picker. The
3838 // supervisor applies each broker publication and hands it every terminal read
3939 // from its single input lifetime; the picker decides ownership.
40+//
41+// The commit seam (Plan 001 P5.3b) is what lets the supervisor turn a user
42+// commit into one exact broker stream without ever reading the presentation
43+// model itself: the picker records the decision and the key it committed
44+// atomically, the supervisor wakes on OpsReady, and ResolveKey revalidates
45+// exactly that key. The picker never opens a stream, and the supervisor never
46+// reconstructs a selection from a display label.
4047 type pickerHost interface {
4148 pickerInputConsumer
4249 // ApplySnapshot folds one broker publication into the catalogue and
4350 // refreshes the presentation immediately.
4451 ApplySnapshot(ports.BrokerSnapshot)
52+ // TakeOp returns and clears the accumulated presentation decision together
53+ // with the catalogue key captured with a commit decision.
54+ TakeOp() (pickerOp, string)
55+ // ResolveKey revalidates exactly one committed catalogue key into the
56+ // exact broker stream request the user committed.
57+ ResolveKey(string, pickerResolveBase) (ports.BrokerOpenStreamRequest, error)
58+ // SetOwnsInput releases or re-acquires picker input ownership at an attach
59+ // boundary, so exactly one owner consumes the shared terminal reader.
60+ SetOwnsInput(bool)
61+ // OpsReady wakes the supervisor when a presentation decision was recorded.
62+ // It is a capacity-one coalescing signal, never closed while the picker
63+ // lives, so a driver that ignores it only misses a wakeup, never blocks.
64+ OpsReady() <-chan struct{}
4565 }
4666
4767 // pickerInputConsumer is the supervisor's narrow input seam. ConsumeTerminalRead
@@ -74,6 +94,7 @@ type pickerController struct {
7494 notices ui.ToastManager
7595 interaction uint64
7696 ownsInput bool
97+ opsReady chan struct{}
7798 lastOp pickerOp
7899 // lastCommitKey is the catalogue key captured with the pending commit
79100 // decision, so TakeOp can hand a driver the exact committed row.
@@ -92,6 +113,7 @@ func newPickerController(clock ports.Clock, freshness time.Duration) *pickerCont
92113 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
93114 renderer: newPickerRenderer(),
94115 ownsInput: true,
116+ opsReady: make(chan struct{}, 1),
95117 }
96118 controller.consumer.setOwned(pickerGeneration, pickerGeneration)
97119 return controller
@@ -167,6 +189,24 @@ func (p *pickerController) recordOpLocked(op pickerOp) {
167189 p.lastCommitKey = p.commitKeyLocked()
168190 }
169191 p.lastOp = mergePickerOps(p.lastOp, op)
192+ if p.lastOp.commit {
193+ // Coalesce exactly one wakeup: a driver that has not yet run TakeOp
194+ // re-reads the accumulated decision, so a dropped signal is harmless.
195+ select {
196+ case p.opsReady <- struct{}{}:
197+ default:
198+ }
199+ }
200+}
201+
202+// OpsReady wakes the supervisor when a commit decision was recorded. It is a
203+// capacity-one coalescing signal; TakeOp remains the authority on what was
204+// decided, so a driver never infers the decision from the wakeup alone.
205+func (p *pickerController) OpsReady() <-chan struct{} {
206+ if p == nil {
207+ return nil
208+ }
209+ return p.opsReady
170210 }
171211
172212 // commitKeyLocked returns the opaque catalogue key of the row under the cursor
@@ -37,11 +37,31 @@ import (
37 // pickerHost is the supervisor's view of the client-owned picker. The37 // pickerHost is the supervisor's view of the client-owned picker. The
38 // supervisor applies each broker publication and hands it every terminal read38 // supervisor applies each broker publication and hands it every terminal read
39 // from its single input lifetime; the picker decides ownership.39 // from its single input lifetime; the picker decides ownership.
40+//
41+// The commit seam (Plan 001 P5.3b) is what lets the supervisor turn a user
42+// commit into one exact broker stream without ever reading the presentation
43+// model itself: the picker records the decision and the key it committed
44+// atomically, the supervisor wakes on OpsReady, and ResolveKey revalidates
45+// exactly that key. The picker never opens a stream, and the supervisor never
46+// reconstructs a selection from a display label.
40 type pickerHost interface {47 type pickerHost interface {
41 pickerInputConsumer48 pickerInputConsumer
42 // ApplySnapshot folds one broker publication into the catalogue and49 // ApplySnapshot folds one broker publication into the catalogue and
43 // refreshes the presentation immediately.50 // refreshes the presentation immediately.
44 ApplySnapshot(ports.BrokerSnapshot)51 ApplySnapshot(ports.BrokerSnapshot)
52+ // TakeOp returns and clears the accumulated presentation decision together
53+ // with the catalogue key captured with a commit decision.
54+ TakeOp() (pickerOp, string)
55+ // ResolveKey revalidates exactly one committed catalogue key into the
56+ // exact broker stream request the user committed.
57+ ResolveKey(string, pickerResolveBase) (ports.BrokerOpenStreamRequest, error)
58+ // SetOwnsInput releases or re-acquires picker input ownership at an attach
59+ // boundary, so exactly one owner consumes the shared terminal reader.
60+ SetOwnsInput(bool)
61+ // OpsReady wakes the supervisor when a presentation decision was recorded.
62+ // It is a capacity-one coalescing signal, never closed while the picker
63+ // lives, so a driver that ignores it only misses a wakeup, never blocks.
64+ OpsReady() <-chan struct{}
45 }65 }
46 66
47 // pickerInputConsumer is the supervisor's narrow input seam. ConsumeTerminalRead67 // pickerInputConsumer is the supervisor's narrow input seam. ConsumeTerminalRead
@@ -74,6 +94,7 @@ type pickerController struct {
74 notices ui.ToastManager94 notices ui.ToastManager
75 interaction uint6495 interaction uint64
76 ownsInput bool96 ownsInput bool
97+ opsReady chan struct{}
77 lastOp pickerOp98 lastOp pickerOp
78 // lastCommitKey is the catalogue key captured with the pending commit99 // lastCommitKey is the catalogue key captured with the pending commit
79 // decision, so TakeOp can hand a driver the exact committed row.100 // decision, so TakeOp can hand a driver the exact committed row.
@@ -92,6 +113,7 @@ func newPickerController(clock ports.Clock, freshness time.Duration) *pickerCont
92 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),113 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
93 renderer: newPickerRenderer(),114 renderer: newPickerRenderer(),
94 ownsInput: true,115 ownsInput: true,
116+ opsReady: make(chan struct{}, 1),
95 }117 }
96 controller.consumer.setOwned(pickerGeneration, pickerGeneration)118 controller.consumer.setOwned(pickerGeneration, pickerGeneration)
97 return controller119 return controller
@@ -167,6 +189,24 @@ func (p *pickerController) recordOpLocked(op pickerOp) {
167 p.lastCommitKey = p.commitKeyLocked()189 p.lastCommitKey = p.commitKeyLocked()
168 }190 }
169 p.lastOp = mergePickerOps(p.lastOp, op)191 p.lastOp = mergePickerOps(p.lastOp, op)
192+ if p.lastOp.commit {
193+ // Coalesce exactly one wakeup: a driver that has not yet run TakeOp
194+ // re-reads the accumulated decision, so a dropped signal is harmless.
195+ select {
196+ case p.opsReady <- struct{}{}:
197+ default:
198+ }
199+ }
200+}
201+
202+// OpsReady wakes the supervisor when a commit decision was recorded. It is a
203+// capacity-one coalescing signal; TakeOp remains the authority on what was
204+// decided, so a driver never infers the decision from the wakeup alone.
205+func (p *pickerController) OpsReady() <-chan struct{} {
206+ if p == nil {
207+ return nil
208+ }
209+ return p.opsReady
170 }210 }
171 211
172 // commitKeyLocked returns the opaque catalogue key of the row under the cursor212 // commitKeyLocked returns the opaque catalogue key of the row under the cursor
M internal/usecase/client/supervisor.go +145 -18
@@ -8,6 +8,7 @@ import (
88 "math/rand/v2"
99 "reflect"
1010 "sync"
11+ "sync/atomic"
1112 "time"
1213
1314 "github.com/bnema/vev/internal/ports"
@@ -153,6 +154,17 @@ const (
153154 // supervisorBrokerLoss is the loss of an established connection observed
154155 // through BrokerService.Done/Err while no logical stream is held.
155156 supervisorBrokerLoss
157+ // supervisorAttachBegin marks the admission of one committed attachment
158+ // stream: the picker released input and the supervisor is presenting the
159+ // connecting state until the initial publication is committed.
160+ supervisorAttachBegin
161+ // supervisorAttached marks a committed initial publication (Plan 001
162+ // P5.3b): the worker proved the full output frame was written, flushed,
163+ // and UI-committed. Welcome alone never produces it.
164+ supervisorAttached
165+ // supervisorAttachEnded returns a settled attachment to the picker without
166+ // restarting the process. event.err carries the typed failure, if any.
167+ supervisorAttachEnded
156168 // supervisorNonRetryable is a failure that retrying can never fix.
157169 supervisorNonRetryable
158170 // supervisorTerminal ends the process: terminal EOF, process cancellation,
@@ -183,6 +195,21 @@ func reduceSupervisor(state State, event supervisorEvent) State {
183195 state.Connectivity = ConnectivityRetryWait
184196 state.Attempt++
185197 state.Err = event.err
198+ case supervisorAttachBegin:
199+ // Connecting is the honest presentation until the committed initial
200+ // publication: the broker connection is still ready and the attempt
201+ // cadence is untouched.
202+ state.Presentation = PresentConnecting
203+ state.Err = nil
204+ case supervisorAttached:
205+ state.Presentation = PresentAttached
206+ state.Err = nil
207+ case supervisorAttachEnded:
208+ // A settled attachment always returns to the picker in the same
209+ // process, attached or not; a stream that fails after attachment must
210+ // never leave the attached presentation showing.
211+ state.Presentation = PresentPicker
212+ state.Err = event.err
186213 case supervisorNonRetryable:
187214 state.Connectivity = ConnectivityDisconnected
188215 state.Err = event.err
@@ -222,6 +249,18 @@ type SupervisorConfig struct {
222249 // so the picker never starts a second reader. When nil the supervisor drops
223250 // terminal input exactly as P5.1a does. Optional.
224251 Picker pickerHost
252+ // AttachmentEnvironment is composition-owned process context copied into
253+ // each attachment Hello. The use case deliberately does not inspect the
254+ // process environment; omitted fields retain their protocol zero values.
255+ AttachmentEnvironment AttachmentEnvironment
256+}
257+
258+// AttachmentEnvironment is the composition seam for client environment data
259+// carried by attachment Hello messages.
260+type AttachmentEnvironment struct {
261+ TermEnv string
262+ Cwd string
263+ TrueColor bool
225264 }
226265
227266 // Supervisor owns one autonomous client process: raw mode, one terminal input
@@ -231,6 +270,21 @@ type Supervisor struct {
231270
232271 mu sync.Mutex
233272 state State
273+
274+ // attachments is the single foreground host every committed attachment
275+ // runs through (Plan 001 P5.3b). The supervisor owns it for the whole run
276+ // and retains close authority over each stream it admits.
277+ attachments *attachmentHost
278+ // nextStream is the strictly increasing logical stream ID allocator for
279+ // the current broker connection. Failed admitted opens consume their ID,
280+ // exactly as the pool contract requires.
281+ nextStream atomic.Uint64
282+ // nextAttachment identifies one attachment run within the connection
283+ // attempt generation.
284+ nextAttachment atomic.Uint64
285+ // clientID is the stable client identity carried in every Hello this
286+ // supervisor sends, across reconnects and attachments.
287+ clientID [16]byte
234288 }
235289
236290 // NewSupervisor validates the required dependencies and returns a supervisor
@@ -257,10 +311,22 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) {
257311 if cfg.Jitter == nil {
258312 cfg.Jitter = rand.Float64
259313 }
260- return &Supervisor{
261- cfg: cfg,
262- state: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected},
263- }, nil
314+ supervisor := &Supervisor{
315+ cfg: cfg,
316+ state: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected},
317+ clientID: newClientID(),
318+ }
319+ // The host is the supervisor's existing foreground grant. It owns no raw
320+ // mode and starts no reader: the supervisor keeps its one terminal input
321+ // lifetime, so the attachment path adds neither a second reader nor a
322+ // second writer. Terminal input pumping into a session and UI actions are
323+ // deliberately left to P5.4 (see the P5.1b integration note).
324+ supervisor.attachments = newAttachmentHost(attachmentHostConfig{
325+ Terminal: cfg.Terminal,
326+ Clock: cfg.Clock,
327+ OnAttached: func(AttachmentToken) { supervisor.transition(supervisorEvent{kind: supervisorAttached}) },
328+ })
329+ return supervisor, nil
264330 }
265331
266332 // State returns the current immutable projection.
@@ -375,12 +441,30 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
375441 }
376442 s.transition(supervisorEvent{kind: supervisorReady})
377443
378- lossErr, terminated, termErr := s.awaitLoss(ctx, input, service, sub)
379- retire()
380- if terminated {
381- s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr})
382- return termErr
444+ // Ready phase: fold publications, admit committed attachments one at a
445+ // time, and return to the picker after each. A committed attachment
446+ // never replaces the broker connection; only a broker loss leaves this
447+ // loop, and cancellation or terminal EOF ends the run.
448+ var lossErr error
449+ ready:
450+ for {
451+ ready := s.awaitReady(ctx, input, service, sub)
452+ if ready.terminated {
453+ retire()
454+ s.transition(supervisorEvent{kind: supervisorTerminal, err: ready.termErr})
455+ return ready.termErr
456+ }
457+ if ready.commitKey == "" {
458+ lossErr = ready.lossErr
459+ break ready
460+ }
461+ if terminated, termErr := s.runCommittedAttachment(ctx, input, service, ready.commitKey); terminated {
462+ retire()
463+ s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr})
464+ return termErr
465+ }
383466 }
467+ retire()
384468 if cause := ctx.Err(); cause != nil {
385469 // Parent cancellation wins over a loss that settled at the same instant,
386470 // exactly as it does at the attempt settle. The connection was already
@@ -525,28 +609,71 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet
525609 }
526610 }
527611
528-// awaitLoss waits for an established connection to be lost, or for the run to
529-// be terminated first. A loss is reported with the service's stable terminal
530-// cause.
531-func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) (error, bool, error) {
612+// readyOutcome is the result of one wait inside the established-connection
613+// phase: either the broker connection was lost, the run was terminated, or a
614+// committed picker selection is ready to be attached.
615+type readyOutcome struct {
616+ // commitKey is the exact catalogue key captured with a commit decision. A
617+ // non-empty value names one attachment to admit; the ready loop never
618+ // resolves anything else.
619+ commitKey string
620+ // lossErr is the broker service's stable terminal cause when the
621+ // established connection was lost.
622+ lossErr error
623+ // terminated reports that the run itself must end, with termErr carrying
624+ // the process cancellation or terminal-read cause.
625+ terminated bool
626+ termErr error
627+}
628+
629+// awaitReady waits for an established connection to be lost, for a committed
630+// picker selection, or for the run to be terminated first. Publications are
631+// folded into the picker as they arrive; a commit decision wakes the loop so
632+// the supervisor can admit exactly the row the user committed. It opens no
633+// stream and starts no terminal work itself.
634+func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) readyOutcome {
532635 var changed <-chan struct{}
533- if s.cfg.Picker != nil && !supervisorNil(sub) {
534- changed = sub.Changed()
636+ var ops <-chan struct{}
637+ if s.cfg.Picker != nil {
638+ if !supervisorNil(sub) {
639+ changed = sub.Changed()
640+ }
641+ ops = s.cfg.Picker.OpsReady()
535642 }
536643 for {
537644 select {
538645 case <-service.Done():
539- return service.Err(), false, nil
646+ return readyOutcome{lossErr: service.Err()}
540647 case <-changed:
541648 s.cfg.Picker.ApplySnapshot(service.Snapshot())
649+ case <-ops:
650+ if key, ok := s.takeCommittedKey(); ok {
651+ return readyOutcome{commitKey: key}
652+ }
542653 case <-ctx.Done():
543- return nil, true, ctx.Err()
654+ return readyOutcome{terminated: true, termErr: ctx.Err()}
544655 case err := <-input.EOF():
545- return nil, true, terminalReadCause(err)
656+ return readyOutcome{terminated: true, termErr: terminalReadCause(err)}
546657 }
547658 }
548659 }
549660
661+// takeCommittedKey drains exactly one committed presentation decision. Only a
662+// commit with a captured catalogue key admits an attachment; every other
663+// decision (cursor moves, search, kill, close) is consumed and dropped here,
664+// so picker input alone can never open, write, or retarget a session.
665+func (s *Supervisor) takeCommittedKey() (string, bool) {
666+ picker := s.cfg.Picker
667+ if picker == nil {
668+ return "", false
669+ }
670+ op, key := picker.TakeOp()
671+ if !op.commit || key == "" {
672+ return "", false
673+ }
674+ return key, true
675+}
676+
550677 // awaitTermination parks the supervisor on the picker after a non-retryable
551678 // failure until the terminal ends or the process is cancelled. A clean terminal
552679 // EOF returns nil; every other cause is reported.
@@ -8,6 +8,7 @@ import (
8 "math/rand/v2"8 "math/rand/v2"
9 "reflect"9 "reflect"
10 "sync"10 "sync"
11+ "sync/atomic"
11 "time"12 "time"
12 13
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
@@ -153,6 +154,17 @@ const (
153 // supervisorBrokerLoss is the loss of an established connection observed154 // supervisorBrokerLoss is the loss of an established connection observed
154 // through BrokerService.Done/Err while no logical stream is held.155 // through BrokerService.Done/Err while no logical stream is held.
155 supervisorBrokerLoss156 supervisorBrokerLoss
157+ // supervisorAttachBegin marks the admission of one committed attachment
158+ // stream: the picker released input and the supervisor is presenting the
159+ // connecting state until the initial publication is committed.
160+ supervisorAttachBegin
161+ // supervisorAttached marks a committed initial publication (Plan 001
162+ // P5.3b): the worker proved the full output frame was written, flushed,
163+ // and UI-committed. Welcome alone never produces it.
164+ supervisorAttached
165+ // supervisorAttachEnded returns a settled attachment to the picker without
166+ // restarting the process. event.err carries the typed failure, if any.
167+ supervisorAttachEnded
156 // supervisorNonRetryable is a failure that retrying can never fix.168 // supervisorNonRetryable is a failure that retrying can never fix.
157 supervisorNonRetryable169 supervisorNonRetryable
158 // supervisorTerminal ends the process: terminal EOF, process cancellation,170 // supervisorTerminal ends the process: terminal EOF, process cancellation,
@@ -183,6 +195,21 @@ func reduceSupervisor(state State, event supervisorEvent) State {
183 state.Connectivity = ConnectivityRetryWait195 state.Connectivity = ConnectivityRetryWait
184 state.Attempt++196 state.Attempt++
185 state.Err = event.err197 state.Err = event.err
198+ case supervisorAttachBegin:
199+ // Connecting is the honest presentation until the committed initial
200+ // publication: the broker connection is still ready and the attempt
201+ // cadence is untouched.
202+ state.Presentation = PresentConnecting
203+ state.Err = nil
204+ case supervisorAttached:
205+ state.Presentation = PresentAttached
206+ state.Err = nil
207+ case supervisorAttachEnded:
208+ // A settled attachment always returns to the picker in the same
209+ // process, attached or not; a stream that fails after attachment must
210+ // never leave the attached presentation showing.
211+ state.Presentation = PresentPicker
212+ state.Err = event.err
186 case supervisorNonRetryable:213 case supervisorNonRetryable:
187 state.Connectivity = ConnectivityDisconnected214 state.Connectivity = ConnectivityDisconnected
188 state.Err = event.err215 state.Err = event.err
@@ -222,6 +249,18 @@ type SupervisorConfig struct {
222 // so the picker never starts a second reader. When nil the supervisor drops249 // so the picker never starts a second reader. When nil the supervisor drops
223 // terminal input exactly as P5.1a does. Optional.250 // terminal input exactly as P5.1a does. Optional.
224 Picker pickerHost251 Picker pickerHost
252+ // AttachmentEnvironment is composition-owned process context copied into
253+ // each attachment Hello. The use case deliberately does not inspect the
254+ // process environment; omitted fields retain their protocol zero values.
255+ AttachmentEnvironment AttachmentEnvironment
256+}
257+
258+// AttachmentEnvironment is the composition seam for client environment data
259+// carried by attachment Hello messages.
260+type AttachmentEnvironment struct {
261+ TermEnv string
262+ Cwd string
263+ TrueColor bool
225 }264 }
226 265
227 // Supervisor owns one autonomous client process: raw mode, one terminal input266 // Supervisor owns one autonomous client process: raw mode, one terminal input
@@ -231,6 +270,21 @@ type Supervisor struct {
231 270
232 mu sync.Mutex271 mu sync.Mutex
233 state State272 state State
273+
274+ // attachments is the single foreground host every committed attachment
275+ // runs through (Plan 001 P5.3b). The supervisor owns it for the whole run
276+ // and retains close authority over each stream it admits.
277+ attachments *attachmentHost
278+ // nextStream is the strictly increasing logical stream ID allocator for
279+ // the current broker connection. Failed admitted opens consume their ID,
280+ // exactly as the pool contract requires.
281+ nextStream atomic.Uint64
282+ // nextAttachment identifies one attachment run within the connection
283+ // attempt generation.
284+ nextAttachment atomic.Uint64
285+ // clientID is the stable client identity carried in every Hello this
286+ // supervisor sends, across reconnects and attachments.
287+ clientID [16]byte
234 }288 }
235 289
236 // NewSupervisor validates the required dependencies and returns a supervisor290 // NewSupervisor validates the required dependencies and returns a supervisor
@@ -257,10 +311,22 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) {
257 if cfg.Jitter == nil {311 if cfg.Jitter == nil {
258 cfg.Jitter = rand.Float64312 cfg.Jitter = rand.Float64
259 }313 }
260- return &Supervisor{314+ supervisor := &Supervisor{
261- cfg: cfg,315+ cfg: cfg,
262- state: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected},316+ state: State{Presentation: PresentPicker, Connectivity: ConnectivityDisconnected},
263- }, nil317+ clientID: newClientID(),
318+ }
319+ // The host is the supervisor's existing foreground grant. It owns no raw
320+ // mode and starts no reader: the supervisor keeps its one terminal input
321+ // lifetime, so the attachment path adds neither a second reader nor a
322+ // second writer. Terminal input pumping into a session and UI actions are
323+ // deliberately left to P5.4 (see the P5.1b integration note).
324+ supervisor.attachments = newAttachmentHost(attachmentHostConfig{
325+ Terminal: cfg.Terminal,
326+ Clock: cfg.Clock,
327+ OnAttached: func(AttachmentToken) { supervisor.transition(supervisorEvent{kind: supervisorAttached}) },
328+ })
329+ return supervisor, nil
264 }330 }
265 331
266 // State returns the current immutable projection.332 // State returns the current immutable projection.
@@ -375,12 +441,30 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
375 }441 }
376 s.transition(supervisorEvent{kind: supervisorReady})442 s.transition(supervisorEvent{kind: supervisorReady})
377 443
378- lossErr, terminated, termErr := s.awaitLoss(ctx, input, service, sub)444+ // Ready phase: fold publications, admit committed attachments one at a
379- retire()445+ // time, and return to the picker after each. A committed attachment
380- if terminated {446+ // never replaces the broker connection; only a broker loss leaves this
381- s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr})447+ // loop, and cancellation or terminal EOF ends the run.
382- return termErr448+ var lossErr error
449+ ready:
450+ for {
451+ ready := s.awaitReady(ctx, input, service, sub)
452+ if ready.terminated {
453+ retire()
454+ s.transition(supervisorEvent{kind: supervisorTerminal, err: ready.termErr})
455+ return ready.termErr
456+ }
457+ if ready.commitKey == "" {
458+ lossErr = ready.lossErr
459+ break ready
460+ }
461+ if terminated, termErr := s.runCommittedAttachment(ctx, input, service, ready.commitKey); terminated {
462+ retire()
463+ s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr})
464+ return termErr
465+ }
383 }466 }
467+ retire()
384 if cause := ctx.Err(); cause != nil {468 if cause := ctx.Err(); cause != nil {
385 // Parent cancellation wins over a loss that settled at the same instant,469 // Parent cancellation wins over a loss that settled at the same instant,
386 // exactly as it does at the attempt settle. The connection was already470 // exactly as it does at the attempt settle. The connection was already
@@ -525,28 +609,71 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet
525 }609 }
526 }610 }
527 611
528-// awaitLoss waits for an established connection to be lost, or for the run to612+// readyOutcome is the result of one wait inside the established-connection
529-// be terminated first. A loss is reported with the service's stable terminal613+// phase: either the broker connection was lost, the run was terminated, or a
530-// cause.614+// committed picker selection is ready to be attached.
531-func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) (error, bool, error) {615+type readyOutcome struct {
616+ // commitKey is the exact catalogue key captured with a commit decision. A
617+ // non-empty value names one attachment to admit; the ready loop never
618+ // resolves anything else.
619+ commitKey string
620+ // lossErr is the broker service's stable terminal cause when the
621+ // established connection was lost.
622+ lossErr error
623+ // terminated reports that the run itself must end, with termErr carrying
624+ // the process cancellation or terminal-read cause.
625+ terminated bool
626+ termErr error
627+}
628+
629+// awaitReady waits for an established connection to be lost, for a committed
630+// picker selection, or for the run to be terminated first. Publications are
631+// folded into the picker as they arrive; a commit decision wakes the loop so
632+// the supervisor can admit exactly the row the user committed. It opens no
633+// stream and starts no terminal work itself.
634+func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) readyOutcome {
532 var changed <-chan struct{}635 var changed <-chan struct{}
533- if s.cfg.Picker != nil && !supervisorNil(sub) {636+ var ops <-chan struct{}
534- changed = sub.Changed()637+ if s.cfg.Picker != nil {
638+ if !supervisorNil(sub) {
639+ changed = sub.Changed()
640+ }
641+ ops = s.cfg.Picker.OpsReady()
535 }642 }
536 for {643 for {
537 select {644 select {
538 case <-service.Done():645 case <-service.Done():
539- return service.Err(), false, nil646+ return readyOutcome{lossErr: service.Err()}
540 case <-changed:647 case <-changed:
541 s.cfg.Picker.ApplySnapshot(service.Snapshot())648 s.cfg.Picker.ApplySnapshot(service.Snapshot())
649+ case <-ops:
650+ if key, ok := s.takeCommittedKey(); ok {
651+ return readyOutcome{commitKey: key}
652+ }
542 case <-ctx.Done():653 case <-ctx.Done():
543- return nil, true, ctx.Err()654+ return readyOutcome{terminated: true, termErr: ctx.Err()}
544 case err := <-input.EOF():655 case err := <-input.EOF():
545- return nil, true, terminalReadCause(err)656+ return readyOutcome{terminated: true, termErr: terminalReadCause(err)}
546 }657 }
547 }658 }
548 }659 }
549 660
661+// takeCommittedKey drains exactly one committed presentation decision. Only a
662+// commit with a captured catalogue key admits an attachment; every other
663+// decision (cursor moves, search, kill, close) is consumed and dropped here,
664+// so picker input alone can never open, write, or retarget a session.
665+func (s *Supervisor) takeCommittedKey() (string, bool) {
666+ picker := s.cfg.Picker
667+ if picker == nil {
668+ return "", false
669+ }
670+ op, key := picker.TakeOp()
671+ if !op.commit || key == "" {
672+ return "", false
673+ }
674+ return key, true
675+}
676+
550 // awaitTermination parks the supervisor on the picker after a non-retryable677 // awaitTermination parks the supervisor on the picker after a non-retryable
551 // failure until the terminal ends or the process is cancelled. A clean terminal678 // failure until the terminal ends or the process is cancelled. A clean terminal
552 // EOF returns nil; every other cause is reported.679 // EOF returns nil; every other cause is reported.
A internal/usecase/client/supervisor_attach.go +407 -0
@@ -0,0 +1,407 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+ "sync/atomic"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol"
13+)
14+
15+// Committed attachment orchestration (Plan 001 P5.3b, offline and unactivated).
16+//
17+// After the picker resolves a committed selection to one exact
18+// ports.BrokerOpenStreamRequest, the supervisor opens that logical stream on
19+// the broker and hands the typed connection to the real attachment worker
20+// through its existing foreground grant. The supervisor still owns the
21+// terminal, raw mode, the single input lifetime, and the close authority over
22+// the stream; the picker never opens, writes, or retargets a session.
23+//
24+// One absolute deadline bounds the whole attachment: stream open, the session
25+// preamble, Hello/Welcome, and the committed initial publication. It is never
26+// restarted and never extended. When the admitted stream exposes an accepted
27+// absolute deadline (ports.HandshakeDeadlineProvider) the earlier of the two is
28+// kept, so queue time spent before the stream reached this client is consumed
29+// rather than repaid with a second budget; an already-elapsed deadline fails the
30+// attachment immediately. The deadline is driven by ports.Clock/ports.Timer,
31+// never the wall clock and never a sleep.
32+
33+// attachmentTimeoutError is the one typed failure a deadline ends an attachment
34+// with. The cause stays local for errors.Is/As and is never display text.
35+func attachmentTimeoutError(cause error) error {
36+ return ports.BrokerError{Code: ports.BrokerErrorTimeout, Text: "attachment timed out", Cause: cause}
37+}
38+
39+// attachmentDeadline owns the one absolute deadline of a whole attachment. It
40+// is safe for concurrent use: its timer goroutine expires it while the
41+// supervisor's run goroutine may adopt a later deadline after the stream opens.
42+type attachmentDeadline struct {
43+ clock ports.Clock
44+ ctx context.Context
45+ cancel context.CancelFunc
46+ timedOut chan struct{}
47+
48+ mu sync.Mutex
49+ deadline time.Time
50+ timer ports.Timer
51+ stop chan struct{}
52+ done chan struct{}
53+ expired atomic.Bool
54+ expireOnce sync.Once
55+ // finishOnce makes finish idempotent across the deferred teardown and any
56+ // early failure path.
57+ finishOnce sync.Once
58+}
59+
60+// startAttachmentDeadline arms the pre-open budget of one attachment. The
61+// returned deadline already bounds the OpenStream call; adopt may fold the
62+// stream's own accepted absolute deadline into it, but the earlier deadline
63+// always stays in force.
64+func startAttachmentDeadline(parent context.Context, clock ports.Clock) *attachmentDeadline {
65+ if parent == nil {
66+ parent = context.Background()
67+ }
68+ if supervisorNil(clock) {
69+ clock = systemClock{}
70+ }
71+ ctx, cancel := context.WithCancel(parent)
72+ deadline := &attachmentDeadline{
73+ clock: clock,
74+ ctx: ctx,
75+ cancel: cancel,
76+ timedOut: make(chan struct{}),
77+ }
78+ deadline.mu.Lock()
79+ deadline.deadline = clock.Now().Add(protocol.HandshakeTimeout)
80+ deadline.armLocked(protocol.HandshakeTimeout)
81+ deadline.mu.Unlock()
82+ return deadline
83+}
84+
85+// armLocked starts a fresh timer for exactly duration. Callers hold d.mu and
86+// must have stopped any previous timer. It never derives duration from
87+// protocol.HandshakeTimeout itself, so folding in an absolute deadline can only
88+// shrink the remaining budget, never restart it.
89+func (d *attachmentDeadline) armLocked(duration time.Duration) {
90+ if duration <= 0 {
91+ d.expire()
92+ return
93+ }
94+ timer := d.clock.NewTimer(duration)
95+ if supervisorNil(timer) {
96+ timer = systemClock{}.NewTimer(duration)
97+ }
98+ stop := make(chan struct{})
99+ done := make(chan struct{})
100+ d.timer = timer
101+ d.stop = stop
102+ d.done = done
103+ go func() {
104+ defer close(done)
105+ select {
106+ case <-timer.C():
107+ d.expire()
108+ case <-stop:
109+ }
110+ }()
111+}
112+
113+// stopTimerLocked stops and joins the current timer goroutine. Callers hold
114+// d.mu. It is a no-op when no timer is armed.
115+func (d *attachmentDeadline) stopTimerLocked() {
116+ if d.timer == nil {
117+ return
118+ }
119+ close(d.stop)
120+ <-d.done
121+ d.timer.Stop()
122+ d.timer, d.stop, d.done = nil, nil, nil
123+}
124+
125+// expire closes the timeout signal and cancels the attachment context exactly
126+// once without taking d.mu. Timer retirement may join a goroutine that has
127+// already selected the timer channel, so expiry must never need that mutex.
128+func (d *attachmentDeadline) expire() {
129+ d.expireOnce.Do(func() {
130+ d.expired.Store(true)
131+ close(d.timedOut)
132+ d.cancel()
133+ })
134+}
135+
136+// adopt folds the admitted stream's accepted absolute deadline into the one
137+// attachment deadline, keeping the EARLIER of the two. It reports
138+// errAttachmentDeadline when the effective deadline already elapsed (including
139+// during stream open), which fails the attachment instead of starting a second
140+// budget. A stream without the optional seam keeps the pre-open deadline
141+// unchanged.
142+//
143+// Adoption never extends the deadline. The accepted deadline is anchored
144+// before this client's attempt began, so it is normally later than the client's
145+// own budget once the stream is admitted; adopting it verbatim would repay the
146+// open stage with a second full budget and let the whole attachment outlive the
147+// one absolute deadline. Keeping the earlier value still consumes the time
148+// already spent before this client saw the stream, because that anchor is
149+// earlier.
150+func (d *attachmentDeadline) adopt(stream ports.BrokerLogicalConnection) error {
151+ provider, ok := stream.(ports.HandshakeDeadlineProvider)
152+ if !ok {
153+ return nil
154+ }
155+ accepted := provider.HandshakeDeadline()
156+ if accepted.IsZero() {
157+ return nil
158+ }
159+ d.mu.Lock()
160+ defer d.mu.Unlock()
161+ d.stopTimerLocked()
162+ // Expiry is lock-free and may win while the old timer is being joined.
163+ // The post-join flag is authoritative; never re-arm a cancelled context.
164+ if d.expired.Load() {
165+ return errAttachmentDeadline
166+ }
167+ if accepted.Before(d.deadline) {
168+ d.deadline = accepted
169+ }
170+ remaining := d.deadline.Sub(d.clock.Now())
171+ if remaining <= 0 {
172+ d.expire()
173+ return errAttachmentDeadline
174+ }
175+ if d.expired.Load() {
176+ return errAttachmentDeadline
177+ }
178+ d.armLocked(remaining)
179+ return nil
180+}
181+
182+// Context bounds every stage of the attachment (stream open, preamble,
183+// Hello/Welcome, initial publication) with the one absolute deadline.
184+func (d *attachmentDeadline) Context() context.Context { return d.ctx }
185+
186+// TimedOut reports whether the absolute deadline fired.
187+func (d *attachmentDeadline) TimedOut() bool {
188+ select {
189+ case <-d.timedOut:
190+ return true
191+ default:
192+ return false
193+ }
194+}
195+
196+// cause maps an open-stream failure to the typed timeout when the deadline
197+// fired, so an expired budget is never reported as an ordinary unavailability.
198+func (d *attachmentDeadline) cause(err error) error {
199+ if d.TimedOut() {
200+ return attachmentTimeoutError(errors.Join(errAttachmentDeadline, err))
201+ }
202+ return normalizeUnavailable(err)
203+}
204+
205+// disarm stops the deadline without cancelling the worker context. It is safe
206+// to race with expiry and with the deferred finish.
207+func (d *attachmentDeadline) disarm() {
208+ if d == nil {
209+ return
210+ }
211+ d.mu.Lock()
212+ d.stopTimerLocked()
213+ d.mu.Unlock()
214+}
215+
216+// finish stops the deadline timer and cancels the attachment context. It is
217+// idempotent and is the caller's deferred teardown for one attachment.
218+func (d *attachmentDeadline) finish() {
219+ if d == nil {
220+ return
221+ }
222+ d.finishOnce.Do(func() {
223+ d.mu.Lock()
224+ d.stopTimerLocked()
225+ d.mu.Unlock()
226+ d.cancel()
227+ })
228+}
229+
230+// attachmentSettlement is one worker run's outcome as observed by the
231+// supervisor: whether the supervisor adopted the event or retired the run.
232+type attachmentSettlement struct {
233+ event AttachmentEvent
234+ adopted bool
235+}
236+
237+// resolveCommittedStreamRequest turns exactly one committed catalogue key into
238+// the exact broker stream request the user committed. The stream ID is
239+// allocated strictly increasing per connection and is consumed even when
240+// resolution refuses, matching the pool's never-reused ID contract.
241+func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) {
242+ base := pickerResolveBase{
243+ Connection: service.ConnectionID(),
244+ Stream: ports.BrokerStreamID(s.nextStream.Add(1)),
245+ }
246+ return s.cfg.Picker.ResolveKey(key, base)
247+}
248+
249+// newAttachmentWorker builds the real typed-session worker for one admitted
250+// request. The worker receives no endpoint, route, or raw-mode authority: it
251+// only drives the typed session protocol on the stream the supervisor opened.
252+func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, beforeAttached func() error) (AttachmentWorker, error) {
253+ geometry, err := s.cfg.Terminal.Geometry()
254+ if err != nil {
255+ return nil, fmt.Errorf("vev: reading terminal geometry: %w", err)
256+ }
257+ environment := s.cfg.AttachmentEnvironment
258+ return newSessionAttachmentWorker(sessionAttachmentConfig{
259+ Request: request,
260+ ClientID: s.clientID,
261+ Geometry: geometry,
262+ TermEnv: environment.TermEnv,
263+ Cwd: environment.Cwd,
264+ TrueColor: environment.TrueColor,
265+ BeforeAttached: beforeAttached,
266+ }), nil
267+}
268+
269+// runCommittedAttachment admits one committed selection end to end: resolve,
270+// open the exact stream under the one absolute deadline, run the real
271+// attachment worker through the supervisor's foreground grant, and return to
272+// the picker. It reports terminated=true only when the run itself must end
273+// (process cancellation or terminal EOF); every attachment outcome returns to
274+// the picker in the same process.
275+func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) {
276+ picker := s.cfg.Picker
277+ if picker == nil {
278+ return false, nil
279+ }
280+ // Present connecting before releasing picker input, so a slow resolve/open
281+ // never leaves a visible picker whose keystrokes are silently discarded.
282+ s.transition(supervisorEvent{kind: supervisorAttachBegin})
283+ // Release picker input for the whole attachment. Exactly one owner
284+ // consumes the shared terminal reader, so picker input can never reach a
285+ // session and the attachment path starts no second reader.
286+ picker.SetOwnsInput(false)
287+ defer picker.SetOwnsInput(true)
288+
289+ request, err := s.resolveCommittedStreamRequest(service, key)
290+ if err != nil {
291+ // A refused selection is a typed presentation notice; it never opens a
292+ // stream or becomes a session write.
293+ s.reportAttachmentFailure(err)
294+ return false, nil
295+ }
296+
297+ deadline := startAttachmentDeadline(ctx, s.cfg.Clock)
298+ defer deadline.finish()
299+
300+ stream, err := service.OpenStream(deadline.Context(), request)
301+ if err != nil {
302+ s.reportAttachmentFailure(deadline.cause(err))
303+ return false, nil
304+ }
305+ if supervisorNil(stream) {
306+ s.reportAttachmentFailure(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker returned no stream"})
307+ return false, nil
308+ }
309+ if err := deadline.adopt(stream); err != nil {
310+ // The accepted absolute deadline already elapsed before this client
311+ // saw the stream. Fail the attachment instead of restarting a budget.
312+ _ = stream.Close()
313+ s.reportAttachmentFailure(attachmentTimeoutError(err))
314+ return false, nil
315+ }
316+
317+ worker, err := s.newAttachmentWorker(request, func() error {
318+ deadline.disarm()
319+ if deadline.expired.Load() {
320+ return errAttachmentDeadline
321+ }
322+ return nil
323+ })
324+ if err != nil {
325+ _ = stream.Close()
326+ s.reportAttachmentFailure(err)
327+ return false, nil
328+ }
329+
330+ token := AttachmentToken{Generation: s.State().Generation, Attempt: s.nextAttachment.Add(1)}
331+ run, ok := s.attachments.Begin(deadline.Context(), token, worker, stream)
332+ if !ok {
333+ // Another foreground owns the terminal, or the shared reader is
334+ // claimed. Begin never closes the stream, so the supervisor does.
335+ _ = stream.Close()
336+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
337+ return false, nil
338+ }
339+ return s.settleAttachment(ctx, input, service, deadline, run)
340+}
341+
342+// settleAttachment joins one admitted attachment while watching the parent
343+// context, terminal EOF, and broker-connection loss. The deadline is the only
344+// other stop: when it fires before the worker settled, the attachment ends as
345+// a typed timeout without claiming attachment.
346+func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun) (bool, error) {
347+ settled := make(chan attachmentSettlement, 1)
348+ go func() {
349+ event, adopted := run.Wait(deadline.Context())
350+ settled <- attachmentSettlement{event: event, adopted: adopted}
351+ }()
352+
353+ var result attachmentSettlement
354+ var terminated bool
355+ var termErr error
356+ select {
357+ case result = <-settled:
358+ case <-service.Done():
359+ // The broker connection is gone; the supervisor cancels the run and
360+ // lets the ready loop observe the loss and retire the connection.
361+ run.Cancel()
362+ result = <-settled
363+ case <-ctx.Done():
364+ run.Cancel()
365+ result = <-settled
366+ terminated, termErr = true, ctx.Err()
367+ case err := <-input.EOF():
368+ run.Cancel()
369+ result = <-settled
370+ terminated, termErr = true, terminalReadCause(err)
371+ }
372+
373+ if terminated {
374+ return true, termErr
375+ }
376+ if deadline.TimedOut() && !result.adopted {
377+ timeout := attachmentTimeoutError(errAttachmentDeadline)
378+ s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout})
379+ s.notifyAttachment(timeout)
380+ return false, nil
381+ }
382+ if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) {
383+ s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err})
384+ s.notifyAttachment(result.event.Err)
385+ return false, nil
386+ }
387+ // An orderly end, or a run the supervisor retired for a broker loss,
388+ // returns to the picker with no attachment failure reported. A stream that
389+ // failed after attachment was lost/failed above, so it can never leave the
390+ // attached presentation showing.
391+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
392+ return false, nil
393+}
394+
395+// reportAttachmentFailure returns to the picker with a typed, visible failure.
396+func (s *Supervisor) reportAttachmentFailure(err error) {
397+ s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err})
398+ s.notifyAttachment(err)
399+}
400+
401+// notifyAttachment publishes the typed failure to the optional observer.
402+func (s *Supervisor) notifyAttachment(err error) {
403+ if s.cfg.Notify == nil || err == nil {
404+ return
405+ }
406+ s.cfg.Notify(s.State(), err)
407+}
@@ -0,0 +1,407 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+ "sync/atomic"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol"
13+)
14+
15+// Committed attachment orchestration (Plan 001 P5.3b, offline and unactivated).
16+//
17+// After the picker resolves a committed selection to one exact
18+// ports.BrokerOpenStreamRequest, the supervisor opens that logical stream on
19+// the broker and hands the typed connection to the real attachment worker
20+// through its existing foreground grant. The supervisor still owns the
21+// terminal, raw mode, the single input lifetime, and the close authority over
22+// the stream; the picker never opens, writes, or retargets a session.
23+//
24+// One absolute deadline bounds the whole attachment: stream open, the session
25+// preamble, Hello/Welcome, and the committed initial publication. It is never
26+// restarted and never extended. When the admitted stream exposes an accepted
27+// absolute deadline (ports.HandshakeDeadlineProvider) the earlier of the two is
28+// kept, so queue time spent before the stream reached this client is consumed
29+// rather than repaid with a second budget; an already-elapsed deadline fails the
30+// attachment immediately. The deadline is driven by ports.Clock/ports.Timer,
31+// never the wall clock and never a sleep.
32+
33+// attachmentTimeoutError is the one typed failure a deadline ends an attachment
34+// with. The cause stays local for errors.Is/As and is never display text.
35+func attachmentTimeoutError(cause error) error {
36+ return ports.BrokerError{Code: ports.BrokerErrorTimeout, Text: "attachment timed out", Cause: cause}
37+}
38+
39+// attachmentDeadline owns the one absolute deadline of a whole attachment. It
40+// is safe for concurrent use: its timer goroutine expires it while the
41+// supervisor's run goroutine may adopt a later deadline after the stream opens.
42+type attachmentDeadline struct {
43+ clock ports.Clock
44+ ctx context.Context
45+ cancel context.CancelFunc
46+ timedOut chan struct{}
47+
48+ mu sync.Mutex
49+ deadline time.Time
50+ timer ports.Timer
51+ stop chan struct{}
52+ done chan struct{}
53+ expired atomic.Bool
54+ expireOnce sync.Once
55+ // finishOnce makes finish idempotent across the deferred teardown and any
56+ // early failure path.
57+ finishOnce sync.Once
58+}
59+
60+// startAttachmentDeadline arms the pre-open budget of one attachment. The
61+// returned deadline already bounds the OpenStream call; adopt may fold the
62+// stream's own accepted absolute deadline into it, but the earlier deadline
63+// always stays in force.
64+func startAttachmentDeadline(parent context.Context, clock ports.Clock) *attachmentDeadline {
65+ if parent == nil {
66+ parent = context.Background()
67+ }
68+ if supervisorNil(clock) {
69+ clock = systemClock{}
70+ }
71+ ctx, cancel := context.WithCancel(parent)
72+ deadline := &attachmentDeadline{
73+ clock: clock,
74+ ctx: ctx,
75+ cancel: cancel,
76+ timedOut: make(chan struct{}),
77+ }
78+ deadline.mu.Lock()
79+ deadline.deadline = clock.Now().Add(protocol.HandshakeTimeout)
80+ deadline.armLocked(protocol.HandshakeTimeout)
81+ deadline.mu.Unlock()
82+ return deadline
83+}
84+
85+// armLocked starts a fresh timer for exactly duration. Callers hold d.mu and
86+// must have stopped any previous timer. It never derives duration from
87+// protocol.HandshakeTimeout itself, so folding in an absolute deadline can only
88+// shrink the remaining budget, never restart it.
89+func (d *attachmentDeadline) armLocked(duration time.Duration) {
90+ if duration <= 0 {
91+ d.expire()
92+ return
93+ }
94+ timer := d.clock.NewTimer(duration)
95+ if supervisorNil(timer) {
96+ timer = systemClock{}.NewTimer(duration)
97+ }
98+ stop := make(chan struct{})
99+ done := make(chan struct{})
100+ d.timer = timer
101+ d.stop = stop
102+ d.done = done
103+ go func() {
104+ defer close(done)
105+ select {
106+ case <-timer.C():
107+ d.expire()
108+ case <-stop:
109+ }
110+ }()
111+}
112+
113+// stopTimerLocked stops and joins the current timer goroutine. Callers hold
114+// d.mu. It is a no-op when no timer is armed.
115+func (d *attachmentDeadline) stopTimerLocked() {
116+ if d.timer == nil {
117+ return
118+ }
119+ close(d.stop)
120+ <-d.done
121+ d.timer.Stop()
122+ d.timer, d.stop, d.done = nil, nil, nil
123+}
124+
125+// expire closes the timeout signal and cancels the attachment context exactly
126+// once without taking d.mu. Timer retirement may join a goroutine that has
127+// already selected the timer channel, so expiry must never need that mutex.
128+func (d *attachmentDeadline) expire() {
129+ d.expireOnce.Do(func() {
130+ d.expired.Store(true)
131+ close(d.timedOut)
132+ d.cancel()
133+ })
134+}
135+
136+// adopt folds the admitted stream's accepted absolute deadline into the one
137+// attachment deadline, keeping the EARLIER of the two. It reports
138+// errAttachmentDeadline when the effective deadline already elapsed (including
139+// during stream open), which fails the attachment instead of starting a second
140+// budget. A stream without the optional seam keeps the pre-open deadline
141+// unchanged.
142+//
143+// Adoption never extends the deadline. The accepted deadline is anchored
144+// before this client's attempt began, so it is normally later than the client's
145+// own budget once the stream is admitted; adopting it verbatim would repay the
146+// open stage with a second full budget and let the whole attachment outlive the
147+// one absolute deadline. Keeping the earlier value still consumes the time
148+// already spent before this client saw the stream, because that anchor is
149+// earlier.
150+func (d *attachmentDeadline) adopt(stream ports.BrokerLogicalConnection) error {
151+ provider, ok := stream.(ports.HandshakeDeadlineProvider)
152+ if !ok {
153+ return nil
154+ }
155+ accepted := provider.HandshakeDeadline()
156+ if accepted.IsZero() {
157+ return nil
158+ }
159+ d.mu.Lock()
160+ defer d.mu.Unlock()
161+ d.stopTimerLocked()
162+ // Expiry is lock-free and may win while the old timer is being joined.
163+ // The post-join flag is authoritative; never re-arm a cancelled context.
164+ if d.expired.Load() {
165+ return errAttachmentDeadline
166+ }
167+ if accepted.Before(d.deadline) {
168+ d.deadline = accepted
169+ }
170+ remaining := d.deadline.Sub(d.clock.Now())
171+ if remaining <= 0 {
172+ d.expire()
173+ return errAttachmentDeadline
174+ }
175+ if d.expired.Load() {
176+ return errAttachmentDeadline
177+ }
178+ d.armLocked(remaining)
179+ return nil
180+}
181+
182+// Context bounds every stage of the attachment (stream open, preamble,
183+// Hello/Welcome, initial publication) with the one absolute deadline.
184+func (d *attachmentDeadline) Context() context.Context { return d.ctx }
185+
186+// TimedOut reports whether the absolute deadline fired.
187+func (d *attachmentDeadline) TimedOut() bool {
188+ select {
189+ case <-d.timedOut:
190+ return true
191+ default:
192+ return false
193+ }
194+}
195+
196+// cause maps an open-stream failure to the typed timeout when the deadline
197+// fired, so an expired budget is never reported as an ordinary unavailability.
198+func (d *attachmentDeadline) cause(err error) error {
199+ if d.TimedOut() {
200+ return attachmentTimeoutError(errors.Join(errAttachmentDeadline, err))
201+ }
202+ return normalizeUnavailable(err)
203+}
204+
205+// disarm stops the deadline without cancelling the worker context. It is safe
206+// to race with expiry and with the deferred finish.
207+func (d *attachmentDeadline) disarm() {
208+ if d == nil {
209+ return
210+ }
211+ d.mu.Lock()
212+ d.stopTimerLocked()
213+ d.mu.Unlock()
214+}
215+
216+// finish stops the deadline timer and cancels the attachment context. It is
217+// idempotent and is the caller's deferred teardown for one attachment.
218+func (d *attachmentDeadline) finish() {
219+ if d == nil {
220+ return
221+ }
222+ d.finishOnce.Do(func() {
223+ d.mu.Lock()
224+ d.stopTimerLocked()
225+ d.mu.Unlock()
226+ d.cancel()
227+ })
228+}
229+
230+// attachmentSettlement is one worker run's outcome as observed by the
231+// supervisor: whether the supervisor adopted the event or retired the run.
232+type attachmentSettlement struct {
233+ event AttachmentEvent
234+ adopted bool
235+}
236+
237+// resolveCommittedStreamRequest turns exactly one committed catalogue key into
238+// the exact broker stream request the user committed. The stream ID is
239+// allocated strictly increasing per connection and is consumed even when
240+// resolution refuses, matching the pool's never-reused ID contract.
241+func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) {
242+ base := pickerResolveBase{
243+ Connection: service.ConnectionID(),
244+ Stream: ports.BrokerStreamID(s.nextStream.Add(1)),
245+ }
246+ return s.cfg.Picker.ResolveKey(key, base)
247+}
248+
249+// newAttachmentWorker builds the real typed-session worker for one admitted
250+// request. The worker receives no endpoint, route, or raw-mode authority: it
251+// only drives the typed session protocol on the stream the supervisor opened.
252+func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, beforeAttached func() error) (AttachmentWorker, error) {
253+ geometry, err := s.cfg.Terminal.Geometry()
254+ if err != nil {
255+ return nil, fmt.Errorf("vev: reading terminal geometry: %w", err)
256+ }
257+ environment := s.cfg.AttachmentEnvironment
258+ return newSessionAttachmentWorker(sessionAttachmentConfig{
259+ Request: request,
260+ ClientID: s.clientID,
261+ Geometry: geometry,
262+ TermEnv: environment.TermEnv,
263+ Cwd: environment.Cwd,
264+ TrueColor: environment.TrueColor,
265+ BeforeAttached: beforeAttached,
266+ }), nil
267+}
268+
269+// runCommittedAttachment admits one committed selection end to end: resolve,
270+// open the exact stream under the one absolute deadline, run the real
271+// attachment worker through the supervisor's foreground grant, and return to
272+// the picker. It reports terminated=true only when the run itself must end
273+// (process cancellation or terminal EOF); every attachment outcome returns to
274+// the picker in the same process.
275+func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) {
276+ picker := s.cfg.Picker
277+ if picker == nil {
278+ return false, nil
279+ }
280+ // Present connecting before releasing picker input, so a slow resolve/open
281+ // never leaves a visible picker whose keystrokes are silently discarded.
282+ s.transition(supervisorEvent{kind: supervisorAttachBegin})
283+ // Release picker input for the whole attachment. Exactly one owner
284+ // consumes the shared terminal reader, so picker input can never reach a
285+ // session and the attachment path starts no second reader.
286+ picker.SetOwnsInput(false)
287+ defer picker.SetOwnsInput(true)
288+
289+ request, err := s.resolveCommittedStreamRequest(service, key)
290+ if err != nil {
291+ // A refused selection is a typed presentation notice; it never opens a
292+ // stream or becomes a session write.
293+ s.reportAttachmentFailure(err)
294+ return false, nil
295+ }
296+
297+ deadline := startAttachmentDeadline(ctx, s.cfg.Clock)
298+ defer deadline.finish()
299+
300+ stream, err := service.OpenStream(deadline.Context(), request)
301+ if err != nil {
302+ s.reportAttachmentFailure(deadline.cause(err))
303+ return false, nil
304+ }
305+ if supervisorNil(stream) {
306+ s.reportAttachmentFailure(ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker returned no stream"})
307+ return false, nil
308+ }
309+ if err := deadline.adopt(stream); err != nil {
310+ // The accepted absolute deadline already elapsed before this client
311+ // saw the stream. Fail the attachment instead of restarting a budget.
312+ _ = stream.Close()
313+ s.reportAttachmentFailure(attachmentTimeoutError(err))
314+ return false, nil
315+ }
316+
317+ worker, err := s.newAttachmentWorker(request, func() error {
318+ deadline.disarm()
319+ if deadline.expired.Load() {
320+ return errAttachmentDeadline
321+ }
322+ return nil
323+ })
324+ if err != nil {
325+ _ = stream.Close()
326+ s.reportAttachmentFailure(err)
327+ return false, nil
328+ }
329+
330+ token := AttachmentToken{Generation: s.State().Generation, Attempt: s.nextAttachment.Add(1)}
331+ run, ok := s.attachments.Begin(deadline.Context(), token, worker, stream)
332+ if !ok {
333+ // Another foreground owns the terminal, or the shared reader is
334+ // claimed. Begin never closes the stream, so the supervisor does.
335+ _ = stream.Close()
336+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
337+ return false, nil
338+ }
339+ return s.settleAttachment(ctx, input, service, deadline, run)
340+}
341+
342+// settleAttachment joins one admitted attachment while watching the parent
343+// context, terminal EOF, and broker-connection loss. The deadline is the only
344+// other stop: when it fires before the worker settled, the attachment ends as
345+// a typed timeout without claiming attachment.
346+func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, deadline *attachmentDeadline, run *attachmentRun) (bool, error) {
347+ settled := make(chan attachmentSettlement, 1)
348+ go func() {
349+ event, adopted := run.Wait(deadline.Context())
350+ settled <- attachmentSettlement{event: event, adopted: adopted}
351+ }()
352+
353+ var result attachmentSettlement
354+ var terminated bool
355+ var termErr error
356+ select {
357+ case result = <-settled:
358+ case <-service.Done():
359+ // The broker connection is gone; the supervisor cancels the run and
360+ // lets the ready loop observe the loss and retire the connection.
361+ run.Cancel()
362+ result = <-settled
363+ case <-ctx.Done():
364+ run.Cancel()
365+ result = <-settled
366+ terminated, termErr = true, ctx.Err()
367+ case err := <-input.EOF():
368+ run.Cancel()
369+ result = <-settled
370+ terminated, termErr = true, terminalReadCause(err)
371+ }
372+
373+ if terminated {
374+ return true, termErr
375+ }
376+ if deadline.TimedOut() && !result.adopted {
377+ timeout := attachmentTimeoutError(errAttachmentDeadline)
378+ s.transition(supervisorEvent{kind: supervisorAttachEnded, err: timeout})
379+ s.notifyAttachment(timeout)
380+ return false, nil
381+ }
382+ if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) {
383+ s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err})
384+ s.notifyAttachment(result.event.Err)
385+ return false, nil
386+ }
387+ // An orderly end, or a run the supervisor retired for a broker loss,
388+ // returns to the picker with no attachment failure reported. A stream that
389+ // failed after attachment was lost/failed above, so it can never leave the
390+ // attached presentation showing.
391+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
392+ return false, nil
393+}
394+
395+// reportAttachmentFailure returns to the picker with a typed, visible failure.
396+func (s *Supervisor) reportAttachmentFailure(err error) {
397+ s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err})
398+ s.notifyAttachment(err)
399+}
400+
401+// notifyAttachment publishes the typed failure to the optional observer.
402+func (s *Supervisor) notifyAttachment(err error) {
403+ if s.cfg.Notify == nil || err == nil {
404+ return
405+ }
406+ s.cfg.Notify(s.State(), err)
407+}
A internal/usecase/client/supervisor_attach_test.go +846 -0
@@ -0,0 +1,846 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/protocol"
17+)
18+
19+// Committed attachment supervisor tests (Plan 001 P5.3b). They drive the real
20+// Run loop over the in-package broker fakes, with a fake clock, no sleeps, and
21+// no production wiring.
22+
23+// attachTestTerminal is the supervisor-owned terminal double: it records output,
24+// flushes, raw entrances/restores, and UI publication boundaries so a test can
25+// prove exactly what a committed attachment wrote.
26+type attachTestTerminal struct {
27+ in io.Reader
28+
29+ mu sync.Mutex
30+ out bytes.Buffer
31+ flushes int
32+ restores int
33+ publications []ports.UIContext
34+ successes []bool
35+ publishEntered chan struct{}
36+ publishRelease <-chan struct{}
37+}
38+
39+func (t *attachTestTerminal) EnterRaw() (func() error, error) {
40+ return func() error {
41+ t.mu.Lock()
42+ t.restores++
43+ t.mu.Unlock()
44+ return nil
45+ }, nil
46+}
47+
48+func (t *attachTestTerminal) Geometry() (domain.Geometry, error) {
49+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
50+}
51+
52+func (t *attachTestTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
53+
54+func (t *attachTestTerminal) In() io.Reader { return t.in }
55+
56+func (t *attachTestTerminal) Out() io.Writer { return &attachTestWriter{term: t} }
57+
58+func (t *attachTestTerminal) Flush() error {
59+ t.mu.Lock()
60+ defer t.mu.Unlock()
61+ t.flushes++
62+ return nil
63+}
64+
65+func (t *attachTestTerminal) BeginOutput(ports.UIContext) {}
66+
67+func (t *attachTestTerminal) EndOutput(success bool) {
68+ t.mu.Lock()
69+ defer t.mu.Unlock()
70+ t.successes = append(t.successes, success)
71+}
72+
73+func (t *attachTestTerminal) PublishContext(uiContext ports.UIContext) error {
74+ if t.publishEntered != nil {
75+ select {
76+ case t.publishEntered <- struct{}{}:
77+ default:
78+ }
79+ }
80+ if t.publishRelease != nil {
81+ <-t.publishRelease
82+ }
83+ t.mu.Lock()
84+ defer t.mu.Unlock()
85+ t.publications = append(t.publications, uiContext)
86+ return nil
87+}
88+
89+func (t *attachTestTerminal) written() string {
90+ t.mu.Lock()
91+ defer t.mu.Unlock()
92+ return t.out.String()
93+}
94+
95+func (t *attachTestTerminal) counts() (writes int, flushes int, restores int) {
96+ t.mu.Lock()
97+ defer t.mu.Unlock()
98+ return len(t.out.Bytes()), t.flushes, t.restores
99+}
100+
101+type attachTestWriter struct{ term *attachTestTerminal }
102+
103+func (w *attachTestWriter) Write(data []byte) (int, error) {
104+ w.term.mu.Lock()
105+ defer w.term.mu.Unlock()
106+ return w.term.out.Write(data)
107+}
108+
109+// attachTestReader blocks until a chunk is pushed, then returns EOF once
110+// closed. It is the supervisor's single terminal reader.
111+type attachTestReader struct {
112+ chunks chan []byte
113+}
114+
115+func newAttachTestReader() *attachTestReader {
116+ return &attachTestReader{chunks: make(chan []byte, 8)}
117+}
118+
119+func (r *attachTestReader) Read(buffer []byte) (int, error) {
120+ chunk, ok := <-r.chunks
121+ if !ok {
122+ return 0, io.EOF
123+ }
124+ return copy(buffer, chunk), nil
125+}
126+
127+func (r *attachTestReader) push(data []byte) { r.chunks <- data }
128+
129+func (r *attachTestReader) close() { close(r.chunks) }
130+
131+// attachTestPicker is the supervisor's picker seam double. It records input
132+// ownership transitions, resolves exactly the committed key to one canned
133+// request, and coalesces commit wakeups exactly like the real controller.
134+type attachTestPicker struct {
135+ mu sync.Mutex
136+ opsReady chan struct{}
137+ ownsInput bool
138+ consumed int
139+ op pickerOp
140+ key string
141+ request ports.BrokerOpenStreamRequest
142+ resolved int
143+}
144+
145+func newAttachTestPicker() *attachTestPicker {
146+ return &attachTestPicker{opsReady: make(chan struct{}, 1), ownsInput: true}
147+}
148+
149+func (p *attachTestPicker) ApplySnapshot(ports.BrokerSnapshot) {}
150+
151+func (p *attachTestPicker) ConsumeTerminalRead([]byte) bool {
152+ p.mu.Lock()
153+ defer p.mu.Unlock()
154+ if !p.ownsInput {
155+ return false
156+ }
157+ p.consumed++
158+ return true
159+}
160+
161+func (p *attachTestPicker) TakeOp() (pickerOp, string) {
162+ p.mu.Lock()
163+ defer p.mu.Unlock()
164+ op, key := p.op, p.key
165+ p.op, p.key = pickerOp{}, ""
166+ return op, key
167+}
168+
169+func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
170+ p.mu.Lock()
171+ defer p.mu.Unlock()
172+ p.resolved++
173+ request := p.request
174+ request.Connection = base.Connection
175+ request.Stream = base.Stream
176+ return request, nil
177+}
178+
179+func (p *attachTestPicker) SetOwnsInput(owns bool) {
180+ p.mu.Lock()
181+ defer p.mu.Unlock()
182+ p.ownsInput = owns
183+}
184+
185+func (p *attachTestPicker) OpsReady() <-chan struct{} { return p.opsReady }
186+
187+func (p *attachTestPicker) commit(request ports.BrokerOpenStreamRequest) {
188+ p.mu.Lock()
189+ p.op.commit = true
190+ p.key = "row"
191+ p.request = request
192+ p.mu.Unlock()
193+ select {
194+ case p.opsReady <- struct{}{}:
195+ default:
196+ }
197+}
198+
199+func (p *attachTestPicker) owns() bool {
200+ p.mu.Lock()
201+ defer p.mu.Unlock()
202+ return p.ownsInput
203+}
204+
205+func (p *attachTestPicker) consumedCount() int {
206+ p.mu.Lock()
207+ defer p.mu.Unlock()
208+ return p.consumed
209+}
210+
211+func (p *attachTestPicker) resolveCount() int {
212+ p.mu.Lock()
213+ defer p.mu.Unlock()
214+ return p.resolved
215+}
216+
217+// attachTestHarness is one running supervisor with its scripted broker.
218+type attachTestHarness struct {
219+ sup *Supervisor
220+ service *supervisorTestService
221+ clock *supervisorTestClock
222+ terminal *attachTestTerminal
223+ reader *attachTestReader
224+ picker *attachTestPicker
225+ cancel context.CancelFunc
226+ runDone chan struct{}
227+
228+ runMu sync.Mutex
229+ runErr error
230+}
231+
232+// startAttachHarness runs one supervisor over a scripted picker and service.
233+// The caller installs service.setOpenStream before committing a selection.
234+func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness {
235+ t.Helper()
236+ ctx, cancel := context.WithCancel(context.Background())
237+ clock := newSupervisorTestClock()
238+ reader := newAttachTestReader()
239+ terminal := &attachTestTerminal{in: reader}
240+ service := newSupervisorTestService(ports.BrokerConnectionID{1})
241+ service.publish(3, 1)
242+
243+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
244+ return service, nil
245+ })
246+ sup := mustSupervisor(t, SupervisorConfig{
247+ Connector: connector,
248+ Terminal: terminal,
249+ Clock: clock,
250+ Jitter: func() float64 { return 0 },
251+ Picker: picker,
252+ })
253+
254+ harness := &attachTestHarness{sup: sup, service: service, clock: clock, terminal: terminal, reader: reader, cancel: cancel, runDone: make(chan struct{})}
255+ go func() {
256+ err := sup.Run(ctx)
257+ harness.runMu.Lock()
258+ harness.runErr = err
259+ harness.runMu.Unlock()
260+ close(harness.runDone)
261+ }()
262+ require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond)
263+
264+ t.Cleanup(func() {
265+ cancel()
266+ select {
267+ case <-harness.runDone:
268+ case <-time.After(5 * time.Second):
269+ t.Error("the supervisor did not stop")
270+ }
271+ })
272+ return harness
273+}
274+
275+// waitRun joins the supervisor and returns its terminal error.
276+func (h *attachTestHarness) waitRun(t *testing.T) error {
277+ t.Helper()
278+ select {
279+ case <-h.runDone:
280+ case <-time.After(5 * time.Second):
281+ t.Fatal("the supervisor did not stop")
282+ }
283+ h.runMu.Lock()
284+ defer h.runMu.Unlock()
285+ return h.runErr
286+}
287+
288+// snapshotTimers returns the armed timers for assertions without racing the
289+// clock's own bookkeeping.
290+func (c *supervisorTestClock) snapshotTimers() []*supervisorTestTimer {
291+ c.mu.Lock()
292+ defer c.mu.Unlock()
293+ return append([]*supervisorTestTimer(nil), c.timers...)
294+}
295+
296+// awaitAttachedState waits until the supervisor presents the attached state.
297+func awaitAttachedState(t *testing.T, sup *Supervisor) {
298+ t.Helper()
299+ require.Eventually(t, func() bool { return sup.State().Presentation == PresentAttached }, 5*time.Second, time.Millisecond)
300+}
301+
302+// awaitPickerState waits until the supervisor is back in the picker.
303+func awaitPickerState(t *testing.T, sup *Supervisor) {
304+ t.Helper()
305+ require.Eventually(t, func() bool { return sup.State().Presentation == PresentPicker }, 5*time.Second, time.Millisecond)
306+}
307+
308+// awaitStreamHello waits until the admitted stream has sent its first client
309+// message, polling the guarded stream pointer rather than a stale snapshot.
310+func awaitStreamHello(t *testing.T, mu *sync.Mutex, stream **sessionTestStream) {
311+ t.Helper()
312+ require.Eventually(t, func() bool {
313+ mu.Lock()
314+ defer mu.Unlock()
315+ return *stream != nil && len((*stream).messages()) >= 1
316+ }, 5*time.Second, time.Millisecond)
317+}
318+
319+// deliverReadyStream scripts Welcome plus one committed initial publication.
320+func deliverReadyStream(t *testing.T, stream *sessionTestStream) {
321+ t.Helper()
322+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
323+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
324+}
325+
326+// TestSupervisorReducerAttachmentPresentation pins the presentation
327+// transitions of one attachment: connecting until the committed publication,
328+// attached, then back to the picker without disturbing connectivity.
329+func TestSupervisorReducerAttachmentPresentation(t *testing.T) {
330+ ready := State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 2}
331+ connecting := reduceSupervisor(ready, supervisorEvent{kind: supervisorAttachBegin})
332+ require.Equal(t, PresentConnecting, connecting.Presentation)
333+ require.Equal(t, ConnectivityReady, connecting.Connectivity)
334+ require.Zero(t, connecting.Attempt)
335+
336+ attached := reduceSupervisor(connecting, supervisorEvent{kind: supervisorAttached})
337+ require.Equal(t, PresentAttached, attached.Presentation)
338+ require.Equal(t, ConnectivityReady, attached.Connectivity)
339+
340+ returned := reduceSupervisor(attached, supervisorEvent{kind: supervisorAttachEnded, err: errAttachmentDeadline})
341+ require.Equal(t, PresentPicker, returned.Presentation)
342+ require.Equal(t, ConnectivityReady, returned.Connectivity)
343+ require.ErrorIs(t, returned.Err, errAttachmentDeadline)
344+}
345+
346+// TestSupervisorAttachmentLocalRemoteParity proves the same supervisor path
347+// opens the exact resolved request for a local and a remote selection, with no
348+// dialer choice in the client.
349+func TestSupervisorAttachmentLocalRemoteParity(t *testing.T) {
350+ for _, local := range []bool{true, false} {
351+ name := "remote"
352+ if local {
353+ name = "local"
354+ }
355+ t.Run(name, func(t *testing.T) {
356+ picker := newAttachTestPicker()
357+ harness := startAttachHarness(t, picker)
358+ var (
359+ streamMu sync.Mutex
360+ stream *sessionTestStream
361+ )
362+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
363+ admitted := newSessionTestStream()
364+ streamMu.Lock()
365+ stream = admitted
366+ streamMu.Unlock()
367+ return admitted, nil
368+ })
369+ picker.commit(sessionTestRequest(local))
370+
371+ require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
372+ opened := harness.service.openedRequests()[0]
373+ require.Equal(t, local, opened.Local)
374+ require.Equal(t, ports.BrokerStreamAttachment, opened.Purpose)
375+ require.Equal(t, ports.BrokerAdmissionExact, opened.Admission)
376+ require.Equal(t, "alpha", opened.Target.SessionName)
377+ require.Equal(t, ports.BrokerConnectionID{1}, opened.Connection)
378+ require.Equal(t, ports.BrokerStreamID(1), opened.Stream)
379+ if !local {
380+ require.Equal(t, "user@example", opened.Endpoint)
381+ }
382+
383+ // The stream now runs the typed protocol and commits the initial
384+ // publication before attachment is reported.
385+ streamMu.Lock()
386+ admitted := stream
387+ streamMu.Unlock()
388+ require.NotNil(t, admitted)
389+ require.Eventually(t, func() bool { return len(admitted.messages()) >= 1 }, 5*time.Second, time.Millisecond)
390+ require.Equal(t, PresentConnecting, harness.sup.State().Presentation, "connecting until the publication commits")
391+ require.False(t, picker.owns(), "the picker released input for the attachment")
392+ deliverReadyStream(t, admitted)
393+ awaitAttachedState(t, harness.sup)
394+ require.Equal(t, "\x1b[Hready", harness.terminal.written())
395+ require.Equal(t, []bool{true}, harness.terminal.successes, "the frame committed through the UI transaction")
396+ })
397+ }
398+}
399+
400+// TestSupervisorAttachmentDeadlineElapsedDuringOpenFailsWithoutRestart proves a
401+// deadline that passed during stream open fails the attachment immediately
402+// instead of starting a second budget.
403+func TestSupervisorAttachmentDeadlineElapsedDuringOpenFailsWithoutRestart(t *testing.T) {
404+ picker := newAttachTestPicker()
405+ var (
406+ streamMu sync.Mutex
407+ stream *sessionTestStream
408+ )
409+ harness := startAttachHarness(t, picker)
410+ clock := harness.clock
411+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
412+ // The accepted absolute deadline elapsed before this client saw it.
413+ admitted := &sessionTestStreamWithDeadline{
414+ sessionTestStream: newSessionTestStream(),
415+ deadline: clock.Now().Add(-time.Second),
416+ }
417+ streamMu.Lock()
418+ stream = admitted.sessionTestStream
419+ streamMu.Unlock()
420+ return admitted, nil
421+ })
422+ picker.commit(sessionTestRequest(true))
423+
424+ awaitPickerState(t, harness.sup)
425+ require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond)
426+ var typed ports.BrokerError
427+ require.ErrorAs(t, harness.sup.State().Err, &typed)
428+ require.Equal(t, ports.BrokerErrorTimeout, typed.Code)
429+
430+ streamMu.Lock()
431+ require.NotNil(t, stream)
432+ require.True(t, stream.closedNow(), "an expired stream is closed")
433+ streamMu.Unlock()
434+
435+ // Exactly one deadline timer was ever armed, and it was stopped: the
436+ // elapsed accepted deadline never restarted a fresh budget.
437+ require.Equal(t, 1, harness.clock.timerCount(), "the elapsed deadline must not restart a budget")
438+ require.True(t, harness.clock.allStopped(), "the pre-open budget is retired")
439+}
440+
441+// TestSupervisorAttachmentDeadlineNotExtendedAcrossStages proves one absolute
442+// deadline spans the whole attachment: adopting the accepted deadline arms
443+// exactly its remaining time, and crossing Welcome never restarts it.
444+func TestSupervisorAttachmentDeadlineNotExtendedAcrossStages(t *testing.T) {
445+ picker := newAttachTestPicker()
446+ var (
447+ streamMu sync.Mutex
448+ stream *sessionTestStream
449+ )
450+ harness := startAttachHarness(t, picker)
451+ clock := harness.clock
452+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
453+ admitted := &sessionTestStreamWithDeadline{
454+ sessionTestStream: newSessionTestStream(),
455+ deadline: clock.Now().Add(5 * time.Second),
456+ }
457+ streamMu.Lock()
458+ stream = admitted.sessionTestStream
459+ streamMu.Unlock()
460+ return admitted, nil
461+ })
462+ picker.commit(sessionTestRequest(true))
463+
464+ awaitStreamHello(t, &streamMu, &stream)
465+
466+ streamMu.Lock()
467+ admitted := stream
468+ streamMu.Unlock()
469+
470+ // Wait until adoption armed the second timer.
471+ require.Eventually(t, func() bool { return harness.clock.timerCount() == 2 }, 5*time.Second, time.Millisecond)
472+ timers := harness.clock.snapshotTimers()
473+ require.Equal(t, protocol.HandshakeTimeout, timers[0].delay, "the pre-open budget bounds stream open")
474+ require.Equal(t, 5*time.Second, timers[1].delay, "the accepted deadline is adopted verbatim, never restarted")
475+ require.True(t, timers[0].stopped(), "the pre-open budget is replaced by the accepted deadline")
476+ require.False(t, timers[1].stopped())
477+
478+ // Crossing Welcome does not arm another deadline.
479+ admitted.deliver(protocol.Welcome{SessionName: "alpha"})
480+ require.Never(t, func() bool { return harness.clock.timerCount() > 2 }, 20*time.Millisecond, 2*time.Millisecond, "no stage may restart the deadline")
481+
482+ // Firing the one adopted deadline ends the attachment as a typed timeout
483+ // before publication, without claiming attachment.
484+ timers[1].fire()
485+ awaitPickerState(t, harness.sup)
486+ require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond)
487+ var typed ports.BrokerError
488+ require.ErrorAs(t, harness.sup.State().Err, &typed)
489+ require.Equal(t, ports.BrokerErrorTimeout, typed.Code)
490+ require.Empty(t, harness.terminal.publications, "a timed-out attachment never published")
491+}
492+
493+// TestSupervisorAttachmentLaterAcceptedDeadlineNeverExtendsBudget proves the
494+// one absolute deadline is the earlier of the client's own pre-open budget and
495+// the admitted stream's accepted deadline. The accepted deadline is anchored
496+// before this client's attempt began, so it is normally the later of the two;
497+// folding it in verbatim would repay the open stage with a fresh budget and let
498+// the attachment outlive the deadline.
499+func TestSupervisorAttachmentLaterAcceptedDeadlineNeverExtendsBudget(t *testing.T) {
500+ picker := newAttachTestPicker()
501+ var (
502+ streamMu sync.Mutex
503+ stream *sessionTestStream
504+ )
505+ harness := startAttachHarness(t, picker)
506+ clock := harness.clock
507+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
508+ admitted := &sessionTestStreamWithDeadline{
509+ sessionTestStream: newSessionTestStream(),
510+ deadline: clock.Now().Add(4 * protocol.HandshakeTimeout),
511+ }
512+ streamMu.Lock()
513+ stream = admitted.sessionTestStream
514+ streamMu.Unlock()
515+ return admitted, nil
516+ })
517+ picker.commit(sessionTestRequest(true))
518+
519+ awaitStreamHello(t, &streamMu, &stream)
520+
521+ require.Eventually(t, func() bool { return harness.clock.timerCount() == 2 }, 5*time.Second, time.Millisecond)
522+ timers := harness.clock.snapshotTimers()
523+ require.Equal(t, protocol.HandshakeTimeout, timers[0].delay, "the pre-open budget bounds stream open")
524+ require.Equal(t, protocol.HandshakeTimeout, timers[1].delay,
525+ "a later accepted deadline never repays the open stage with a second budget")
526+ require.True(t, timers[0].stopped())
527+ require.False(t, timers[1].stopped())
528+
529+ // The one deadline still ends the attachment as a typed timeout before
530+ // publication, and it does so no later than the client's own budget.
531+ timers[1].fire()
532+ awaitPickerState(t, harness.sup)
533+ require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond)
534+ var typed ports.BrokerError
535+ require.ErrorAs(t, harness.sup.State().Err, &typed)
536+ require.Equal(t, ports.BrokerErrorTimeout, typed.Code)
537+ require.Empty(t, harness.terminal.publications, "a timed-out attachment never published")
538+}
539+
540+// TestSupervisorAttachmentPreOpenBudgetWithoutProvider proves a stream without
541+// the optional seam keeps the one pre-open budget: no second timer, no restart.
542+func TestSupervisorAttachmentPreOpenBudgetWithoutProvider(t *testing.T) {
543+ picker := newAttachTestPicker()
544+ var (
545+ streamMu sync.Mutex
546+ stream *sessionTestStream
547+ )
548+ harness := startAttachHarness(t, picker)
549+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
550+ admitted := newSessionTestStream()
551+ streamMu.Lock()
552+ stream = admitted
553+ streamMu.Unlock()
554+ return admitted, nil
555+ })
556+ picker.commit(sessionTestRequest(true))
557+
558+ awaitStreamHello(t, &streamMu, &stream)
559+
560+ streamMu.Lock()
561+ admitted := stream
562+ streamMu.Unlock()
563+ require.Equal(t, 1, harness.clock.timerCount())
564+ timers := harness.clock.snapshotTimers()
565+ require.Equal(t, protocol.HandshakeTimeout, timers[0].delay)
566+ require.False(t, timers[0].stopped())
567+
568+ timers[0].fire()
569+ awaitPickerState(t, harness.sup)
570+ require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond)
571+ var typed ports.BrokerError
572+ require.ErrorAs(t, harness.sup.State().Err, &typed)
573+ require.Equal(t, ports.BrokerErrorTimeout, typed.Code)
574+ require.True(t, admitted.closedNow(), "the expired stream is closed")
575+}
576+
577+func TestSupervisorAttachmentDeadlineDisarmedAfterPublication(t *testing.T) {
578+ picker := newAttachTestPicker()
579+ var stream *sessionTestStream
580+ var streamMu sync.Mutex
581+ harness := startAttachHarness(t, picker)
582+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
583+ admitted := newSessionTestStream()
584+ streamMu.Lock()
585+ stream = admitted
586+ streamMu.Unlock()
587+ return admitted, nil
588+ })
589+ picker.commit(sessionTestRequest(true))
590+ awaitStreamHello(t, &streamMu, &stream)
591+ streamMu.Lock()
592+ admitted := stream
593+ streamMu.Unlock()
594+ deliverReadyStream(t, admitted)
595+ awaitAttachedState(t, harness.sup)
596+
597+ timer := harness.clock.snapshotTimers()[0]
598+ require.True(t, timer.stopped(), "the initial-publication commit disarms the budget")
599+ timer.fire()
600+ // This bounded negative window gives the retired deadline goroutine an
601+ // opportunity to mishandle the late fire; no sleep is used for ordering.
602+ require.Never(t, func() bool {
603+ return harness.sup.State().Presentation != PresentAttached || admitted.closedNow() || harness.sup.State().Err != nil
604+ }, 50*time.Millisecond, time.Millisecond)
605+}
606+
607+func TestAttachmentDeadlineConcurrentExpiryAndRetirement(t *testing.T) {
608+ tests := []struct {
609+ name string
610+ retire func(*attachmentDeadline)
611+ }{
612+ {name: "disarm", retire: (*attachmentDeadline).disarm},
613+ {name: "adopt", retire: func(deadline *attachmentDeadline) {
614+ _ = deadline.adopt(&sessionTestStreamWithDeadline{sessionTestStream: newSessionTestStream(), deadline: time.Unix(2000, 0)})
615+ }},
616+ }
617+ for _, tt := range tests {
618+ t.Run(tt.name, func(t *testing.T) {
619+ clock := newSupervisorTestClock()
620+ deadline := startAttachmentDeadline(context.Background(), clock)
621+ timer := clock.snapshotTimers()[0]
622+ done := make(chan struct{})
623+ go func() {
624+ timer.fire()
625+ tt.retire(deadline)
626+ close(done)
627+ }()
628+ select {
629+ case <-done:
630+ case <-time.After(time.Second):
631+ t.Fatal("concurrent expiry and retirement hung")
632+ }
633+ deadline.finish()
634+ require.True(t, clock.allStopped())
635+ })
636+ }
637+}
638+
639+// TestSupervisorAttachmentPostAttachmentLossReturnsToPicker proves a stream
640+// that fails after attachment never keeps the attached presentation: the
641+// supervisor returns to the picker with the typed loss.
642+func TestSupervisorAttachmentPostAttachmentLossReturnsToPicker(t *testing.T) {
643+ picker := newAttachTestPicker()
644+ var (
645+ streamMu sync.Mutex
646+ stream *sessionTestStream
647+ )
648+ harness := startAttachHarness(t, picker)
649+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
650+ admitted := newSessionTestStream()
651+ streamMu.Lock()
652+ stream = admitted
653+ streamMu.Unlock()
654+ return admitted, nil
655+ })
656+ picker.commit(sessionTestRequest(true))
657+ require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
658+
659+ streamMu.Lock()
660+ admitted := stream
661+ streamMu.Unlock()
662+ deliverReadyStream(t, admitted)
663+ awaitAttachedState(t, harness.sup)
664+
665+ admitted.fail(ports.BrokerStreamLost{
666+ Connection: ports.BrokerConnectionID{1},
667+ Stream: 1,
668+ Epoch: 3,
669+ Cause: domain.RemoteFailureTransport,
670+ Err: errors.New("attachment lost"),
671+ })
672+ awaitPickerState(t, harness.sup)
673+ require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond)
674+ require.NotEqual(t, PresentAttached, harness.sup.State().Presentation, "a failed stream is never still attached")
675+}
676+
677+// TestSupervisorAttachmentCancellationRetiresGeneration proves cancellation
678+// mid-attach closes the stream, ends the run, and never reports attachment.
679+func TestSupervisorAttachmentCancellationRetiresGeneration(t *testing.T) {
680+ picker := newAttachTestPicker()
681+ var (
682+ streamMu sync.Mutex
683+ stream *sessionTestStream
684+ )
685+ harness := startAttachHarness(t, picker)
686+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
687+ admitted := newSessionTestStream()
688+ streamMu.Lock()
689+ stream = admitted
690+ streamMu.Unlock()
691+ return admitted, nil
692+ })
693+ picker.commit(sessionTestRequest(true))
694+ require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
695+
696+ harness.cancel()
697+ require.ErrorIs(t, harness.waitRun(t), context.Canceled)
698+ streamMu.Lock()
699+ admitted := stream
700+ streamMu.Unlock()
701+ require.True(t, admitted.closedNow(), "the supervisor close authority released the stream")
702+ require.Equal(t, PresentTerminating, harness.sup.State().Presentation)
703+ require.Empty(t, harness.terminal.publications, "a cancelled attachment never published")
704+}
705+
706+// TestSupervisorPickerInputNeverReachesSession proves picker bytes produce no
707+// stream open, no session write, and no terminal write.
708+func TestSupervisorPickerInputNeverReachesSession(t *testing.T) {
709+ picker := newAttachTestPicker()
710+ harness := startAttachHarness(t, picker)
711+ opened := false
712+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
713+ opened = true
714+ return newSessionTestStream(), nil
715+ })
716+
717+ harness.reader.push([]byte("j"))
718+ require.Eventually(t, func() bool { return picker.consumedCount() >= 1 }, 5*time.Second, time.Millisecond)
719+ require.False(t, opened, "picker input must never open a stream")
720+ require.Zero(t, picker.resolveCount(), "picker input must never resolve a selection")
721+ require.Empty(t, harness.service.openedRequests())
722+ _, flushes, _ := harness.terminal.counts()
723+ require.Zero(t, flushes, "picker input must never flush session output")
724+ require.Empty(t, harness.terminal.written(), "picker input must never write session bytes")
725+}
726+
727+// TestSupervisorAttachmentReachableFromRealPicker drives the real client-owned
728+// picker: a committed row resolves to one exact stream request and its
729+// publication attaches.
730+func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) {
731+ ctx, cancel := context.WithCancel(context.Background())
732+ defer cancel()
733+
734+ clock := newSupervisorTestClock()
735+ reader := newPickerChunkReader()
736+ t.Cleanup(reader.close)
737+ terminal := &attachTestTerminal{in: reader}
738+ service := newSupervisorTestService(ports.BrokerConnectionID{1})
739+ service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
740+ admittedStreams := make(chan *sessionTestStream, 1)
741+ service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
742+ admitted := newSessionTestStream()
743+ admittedStreams <- admitted
744+ return admitted, nil
745+ })
746+
747+ controller := newPickerController(clock, pickerTestFreshness)
748+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil })
749+ sup := mustSupervisor(t, SupervisorConfig{
750+ Connector: connector,
751+ Terminal: terminal,
752+ Clock: clock,
753+ Jitter: func() float64 { return 0 },
754+ Picker: controller,
755+ })
756+ runErr := make(chan error, 1)
757+ go func() { runErr <- sup.Run(ctx) }()
758+
759+ require.Eventually(t, func() bool { return controller.Catalogue().Revision() == 1 }, 5*time.Second, time.Millisecond)
760+ require.Equal(t, []string{"alpha"}, pickerSessionLabels(controller.Catalogue().Lines()))
761+
762+ // The user commits the row under the cursor through the shared reader.
763+ reader.push([]byte("\r"))
764+ require.Eventually(t, func() bool { return len(service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
765+ opened := service.openedRequests()[0]
766+ require.Equal(t, "alpha", opened.Target.SessionName)
767+ require.True(t, opened.Local)
768+ require.Equal(t, ports.BrokerAdmissionExact, opened.Admission)
769+ require.Equal(t, ports.BrokerStreamID(1), opened.Stream)
770+
771+ var stream *sessionTestStream
772+ select {
773+ case stream = <-admittedStreams:
774+ case <-time.After(5 * time.Second):
775+ t.Fatal("the resolved stream was never admitted")
776+ }
777+ deliverReadyStream(t, stream)
778+ awaitAttachedState(t, sup)
779+
780+ cancel()
781+ require.ErrorIs(t, <-runErr, context.Canceled)
782+ require.True(t, stream.closedNow())
783+ require.Equal(t, 1, terminal.restores, "the supervisor restored raw mode exactly once")
784+}
785+
786+// TestSupervisorAttachmentConcurrentCommitAndCancellation deterministically
787+// retires attachment authority at each blocking boundary under review.
788+func TestSupervisorAttachmentConcurrentCommitAndCancellation(t *testing.T) {
789+ tests := []struct {
790+ name string
791+ run func(*testing.T, *attachTestHarness, *attachTestPicker) *sessionTestStream
792+ }{
793+ {name: "open in flight", run: func(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream {
794+ entered := make(chan struct{})
795+ release := make(chan struct{})
796+ stream := newSessionTestStream()
797+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
798+ close(entered)
799+ <-release
800+ return stream, nil
801+ })
802+ picker.commit(sessionTestRequest(true))
803+ <-entered
804+ harness.cancel()
805+ close(release)
806+ return stream
807+ }},
808+ {name: "publication commit in flight", run: func(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream {
809+ stream := newSessionTestStream()
810+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
811+ return stream, nil
812+ })
813+ entered := make(chan struct{}, 1)
814+ release := make(chan struct{})
815+ harness.terminal.publishEntered = entered
816+ harness.terminal.publishRelease = release
817+ picker.commit(sessionTestRequest(true))
818+ awaitHello(t, stream)
819+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
820+ stream.deliver(sessionTestOutput(1, "initial"))
821+ <-entered
822+ harness.cancel()
823+ close(release)
824+ return stream
825+ }},
826+ }
827+ for _, tt := range tests {
828+ t.Run(tt.name, func(t *testing.T) {
829+ picker := newAttachTestPicker()
830+ harness := startAttachHarness(t, picker)
831+ stream := tt.run(t, harness, picker)
832+ // Cancellation revokes picker authority immediately, before teardown
833+ // completes. Inject work in that teardown window and prove neither path
834+ // can consume it.
835+ resolved, consumed := picker.resolveCount(), picker.consumedCount()
836+ picker.commit(sessionTestRequest(true))
837+ harness.reader.push([]byte("retired"))
838+ require.False(t, picker.owns())
839+ require.Equal(t, resolved, picker.resolveCount(), "revoked picker operations must not resolve")
840+ require.Equal(t, consumed, picker.consumedCount(), "revoked input must not be consumed")
841+ require.ErrorIs(t, harness.waitRun(t), context.Canceled)
842+ require.True(t, stream.closedNow(), "the admitted stream must be released")
843+ require.NotEqual(t, PresentAttached, harness.sup.State().Presentation)
844+ })
845+ }
846+}
@@ -0,0 +1,846 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/protocol"
17+)
18+
19+// Committed attachment supervisor tests (Plan 001 P5.3b). They drive the real
20+// Run loop over the in-package broker fakes, with a fake clock, no sleeps, and
21+// no production wiring.
22+
23+// attachTestTerminal is the supervisor-owned terminal double: it records output,
24+// flushes, raw entrances/restores, and UI publication boundaries so a test can
25+// prove exactly what a committed attachment wrote.
26+type attachTestTerminal struct {
27+ in io.Reader
28+
29+ mu sync.Mutex
30+ out bytes.Buffer
31+ flushes int
32+ restores int
33+ publications []ports.UIContext
34+ successes []bool
35+ publishEntered chan struct{}
36+ publishRelease <-chan struct{}
37+}
38+
39+func (t *attachTestTerminal) EnterRaw() (func() error, error) {
40+ return func() error {
41+ t.mu.Lock()
42+ t.restores++
43+ t.mu.Unlock()
44+ return nil
45+ }, nil
46+}
47+
48+func (t *attachTestTerminal) Geometry() (domain.Geometry, error) {
49+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
50+}
51+
52+func (t *attachTestTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
53+
54+func (t *attachTestTerminal) In() io.Reader { return t.in }
55+
56+func (t *attachTestTerminal) Out() io.Writer { return &attachTestWriter{term: t} }
57+
58+func (t *attachTestTerminal) Flush() error {
59+ t.mu.Lock()
60+ defer t.mu.Unlock()
61+ t.flushes++
62+ return nil
63+}
64+
65+func (t *attachTestTerminal) BeginOutput(ports.UIContext) {}
66+
67+func (t *attachTestTerminal) EndOutput(success bool) {
68+ t.mu.Lock()
69+ defer t.mu.Unlock()
70+ t.successes = append(t.successes, success)
71+}
72+
73+func (t *attachTestTerminal) PublishContext(uiContext ports.UIContext) error {
74+ if t.publishEntered != nil {
75+ select {
76+ case t.publishEntered <- struct{}{}:
77+ default:
78+ }
79+ }
80+ if t.publishRelease != nil {
81+ <-t.publishRelease
82+ }
83+ t.mu.Lock()
84+ defer t.mu.Unlock()
85+ t.publications = append(t.publications, uiContext)
86+ return nil
87+}
88+
89+func (t *attachTestTerminal) written() string {
90+ t.mu.Lock()
91+ defer t.mu.Unlock()
92+ return t.out.String()
93+}
94+
95+func (t *attachTestTerminal) counts() (writes int, flushes int, restores int) {
96+ t.mu.Lock()
97+ defer t.mu.Unlock()
98+ return len(t.out.Bytes()), t.flushes, t.restores
99+}
100+
101+type attachTestWriter struct{ term *attachTestTerminal }
102+
103+func (w *attachTestWriter) Write(data []byte) (int, error) {
104+ w.term.mu.Lock()
105+ defer w.term.mu.Unlock()
106+ return w.term.out.Write(data)
107+}
108+
109+// attachTestReader blocks until a chunk is pushed, then returns EOF once
110+// closed. It is the supervisor's single terminal reader.
111+type attachTestReader struct {
112+ chunks chan []byte
113+}
114+
115+func newAttachTestReader() *attachTestReader {
116+ return &attachTestReader{chunks: make(chan []byte, 8)}
117+}
118+
119+func (r *attachTestReader) Read(buffer []byte) (int, error) {
120+ chunk, ok := <-r.chunks
121+ if !ok {
122+ return 0, io.EOF
123+ }
124+ return copy(buffer, chunk), nil
125+}
126+
127+func (r *attachTestReader) push(data []byte) { r.chunks <- data }
128+
129+func (r *attachTestReader) close() { close(r.chunks) }
130+
131+// attachTestPicker is the supervisor's picker seam double. It records input
132+// ownership transitions, resolves exactly the committed key to one canned
133+// request, and coalesces commit wakeups exactly like the real controller.
134+type attachTestPicker struct {
135+ mu sync.Mutex
136+ opsReady chan struct{}
137+ ownsInput bool
138+ consumed int
139+ op pickerOp
140+ key string
141+ request ports.BrokerOpenStreamRequest
142+ resolved int
143+}
144+
145+func newAttachTestPicker() *attachTestPicker {
146+ return &attachTestPicker{opsReady: make(chan struct{}, 1), ownsInput: true}
147+}
148+
149+func (p *attachTestPicker) ApplySnapshot(ports.BrokerSnapshot) {}
150+
151+func (p *attachTestPicker) ConsumeTerminalRead([]byte) bool {
152+ p.mu.Lock()
153+ defer p.mu.Unlock()
154+ if !p.ownsInput {
155+ return false
156+ }
157+ p.consumed++
158+ return true
159+}
160+
161+func (p *attachTestPicker) TakeOp() (pickerOp, string) {
162+ p.mu.Lock()
163+ defer p.mu.Unlock()
164+ op, key := p.op, p.key
165+ p.op, p.key = pickerOp{}, ""
166+ return op, key
167+}
168+
169+func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
170+ p.mu.Lock()
171+ defer p.mu.Unlock()
172+ p.resolved++
173+ request := p.request
174+ request.Connection = base.Connection
175+ request.Stream = base.Stream
176+ return request, nil
177+}
178+
179+func (p *attachTestPicker) SetOwnsInput(owns bool) {
180+ p.mu.Lock()
181+ defer p.mu.Unlock()
182+ p.ownsInput = owns
183+}
184+
185+func (p *attachTestPicker) OpsReady() <-chan struct{} { return p.opsReady }
186+
187+func (p *attachTestPicker) commit(request ports.BrokerOpenStreamRequest) {
188+ p.mu.Lock()
189+ p.op.commit = true
190+ p.key = "row"
191+ p.request = request
192+ p.mu.Unlock()
193+ select {
194+ case p.opsReady <- struct{}{}:
195+ default:
196+ }
197+}
198+
199+func (p *attachTestPicker) owns() bool {
200+ p.mu.Lock()
201+ defer p.mu.Unlock()
202+ return p.ownsInput
203+}
204+
205+func (p *attachTestPicker) consumedCount() int {
206+ p.mu.Lock()
207+ defer p.mu.Unlock()
208+ return p.consumed
209+}
210+
211+func (p *attachTestPicker) resolveCount() int {
212+ p.mu.Lock()
213+ defer p.mu.Unlock()
214+ return p.resolved
215+}
216+
217+// attachTestHarness is one running supervisor with its scripted broker.
218+type attachTestHarness struct {
219+ sup *Supervisor
220+ service *supervisorTestService
221+ clock *supervisorTestClock
222+ terminal *attachTestTerminal
223+ reader *attachTestReader
224+ picker *attachTestPicker
225+ cancel context.CancelFunc
226+ runDone chan struct{}
227+
228+ runMu sync.Mutex
229+ runErr error
230+}
231+
232+// startAttachHarness runs one supervisor over a scripted picker and service.
233+// The caller installs service.setOpenStream before committing a selection.
234+func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness {
235+ t.Helper()
236+ ctx, cancel := context.WithCancel(context.Background())
237+ clock := newSupervisorTestClock()
238+ reader := newAttachTestReader()
239+ terminal := &attachTestTerminal{in: reader}
240+ service := newSupervisorTestService(ports.BrokerConnectionID{1})
241+ service.publish(3, 1)
242+
243+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
244+ return service, nil
245+ })
246+ sup := mustSupervisor(t, SupervisorConfig{
247+ Connector: connector,
248+ Terminal: terminal,
249+ Clock: clock,
250+ Jitter: func() float64 { return 0 },
251+ Picker: picker,
252+ })
253+
254+ harness := &attachTestHarness{sup: sup, service: service, clock: clock, terminal: terminal, reader: reader, cancel: cancel, runDone: make(chan struct{})}
255+ go func() {
256+ err := sup.Run(ctx)
257+ harness.runMu.Lock()
258+ harness.runErr = err
259+ harness.runMu.Unlock()
260+ close(harness.runDone)
261+ }()
262+ require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond)
263+
264+ t.Cleanup(func() {
265+ cancel()
266+ select {
267+ case <-harness.runDone:
268+ case <-time.After(5 * time.Second):
269+ t.Error("the supervisor did not stop")
270+ }
271+ })
272+ return harness
273+}
274+
275+// waitRun joins the supervisor and returns its terminal error.
276+func (h *attachTestHarness) waitRun(t *testing.T) error {
277+ t.Helper()
278+ select {
279+ case <-h.runDone:
280+ case <-time.After(5 * time.Second):
281+ t.Fatal("the supervisor did not stop")
282+ }
283+ h.runMu.Lock()
284+ defer h.runMu.Unlock()
285+ return h.runErr
286+}
287+
288+// snapshotTimers returns the armed timers for assertions without racing the
289+// clock's own bookkeeping.
290+func (c *supervisorTestClock) snapshotTimers() []*supervisorTestTimer {
291+ c.mu.Lock()
292+ defer c.mu.Unlock()
293+ return append([]*supervisorTestTimer(nil), c.timers...)
294+}
295+
296+// awaitAttachedState waits until the supervisor presents the attached state.
297+func awaitAttachedState(t *testing.T, sup *Supervisor) {
298+ t.Helper()
299+ require.Eventually(t, func() bool { return sup.State().Presentation == PresentAttached }, 5*time.Second, time.Millisecond)
300+}
301+
302+// awaitPickerState waits until the supervisor is back in the picker.
303+func awaitPickerState(t *testing.T, sup *Supervisor) {
304+ t.Helper()
305+ require.Eventually(t, func() bool { return sup.State().Presentation == PresentPicker }, 5*time.Second, time.Millisecond)
306+}
307+
308+// awaitStreamHello waits until the admitted stream has sent its first client
309+// message, polling the guarded stream pointer rather than a stale snapshot.
310+func awaitStreamHello(t *testing.T, mu *sync.Mutex, stream **sessionTestStream) {
311+ t.Helper()
312+ require.Eventually(t, func() bool {
313+ mu.Lock()
314+ defer mu.Unlock()
315+ return *stream != nil && len((*stream).messages()) >= 1
316+ }, 5*time.Second, time.Millisecond)
317+}
318+
319+// deliverReadyStream scripts Welcome plus one committed initial publication.
320+func deliverReadyStream(t *testing.T, stream *sessionTestStream) {
321+ t.Helper()
322+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
323+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
324+}
325+
326+// TestSupervisorReducerAttachmentPresentation pins the presentation
327+// transitions of one attachment: connecting until the committed publication,
328+// attached, then back to the picker without disturbing connectivity.
329+func TestSupervisorReducerAttachmentPresentation(t *testing.T) {
330+ ready := State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Generation: 2}
331+ connecting := reduceSupervisor(ready, supervisorEvent{kind: supervisorAttachBegin})
332+ require.Equal(t, PresentConnecting, connecting.Presentation)
333+ require.Equal(t, ConnectivityReady, connecting.Connectivity)
334+ require.Zero(t, connecting.Attempt)
335+
336+ attached := reduceSupervisor(connecting, supervisorEvent{kind: supervisorAttached})
337+ require.Equal(t, PresentAttached, attached.Presentation)
338+ require.Equal(t, ConnectivityReady, attached.Connectivity)
339+
340+ returned := reduceSupervisor(attached, supervisorEvent{kind: supervisorAttachEnded, err: errAttachmentDeadline})
341+ require.Equal(t, PresentPicker, returned.Presentation)
342+ require.Equal(t, ConnectivityReady, returned.Connectivity)
343+ require.ErrorIs(t, returned.Err, errAttachmentDeadline)
344+}
345+
346+// TestSupervisorAttachmentLocalRemoteParity proves the same supervisor path
347+// opens the exact resolved request for a local and a remote selection, with no
348+// dialer choice in the client.
349+func TestSupervisorAttachmentLocalRemoteParity(t *testing.T) {
350+ for _, local := range []bool{true, false} {
351+ name := "remote"
352+ if local {
353+ name = "local"
354+ }
355+ t.Run(name, func(t *testing.T) {
356+ picker := newAttachTestPicker()
357+ harness := startAttachHarness(t, picker)
358+ var (
359+ streamMu sync.Mutex
360+ stream *sessionTestStream
361+ )
362+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
363+ admitted := newSessionTestStream()
364+ streamMu.Lock()
365+ stream = admitted
366+ streamMu.Unlock()
367+ return admitted, nil
368+ })
369+ picker.commit(sessionTestRequest(local))
370+
371+ require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
372+ opened := harness.service.openedRequests()[0]
373+ require.Equal(t, local, opened.Local)
374+ require.Equal(t, ports.BrokerStreamAttachment, opened.Purpose)
375+ require.Equal(t, ports.BrokerAdmissionExact, opened.Admission)
376+ require.Equal(t, "alpha", opened.Target.SessionName)
377+ require.Equal(t, ports.BrokerConnectionID{1}, opened.Connection)
378+ require.Equal(t, ports.BrokerStreamID(1), opened.Stream)
379+ if !local {
380+ require.Equal(t, "user@example", opened.Endpoint)
381+ }
382+
383+ // The stream now runs the typed protocol and commits the initial
384+ // publication before attachment is reported.
385+ streamMu.Lock()
386+ admitted := stream
387+ streamMu.Unlock()
388+ require.NotNil(t, admitted)
389+ require.Eventually(t, func() bool { return len(admitted.messages()) >= 1 }, 5*time.Second, time.Millisecond)
390+ require.Equal(t, PresentConnecting, harness.sup.State().Presentation, "connecting until the publication commits")
391+ require.False(t, picker.owns(), "the picker released input for the attachment")
392+ deliverReadyStream(t, admitted)
393+ awaitAttachedState(t, harness.sup)
394+ require.Equal(t, "\x1b[Hready", harness.terminal.written())
395+ require.Equal(t, []bool{true}, harness.terminal.successes, "the frame committed through the UI transaction")
396+ })
397+ }
398+}
399+
400+// TestSupervisorAttachmentDeadlineElapsedDuringOpenFailsWithoutRestart proves a
401+// deadline that passed during stream open fails the attachment immediately
402+// instead of starting a second budget.
403+func TestSupervisorAttachmentDeadlineElapsedDuringOpenFailsWithoutRestart(t *testing.T) {
404+ picker := newAttachTestPicker()
405+ var (
406+ streamMu sync.Mutex
407+ stream *sessionTestStream
408+ )
409+ harness := startAttachHarness(t, picker)
410+ clock := harness.clock
411+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
412+ // The accepted absolute deadline elapsed before this client saw it.
413+ admitted := &sessionTestStreamWithDeadline{
414+ sessionTestStream: newSessionTestStream(),
415+ deadline: clock.Now().Add(-time.Second),
416+ }
417+ streamMu.Lock()
418+ stream = admitted.sessionTestStream
419+ streamMu.Unlock()
420+ return admitted, nil
421+ })
422+ picker.commit(sessionTestRequest(true))
423+
424+ awaitPickerState(t, harness.sup)
425+ require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond)
426+ var typed ports.BrokerError
427+ require.ErrorAs(t, harness.sup.State().Err, &typed)
428+ require.Equal(t, ports.BrokerErrorTimeout, typed.Code)
429+
430+ streamMu.Lock()
431+ require.NotNil(t, stream)
432+ require.True(t, stream.closedNow(), "an expired stream is closed")
433+ streamMu.Unlock()
434+
435+ // Exactly one deadline timer was ever armed, and it was stopped: the
436+ // elapsed accepted deadline never restarted a fresh budget.
437+ require.Equal(t, 1, harness.clock.timerCount(), "the elapsed deadline must not restart a budget")
438+ require.True(t, harness.clock.allStopped(), "the pre-open budget is retired")
439+}
440+
441+// TestSupervisorAttachmentDeadlineNotExtendedAcrossStages proves one absolute
442+// deadline spans the whole attachment: adopting the accepted deadline arms
443+// exactly its remaining time, and crossing Welcome never restarts it.
444+func TestSupervisorAttachmentDeadlineNotExtendedAcrossStages(t *testing.T) {
445+ picker := newAttachTestPicker()
446+ var (
447+ streamMu sync.Mutex
448+ stream *sessionTestStream
449+ )
450+ harness := startAttachHarness(t, picker)
451+ clock := harness.clock
452+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
453+ admitted := &sessionTestStreamWithDeadline{
454+ sessionTestStream: newSessionTestStream(),
455+ deadline: clock.Now().Add(5 * time.Second),
456+ }
457+ streamMu.Lock()
458+ stream = admitted.sessionTestStream
459+ streamMu.Unlock()
460+ return admitted, nil
461+ })
462+ picker.commit(sessionTestRequest(true))
463+
464+ awaitStreamHello(t, &streamMu, &stream)
465+
466+ streamMu.Lock()
467+ admitted := stream
468+ streamMu.Unlock()
469+
470+ // Wait until adoption armed the second timer.
471+ require.Eventually(t, func() bool { return harness.clock.timerCount() == 2 }, 5*time.Second, time.Millisecond)
472+ timers := harness.clock.snapshotTimers()
473+ require.Equal(t, protocol.HandshakeTimeout, timers[0].delay, "the pre-open budget bounds stream open")
474+ require.Equal(t, 5*time.Second, timers[1].delay, "the accepted deadline is adopted verbatim, never restarted")
475+ require.True(t, timers[0].stopped(), "the pre-open budget is replaced by the accepted deadline")
476+ require.False(t, timers[1].stopped())
477+
478+ // Crossing Welcome does not arm another deadline.
479+ admitted.deliver(protocol.Welcome{SessionName: "alpha"})
480+ require.Never(t, func() bool { return harness.clock.timerCount() > 2 }, 20*time.Millisecond, 2*time.Millisecond, "no stage may restart the deadline")
481+
482+ // Firing the one adopted deadline ends the attachment as a typed timeout
483+ // before publication, without claiming attachment.
484+ timers[1].fire()
485+ awaitPickerState(t, harness.sup)
486+ require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond)
487+ var typed ports.BrokerError
488+ require.ErrorAs(t, harness.sup.State().Err, &typed)
489+ require.Equal(t, ports.BrokerErrorTimeout, typed.Code)
490+ require.Empty(t, harness.terminal.publications, "a timed-out attachment never published")
491+}
492+
493+// TestSupervisorAttachmentLaterAcceptedDeadlineNeverExtendsBudget proves the
494+// one absolute deadline is the earlier of the client's own pre-open budget and
495+// the admitted stream's accepted deadline. The accepted deadline is anchored
496+// before this client's attempt began, so it is normally the later of the two;
497+// folding it in verbatim would repay the open stage with a fresh budget and let
498+// the attachment outlive the deadline.
499+func TestSupervisorAttachmentLaterAcceptedDeadlineNeverExtendsBudget(t *testing.T) {
500+ picker := newAttachTestPicker()
501+ var (
502+ streamMu sync.Mutex
503+ stream *sessionTestStream
504+ )
505+ harness := startAttachHarness(t, picker)
506+ clock := harness.clock
507+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
508+ admitted := &sessionTestStreamWithDeadline{
509+ sessionTestStream: newSessionTestStream(),
510+ deadline: clock.Now().Add(4 * protocol.HandshakeTimeout),
511+ }
512+ streamMu.Lock()
513+ stream = admitted.sessionTestStream
514+ streamMu.Unlock()
515+ return admitted, nil
516+ })
517+ picker.commit(sessionTestRequest(true))
518+
519+ awaitStreamHello(t, &streamMu, &stream)
520+
521+ require.Eventually(t, func() bool { return harness.clock.timerCount() == 2 }, 5*time.Second, time.Millisecond)
522+ timers := harness.clock.snapshotTimers()
523+ require.Equal(t, protocol.HandshakeTimeout, timers[0].delay, "the pre-open budget bounds stream open")
524+ require.Equal(t, protocol.HandshakeTimeout, timers[1].delay,
525+ "a later accepted deadline never repays the open stage with a second budget")
526+ require.True(t, timers[0].stopped())
527+ require.False(t, timers[1].stopped())
528+
529+ // The one deadline still ends the attachment as a typed timeout before
530+ // publication, and it does so no later than the client's own budget.
531+ timers[1].fire()
532+ awaitPickerState(t, harness.sup)
533+ require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond)
534+ var typed ports.BrokerError
535+ require.ErrorAs(t, harness.sup.State().Err, &typed)
536+ require.Equal(t, ports.BrokerErrorTimeout, typed.Code)
537+ require.Empty(t, harness.terminal.publications, "a timed-out attachment never published")
538+}
539+
540+// TestSupervisorAttachmentPreOpenBudgetWithoutProvider proves a stream without
541+// the optional seam keeps the one pre-open budget: no second timer, no restart.
542+func TestSupervisorAttachmentPreOpenBudgetWithoutProvider(t *testing.T) {
543+ picker := newAttachTestPicker()
544+ var (
545+ streamMu sync.Mutex
546+ stream *sessionTestStream
547+ )
548+ harness := startAttachHarness(t, picker)
549+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
550+ admitted := newSessionTestStream()
551+ streamMu.Lock()
552+ stream = admitted
553+ streamMu.Unlock()
554+ return admitted, nil
555+ })
556+ picker.commit(sessionTestRequest(true))
557+
558+ awaitStreamHello(t, &streamMu, &stream)
559+
560+ streamMu.Lock()
561+ admitted := stream
562+ streamMu.Unlock()
563+ require.Equal(t, 1, harness.clock.timerCount())
564+ timers := harness.clock.snapshotTimers()
565+ require.Equal(t, protocol.HandshakeTimeout, timers[0].delay)
566+ require.False(t, timers[0].stopped())
567+
568+ timers[0].fire()
569+ awaitPickerState(t, harness.sup)
570+ require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond)
571+ var typed ports.BrokerError
572+ require.ErrorAs(t, harness.sup.State().Err, &typed)
573+ require.Equal(t, ports.BrokerErrorTimeout, typed.Code)
574+ require.True(t, admitted.closedNow(), "the expired stream is closed")
575+}
576+
577+func TestSupervisorAttachmentDeadlineDisarmedAfterPublication(t *testing.T) {
578+ picker := newAttachTestPicker()
579+ var stream *sessionTestStream
580+ var streamMu sync.Mutex
581+ harness := startAttachHarness(t, picker)
582+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
583+ admitted := newSessionTestStream()
584+ streamMu.Lock()
585+ stream = admitted
586+ streamMu.Unlock()
587+ return admitted, nil
588+ })
589+ picker.commit(sessionTestRequest(true))
590+ awaitStreamHello(t, &streamMu, &stream)
591+ streamMu.Lock()
592+ admitted := stream
593+ streamMu.Unlock()
594+ deliverReadyStream(t, admitted)
595+ awaitAttachedState(t, harness.sup)
596+
597+ timer := harness.clock.snapshotTimers()[0]
598+ require.True(t, timer.stopped(), "the initial-publication commit disarms the budget")
599+ timer.fire()
600+ // This bounded negative window gives the retired deadline goroutine an
601+ // opportunity to mishandle the late fire; no sleep is used for ordering.
602+ require.Never(t, func() bool {
603+ return harness.sup.State().Presentation != PresentAttached || admitted.closedNow() || harness.sup.State().Err != nil
604+ }, 50*time.Millisecond, time.Millisecond)
605+}
606+
607+func TestAttachmentDeadlineConcurrentExpiryAndRetirement(t *testing.T) {
608+ tests := []struct {
609+ name string
610+ retire func(*attachmentDeadline)
611+ }{
612+ {name: "disarm", retire: (*attachmentDeadline).disarm},
613+ {name: "adopt", retire: func(deadline *attachmentDeadline) {
614+ _ = deadline.adopt(&sessionTestStreamWithDeadline{sessionTestStream: newSessionTestStream(), deadline: time.Unix(2000, 0)})
615+ }},
616+ }
617+ for _, tt := range tests {
618+ t.Run(tt.name, func(t *testing.T) {
619+ clock := newSupervisorTestClock()
620+ deadline := startAttachmentDeadline(context.Background(), clock)
621+ timer := clock.snapshotTimers()[0]
622+ done := make(chan struct{})
623+ go func() {
624+ timer.fire()
625+ tt.retire(deadline)
626+ close(done)
627+ }()
628+ select {
629+ case <-done:
630+ case <-time.After(time.Second):
631+ t.Fatal("concurrent expiry and retirement hung")
632+ }
633+ deadline.finish()
634+ require.True(t, clock.allStopped())
635+ })
636+ }
637+}
638+
639+// TestSupervisorAttachmentPostAttachmentLossReturnsToPicker proves a stream
640+// that fails after attachment never keeps the attached presentation: the
641+// supervisor returns to the picker with the typed loss.
642+func TestSupervisorAttachmentPostAttachmentLossReturnsToPicker(t *testing.T) {
643+ picker := newAttachTestPicker()
644+ var (
645+ streamMu sync.Mutex
646+ stream *sessionTestStream
647+ )
648+ harness := startAttachHarness(t, picker)
649+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
650+ admitted := newSessionTestStream()
651+ streamMu.Lock()
652+ stream = admitted
653+ streamMu.Unlock()
654+ return admitted, nil
655+ })
656+ picker.commit(sessionTestRequest(true))
657+ require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
658+
659+ streamMu.Lock()
660+ admitted := stream
661+ streamMu.Unlock()
662+ deliverReadyStream(t, admitted)
663+ awaitAttachedState(t, harness.sup)
664+
665+ admitted.fail(ports.BrokerStreamLost{
666+ Connection: ports.BrokerConnectionID{1},
667+ Stream: 1,
668+ Epoch: 3,
669+ Cause: domain.RemoteFailureTransport,
670+ Err: errors.New("attachment lost"),
671+ })
672+ awaitPickerState(t, harness.sup)
673+ require.Eventually(t, func() bool { return harness.sup.State().Err != nil }, 5*time.Second, time.Millisecond)
674+ require.NotEqual(t, PresentAttached, harness.sup.State().Presentation, "a failed stream is never still attached")
675+}
676+
677+// TestSupervisorAttachmentCancellationRetiresGeneration proves cancellation
678+// mid-attach closes the stream, ends the run, and never reports attachment.
679+func TestSupervisorAttachmentCancellationRetiresGeneration(t *testing.T) {
680+ picker := newAttachTestPicker()
681+ var (
682+ streamMu sync.Mutex
683+ stream *sessionTestStream
684+ )
685+ harness := startAttachHarness(t, picker)
686+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
687+ admitted := newSessionTestStream()
688+ streamMu.Lock()
689+ stream = admitted
690+ streamMu.Unlock()
691+ return admitted, nil
692+ })
693+ picker.commit(sessionTestRequest(true))
694+ require.Eventually(t, func() bool { return len(harness.service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
695+
696+ harness.cancel()
697+ require.ErrorIs(t, harness.waitRun(t), context.Canceled)
698+ streamMu.Lock()
699+ admitted := stream
700+ streamMu.Unlock()
701+ require.True(t, admitted.closedNow(), "the supervisor close authority released the stream")
702+ require.Equal(t, PresentTerminating, harness.sup.State().Presentation)
703+ require.Empty(t, harness.terminal.publications, "a cancelled attachment never published")
704+}
705+
706+// TestSupervisorPickerInputNeverReachesSession proves picker bytes produce no
707+// stream open, no session write, and no terminal write.
708+func TestSupervisorPickerInputNeverReachesSession(t *testing.T) {
709+ picker := newAttachTestPicker()
710+ harness := startAttachHarness(t, picker)
711+ opened := false
712+ harness.service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
713+ opened = true
714+ return newSessionTestStream(), nil
715+ })
716+
717+ harness.reader.push([]byte("j"))
718+ require.Eventually(t, func() bool { return picker.consumedCount() >= 1 }, 5*time.Second, time.Millisecond)
719+ require.False(t, opened, "picker input must never open a stream")
720+ require.Zero(t, picker.resolveCount(), "picker input must never resolve a selection")
721+ require.Empty(t, harness.service.openedRequests())
722+ _, flushes, _ := harness.terminal.counts()
723+ require.Zero(t, flushes, "picker input must never flush session output")
724+ require.Empty(t, harness.terminal.written(), "picker input must never write session bytes")
725+}
726+
727+// TestSupervisorAttachmentReachableFromRealPicker drives the real client-owned
728+// picker: a committed row resolves to one exact stream request and its
729+// publication attaches.
730+func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) {
731+ ctx, cancel := context.WithCancel(context.Background())
732+ defer cancel()
733+
734+ clock := newSupervisorTestClock()
735+ reader := newPickerChunkReader()
736+ t.Cleanup(reader.close)
737+ terminal := &attachTestTerminal{in: reader}
738+ service := newSupervisorTestService(ports.BrokerConnectionID{1})
739+ service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
740+ admittedStreams := make(chan *sessionTestStream, 1)
741+ service.setOpenStream(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
742+ admitted := newSessionTestStream()
743+ admittedStreams <- admitted
744+ return admitted, nil
745+ })
746+
747+ controller := newPickerController(clock, pickerTestFreshness)
748+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil })
749+ sup := mustSupervisor(t, SupervisorConfig{
750+ Connector: connector,
751+ Terminal: terminal,
752+ Clock: clock,
753+ Jitter: func() float64 { return 0 },
754+ Picker: controller,
755+ })
756+ runErr := make(chan error, 1)
757+ go func() { runErr <- sup.Run(ctx) }()
758+
759+ require.Eventually(t, func() bool { return controller.Catalogue().Revision() == 1 }, 5*time.Second, time.Millisecond)
760+ require.Equal(t, []string{"alpha"}, pickerSessionLabels(controller.Catalogue().Lines()))
761+
762+ // The user commits the row under the cursor through the shared reader.
763+ reader.push([]byte("\r"))
764+ require.Eventually(t, func() bool { return len(service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
765+ opened := service.openedRequests()[0]
766+ require.Equal(t, "alpha", opened.Target.SessionName)
767+ require.True(t, opened.Local)
768+ require.Equal(t, ports.BrokerAdmissionExact, opened.Admission)
769+ require.Equal(t, ports.BrokerStreamID(1), opened.Stream)
770+
771+ var stream *sessionTestStream
772+ select {
773+ case stream = <-admittedStreams:
774+ case <-time.After(5 * time.Second):
775+ t.Fatal("the resolved stream was never admitted")
776+ }
777+ deliverReadyStream(t, stream)
778+ awaitAttachedState(t, sup)
779+
780+ cancel()
781+ require.ErrorIs(t, <-runErr, context.Canceled)
782+ require.True(t, stream.closedNow())
783+ require.Equal(t, 1, terminal.restores, "the supervisor restored raw mode exactly once")
784+}
785+
786+// TestSupervisorAttachmentConcurrentCommitAndCancellation deterministically
787+// retires attachment authority at each blocking boundary under review.
788+func TestSupervisorAttachmentConcurrentCommitAndCancellation(t *testing.T) {
789+ tests := []struct {
790+ name string
791+ run func(*testing.T, *attachTestHarness, *attachTestPicker) *sessionTestStream
792+ }{
793+ {name: "open in flight", run: func(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream {
794+ entered := make(chan struct{})
795+ release := make(chan struct{})
796+ stream := newSessionTestStream()
797+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
798+ close(entered)
799+ <-release
800+ return stream, nil
801+ })
802+ picker.commit(sessionTestRequest(true))
803+ <-entered
804+ harness.cancel()
805+ close(release)
806+ return stream
807+ }},
808+ {name: "publication commit in flight", run: func(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream {
809+ stream := newSessionTestStream()
810+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
811+ return stream, nil
812+ })
813+ entered := make(chan struct{}, 1)
814+ release := make(chan struct{})
815+ harness.terminal.publishEntered = entered
816+ harness.terminal.publishRelease = release
817+ picker.commit(sessionTestRequest(true))
818+ awaitHello(t, stream)
819+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
820+ stream.deliver(sessionTestOutput(1, "initial"))
821+ <-entered
822+ harness.cancel()
823+ close(release)
824+ return stream
825+ }},
826+ }
827+ for _, tt := range tests {
828+ t.Run(tt.name, func(t *testing.T) {
829+ picker := newAttachTestPicker()
830+ harness := startAttachHarness(t, picker)
831+ stream := tt.run(t, harness, picker)
832+ // Cancellation revokes picker authority immediately, before teardown
833+ // completes. Inject work in that teardown window and prove neither path
834+ // can consume it.
835+ resolved, consumed := picker.resolveCount(), picker.consumedCount()
836+ picker.commit(sessionTestRequest(true))
837+ harness.reader.push([]byte("retired"))
838+ require.False(t, picker.owns())
839+ require.Equal(t, resolved, picker.resolveCount(), "revoked picker operations must not resolve")
840+ require.Equal(t, consumed, picker.consumedCount(), "revoked input must not be consumed")
841+ require.ErrorIs(t, harness.waitRun(t), context.Canceled)
842+ require.True(t, stream.closedNow(), "the admitted stream must be released")
843+ require.NotEqual(t, PresentAttached, harness.sup.State().Presentation)
844+ })
845+ }
846+}
M internal/usecase/client/supervisor_test.go +29 -2
@@ -322,6 +322,12 @@ type supervisorTestService struct {
322322 // closeEntered reports that Close began waiting on it.
323323 closeGate chan struct{}
324324 closeEntered chan struct{}
325+
326+ // openStream scripts logical-stream admission for the P5.3b attachment
327+ // path. When nil, OpenStream keeps the historical refusal. openCalls
328+ // records every exact request the supervisor admitted.
329+ openStream func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error)
330+ openCalls []ports.BrokerOpenStreamRequest
325331 }
326332
327333 func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService {
@@ -355,8 +361,29 @@ func (s *supervisorTestService) Subscribe() (ports.BrokerSubscription, error) {
355361 }), nil
356362 }
357363
358-func (s *supervisorTestService) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
359- return nil, errors.New("supervisor test service does not support streams")
364+func (s *supervisorTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
365+ s.mu.Lock()
366+ s.openCalls = append(s.openCalls, request)
367+ open := s.openStream
368+ s.mu.Unlock()
369+ if open == nil {
370+ return nil, errors.New("supervisor test service does not support streams")
371+ }
372+ return open(ctx, request)
373+}
374+
375+// setOpenStream installs the logical-stream admission all P5.3b tests drive.
376+func (s *supervisorTestService) setOpenStream(open func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error)) {
377+ s.mu.Lock()
378+ defer s.mu.Unlock()
379+ s.openStream = open
380+}
381+
382+// openedRequests returns a copy of every exact stream request admitted so far.
383+func (s *supervisorTestService) openedRequests() []ports.BrokerOpenStreamRequest {
384+ s.mu.Lock()
385+ defer s.mu.Unlock()
386+ return append([]ports.BrokerOpenStreamRequest(nil), s.openCalls...)
360387 }
361388
362389 func (s *supervisorTestService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {
@@ -322,6 +322,12 @@ type supervisorTestService struct {
322 // closeEntered reports that Close began waiting on it.322 // closeEntered reports that Close began waiting on it.
323 closeGate chan struct{}323 closeGate chan struct{}
324 closeEntered chan struct{}324 closeEntered chan struct{}
325+
326+ // openStream scripts logical-stream admission for the P5.3b attachment
327+ // path. When nil, OpenStream keeps the historical refusal. openCalls
328+ // records every exact request the supervisor admitted.
329+ openStream func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error)
330+ openCalls []ports.BrokerOpenStreamRequest
325 }331 }
326 332
327 func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService {333 func newSupervisorTestService(id ports.BrokerConnectionID) *supervisorTestService {
@@ -355,8 +361,29 @@ func (s *supervisorTestService) Subscribe() (ports.BrokerSubscription, error) {
355 }), nil361 }), nil
356 }362 }
357 363
358-func (s *supervisorTestService) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {364+func (s *supervisorTestService) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
359- return nil, errors.New("supervisor test service does not support streams")365+ s.mu.Lock()
366+ s.openCalls = append(s.openCalls, request)
367+ open := s.openStream
368+ s.mu.Unlock()
369+ if open == nil {
370+ return nil, errors.New("supervisor test service does not support streams")
371+ }
372+ return open(ctx, request)
373+}
374+
375+// setOpenStream installs the logical-stream admission all P5.3b tests drive.
376+func (s *supervisorTestService) setOpenStream(open func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error)) {
377+ s.mu.Lock()
378+ defer s.mu.Unlock()
379+ s.openStream = open
380+}
381+
382+// openedRequests returns a copy of every exact stream request admitted so far.
383+func (s *supervisorTestService) openedRequests() []ports.BrokerOpenStreamRequest {
384+ s.mu.Lock()
385+ defer s.mu.Unlock()
386+ return append([]ports.BrokerOpenStreamRequest(nil), s.openCalls...)
360 }387 }
361 388
362 func (s *supervisorTestService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {389 func (s *supervisorTestService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {

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