detached 952412b

feat(client): own the autonomous client lifecycle

The supervisor now owns the whole lifecycle of an autonomous client: one-shot
initial navigation, attached input and geometry, detach actions, and the return
to the picker. Offline and unactivated: no production composition uses it.

- Initial navigation is consumed exactly once after the first ready
  publication and resolves through the client-owned catalogue to an ephemeral
  creation admission, so creating a session with no arguments is broker
  navigation rather than a special client path. It is never replayed on a
  reconnect.
- Detach-to-picker and detach-and-exit are distinct typed actions: the first
  returns the terminal to the picker in the same process, the second ends the
  run. Session termination, session deletion, destination failure, and broker
  loss each return to the picker with a typed bounded notice.
- Broker loss discards attachment authority, reconnects without reattaching,
  and replays no mutation whose outcome is unknown: no committed key or pending
  operation survives the generation that lost the connection.
- One terminal reader and one writer remain. The supervisor's single input
  lifetime owns the pump; picker and attachment claims are mutually exclusive;
  data-ready wakeups are per claim rather than shared, so a stale runner cannot
  consume a wakeup it cannot use, and a claim with data outstanding is always
  woken. Readiness channels are pruned when their claim is revoked.
- Undecided input never crosses an owner-class boundary: bytes queued while the
  picker owned input are disposed of at the handoff instead of becoming session
  input, and an attachment's undecided bytes are dropped at finalize instead of
  becoming picker input (which could otherwise commit a row). Attachment-to-
  attachment replacement stays lossless.
- Geometry is collected once per run and consumed per attachment, so resize is
  never coalesced across attachments and a revoked attachment cannot submit.
- Lifecycle actions carry the grant token; a stale token is skipped rather than
  terminating the delivery loop, so a later detach still arrives.

Review disposition: four review rounds, each fix independently verified, and
the two behavioural fixes confirmed by mutation testing (disabling them fails
the tests that claim them). Documented gap: the owner-class drop performed when
the picker releases its claim is defence in depth for a handoff window this
harness cannot stage deterministically, so no test pins it; removing it leaves
the suite green. The invariant is stated at the call site, and the gap is
recorded rather than covered by a test that would not fail without the code.
commit
952412b5ef95b3ac3489cc6720fb1ae4f145fb6e
author
brice · · 2026-09-19 12:15 UTC
parent
2dfda5e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +1121 -117
M internal/usecase/client/attachment_session.go +116 -28
@@ -23,9 +23,8 @@ import (
2323 //
2424 // The worker owns no endpoint, route, raw mode, terminal writer, or second
2525 // 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.
26+// AttachmentForeground. Input, geometry and lifecycle sends therefore retain
27+// the foreground token and stop when the grant is superseded.
2928
3029 var (
3130 // errAttachmentDeadline is the stable local sentinel of an expired
@@ -214,39 +213,128 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
214213 }
215214 }
216215
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.
216+type attachmentClientEvent struct {
217+ message protocol.ClientMessage
218+ input *AttachmentInputEvent
219+ action AttachmentLifecycleActionKind
220+}
221+
222+var (
223+ errDetachToPicker = errors.New("client: detach to picker")
224+ errDetachAndExit = errors.New("client: detach and exit")
225+)
226+
227+// pumpAttached publishes output and concurrently forwards the foreground's
228+// authorized input, latest geometry and explicit lifecycle decision.
221229 func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent {
230+ pumpCtx, cancel := context.WithCancel(ctx)
231+ defer cancel()
232+ outgoing := make(chan attachmentClientEvent)
233+ go pumpAttachmentInput(pumpCtx, fg, outgoing)
234+ go pumpAttachmentGeometry(pumpCtx, fg, outgoing)
235+ go pumpAttachmentLifecycle(pumpCtx, fg, outgoing)
222236 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
237+ select {
238+ case result := <-incoming:
239+ if result.err != nil {
240+ return w.settle(ctx, fg, stream, token, result.err)
234241 }
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)}
242+ switch typed := result.message.(type) {
243+ case protocol.Output:
244+ next, ok := state.next(typed)
245+ if !ok {
246+ continue
247+ }
248+ state = next
249+ if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusAttached), typed.Data); err != nil {
250+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
251+ }
252+ case protocol.ErrorMsg:
253+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}
254+ case protocol.Detached:
255+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded}
238256 }
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.
257+ case event := <-outgoing:
258+ if event.input != nil && event.input.Err != nil {
259+ fg.PreserveInput(event.input.Data)
260+ return w.settle(ctx, fg, stream, token, event.input.Err)
261+ }
262+ if err := w.send(ctx, fg, stream, event.message); err != nil {
263+ if event.input != nil {
264+ fg.PreserveInput(event.input.Data)
265+ }
266+ return w.settle(ctx, fg, stream, token, err)
267+ }
268+ if event.input != nil {
269+ fg.AckInput()
270+ }
271+ if event.action == AttachmentDetachToPicker {
272+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachToPicker}
273+ }
274+ if event.action == AttachmentDetachAndExit {
275+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachAndExit}
276+ }
277+ case <-ctx.Done():
278+ return w.settle(ctx, fg, stream, token, ctx.Err())
279+ case <-fg.Done():
280+ return w.settle(ctx, fg, stream, token, errAttachmentForegroundRevoked)
281+ }
282+ }
283+}
284+
285+func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) {
286+ var sequence uint64
287+ for {
288+ event, ok := fg.Input(ctx)
289+ if !ok {
290+ return
291+ }
292+ if event.Err != nil {
293+ select {
294+ case out <- attachmentClientEvent{input: &event}:
295+ case <-ctx.Done():
296+ fg.PreserveInput(event.Data)
297+ }
298+ return
299+ }
300+ sequence++
301+ copyEvent := event
302+ select {
303+ case out <- attachmentClientEvent{message: protocol.Input{InputSeq: sequence, Data: append([]byte(nil), event.Data...)}, input: &copyEvent}:
304+ case <-ctx.Done():
305+ fg.PreserveInput(event.Data)
306+ return
307+ }
308+ }
309+}
310+
311+func pumpAttachmentGeometry(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) {
312+ for {
313+ geometry, ok := fg.Resize(ctx)
314+ if !ok {
315+ return
316+ }
317+ geometry = geometry.NormalizePixels()
318+ select {
319+ case out <- attachmentClientEvent{message: protocol.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight}}:
320+ case <-ctx.Done():
321+ return
246322 }
247323 }
248324 }
249325
326+func pumpAttachmentLifecycle(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) {
327+ action, ok := fg.Lifecycle(ctx)
328+ if !ok {
329+ return
330+ }
331+ select {
332+ case out <- attachmentClientEvent{message: protocol.Detach{}, action: action}:
333+ case <-ctx.Done():
334+ return
335+ }
336+}
337+
250338 func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) error {
251339 if welcome.CommittedIdentity != nil {
252340 return w.validateIdentity("welcome", welcome.CommittedIdentity.Target)
@@ -23,9 +23,8 @@ import (
23 //23 //
24 // The worker owns no endpoint, route, raw mode, terminal writer, or second24 // The worker owns no endpoint, route, raw mode, terminal writer, or second
25 // reader. It reaches the terminal only through the supervisor-granted25 // reader. It reaches the terminal only through the supervisor-granted
26-// AttachmentForeground. Terminal input pumping into the session and UI action26+// AttachmentForeground. Input, geometry and lifecycle sends therefore retain
27-// admission are deliberately left to P5.4, exactly as the P5.1b host documents,27+// the foreground token and stop when the grant is superseded.
28-// so this slice adds no path from picker input to a session.
29 28
30 var (29 var (
31 // errAttachmentDeadline is the stable local sentinel of an expired30 // errAttachmentDeadline is the stable local sentinel of an expired
@@ -214,39 +213,128 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
214 }213 }
215 }214 }
216 215
217-// pumpAttached publishes accepted output after attachment until the stream216+type attachmentClientEvent struct {
218-// ends. It never unmarks attachment: a stream failure ends the run as217+ message protocol.ClientMessage
219-// lost/ended, so the supervisor returns to the picker and never keeps the218+ input *AttachmentInputEvent
220-// attached presentation showing.219+ action AttachmentLifecycleActionKind
220+}
221+
222+var (
223+ errDetachToPicker = errors.New("client: detach to picker")
224+ errDetachAndExit = errors.New("client: detach and exit")
225+)
226+
227+// pumpAttached publishes output and concurrently forwards the foreground's
228+// authorized input, latest geometry and explicit lifecycle decision.
221 func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent {229 func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection, token AttachmentToken, state outputApplyState, incoming <-chan serverReceive) AttachmentEvent {
230+ pumpCtx, cancel := context.WithCancel(ctx)
231+ defer cancel()
232+ outgoing := make(chan attachmentClientEvent)
233+ go pumpAttachmentInput(pumpCtx, fg, outgoing)
234+ go pumpAttachmentGeometry(pumpCtx, fg, outgoing)
235+ go pumpAttachmentLifecycle(pumpCtx, fg, outgoing)
222 for {236 for {
223- message, err := w.awaitServer(ctx, fg, incoming)237+ select {
224- if err != nil {238+ case result := <-incoming:
225- return w.settle(ctx, fg, stream, token, err)239+ if result.err != nil {
226- }240+ return w.settle(ctx, fg, stream, token, result.err)
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 }241 }
235- state = next242+ switch typed := result.message.(type) {
236- if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusAttached), typed.Data); err != nil {243+ case protocol.Output:
237- return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}244+ next, ok := state.next(typed)
245+ if !ok {
246+ continue
247+ }
248+ state = next
249+ if err := fg.Output(state.uiContext(ports.UIContext{Generation: token.Generation}, ports.UIStatusAttached), typed.Data); err != nil {
250+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
251+ }
252+ case protocol.ErrorMsg:
253+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}
254+ case protocol.Detached:
255+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded}
238 }256 }
239- case protocol.ErrorMsg:257+ case event := <-outgoing:
240- return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: typed.Code, Text: typed.Text}}258+ if event.input != nil && event.input.Err != nil {
241- case protocol.Detached:259+ fg.PreserveInput(event.input.Data)
242- return AttachmentEvent{Token: token, Kind: AttachmentEventEnded}260+ return w.settle(ctx, fg, stream, token, event.input.Err)
243- default:261+ }
244- // Messages outside the attached output flow are consumed and262+ if err := w.send(ctx, fg, stream, event.message); err != nil {
245- // ignored until the stream ends; this slice adds no other handler.263+ if event.input != nil {
264+ fg.PreserveInput(event.input.Data)
265+ }
266+ return w.settle(ctx, fg, stream, token, err)
267+ }
268+ if event.input != nil {
269+ fg.AckInput()
270+ }
271+ if event.action == AttachmentDetachToPicker {
272+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachToPicker}
273+ }
274+ if event.action == AttachmentDetachAndExit {
275+ return AttachmentEvent{Token: token, Kind: AttachmentEventEnded, Err: errDetachAndExit}
276+ }
277+ case <-ctx.Done():
278+ return w.settle(ctx, fg, stream, token, ctx.Err())
279+ case <-fg.Done():
280+ return w.settle(ctx, fg, stream, token, errAttachmentForegroundRevoked)
281+ }
282+ }
283+}
284+
285+func pumpAttachmentInput(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) {
286+ var sequence uint64
287+ for {
288+ event, ok := fg.Input(ctx)
289+ if !ok {
290+ return
291+ }
292+ if event.Err != nil {
293+ select {
294+ case out <- attachmentClientEvent{input: &event}:
295+ case <-ctx.Done():
296+ fg.PreserveInput(event.Data)
297+ }
298+ return
299+ }
300+ sequence++
301+ copyEvent := event
302+ select {
303+ case out <- attachmentClientEvent{message: protocol.Input{InputSeq: sequence, Data: append([]byte(nil), event.Data...)}, input: &copyEvent}:
304+ case <-ctx.Done():
305+ fg.PreserveInput(event.Data)
306+ return
307+ }
308+ }
309+}
310+
311+func pumpAttachmentGeometry(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) {
312+ for {
313+ geometry, ok := fg.Resize(ctx)
314+ if !ok {
315+ return
316+ }
317+ geometry = geometry.NormalizePixels()
318+ select {
319+ case out <- attachmentClientEvent{message: protocol.Resize{Size: geometry.Size, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight}}:
320+ case <-ctx.Done():
321+ return
246 }322 }
247 }323 }
248 }324 }
249 325
326+func pumpAttachmentLifecycle(ctx context.Context, fg AttachmentForeground, out chan<- attachmentClientEvent) {
327+ action, ok := fg.Lifecycle(ctx)
328+ if !ok {
329+ return
330+ }
331+ select {
332+ case out <- attachmentClientEvent{message: protocol.Detach{}, action: action}:
333+ case <-ctx.Done():
334+ return
335+ }
336+}
337+
250 func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) error {338 func (w *sessionAttachmentWorker) validateWelcome(welcome protocol.Welcome) error {
251 if welcome.CommittedIdentity != nil {339 if welcome.CommittedIdentity != nil {
252 return w.validateIdentity("welcome", welcome.CommittedIdentity.Target)340 return w.validateIdentity("welcome", welcome.CommittedIdentity.Target)
M internal/usecase/client/attachment_worker.go +185 -26
@@ -157,10 +157,11 @@ type AttachmentForeground interface {
157157 // so a cancelled worker can still save input; it is a no-op once authority is
158158 // revoked.
159159 PreserveInput(data []byte)
160- // Resize blocks until the next authorized terminal resize. It returns false
161- // once authority is revoked or ctx ends. A nil or closed resize source is
162- // disabled, not an attachment end; Resize waits for cancellation in that case.
160+ // Resize blocks until the latest authorized terminal resize for this
161+ // attachment. It returns false once authority is revoked or ctx ends.
163162 Resize(ctx context.Context) (domain.Geometry, bool)
163+ // Lifecycle blocks for one explicit typed lifecycle action.
164+ Lifecycle(ctx context.Context) (AttachmentLifecycleActionKind, bool)
164165 // Output publishes context and writes and flushes bytes in one authorized
165166 // transaction. Missing UI or terminal returns ports.ErrUIUnavailable.
166167 Output(uiContext ports.UIContext, data []byte) error
@@ -212,6 +213,18 @@ type attachmentAuthority struct {
212213 state attachmentAuthorityState
213214 }
214215
216+func (a *attachmentAuthority) currentToken() AttachmentToken {
217+ if a == nil {
218+ return AttachmentToken{}
219+ }
220+ a.mu.Lock()
221+ defer a.mu.Unlock()
222+ if a.current == nil {
223+ return AttachmentToken{}
224+ }
225+ return a.current.token
226+}
227+
215228 // grant installs fg as the sole foreground in the active state. It fails while
216229 // another grant is live or while the shared input pump already has a consumer,
217230 // which is what keeps exactly one foreground owner. The host never allocates a
@@ -345,6 +358,8 @@ type attachmentHostConfig struct {
345358 // Input is the supervisor-owned single terminal reader. The host claims one
346359 // consumer per foreground and revokes it after finalization. Optional.
347360 Input *terminalInputPump
361+ // Actions carries explicit lifecycle decisions for the current attachment.
362+ Actions <-chan AttachmentLifecycleAction
348363 // UI is the supervisor-owned UI publication transaction. When nil it is
349364 // taken from Terminal when that implements ports.UIOutputTransaction.
350365 UI ports.UIOutputTransaction
@@ -363,8 +378,18 @@ type attachmentHost struct {
363378 input *terminalInputPump
364379 ui ports.UIOutputTransaction
365380 resizes <-chan domain.Geometry
381+ actions <-chan AttachmentLifecycleAction
366382 onAttach func(AttachmentToken)
367383 authority attachmentAuthority
384+
385+ geometryMu sync.Mutex
386+ geometry domain.Geometry
387+ geometryValid bool
388+ geometrySeq uint64
389+ geometryUpdate chan struct{}
390+ geometryCancel context.CancelFunc
391+ geometryDone chan struct{}
392+ ownerBoundary bool
368393 }
369394
370395 // newAttachmentHost builds the reusable foreground host. A nil clock falls back
@@ -384,12 +409,14 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost {
384409 resizes = cfg.Terminal.ResizeEvents()
385410 }
386411 return &attachmentHost{
387- term: cfg.Terminal,
388- clock: clock,
389- input: cfg.Input,
390- ui: ui,
391- resizes: resizes,
392- onAttach: cfg.OnAttached,
412+ term: cfg.Terminal,
413+ clock: clock,
414+ input: cfg.Input,
415+ ui: ui,
416+ resizes: resizes,
417+ actions: cfg.Actions,
418+ onAttach: cfg.OnAttached,
419+ geometryUpdate: make(chan struct{}, 1),
393420 }
394421 }
395422
@@ -452,6 +479,82 @@ func (h *attachmentHost) Run(ctx context.Context, token AttachmentToken, worker
452479 }
453480
454481 // newForeground allocates a candidate; grant claims input before publishing it.
482+func (h *attachmentHost) setInput(input *terminalInputPump) {
483+ if h == nil {
484+ return
485+ }
486+ h.input = input
487+ h.ownerBoundary = true
488+}
489+
490+func (h *attachmentHost) startGeometry(ctx context.Context) {
491+ if h == nil {
492+ return
493+ }
494+ if h.geometryDone != nil {
495+ select {
496+ case <-h.geometryDone:
497+ h.geometryDone = nil
498+ h.geometryCancel = nil
499+ default:
500+ return
501+ }
502+ }
503+ geometryCtx, cancel := context.WithCancel(ctx)
504+ h.geometryCancel = cancel
505+ h.geometryDone = make(chan struct{})
506+ go func() {
507+ defer close(h.geometryDone)
508+ for {
509+ select {
510+ case geometry, ok := <-h.resizes:
511+ if !ok {
512+ return
513+ }
514+ if !geometry.Valid() {
515+ continue
516+ }
517+ h.geometryMu.Lock()
518+ h.geometry = geometry.NormalizePixels()
519+ h.geometryValid = true
520+ h.geometrySeq++
521+ h.geometryMu.Unlock()
522+ select {
523+ case h.geometryUpdate <- struct{}{}:
524+ default:
525+ }
526+ case <-geometryCtx.Done():
527+ return
528+ }
529+ }
530+ }()
531+}
532+
533+func (h *attachmentHost) stopGeometry() {
534+ if h == nil || h.geometryCancel == nil {
535+ return
536+ }
537+ h.geometryCancel()
538+ <-h.geometryDone
539+ h.geometryCancel = nil
540+ h.geometryDone = nil
541+}
542+
543+func (h *attachmentHost) latestGeometry() (domain.Geometry, bool) {
544+ if h == nil {
545+ return domain.Geometry{}, false
546+ }
547+ h.geometryMu.Lock()
548+ defer h.geometryMu.Unlock()
549+ return h.geometry, h.geometryValid
550+}
551+
552+func (h *attachmentHost) geometryAfter(sequence uint64) (domain.Geometry, uint64, bool) {
553+ h.geometryMu.Lock()
554+ defer h.geometryMu.Unlock()
555+ return h.geometry, h.geometrySeq, h.geometryValid && h.geometrySeq > sequence
556+}
557+
455558 func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.BrokerLogicalConnection) *attachmentForeground {
456559 return &attachmentForeground{
457560 authority: &h.authority,
@@ -462,6 +565,8 @@ func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.Broke
462565 term: h.term,
463566 ui: h.ui,
464567 resizes: h.resizes,
568+ actions: h.actions,
569+ host: h,
465570 done: make(chan struct{}),
466571 decided: true,
467572 onAttached: h.onAttach,
@@ -578,6 +683,8 @@ type attachmentForeground struct {
578683 term ports.Terminal
579684 ui ports.UIOutputTransaction
580685 resizes <-chan domain.Geometry
686+ actions <-chan AttachmentLifecycleAction
687+ host *attachmentHost
581688 done chan struct{}
582689 // finalizedOnce and finishedOnce split the single teardown into the
583690 // deterministic final input window: finalize closes Done and stops output
@@ -588,10 +695,11 @@ type attachmentForeground struct {
588695 // by marking a freshly received delivery undecided; AckInput or PreserveInput
589696 // decides it at most once. decided starts true, so a decision with no
590697 // outstanding delivery is refused instead of silently committing nothing.
591- deliveryMu sync.Mutex
592- decided bool
593- attached atomic.Bool
594- onAttached func(AttachmentToken)
698+ deliveryMu sync.Mutex
699+ decided bool
700+ geometrySeq uint64
701+ attached atomic.Bool
702+ onAttached func(AttachmentToken)
595703 }
596704
597705 // Token is the generation/attempt identity of this grant.
@@ -633,7 +741,15 @@ func (f *attachmentForeground) finish() {
633741 if f == nil {
634742 return
635743 }
636- f.finishedOnce.Do(func() { f.authority.revoke(f) })
744+ f.finishedOnce.Do(func() {
745+ // Autonomous supervisor foregrounds have a host-lifetime geometry
746+ // collector. At that owner-class boundary, no attachment byte may reach
747+ // the picker. Isolated legacy worker replacement remains lossless.
748+ if f.input != nil && f.host != nil && f.host.ownerBoundary {
749+ f.input.dropOwned(f.consumer)
750+ }
751+ f.authority.revoke(f)
752+ })
637753 }
638754
639755 // beginDelivery opens the decision window for the delivery the worker just
@@ -682,7 +798,7 @@ func (f *attachmentForeground) Input(ctx context.Context) (AttachmentInputEvent,
682798 return AttachmentInputEvent{}, false
683799 case <-f.done:
684800 return AttachmentInputEvent{}, false
685- case <-f.input.ready:
801+ case <-f.input.readyFor(f.consumer):
686802 }
687803 }
688804 }
@@ -717,29 +833,72 @@ func (f *attachmentForeground) PreserveInput(data []byte) {
717833 }
718834
719835 // Resize waits for the next authorized terminal resize.
720-func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) {
836+func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecycleActionKind, bool) {
721837 if ctx == nil {
722838 ctx = context.Background()
723839 }
724- if !f.authority.actionAuthorized(f) {
725- return domain.Geometry{}, false
726- }
727- resizes := f.resizes
728840 for {
841+ if !f.authority.actionAuthorized(f) {
842+ return 0, false
843+ }
729844 select {
730- case geometry, ok := <-resizes:
731- if !ok {
732- resizes = nil
845+ case action, ok := <-f.actions:
846+ if !ok || !f.authority.actionAuthorized(f) {
847+ return 0, false
848+ }
849+ if action.Token != f.token {
733850 continue
734- } // uiterm has no resize source
735- if !f.authority.actionAuthorized(f) {
851+ }
852+ return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit
853+ case <-ctx.Done():
854+ return 0, false
855+ case <-f.done:
856+ return 0, false
857+ }
858+ }
859+}
860+
861+func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) {
862+ if ctx == nil {
863+ ctx = context.Background()
864+ }
865+ if !f.authority.actionAuthorized(f) || f.host == nil {
866+ return domain.Geometry{}, false
867+ }
868+ if !f.host.ownerBoundary {
869+ resizes := f.resizes
870+ for {
871+ select {
872+ case geometry, ok := <-resizes:
873+ if !ok {
874+ resizes = nil
875+ continue
876+ }
877+ if !f.authority.actionAuthorized(f) {
878+ return domain.Geometry{}, false
879+ }
880+ return geometry, true
881+ case <-ctx.Done():
882+ return domain.Geometry{}, false
883+ case <-f.done:
736884 return domain.Geometry{}, false
737885 }
738- return geometry, true
886+ }
887+ }
888+ for {
889+ if geometry, next, ok := f.host.geometryAfter(f.geometrySeq); ok {
890+ f.geometrySeq = next
891+ if f.authority.actionAuthorized(f) {
892+ return geometry, true
893+ }
894+ return domain.Geometry{}, false
895+ }
896+ select {
739897 case <-ctx.Done():
740898 return domain.Geometry{}, false
741899 case <-f.done:
742900 return domain.Geometry{}, false
901+ case <-f.host.geometryUpdate:
743902 }
744903 }
745904 }
@@ -157,10 +157,11 @@ type AttachmentForeground interface {
157 // so a cancelled worker can still save input; it is a no-op once authority is157 // so a cancelled worker can still save input; it is a no-op once authority is
158 // revoked.158 // revoked.
159 PreserveInput(data []byte)159 PreserveInput(data []byte)
160- // Resize blocks until the next authorized terminal resize. It returns false160+ // Resize blocks until the latest authorized terminal resize for this
161- // once authority is revoked or ctx ends. A nil or closed resize source is161+ // attachment. It returns false once authority is revoked or ctx ends.
162- // disabled, not an attachment end; Resize waits for cancellation in that case.
163 Resize(ctx context.Context) (domain.Geometry, bool)162 Resize(ctx context.Context) (domain.Geometry, bool)
163+ // Lifecycle blocks for one explicit typed lifecycle action.
164+ Lifecycle(ctx context.Context) (AttachmentLifecycleActionKind, bool)
164 // Output publishes context and writes and flushes bytes in one authorized165 // Output publishes context and writes and flushes bytes in one authorized
165 // transaction. Missing UI or terminal returns ports.ErrUIUnavailable.166 // transaction. Missing UI or terminal returns ports.ErrUIUnavailable.
166 Output(uiContext ports.UIContext, data []byte) error167 Output(uiContext ports.UIContext, data []byte) error
@@ -212,6 +213,18 @@ type attachmentAuthority struct {
212 state attachmentAuthorityState213 state attachmentAuthorityState
213 }214 }
214 215
216+func (a *attachmentAuthority) currentToken() AttachmentToken {
217+ if a == nil {
218+ return AttachmentToken{}
219+ }
220+ a.mu.Lock()
221+ defer a.mu.Unlock()
222+ if a.current == nil {
223+ return AttachmentToken{}
224+ }
225+ return a.current.token
226+}
227+
215 // grant installs fg as the sole foreground in the active state. It fails while228 // grant installs fg as the sole foreground in the active state. It fails while
216 // another grant is live or while the shared input pump already has a consumer,229 // another grant is live or while the shared input pump already has a consumer,
217 // which is what keeps exactly one foreground owner. The host never allocates a230 // which is what keeps exactly one foreground owner. The host never allocates a
@@ -345,6 +358,8 @@ type attachmentHostConfig struct {
345 // Input is the supervisor-owned single terminal reader. The host claims one358 // Input is the supervisor-owned single terminal reader. The host claims one
346 // consumer per foreground and revokes it after finalization. Optional.359 // consumer per foreground and revokes it after finalization. Optional.
347 Input *terminalInputPump360 Input *terminalInputPump
361+ // Actions carries explicit lifecycle decisions for the current attachment.
362+ Actions <-chan AttachmentLifecycleAction
348 // UI is the supervisor-owned UI publication transaction. When nil it is363 // UI is the supervisor-owned UI publication transaction. When nil it is
349 // taken from Terminal when that implements ports.UIOutputTransaction.364 // taken from Terminal when that implements ports.UIOutputTransaction.
350 UI ports.UIOutputTransaction365 UI ports.UIOutputTransaction
@@ -363,8 +378,18 @@ type attachmentHost struct {
363 input *terminalInputPump378 input *terminalInputPump
364 ui ports.UIOutputTransaction379 ui ports.UIOutputTransaction
365 resizes <-chan domain.Geometry380 resizes <-chan domain.Geometry
381+ actions <-chan AttachmentLifecycleAction
366 onAttach func(AttachmentToken)382 onAttach func(AttachmentToken)
367 authority attachmentAuthority383 authority attachmentAuthority
384+
385+ geometryMu sync.Mutex
386+ geometry domain.Geometry
387+ geometryValid bool
388+ geometrySeq uint64
389+ geometryUpdate chan struct{}
390+ geometryCancel context.CancelFunc
391+ geometryDone chan struct{}
392+ ownerBoundary bool
368 }393 }
369 394
370 // newAttachmentHost builds the reusable foreground host. A nil clock falls back395 // newAttachmentHost builds the reusable foreground host. A nil clock falls back
@@ -384,12 +409,14 @@ func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost {
384 resizes = cfg.Terminal.ResizeEvents()409 resizes = cfg.Terminal.ResizeEvents()
385 }410 }
386 return &attachmentHost{411 return &attachmentHost{
387- term: cfg.Terminal,412+ term: cfg.Terminal,
388- clock: clock,413+ clock: clock,
389- input: cfg.Input,414+ input: cfg.Input,
390- ui: ui,415+ ui: ui,
391- resizes: resizes,416+ resizes: resizes,
392- onAttach: cfg.OnAttached,417+ actions: cfg.Actions,
418+ onAttach: cfg.OnAttached,
419+ geometryUpdate: make(chan struct{}, 1),
393 }420 }
394 }421 }
395 422
@@ -452,6 +479,82 @@ func (h *attachmentHost) Run(ctx context.Context, token AttachmentToken, worker
452 }479 }
453 480
454 // newForeground allocates a candidate; grant claims input before publishing it.481 // newForeground allocates a candidate; grant claims input before publishing it.
482+func (h *attachmentHost) setInput(input *terminalInputPump) {
483+ if h == nil {
484+ return
485+ }
486+ h.input = input
487+ h.ownerBoundary = true
488+}
489+
490+func (h *attachmentHost) startGeometry(ctx context.Context) {
491+ if h == nil {
492+ return
493+ }
494+ if h.geometryDone != nil {
495+ select {
496+ case <-h.geometryDone:
497+ h.geometryDone = nil
498+ h.geometryCancel = nil
499+ default:
500+ return
501+ }
502+ }
503+ geometryCtx, cancel := context.WithCancel(ctx)
504+ h.geometryCancel = cancel
505+ h.geometryDone = make(chan struct{})
506+ go func() {
507+ defer close(h.geometryDone)
508+ for {
509+ select {
510+ case geometry, ok := <-h.resizes:
511+ if !ok {
512+ return
513+ }
514+ if !geometry.Valid() {
515+ continue
516+ }
517+ h.geometryMu.Lock()
518+ h.geometry = geometry.NormalizePixels()
519+ h.geometryValid = true
520+ h.geometrySeq++
521+ h.geometryMu.Unlock()
522+ select {
523+ case h.geometryUpdate <- struct{}{}:
524+ default:
525+ }
526+ case <-geometryCtx.Done():
527+ return
528+ }
529+ }
530+ }()
531+}
532+
533+func (h *attachmentHost) stopGeometry() {
534+ if h == nil || h.geometryCancel == nil {
535+ return
536+ }
537+ h.geometryCancel()
538+ <-h.geometryDone
539+ h.geometryCancel = nil
540+ h.geometryDone = nil
541+}
542+
543+func (h *attachmentHost) latestGeometry() (domain.Geometry, bool) {
544+ if h == nil {
545+ return domain.Geometry{}, false
546+ }
547+ h.geometryMu.Lock()
548+ defer h.geometryMu.Unlock()
549+ return h.geometry, h.geometryValid
550+}
551+
552+func (h *attachmentHost) geometryAfter(sequence uint64) (domain.Geometry, uint64, bool) {
553+ h.geometryMu.Lock()
554+ defer h.geometryMu.Unlock()
555+ return h.geometry, h.geometrySeq, h.geometryValid && h.geometrySeq > sequence
556+}
557+
455 func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.BrokerLogicalConnection) *attachmentForeground {558 func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.BrokerLogicalConnection) *attachmentForeground {
456 return &attachmentForeground{559 return &attachmentForeground{
457 authority: &h.authority,560 authority: &h.authority,
@@ -462,6 +565,8 @@ func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.Broke
462 term: h.term,565 term: h.term,
463 ui: h.ui,566 ui: h.ui,
464 resizes: h.resizes,567 resizes: h.resizes,
568+ actions: h.actions,
569+ host: h,
465 done: make(chan struct{}),570 done: make(chan struct{}),
466 decided: true,571 decided: true,
467 onAttached: h.onAttach,572 onAttached: h.onAttach,
@@ -578,6 +683,8 @@ type attachmentForeground struct {
578 term ports.Terminal683 term ports.Terminal
579 ui ports.UIOutputTransaction684 ui ports.UIOutputTransaction
580 resizes <-chan domain.Geometry685 resizes <-chan domain.Geometry
686+ actions <-chan AttachmentLifecycleAction
687+ host *attachmentHost
581 done chan struct{}688 done chan struct{}
582 // finalizedOnce and finishedOnce split the single teardown into the689 // finalizedOnce and finishedOnce split the single teardown into the
583 // deterministic final input window: finalize closes Done and stops output690 // deterministic final input window: finalize closes Done and stops output
@@ -588,10 +695,11 @@ type attachmentForeground struct {
588 // by marking a freshly received delivery undecided; AckInput or PreserveInput695 // by marking a freshly received delivery undecided; AckInput or PreserveInput
589 // decides it at most once. decided starts true, so a decision with no696 // decides it at most once. decided starts true, so a decision with no
590 // outstanding delivery is refused instead of silently committing nothing.697 // outstanding delivery is refused instead of silently committing nothing.
591- deliveryMu sync.Mutex698+ deliveryMu sync.Mutex
592- decided bool699+ decided bool
593- attached atomic.Bool700+ geometrySeq uint64
594- onAttached func(AttachmentToken)701+ attached atomic.Bool
702+ onAttached func(AttachmentToken)
595 }703 }
596 704
597 // Token is the generation/attempt identity of this grant.705 // Token is the generation/attempt identity of this grant.
@@ -633,7 +741,15 @@ func (f *attachmentForeground) finish() {
633 if f == nil {741 if f == nil {
634 return742 return
635 }743 }
636- f.finishedOnce.Do(func() { f.authority.revoke(f) })744+ f.finishedOnce.Do(func() {
745+ // Autonomous supervisor foregrounds have a host-lifetime geometry
746+ // collector. At that owner-class boundary, no attachment byte may reach
747+ // the picker. Isolated legacy worker replacement remains lossless.
748+ if f.input != nil && f.host != nil && f.host.ownerBoundary {
749+ f.input.dropOwned(f.consumer)
750+ }
751+ f.authority.revoke(f)
752+ })
637 }753 }
638 754
639 // beginDelivery opens the decision window for the delivery the worker just755 // beginDelivery opens the decision window for the delivery the worker just
@@ -682,7 +798,7 @@ func (f *attachmentForeground) Input(ctx context.Context) (AttachmentInputEvent,
682 return AttachmentInputEvent{}, false798 return AttachmentInputEvent{}, false
683 case <-f.done:799 case <-f.done:
684 return AttachmentInputEvent{}, false800 return AttachmentInputEvent{}, false
685- case <-f.input.ready:801+ case <-f.input.readyFor(f.consumer):
686 }802 }
687 }803 }
688 }804 }
@@ -717,29 +833,72 @@ func (f *attachmentForeground) PreserveInput(data []byte) {
717 }833 }
718 834
719 // Resize waits for the next authorized terminal resize.835 // Resize waits for the next authorized terminal resize.
720-func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) {836+func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecycleActionKind, bool) {
721 if ctx == nil {837 if ctx == nil {
722 ctx = context.Background()838 ctx = context.Background()
723 }839 }
724- if !f.authority.actionAuthorized(f) {
725- return domain.Geometry{}, false
726- }
727- resizes := f.resizes
728 for {840 for {
841+ if !f.authority.actionAuthorized(f) {
842+ return 0, false
843+ }
729 select {844 select {
730- case geometry, ok := <-resizes:845+ case action, ok := <-f.actions:
731- if !ok {846+ if !ok || !f.authority.actionAuthorized(f) {
732- resizes = nil847+ return 0, false
848+ }
849+ if action.Token != f.token {
733 continue850 continue
734- } // uiterm has no resize source851+ }
735- if !f.authority.actionAuthorized(f) {852+ return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit
853+ case <-ctx.Done():
854+ return 0, false
855+ case <-f.done:
856+ return 0, false
857+ }
858+ }
859+}
860+
861+func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) {
862+ if ctx == nil {
863+ ctx = context.Background()
864+ }
865+ if !f.authority.actionAuthorized(f) || f.host == nil {
866+ return domain.Geometry{}, false
867+ }
868+ if !f.host.ownerBoundary {
869+ resizes := f.resizes
870+ for {
871+ select {
872+ case geometry, ok := <-resizes:
873+ if !ok {
874+ resizes = nil
875+ continue
876+ }
877+ if !f.authority.actionAuthorized(f) {
878+ return domain.Geometry{}, false
879+ }
880+ return geometry, true
881+ case <-ctx.Done():
882+ return domain.Geometry{}, false
883+ case <-f.done:
736 return domain.Geometry{}, false884 return domain.Geometry{}, false
737 }885 }
738- return geometry, true886+ }
887+ }
888+ for {
889+ if geometry, next, ok := f.host.geometryAfter(f.geometrySeq); ok {
890+ f.geometrySeq = next
891+ if f.authority.actionAuthorized(f) {
892+ return geometry, true
893+ }
894+ return domain.Geometry{}, false
895+ }
896+ select {
739 case <-ctx.Done():897 case <-ctx.Done():
740 return domain.Geometry{}, false898 return domain.Geometry{}, false
741 case <-f.done:899 case <-f.done:
742 return domain.Geometry{}, false900 return domain.Geometry{}, false
901+ case <-f.host.geometryUpdate:
743 }902 }
744 }903 }
745 }904 }
M internal/usecase/client/client.go +91 -28
@@ -435,7 +435,7 @@ func (r *Runner) probeKittyDirectGraphics(ctx context.Context, input *terminalIn
435435 case <-timer.C():
436436 replay = append(replay, probe.Finish()...)
437437 goto done
438- case <-input.ready:
438+ case <-input.readyFor(consumer):
439439 result, ok := input.take(ctx, consumer)
440440 if !ok {
441441 if ctx.Err() != nil {
@@ -3627,7 +3627,9 @@ type terminalInputPump struct {
36273627 activation uint64
36283628 closed bool
36293629 active bool
3630- ready chan struct{}
3630+ readyMu sync.Mutex
3631+ ready map[uint64]chan struct{}
3632+ claimChanged chan struct{}
36313633 space chan struct{}
36323634 state chan struct{}
36333635 exited chan struct{}
@@ -3640,15 +3642,16 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
36403642 space := make(chan struct{}, 1)
36413643 space <- struct{}{}
36423644 return &terminalInputPump{
3643- in: in,
3644- done: make(chan struct{}),
3645- automation: make(chan terminalAutomationRequest),
3646- activation: 1,
3647- active: true,
3648- ready: make(chan struct{}, 1),
3649- space: space,
3650- state: make(chan struct{}, 1),
3651- exited: make(chan struct{}),
3645+ in: in,
3646+ done: make(chan struct{}),
3647+ automation: make(chan terminalAutomationRequest),
3648+ activation: 1,
3649+ active: true,
3650+ ready: make(map[uint64]chan struct{}),
3651+ claimChanged: make(chan struct{}, 1),
3652+ space: space,
3653+ state: make(chan struct{}, 1),
3654+ exited: make(chan struct{}),
36523655 }
36533656 }
36543657
@@ -3665,13 +3668,23 @@ func (p *terminalInputPump) claim() uint64 {
36653668 // tryClaim lets competing foreground owners decline admission without panicking.
36663669 func (p *terminalInputPump) tryClaim() (uint64, bool) {
36673670 p.mu.Lock()
3668- defer p.mu.Unlock()
36693671 if p.consumer != 0 {
3672+ p.mu.Unlock()
36703673 return 0, false
36713674 }
36723675 p.nextID++
36733676 p.consumer = p.nextID
3674- return p.consumer, true
3677+ consumer := p.consumer
3678+ p.readyMu.Lock()
3679+ p.ready[consumer] = make(chan struct{}, 1)
3680+ p.readyMu.Unlock()
3681+ ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0
3682+ p.mu.Unlock()
3683+ p.signalClaimChanged()
3684+ if ready {
3685+ p.signalReady(consumer)
3686+ }
3687+ return consumer, true
36753688 }
36763689
36773690 // revoke invalidates an attempt before its replacement is allowed to claim
@@ -3681,22 +3694,48 @@ func (p *terminalInputPump) revoke(consumer uint64) {
36813694 if p.consumer == consumer {
36823695 p.consumer = 0
36833696 }
3684- // An unacknowledged read was never delivered by this scanner. Leave it
3685- // pending and make it available to its replacement.
36863697 if p.delivering == consumer {
3698+ // Legacy attach-attempt replacement remains lossless. Autonomous
3699+ // supervisor ownership uses ack-before-picker-handoff and
3700+ // drop-before-attachment-handoff, so bytes cannot cross owner kinds.
36873701 p.delivering = 0
36883702 p.deliveringResidual = false
36893703 }
36903704 p.mu.Unlock()
3691- p.signalReady()
3705+ p.readyMu.Lock()
3706+ delete(p.ready, consumer)
3707+ p.readyMu.Unlock()
3708+ p.signalClaimChanged()
36923709 if p.afterRevoke != nil {
36933710 p.afterRevoke()
36943711 }
36953712 }
36963713
3697-func (p *terminalInputPump) signalReady() {
3714+func (p *terminalInputPump) signalClaimChanged() {
36983715 select {
3699- case p.ready <- struct{}{}:
3716+ case p.claimChanged <- struct{}{}:
3717+ default:
3718+ }
3719+}
3720+
3721+func (p *terminalInputPump) readyFor(consumer uint64) <-chan struct{} {
3722+ p.readyMu.Lock()
3723+ defer p.readyMu.Unlock()
3724+ return p.ready[consumer]
3725+}
3726+
3727+func (p *terminalInputPump) signalReady(consumer uint64) {
3728+ if consumer == 0 {
3729+ return
3730+ }
3731+ p.readyMu.Lock()
3732+ ready := p.ready[consumer]
3733+ p.readyMu.Unlock()
3734+ if ready == nil {
3735+ return
3736+ }
3737+ select {
3738+ case ready <- struct{}{}:
37003739 default:
37013740 }
37023741 }
@@ -3716,8 +3755,9 @@ func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64
37163755 return false
37173756 }
37183757 p.pending = &result
3758+ consumer := p.consumer
37193759 p.mu.Unlock()
3720- p.signalReady()
3760+ p.signalReady(consumer)
37213761 return true
37223762 }
37233763
@@ -3727,11 +3767,7 @@ func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64
37273767 func (p *terminalInputPump) take(ctx context.Context, consumer uint64) (terminalReadResult, bool) {
37283768 p.mu.Lock()
37293769 if ctx.Err() != nil || p.consumer != consumer || p.delivering != 0 {
3730- ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0
37313770 p.mu.Unlock()
3732- if ready {
3733- p.signalReady()
3734- }
37353771 return terminalReadResult{}, false
37363772 }
37373773 if len(p.residual) != 0 {
@@ -3770,6 +3806,33 @@ func (p *terminalInputPump) preserveResidual(consumer uint64, data []byte) {
37703806
37713807 // ack commits scanner delivery of a leased read and lets the lifecycle reader
37723808 // accept the next result. Only the consumer that holds the lease can ack it.
3809+// dropOwned disposes every undecided byte at an autonomous owner-class
3810+// boundary. Picker-queued bytes are dropped before attachment claim, and
3811+// attachment-queued bytes are dropped before picker claim. No delivery,
3812+// pending read, or preserved residual may cross between those owner classes.
3813+// revoke remains lossless for legacy attachment-to-attachment replacement.
3814+func (p *terminalInputPump) dropOwned(consumer uint64) {
3815+ p.mu.Lock()
3816+ if p.consumer != consumer {
3817+ p.mu.Unlock()
3818+ return
3819+ }
3820+ hadPending := p.pending != nil
3821+ p.pending = nil
3822+ p.residual = nil
3823+ if p.delivering == consumer {
3824+ p.delivering = 0
3825+ p.deliveringResidual = false
3826+ }
3827+ p.mu.Unlock()
3828+ if hadPending {
3829+ select {
3830+ case p.space <- struct{}{}:
3831+ default:
3832+ }
3833+ }
3834+}
3835+
37733836 func (p *terminalInputPump) ack(consumer uint64) {
37743837 p.mu.Lock()
37753838 if p.consumer != consumer || p.delivering != consumer {
@@ -3788,7 +3851,7 @@ func (p *terminalInputPump) ack(consumer uint64) {
37883851 p.mu.Unlock()
37893852 if wasResidual {
37903853 if more {
3791- p.signalReady()
3854+ p.signalReady(consumer)
37923855 }
37933856 return
37943857 }
@@ -3799,7 +3862,7 @@ func (p *terminalInputPump) finish() {
37993862 p.mu.Lock()
38003863 p.closed = true
38013864 p.mu.Unlock()
3802- p.signalReady()
3865+ p.signalClaimChanged()
38033866 p.signalState()
38043867 }
38053868
@@ -3893,7 +3956,7 @@ func (p *terminalInputPump) suspend() {
38933956 }
38943957 }
38953958 p.signalState()
3896- p.signalReady()
3959+ p.signalClaimChanged()
38973960 }
38983961
38993962 func (p *terminalInputPump) resume() {
@@ -3915,7 +3978,7 @@ func (p *terminalInputPump) stop() {
39153978 p.closed = true
39163979 p.mu.Unlock()
39173980 close(p.done)
3918- p.signalReady()
3981+ p.signalClaimChanged()
39193982 p.signalState()
39203983 })
39213984 }
@@ -4266,7 +4329,7 @@ func (p *stdinPump) run() {
42664329 result = request.record
42674330 automated = true
42684331 actionID.Store(result.actionID)
4269- case <-input.ready:
4332+ case <-input.readyFor(consumer):
42704333 var ok bool
42714334 result, ok = input.take(p.ctx, consumer)
42724335 if !ok {
@@ -435,7 +435,7 @@ func (r *Runner) probeKittyDirectGraphics(ctx context.Context, input *terminalIn
435 case <-timer.C():435 case <-timer.C():
436 replay = append(replay, probe.Finish()...)436 replay = append(replay, probe.Finish()...)
437 goto done437 goto done
438- case <-input.ready:438+ case <-input.readyFor(consumer):
439 result, ok := input.take(ctx, consumer)439 result, ok := input.take(ctx, consumer)
440 if !ok {440 if !ok {
441 if ctx.Err() != nil {441 if ctx.Err() != nil {
@@ -3627,7 +3627,9 @@ type terminalInputPump struct {
3627 activation uint643627 activation uint64
3628 closed bool3628 closed bool
3629 active bool3629 active bool
3630- ready chan struct{}3630+ readyMu sync.Mutex
3631+ ready map[uint64]chan struct{}
3632+ claimChanged chan struct{}
3631 space chan struct{}3633 space chan struct{}
3632 state chan struct{}3634 state chan struct{}
3633 exited chan struct{}3635 exited chan struct{}
@@ -3640,15 +3642,16 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
3640 space := make(chan struct{}, 1)3642 space := make(chan struct{}, 1)
3641 space <- struct{}{}3643 space <- struct{}{}
3642 return &terminalInputPump{3644 return &terminalInputPump{
3643- in: in,3645+ in: in,
3644- done: make(chan struct{}),3646+ done: make(chan struct{}),
3645- automation: make(chan terminalAutomationRequest),3647+ automation: make(chan terminalAutomationRequest),
3646- activation: 1,3648+ activation: 1,
3647- active: true,3649+ active: true,
3648- ready: make(chan struct{}, 1),3650+ ready: make(map[uint64]chan struct{}),
3649- space: space,3651+ claimChanged: make(chan struct{}, 1),
3650- state: make(chan struct{}, 1),3652+ space: space,
3651- exited: make(chan struct{}),3653+ state: make(chan struct{}, 1),
3654+ exited: make(chan struct{}),
3652 }3655 }
3653 }3656 }
3654 3657
@@ -3665,13 +3668,23 @@ func (p *terminalInputPump) claim() uint64 {
3665 // tryClaim lets competing foreground owners decline admission without panicking.3668 // tryClaim lets competing foreground owners decline admission without panicking.
3666 func (p *terminalInputPump) tryClaim() (uint64, bool) {3669 func (p *terminalInputPump) tryClaim() (uint64, bool) {
3667 p.mu.Lock()3670 p.mu.Lock()
3668- defer p.mu.Unlock()
3669 if p.consumer != 0 {3671 if p.consumer != 0 {
3672+ p.mu.Unlock()
3670 return 0, false3673 return 0, false
3671 }3674 }
3672 p.nextID++3675 p.nextID++
3673 p.consumer = p.nextID3676 p.consumer = p.nextID
3674- return p.consumer, true3677+ consumer := p.consumer
3678+ p.readyMu.Lock()
3679+ p.ready[consumer] = make(chan struct{}, 1)
3680+ p.readyMu.Unlock()
3681+ ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0
3682+ p.mu.Unlock()
3683+ p.signalClaimChanged()
3684+ if ready {
3685+ p.signalReady(consumer)
3686+ }
3687+ return consumer, true
3675 }3688 }
3676 3689
3677 // revoke invalidates an attempt before its replacement is allowed to claim3690 // revoke invalidates an attempt before its replacement is allowed to claim
@@ -3681,22 +3694,48 @@ func (p *terminalInputPump) revoke(consumer uint64) {
3681 if p.consumer == consumer {3694 if p.consumer == consumer {
3682 p.consumer = 03695 p.consumer = 0
3683 }3696 }
3684- // An unacknowledged read was never delivered by this scanner. Leave it
3685- // pending and make it available to its replacement.
3686 if p.delivering == consumer {3697 if p.delivering == consumer {
3698+ // Legacy attach-attempt replacement remains lossless. Autonomous
3699+ // supervisor ownership uses ack-before-picker-handoff and
3700+ // drop-before-attachment-handoff, so bytes cannot cross owner kinds.
3687 p.delivering = 03701 p.delivering = 0
3688 p.deliveringResidual = false3702 p.deliveringResidual = false
3689 }3703 }
3690 p.mu.Unlock()3704 p.mu.Unlock()
3691- p.signalReady()3705+ p.readyMu.Lock()
3706+ delete(p.ready, consumer)
3707+ p.readyMu.Unlock()
3708+ p.signalClaimChanged()
3692 if p.afterRevoke != nil {3709 if p.afterRevoke != nil {
3693 p.afterRevoke()3710 p.afterRevoke()
3694 }3711 }
3695 }3712 }
3696 3713
3697-func (p *terminalInputPump) signalReady() {3714+func (p *terminalInputPump) signalClaimChanged() {
3698 select {3715 select {
3699- case p.ready <- struct{}{}:3716+ case p.claimChanged <- struct{}{}:
3717+ default:
3718+ }
3719+}
3720+
3721+func (p *terminalInputPump) readyFor(consumer uint64) <-chan struct{} {
3722+ p.readyMu.Lock()
3723+ defer p.readyMu.Unlock()
3724+ return p.ready[consumer]
3725+}
3726+
3727+func (p *terminalInputPump) signalReady(consumer uint64) {
3728+ if consumer == 0 {
3729+ return
3730+ }
3731+ p.readyMu.Lock()
3732+ ready := p.ready[consumer]
3733+ p.readyMu.Unlock()
3734+ if ready == nil {
3735+ return
3736+ }
3737+ select {
3738+ case ready <- struct{}{}:
3700 default:3739 default:
3701 }3740 }
3702 }3741 }
@@ -3716,8 +3755,9 @@ func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64
3716 return false3755 return false
3717 }3756 }
3718 p.pending = &result3757 p.pending = &result
3758+ consumer := p.consumer
3719 p.mu.Unlock()3759 p.mu.Unlock()
3720- p.signalReady()3760+ p.signalReady(consumer)
3721 return true3761 return true
3722 }3762 }
3723 3763
@@ -3727,11 +3767,7 @@ func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64
3727 func (p *terminalInputPump) take(ctx context.Context, consumer uint64) (terminalReadResult, bool) {3767 func (p *terminalInputPump) take(ctx context.Context, consumer uint64) (terminalReadResult, bool) {
3728 p.mu.Lock()3768 p.mu.Lock()
3729 if ctx.Err() != nil || p.consumer != consumer || p.delivering != 0 {3769 if ctx.Err() != nil || p.consumer != consumer || p.delivering != 0 {
3730- ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0
3731 p.mu.Unlock()3770 p.mu.Unlock()
3732- if ready {
3733- p.signalReady()
3734- }
3735 return terminalReadResult{}, false3771 return terminalReadResult{}, false
3736 }3772 }
3737 if len(p.residual) != 0 {3773 if len(p.residual) != 0 {
@@ -3770,6 +3806,33 @@ func (p *terminalInputPump) preserveResidual(consumer uint64, data []byte) {
3770 3806
3771 // ack commits scanner delivery of a leased read and lets the lifecycle reader3807 // ack commits scanner delivery of a leased read and lets the lifecycle reader
3772 // accept the next result. Only the consumer that holds the lease can ack it.3808 // accept the next result. Only the consumer that holds the lease can ack it.
3809+// dropOwned disposes every undecided byte at an autonomous owner-class
3810+// boundary. Picker-queued bytes are dropped before attachment claim, and
3811+// attachment-queued bytes are dropped before picker claim. No delivery,
3812+// pending read, or preserved residual may cross between those owner classes.
3813+// revoke remains lossless for legacy attachment-to-attachment replacement.
3814+func (p *terminalInputPump) dropOwned(consumer uint64) {
3815+ p.mu.Lock()
3816+ if p.consumer != consumer {
3817+ p.mu.Unlock()
3818+ return
3819+ }
3820+ hadPending := p.pending != nil
3821+ p.pending = nil
3822+ p.residual = nil
3823+ if p.delivering == consumer {
3824+ p.delivering = 0
3825+ p.deliveringResidual = false
3826+ }
3827+ p.mu.Unlock()
3828+ if hadPending {
3829+ select {
3830+ case p.space <- struct{}{}:
3831+ default:
3832+ }
3833+ }
3834+}
3835+
3773 func (p *terminalInputPump) ack(consumer uint64) {3836 func (p *terminalInputPump) ack(consumer uint64) {
3774 p.mu.Lock()3837 p.mu.Lock()
3775 if p.consumer != consumer || p.delivering != consumer {3838 if p.consumer != consumer || p.delivering != consumer {
@@ -3788,7 +3851,7 @@ func (p *terminalInputPump) ack(consumer uint64) {
3788 p.mu.Unlock()3851 p.mu.Unlock()
3789 if wasResidual {3852 if wasResidual {
3790 if more {3853 if more {
3791- p.signalReady()3854+ p.signalReady(consumer)
3792 }3855 }
3793 return3856 return
3794 }3857 }
@@ -3799,7 +3862,7 @@ func (p *terminalInputPump) finish() {
3799 p.mu.Lock()3862 p.mu.Lock()
3800 p.closed = true3863 p.closed = true
3801 p.mu.Unlock()3864 p.mu.Unlock()
3802- p.signalReady()3865+ p.signalClaimChanged()
3803 p.signalState()3866 p.signalState()
3804 }3867 }
3805 3868
@@ -3893,7 +3956,7 @@ func (p *terminalInputPump) suspend() {
3893 }3956 }
3894 }3957 }
3895 p.signalState()3958 p.signalState()
3896- p.signalReady()3959+ p.signalClaimChanged()
3897 }3960 }
3898 3961
3899 func (p *terminalInputPump) resume() {3962 func (p *terminalInputPump) resume() {
@@ -3915,7 +3978,7 @@ func (p *terminalInputPump) stop() {
3915 p.closed = true3978 p.closed = true
3916 p.mu.Unlock()3979 p.mu.Unlock()
3917 close(p.done)3980 close(p.done)
3918- p.signalReady()3981+ p.signalClaimChanged()
3919 p.signalState()3982 p.signalState()
3920 })3983 })
3921 }3984 }
@@ -4266,7 +4329,7 @@ func (p *stdinPump) run() {
4266 result = request.record4329 result = request.record
4267 automated = true4330 automated = true
4268 actionID.Store(result.actionID)4331 actionID.Store(result.actionID)
4269- case <-input.ready:4332+ case <-input.readyFor(consumer):
4270 var ok bool4333 var ok bool
4271 result, ok = input.take(p.ctx, consumer)4334 result, ok = input.take(p.ctx, consumer)
4272 if !ok {4335 if !ok {
A internal/usecase/client/lifecycle_p54a_test.go +444 -0
@@ -0,0 +1,444 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+)
16+
17+func TestP54LifecycleActionsAreDistinct(t *testing.T) {
18+ for _, tc := range []struct {
19+ name string
20+ action AttachmentLifecycleActionKind
21+ wantNotice LifecycleNoticeKind
22+ wantExit bool
23+ }{
24+ {name: "detach to picker", action: AttachmentDetachToPicker, wantNotice: LifecycleNoticeDetachToPicker},
25+ {name: "detach and exit", action: AttachmentDetachAndExit, wantNotice: LifecycleNoticeDetachAndExit, wantExit: true},
26+ } {
27+ t.Run(tc.name, func(t *testing.T) {
28+ actions := make(chan AttachmentLifecycleAction, 1)
29+ notices := make(chan LifecycleNotice, 4)
30+ picker := newAttachTestPicker()
31+ harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
32+ cfg.LifecycleActions = actions
33+ cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice }
34+ })
35+ stream := newSessionTestStream()
36+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
37+ return stream, nil
38+ })
39+ picker.commit(sessionTestRequest(true))
40+ awaitStreamHello(t, &sync.Mutex{}, &stream)
41+ deliverReadyStream(t, stream)
42+ awaitAttachedState(t, harness.sup)
43+ actions <- AttachmentLifecycleAction{Token: harness.sup.attachments.authority.currentToken(), Kind: tc.action}
44+ require.Equal(t, tc.wantNotice, (<-notices).Kind)
45+ if tc.wantExit {
46+ require.NoError(t, harness.waitRun(t))
47+ } else {
48+ awaitPickerState(t, harness.sup)
49+ require.True(t, picker.owns())
50+ require.Len(t, harness.service.openedRequests(), 1, "detach must not auto-reattach")
51+ }
52+ })
53+ }
54+}
55+
56+func TestP54AttachmentOutcomesReturnToPicker(t *testing.T) {
57+ for _, tc := range []struct {
58+ name string
59+ settle func(*sessionTestStream)
60+ notice LifecycleNoticeKind
61+ }{
62+ {name: "session termination", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonDetach}) }, notice: LifecycleNoticeSessionEnded},
63+ {name: "session deletion", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonSessionKilled}) }, notice: LifecycleNoticeSessionEnded},
64+ {name: "destination failure", settle: func(s *sessionTestStream) {
65+ s.fail(ports.BrokerStreamLost{Connection: ports.BrokerConnectionID{1}, Stream: 1, Epoch: 3, Cause: domain.RemoteFailureTransport, Err: errors.New("private diagnostic")})
66+ }, notice: LifecycleNoticeDestinationFailed},
67+ } {
68+ t.Run(tc.name, func(t *testing.T) {
69+ picker := newAttachTestPicker()
70+ notices := make(chan LifecycleNotice, 4)
71+ harness := startAttachHarness(t, picker)
72+ harness.sup.cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice }
73+ stream := newSessionTestStream()
74+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
75+ return stream, nil
76+ })
77+ picker.commit(sessionTestRequest(true))
78+ require.Eventually(t, func() bool { return len(stream.messages()) > 0 }, 5*time.Second, time.Millisecond)
79+ deliverReadyStream(t, stream)
80+ awaitAttachedState(t, harness.sup)
81+ tc.settle(stream)
82+ awaitPickerState(t, harness.sup)
83+ require.Equal(t, tc.notice, (<-notices).Kind)
84+ require.Len(t, harness.service.openedRequests(), 1)
85+ })
86+ }
87+}
88+
89+func TestP54ResolveInitialUsesRealCatalogueCreation(t *testing.T) {
90+ clock := newSupervisorTestClock()
91+ catalogue, _ := pickerTestCatalogue(t)
92+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now())}}))
93+ controller := &pickerController{catalogue: catalogue}
94+ request, err := controller.ResolveInitial(InitialNavigationCreateEphemeral, pickerTestBase())
95+ require.NoError(t, err)
96+ require.Equal(t, ports.BrokerAdmissionCreateEphemeral, request.Admission)
97+ require.True(t, request.Local)
98+ require.Empty(t, request.Name)
99+ require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
100+}
101+
102+func TestP54InitialNavigationCreatesEphemeralThroughBroker(t *testing.T) {
103+ picker := newAttachTestPicker()
104+ navigation := InitialNavigationCreateEphemeral
105+ ctx, cancel := context.WithCancel(context.Background())
106+ defer cancel()
107+ clock := newSupervisorTestClock()
108+ reader := newAttachTestReader()
109+ terminal := &attachTestTerminal{in: reader}
110+ service := newSupervisorTestService(ports.BrokerConnectionID{1})
111+ service.publish(3, 1)
112+ stream := newSessionTestStream()
113+ service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
114+ return stream, nil
115+ })
116+ sup := mustSupervisor(t, SupervisorConfig{Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }), Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, InitialNavigation: &navigation})
117+ done := make(chan error, 1)
118+ go func() { done <- sup.Run(ctx) }()
119+ require.Eventually(t, func() bool { return len(service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
120+ request := service.openedRequests()[0]
121+ require.Equal(t, ports.BrokerAdmissionCreateEphemeral, request.Admission)
122+ require.Equal(t, sessionTestRequest(true).Local, request.Local)
123+ require.Equal(t, 1, picker.resolveCount())
124+ stream.deliver(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "creation failed"})
125+ require.Eventually(t, func() bool { return sup.State().Presentation == PresentPicker }, 5*time.Second, time.Millisecond)
126+ cancel()
127+ require.ErrorIs(t, <-done, context.Canceled)
128+}
129+
130+func TestP54InitialNavigationIsNotReplayedAfterReconnect(t *testing.T) {
131+ ctx, cancel := context.WithCancel(context.Background())
132+ defer cancel()
133+ clock := newSupervisorTestClock()
134+ reader := newAttachTestReader()
135+ terminal := &attachTestTerminal{in: reader}
136+ picker := newAttachTestPicker()
137+ navigation := InitialNavigationCreateEphemeral
138+ first := newSupervisorTestService(ports.BrokerConnectionID{1})
139+ first.publish(1, 1)
140+ second := newSupervisorTestService(ports.BrokerConnectionID{2})
141+ stream := newSessionTestStream()
142+ first.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
143+ return stream, nil
144+ })
145+ connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) {
146+ if call == 1 {
147+ return first, nil
148+ }
149+ return second, nil
150+ })
151+ sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, InitialNavigation: &navigation})
152+ done := make(chan error, 1)
153+ go func() { done <- sup.Run(ctx) }()
154+ require.Equal(t, 1, connector.awaitStart(t))
155+ require.Eventually(t, func() bool { return len(first.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
156+ stream.deliver(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "creation failed"})
157+ require.Eventually(t, func() bool { return sup.State().Presentation == PresentPicker }, 5*time.Second, time.Millisecond)
158+ first.lose(ports.BrokerError{Code: ports.BrokerErrorUnavailable})
159+ require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityRetryWait }, 5*time.Second, time.Millisecond)
160+ for connector.callCount() < 2 {
161+ clock.mu.Lock()
162+ var timer *supervisorTestTimer
163+ if len(clock.timers) != 0 {
164+ timer = clock.timers[len(clock.timers)-1]
165+ }
166+ clock.mu.Unlock()
167+ if timer != nil {
168+ timer.fire()
169+ }
170+ }
171+ second.publish(2, 1)
172+ require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond)
173+ require.Empty(t, second.openedRequests(), "one-shot navigation must not replay on the replacement generation")
174+ cancel()
175+ require.ErrorIs(t, <-done, context.Canceled)
176+}
177+
178+func TestP54SingleReaderDeliversAttachedByteAfterRealSupervisorHandoff(t *testing.T) {
179+ picker := newAttachTestPicker()
180+ harness := startAttachHarness(t, picker)
181+ stream := newSessionTestStream()
182+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
183+ return stream, nil
184+ })
185+ picker.commit(sessionTestRequest(true))
186+ require.Eventually(t, func() bool { return len(stream.messages()) > 0 }, 5*time.Second, time.Millisecond)
187+ deliverReadyStream(t, stream)
188+ awaitAttachedState(t, harness.sup)
189+ harness.reader.push([]byte("x"))
190+ require.Eventually(t, func() bool {
191+ for _, message := range stream.messages() {
192+ if input, ok := message.(protocol.Input); ok && string(input.Data) == "x" {
193+ return true
194+ }
195+ }
196+ return false
197+ }, 5*time.Second, time.Millisecond, "one typed byte must survive the picker-to-attachment claim handoff")
198+}
199+
200+func TestP54BrokerRestartDoesNotReattachOrReplayUnknownMutation(t *testing.T) {
201+ ctx, cancel := context.WithCancel(context.Background())
202+ defer cancel()
203+ clock := newSupervisorTestClock()
204+ reader := newSupervisorTestReader()
205+ t.Cleanup(reader.unblock)
206+ first := newSupervisorTestService(ports.BrokerConnectionID{1})
207+ first.publish(1, 1)
208+ second := newSupervisorTestService(ports.BrokerConnectionID{2})
209+ connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) {
210+ if call == 1 {
211+ return first, nil
212+ }
213+ return second, nil
214+ })
215+ notices := make(chan LifecycleNotice, 4)
216+ sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: newSupervisorTestTerminal(reader), Clock: clock, Jitter: func() float64 { return 0 }, NotifyLifecycle: func(n LifecycleNotice) { notices <- n }})
217+ done := make(chan error, 1)
218+ go func() { done <- sup.Run(ctx) }()
219+ require.Equal(t, 1, connector.awaitStart(t))
220+ require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond)
221+ first.lose(ports.BrokerError{Code: ports.BrokerErrorOutcomeUnknown, Text: "reply lost after mutation"})
222+ require.Equal(t, LifecycleNoticeBrokerLost, (<-notices).Kind)
223+ retry := clock.awaitTimer(t)
224+ retry.fire()
225+ require.Equal(t, 2, connector.awaitStart(t))
226+ second.publish(2, 1)
227+ require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond)
228+ require.Equal(t, LifecycleNoticeBrokerReconnected, (<-notices).Kind)
229+ require.Empty(t, first.openedRequests())
230+ require.Empty(t, second.openedRequests(), "restart must not auto-reattach or replay a mutation")
231+ cancel()
232+ require.ErrorIs(t, <-done, context.Canceled)
233+}
234+
235+func TestP54ReadyMapPrunedAcrossClaims(t *testing.T) {
236+ pump := newTerminalInputPump(nil)
237+ for i := 0; i < 100; i++ {
238+ consumer := pump.claim()
239+ pump.revoke(consumer)
240+ }
241+ pump.readyMu.Lock()
242+ defer pump.readyMu.Unlock()
243+ require.Empty(t, pump.ready, "revoked monotonic claim IDs must not retain readiness channels")
244+}
245+
246+// TestP54PickerCommittedReadIsNotReplayedToAttachment proves a picker read the
247+// user already acted on is committed at the handoff and never re-delivered as
248+// session input.
249+func TestP54PickerCommittedReadIsNotReplayedToAttachment(t *testing.T) {
250+ reader := newAttachTestReader()
251+ consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)}
252+ lifetime := startTerminalInputLifetime(reader, consumer)
253+ defer lifetime.stop()
254+ reader.push([]byte("1\r"))
255+ var committed []byte
256+ require.Eventually(t, func() bool {
257+ select {
258+ case committed = <-consumer.reads:
259+ return true
260+ default:
261+ return false
262+ }
263+ }, 5*time.Second, time.Millisecond)
264+ require.Equal(t, []byte("1\r"), committed)
265+ lifetime.releasePicker()
266+ attachment := lifetime.pump.claim()
267+ _, ok := lifetime.pump.take(context.Background(), attachment)
268+ require.False(t, ok, "committed picker read must not cross the handoff")
269+ lifetime.pump.revoke(attachment)
270+}
271+
272+func TestP54AttachmentFinalizeDropsUnleasedPendingBeforePicker(t *testing.T) {
273+ reader := newAttachTestReader()
274+ consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)}
275+ lifetime := startTerminalInputLifetime(reader, consumer)
276+ defer lifetime.stop()
277+ lifetime.releasePicker()
278+ host := newAttachmentHost(attachmentHostConfig{Input: lifetime.pump})
279+ host.ownerBoundary = true
280+ fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream())
281+ require.True(t, host.authority.grant(fg))
282+ reader.push([]byte("ls\r"))
283+ require.Eventually(t, func() bool {
284+ lifetime.pump.mu.Lock()
285+ defer lifetime.pump.mu.Unlock()
286+ return lifetime.pump.pending != nil && lifetime.pump.delivering == 0
287+ }, 5*time.Second, time.Millisecond)
288+ fg.finish()
289+ lifetime.acquirePicker()
290+ assertNoPickerRead(t, consumer.reads)
291+}
292+
293+func TestP54AttachmentFinalizeDropsPreservedResidualBeforePicker(t *testing.T) {
294+ reader := newAttachTestReader()
295+ consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)}
296+ lifetime := startTerminalInputLifetime(reader, consumer)
297+ defer lifetime.stop()
298+ lifetime.releasePicker()
299+ host := newAttachmentHost(attachmentHostConfig{Input: lifetime.pump})
300+ host.ownerBoundary = true
301+ fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream())
302+ require.True(t, host.authority.grant(fg))
303+ reader.push([]byte("\r"))
304+ event, ok := fg.Input(context.Background())
305+ require.True(t, ok)
306+ fg.PreserveInput(event.Data)
307+ fg.finish()
308+ lifetime.acquirePicker()
309+ assertNoPickerRead(t, consumer.reads)
310+}
311+
312+func assertNoPickerRead(t *testing.T, reads <-chan []byte) {
313+ t.Helper()
314+ select {
315+ case got := <-reads:
316+ t.Fatalf("attachment bytes crossed into picker: %q", got)
317+ case <-time.After(100 * time.Millisecond):
318+ }
319+}
320+
321+func TestP54UndecidedAttachmentReadIsDroppedBeforePicker(t *testing.T) {
322+ reader := newAttachTestReader()
323+ consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)}
324+ lifetime := startTerminalInputLifetime(reader, consumer)
325+ defer lifetime.stop()
326+ lifetime.releasePicker()
327+ attachment := lifetime.pump.claim()
328+ reader.push([]byte("ls\r"))
329+ var delivery terminalReadResult
330+ require.Eventually(t, func() bool {
331+ var ok bool
332+ delivery, ok = lifetime.pump.take(context.Background(), attachment)
333+ return ok
334+ }, 5*time.Second, time.Millisecond)
335+ require.Equal(t, []byte("ls\r"), delivery.data)
336+ lifetime.pump.dropOwned(attachment)
337+ lifetime.pump.revoke(attachment)
338+ lifetime.acquirePicker()
339+ select {
340+ case got := <-consumer.reads:
341+ t.Fatalf("attachment bytes crossed into picker: %q", got)
342+ case <-time.After(100 * time.Millisecond):
343+ }
344+}
345+
346+type recordingPickerConsumer struct{ reads chan []byte }
347+
348+func (c *recordingPickerConsumer) ConsumeTerminalRead(data []byte) bool {
349+ c.reads <- append([]byte(nil), data...)
350+ return true
351+}
352+
353+func TestP54InputDecisionProtocol(t *testing.T) {
354+ pump := newTerminalInputPump(nil)
355+ <-pump.space
356+ host := newAttachmentHost(attachmentHostConfig{Input: pump})
357+ fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream())
358+ require.True(t, host.authority.grant(fg))
359+ pump.mu.Lock()
360+ pump.pending = &terminalReadResult{data: []byte("once")}
361+ pump.mu.Unlock()
362+ pump.signalReady(fg.consumer)
363+ event, ok := fg.Input(context.Background())
364+ require.True(t, ok)
365+ require.Equal(t, []byte("once"), event.Data)
366+ fg.PreserveInput(event.Data)
367+ fg.AckInput() // refused: already decided
368+ host.authority.revoke(fg)
369+ next := host.newForeground(AttachmentToken{Generation: 2}, newSessionTestStream())
370+ require.True(t, host.authority.grant(next))
371+ replayed, ok := next.Input(context.Background())
372+ require.True(t, ok)
373+ require.Equal(t, []byte("once"), replayed.Data)
374+ next.AckInput()
375+ next.PreserveInput([]byte("duplicate")) // refused: no outstanding delivery
376+ require.Empty(t, pump.residual)
377+}
378+
379+func TestP54GeometryIsPerAttachmentAndCarriesAcrossPicker(t *testing.T) {
380+ resizes := make(chan domain.Geometry, 4)
381+ host := newAttachmentHost(attachmentHostConfig{})
382+ host.resizes = resizes
383+ host.ownerBoundary = true
384+ ctx, cancel := context.WithCancel(context.Background())
385+ defer cancel()
386+ host.startGeometry(ctx)
387+ defer host.stopGeometry()
388+ first := domain.Geometry{Size: domain.Size{Cols: 90, Rows: 30}}
389+ latest := domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}
390+ resizes <- first
391+ resizes <- latest
392+ require.Eventually(t, func() bool { geometry, ok := host.latestGeometry(); return ok && geometry.Size == latest.Size }, 5*time.Second, time.Millisecond)
393+ fg1 := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream())
394+ require.True(t, host.authority.grant(fg1))
395+ geometry, ok := fg1.Resize(context.Background())
396+ require.True(t, ok)
397+ require.Equal(t, latest.Size, geometry.Size)
398+ host.authority.revoke(fg1)
399+ fg2 := host.newForeground(AttachmentToken{Generation: 2}, newSessionTestStream())
400+ require.True(t, host.authority.grant(fg2))
401+ geometry, ok = fg2.Resize(context.Background())
402+ require.True(t, ok)
403+ require.Equal(t, latest.Size, geometry.Size)
404+ _, stale := fg1.Resize(context.Background())
405+ require.False(t, stale)
406+}
407+
408+func TestP54LifecycleSkipsStaleTokenAndDeliversLiveDetach(t *testing.T) {
409+ actions := make(chan AttachmentLifecycleAction, 2)
410+ host := newAttachmentHost(attachmentHostConfig{Actions: actions})
411+ fg := host.newForeground(AttachmentToken{Generation: 2, Attempt: 3}, newSessionTestStream())
412+ require.True(t, host.authority.grant(fg))
413+ actions <- AttachmentLifecycleAction{Token: AttachmentToken{Generation: 1, Attempt: 9}, Kind: AttachmentDetachAndExit}
414+ actions <- AttachmentLifecycleAction{Token: fg.token, Kind: AttachmentDetachToPicker}
415+ action, ok := fg.Lifecycle(context.Background())
416+ require.True(t, ok)
417+ require.Equal(t, AttachmentDetachToPicker, action)
418+ fg.finish()
419+}
420+
421+func TestP54LifecycleInterleavingsRace(t *testing.T) {
422+ for i := 0; i < 50; i++ {
423+ pump := newTerminalInputPump(nil)
424+ resizes := make(chan domain.Geometry, 1)
425+ actions := make(chan AttachmentLifecycleAction, 1)
426+ host := newAttachmentHost(attachmentHostConfig{Input: pump, Actions: actions})
427+ host.resizes = resizes
428+ ctx, cancel := context.WithCancel(context.Background())
429+ host.startGeometry(ctx)
430+ fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream())
431+ require.True(t, host.authority.grant(fg))
432+ var wg sync.WaitGroup
433+ wg.Add(3)
434+ go func() { defer wg.Done(); resizes <- domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}} }()
435+ go func() {
436+ defer wg.Done()
437+ actions <- AttachmentLifecycleAction{Token: fg.token, Kind: AttachmentDetachToPicker}
438+ }()
439+ go func() { defer wg.Done(); fg.finish() }()
440+ wg.Wait()
441+ cancel()
442+ host.stopGeometry()
443+ }
444+}
@@ -0,0 +1,444 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+)
16+
17+func TestP54LifecycleActionsAreDistinct(t *testing.T) {
18+ for _, tc := range []struct {
19+ name string
20+ action AttachmentLifecycleActionKind
21+ wantNotice LifecycleNoticeKind
22+ wantExit bool
23+ }{
24+ {name: "detach to picker", action: AttachmentDetachToPicker, wantNotice: LifecycleNoticeDetachToPicker},
25+ {name: "detach and exit", action: AttachmentDetachAndExit, wantNotice: LifecycleNoticeDetachAndExit, wantExit: true},
26+ } {
27+ t.Run(tc.name, func(t *testing.T) {
28+ actions := make(chan AttachmentLifecycleAction, 1)
29+ notices := make(chan LifecycleNotice, 4)
30+ picker := newAttachTestPicker()
31+ harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
32+ cfg.LifecycleActions = actions
33+ cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice }
34+ })
35+ stream := newSessionTestStream()
36+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
37+ return stream, nil
38+ })
39+ picker.commit(sessionTestRequest(true))
40+ awaitStreamHello(t, &sync.Mutex{}, &stream)
41+ deliverReadyStream(t, stream)
42+ awaitAttachedState(t, harness.sup)
43+ actions <- AttachmentLifecycleAction{Token: harness.sup.attachments.authority.currentToken(), Kind: tc.action}
44+ require.Equal(t, tc.wantNotice, (<-notices).Kind)
45+ if tc.wantExit {
46+ require.NoError(t, harness.waitRun(t))
47+ } else {
48+ awaitPickerState(t, harness.sup)
49+ require.True(t, picker.owns())
50+ require.Len(t, harness.service.openedRequests(), 1, "detach must not auto-reattach")
51+ }
52+ })
53+ }
54+}
55+
56+func TestP54AttachmentOutcomesReturnToPicker(t *testing.T) {
57+ for _, tc := range []struct {
58+ name string
59+ settle func(*sessionTestStream)
60+ notice LifecycleNoticeKind
61+ }{
62+ {name: "session termination", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonDetach}) }, notice: LifecycleNoticeSessionEnded},
63+ {name: "session deletion", settle: func(s *sessionTestStream) { s.deliver(protocol.Detached{Reason: protocol.ReasonSessionKilled}) }, notice: LifecycleNoticeSessionEnded},
64+ {name: "destination failure", settle: func(s *sessionTestStream) {
65+ s.fail(ports.BrokerStreamLost{Connection: ports.BrokerConnectionID{1}, Stream: 1, Epoch: 3, Cause: domain.RemoteFailureTransport, Err: errors.New("private diagnostic")})
66+ }, notice: LifecycleNoticeDestinationFailed},
67+ } {
68+ t.Run(tc.name, func(t *testing.T) {
69+ picker := newAttachTestPicker()
70+ notices := make(chan LifecycleNotice, 4)
71+ harness := startAttachHarness(t, picker)
72+ harness.sup.cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice }
73+ stream := newSessionTestStream()
74+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
75+ return stream, nil
76+ })
77+ picker.commit(sessionTestRequest(true))
78+ require.Eventually(t, func() bool { return len(stream.messages()) > 0 }, 5*time.Second, time.Millisecond)
79+ deliverReadyStream(t, stream)
80+ awaitAttachedState(t, harness.sup)
81+ tc.settle(stream)
82+ awaitPickerState(t, harness.sup)
83+ require.Equal(t, tc.notice, (<-notices).Kind)
84+ require.Len(t, harness.service.openedRequests(), 1)
85+ })
86+ }
87+}
88+
89+func TestP54ResolveInitialUsesRealCatalogueCreation(t *testing.T) {
90+ clock := newSupervisorTestClock()
91+ catalogue, _ := pickerTestCatalogue(t)
92+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now())}}))
93+ controller := &pickerController{catalogue: catalogue}
94+ request, err := controller.ResolveInitial(InitialNavigationCreateEphemeral, pickerTestBase())
95+ require.NoError(t, err)
96+ require.Equal(t, ports.BrokerAdmissionCreateEphemeral, request.Admission)
97+ require.True(t, request.Local)
98+ require.Empty(t, request.Name)
99+ require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
100+}
101+
102+func TestP54InitialNavigationCreatesEphemeralThroughBroker(t *testing.T) {
103+ picker := newAttachTestPicker()
104+ navigation := InitialNavigationCreateEphemeral
105+ ctx, cancel := context.WithCancel(context.Background())
106+ defer cancel()
107+ clock := newSupervisorTestClock()
108+ reader := newAttachTestReader()
109+ terminal := &attachTestTerminal{in: reader}
110+ service := newSupervisorTestService(ports.BrokerConnectionID{1})
111+ service.publish(3, 1)
112+ stream := newSessionTestStream()
113+ service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
114+ return stream, nil
115+ })
116+ sup := mustSupervisor(t, SupervisorConfig{Connector: newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { return service, nil }), Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, InitialNavigation: &navigation})
117+ done := make(chan error, 1)
118+ go func() { done <- sup.Run(ctx) }()
119+ require.Eventually(t, func() bool { return len(service.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
120+ request := service.openedRequests()[0]
121+ require.Equal(t, ports.BrokerAdmissionCreateEphemeral, request.Admission)
122+ require.Equal(t, sessionTestRequest(true).Local, request.Local)
123+ require.Equal(t, 1, picker.resolveCount())
124+ stream.deliver(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "creation failed"})
125+ require.Eventually(t, func() bool { return sup.State().Presentation == PresentPicker }, 5*time.Second, time.Millisecond)
126+ cancel()
127+ require.ErrorIs(t, <-done, context.Canceled)
128+}
129+
130+func TestP54InitialNavigationIsNotReplayedAfterReconnect(t *testing.T) {
131+ ctx, cancel := context.WithCancel(context.Background())
132+ defer cancel()
133+ clock := newSupervisorTestClock()
134+ reader := newAttachTestReader()
135+ terminal := &attachTestTerminal{in: reader}
136+ picker := newAttachTestPicker()
137+ navigation := InitialNavigationCreateEphemeral
138+ first := newSupervisorTestService(ports.BrokerConnectionID{1})
139+ first.publish(1, 1)
140+ second := newSupervisorTestService(ports.BrokerConnectionID{2})
141+ stream := newSessionTestStream()
142+ first.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
143+ return stream, nil
144+ })
145+ connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) {
146+ if call == 1 {
147+ return first, nil
148+ }
149+ return second, nil
150+ })
151+ sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Jitter: func() float64 { return 0 }, Picker: picker, InitialNavigation: &navigation})
152+ done := make(chan error, 1)
153+ go func() { done <- sup.Run(ctx) }()
154+ require.Equal(t, 1, connector.awaitStart(t))
155+ require.Eventually(t, func() bool { return len(first.openedRequests()) == 1 }, 5*time.Second, time.Millisecond)
156+ stream.deliver(protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "creation failed"})
157+ require.Eventually(t, func() bool { return sup.State().Presentation == PresentPicker }, 5*time.Second, time.Millisecond)
158+ first.lose(ports.BrokerError{Code: ports.BrokerErrorUnavailable})
159+ require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityRetryWait }, 5*time.Second, time.Millisecond)
160+ for connector.callCount() < 2 {
161+ clock.mu.Lock()
162+ var timer *supervisorTestTimer
163+ if len(clock.timers) != 0 {
164+ timer = clock.timers[len(clock.timers)-1]
165+ }
166+ clock.mu.Unlock()
167+ if timer != nil {
168+ timer.fire()
169+ }
170+ }
171+ second.publish(2, 1)
172+ require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond)
173+ require.Empty(t, second.openedRequests(), "one-shot navigation must not replay on the replacement generation")
174+ cancel()
175+ require.ErrorIs(t, <-done, context.Canceled)
176+}
177+
178+func TestP54SingleReaderDeliversAttachedByteAfterRealSupervisorHandoff(t *testing.T) {
179+ picker := newAttachTestPicker()
180+ harness := startAttachHarness(t, picker)
181+ stream := newSessionTestStream()
182+ harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
183+ return stream, nil
184+ })
185+ picker.commit(sessionTestRequest(true))
186+ require.Eventually(t, func() bool { return len(stream.messages()) > 0 }, 5*time.Second, time.Millisecond)
187+ deliverReadyStream(t, stream)
188+ awaitAttachedState(t, harness.sup)
189+ harness.reader.push([]byte("x"))
190+ require.Eventually(t, func() bool {
191+ for _, message := range stream.messages() {
192+ if input, ok := message.(protocol.Input); ok && string(input.Data) == "x" {
193+ return true
194+ }
195+ }
196+ return false
197+ }, 5*time.Second, time.Millisecond, "one typed byte must survive the picker-to-attachment claim handoff")
198+}
199+
200+func TestP54BrokerRestartDoesNotReattachOrReplayUnknownMutation(t *testing.T) {
201+ ctx, cancel := context.WithCancel(context.Background())
202+ defer cancel()
203+ clock := newSupervisorTestClock()
204+ reader := newSupervisorTestReader()
205+ t.Cleanup(reader.unblock)
206+ first := newSupervisorTestService(ports.BrokerConnectionID{1})
207+ first.publish(1, 1)
208+ second := newSupervisorTestService(ports.BrokerConnectionID{2})
209+ connector := newSupervisorTestConnector(func(_ context.Context, call int) (ports.BrokerService, error) {
210+ if call == 1 {
211+ return first, nil
212+ }
213+ return second, nil
214+ })
215+ notices := make(chan LifecycleNotice, 4)
216+ sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: newSupervisorTestTerminal(reader), Clock: clock, Jitter: func() float64 { return 0 }, NotifyLifecycle: func(n LifecycleNotice) { notices <- n }})
217+ done := make(chan error, 1)
218+ go func() { done <- sup.Run(ctx) }()
219+ require.Equal(t, 1, connector.awaitStart(t))
220+ require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond)
221+ first.lose(ports.BrokerError{Code: ports.BrokerErrorOutcomeUnknown, Text: "reply lost after mutation"})
222+ require.Equal(t, LifecycleNoticeBrokerLost, (<-notices).Kind)
223+ retry := clock.awaitTimer(t)
224+ retry.fire()
225+ require.Equal(t, 2, connector.awaitStart(t))
226+ second.publish(2, 1)
227+ require.Eventually(t, func() bool { return sup.State().Connectivity == ConnectivityReady }, 5*time.Second, time.Millisecond)
228+ require.Equal(t, LifecycleNoticeBrokerReconnected, (<-notices).Kind)
229+ require.Empty(t, first.openedRequests())
230+ require.Empty(t, second.openedRequests(), "restart must not auto-reattach or replay a mutation")
231+ cancel()
232+ require.ErrorIs(t, <-done, context.Canceled)
233+}
234+
235+func TestP54ReadyMapPrunedAcrossClaims(t *testing.T) {
236+ pump := newTerminalInputPump(nil)
237+ for i := 0; i < 100; i++ {
238+ consumer := pump.claim()
239+ pump.revoke(consumer)
240+ }
241+ pump.readyMu.Lock()
242+ defer pump.readyMu.Unlock()
243+ require.Empty(t, pump.ready, "revoked monotonic claim IDs must not retain readiness channels")
244+}
245+
246+// TestP54PickerCommittedReadIsNotReplayedToAttachment proves a picker read the
247+// user already acted on is committed at the handoff and never re-delivered as
248+// session input.
249+func TestP54PickerCommittedReadIsNotReplayedToAttachment(t *testing.T) {
250+ reader := newAttachTestReader()
251+ consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)}
252+ lifetime := startTerminalInputLifetime(reader, consumer)
253+ defer lifetime.stop()
254+ reader.push([]byte("1\r"))
255+ var committed []byte
256+ require.Eventually(t, func() bool {
257+ select {
258+ case committed = <-consumer.reads:
259+ return true
260+ default:
261+ return false
262+ }
263+ }, 5*time.Second, time.Millisecond)
264+ require.Equal(t, []byte("1\r"), committed)
265+ lifetime.releasePicker()
266+ attachment := lifetime.pump.claim()
267+ _, ok := lifetime.pump.take(context.Background(), attachment)
268+ require.False(t, ok, "committed picker read must not cross the handoff")
269+ lifetime.pump.revoke(attachment)
270+}
271+
272+func TestP54AttachmentFinalizeDropsUnleasedPendingBeforePicker(t *testing.T) {
273+ reader := newAttachTestReader()
274+ consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)}
275+ lifetime := startTerminalInputLifetime(reader, consumer)
276+ defer lifetime.stop()
277+ lifetime.releasePicker()
278+ host := newAttachmentHost(attachmentHostConfig{Input: lifetime.pump})
279+ host.ownerBoundary = true
280+ fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream())
281+ require.True(t, host.authority.grant(fg))
282+ reader.push([]byte("ls\r"))
283+ require.Eventually(t, func() bool {
284+ lifetime.pump.mu.Lock()
285+ defer lifetime.pump.mu.Unlock()
286+ return lifetime.pump.pending != nil && lifetime.pump.delivering == 0
287+ }, 5*time.Second, time.Millisecond)
288+ fg.finish()
289+ lifetime.acquirePicker()
290+ assertNoPickerRead(t, consumer.reads)
291+}
292+
293+func TestP54AttachmentFinalizeDropsPreservedResidualBeforePicker(t *testing.T) {
294+ reader := newAttachTestReader()
295+ consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)}
296+ lifetime := startTerminalInputLifetime(reader, consumer)
297+ defer lifetime.stop()
298+ lifetime.releasePicker()
299+ host := newAttachmentHost(attachmentHostConfig{Input: lifetime.pump})
300+ host.ownerBoundary = true
301+ fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream())
302+ require.True(t, host.authority.grant(fg))
303+ reader.push([]byte("\r"))
304+ event, ok := fg.Input(context.Background())
305+ require.True(t, ok)
306+ fg.PreserveInput(event.Data)
307+ fg.finish()
308+ lifetime.acquirePicker()
309+ assertNoPickerRead(t, consumer.reads)
310+}
311+
312+func assertNoPickerRead(t *testing.T, reads <-chan []byte) {
313+ t.Helper()
314+ select {
315+ case got := <-reads:
316+ t.Fatalf("attachment bytes crossed into picker: %q", got)
317+ case <-time.After(100 * time.Millisecond):
318+ }
319+}
320+
321+func TestP54UndecidedAttachmentReadIsDroppedBeforePicker(t *testing.T) {
322+ reader := newAttachTestReader()
323+ consumer := &recordingPickerConsumer{reads: make(chan []byte, 1)}
324+ lifetime := startTerminalInputLifetime(reader, consumer)
325+ defer lifetime.stop()
326+ lifetime.releasePicker()
327+ attachment := lifetime.pump.claim()
328+ reader.push([]byte("ls\r"))
329+ var delivery terminalReadResult
330+ require.Eventually(t, func() bool {
331+ var ok bool
332+ delivery, ok = lifetime.pump.take(context.Background(), attachment)
333+ return ok
334+ }, 5*time.Second, time.Millisecond)
335+ require.Equal(t, []byte("ls\r"), delivery.data)
336+ lifetime.pump.dropOwned(attachment)
337+ lifetime.pump.revoke(attachment)
338+ lifetime.acquirePicker()
339+ select {
340+ case got := <-consumer.reads:
341+ t.Fatalf("attachment bytes crossed into picker: %q", got)
342+ case <-time.After(100 * time.Millisecond):
343+ }
344+}
345+
346+type recordingPickerConsumer struct{ reads chan []byte }
347+
348+func (c *recordingPickerConsumer) ConsumeTerminalRead(data []byte) bool {
349+ c.reads <- append([]byte(nil), data...)
350+ return true
351+}
352+
353+func TestP54InputDecisionProtocol(t *testing.T) {
354+ pump := newTerminalInputPump(nil)
355+ <-pump.space
356+ host := newAttachmentHost(attachmentHostConfig{Input: pump})
357+ fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream())
358+ require.True(t, host.authority.grant(fg))
359+ pump.mu.Lock()
360+ pump.pending = &terminalReadResult{data: []byte("once")}
361+ pump.mu.Unlock()
362+ pump.signalReady(fg.consumer)
363+ event, ok := fg.Input(context.Background())
364+ require.True(t, ok)
365+ require.Equal(t, []byte("once"), event.Data)
366+ fg.PreserveInput(event.Data)
367+ fg.AckInput() // refused: already decided
368+ host.authority.revoke(fg)
369+ next := host.newForeground(AttachmentToken{Generation: 2}, newSessionTestStream())
370+ require.True(t, host.authority.grant(next))
371+ replayed, ok := next.Input(context.Background())
372+ require.True(t, ok)
373+ require.Equal(t, []byte("once"), replayed.Data)
374+ next.AckInput()
375+ next.PreserveInput([]byte("duplicate")) // refused: no outstanding delivery
376+ require.Empty(t, pump.residual)
377+}
378+
379+func TestP54GeometryIsPerAttachmentAndCarriesAcrossPicker(t *testing.T) {
380+ resizes := make(chan domain.Geometry, 4)
381+ host := newAttachmentHost(attachmentHostConfig{})
382+ host.resizes = resizes
383+ host.ownerBoundary = true
384+ ctx, cancel := context.WithCancel(context.Background())
385+ defer cancel()
386+ host.startGeometry(ctx)
387+ defer host.stopGeometry()
388+ first := domain.Geometry{Size: domain.Size{Cols: 90, Rows: 30}}
389+ latest := domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}
390+ resizes <- first
391+ resizes <- latest
392+ require.Eventually(t, func() bool { geometry, ok := host.latestGeometry(); return ok && geometry.Size == latest.Size }, 5*time.Second, time.Millisecond)
393+ fg1 := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream())
394+ require.True(t, host.authority.grant(fg1))
395+ geometry, ok := fg1.Resize(context.Background())
396+ require.True(t, ok)
397+ require.Equal(t, latest.Size, geometry.Size)
398+ host.authority.revoke(fg1)
399+ fg2 := host.newForeground(AttachmentToken{Generation: 2}, newSessionTestStream())
400+ require.True(t, host.authority.grant(fg2))
401+ geometry, ok = fg2.Resize(context.Background())
402+ require.True(t, ok)
403+ require.Equal(t, latest.Size, geometry.Size)
404+ _, stale := fg1.Resize(context.Background())
405+ require.False(t, stale)
406+}
407+
408+func TestP54LifecycleSkipsStaleTokenAndDeliversLiveDetach(t *testing.T) {
409+ actions := make(chan AttachmentLifecycleAction, 2)
410+ host := newAttachmentHost(attachmentHostConfig{Actions: actions})
411+ fg := host.newForeground(AttachmentToken{Generation: 2, Attempt: 3}, newSessionTestStream())
412+ require.True(t, host.authority.grant(fg))
413+ actions <- AttachmentLifecycleAction{Token: AttachmentToken{Generation: 1, Attempt: 9}, Kind: AttachmentDetachAndExit}
414+ actions <- AttachmentLifecycleAction{Token: fg.token, Kind: AttachmentDetachToPicker}
415+ action, ok := fg.Lifecycle(context.Background())
416+ require.True(t, ok)
417+ require.Equal(t, AttachmentDetachToPicker, action)
418+ fg.finish()
419+}
420+
421+func TestP54LifecycleInterleavingsRace(t *testing.T) {
422+ for i := 0; i < 50; i++ {
423+ pump := newTerminalInputPump(nil)
424+ resizes := make(chan domain.Geometry, 1)
425+ actions := make(chan AttachmentLifecycleAction, 1)
426+ host := newAttachmentHost(attachmentHostConfig{Input: pump, Actions: actions})
427+ host.resizes = resizes
428+ ctx, cancel := context.WithCancel(context.Background())
429+ host.startGeometry(ctx)
430+ fg := host.newForeground(AttachmentToken{Generation: 1}, newSessionTestStream())
431+ require.True(t, host.authority.grant(fg))
432+ var wg sync.WaitGroup
433+ wg.Add(3)
434+ go func() { defer wg.Done(); resizes <- domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}} }()
435+ go func() {
436+ defer wg.Done()
437+ actions <- AttachmentLifecycleAction{Token: fg.token, Kind: AttachmentDetachToPicker}
438+ }()
439+ go func() { defer wg.Done(); fg.finish() }()
440+ wg.Wait()
441+ cancel()
442+ host.stopGeometry()
443+ }
444+}
M internal/usecase/client/picker_controller.go +9 -0
@@ -361,6 +361,15 @@ func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStre
361361 return p.ResolveKey(key, base)
362362 }
363363
364+// ResolveInitial resolves the one-shot no-argument ephemeral creation through
365+// the current catalogue. It does not inspect mutable picker model state.
366+func (p *pickerController) ResolveInitial(navigation InitialNavigation, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
367+ if navigation != InitialNavigationCreateEphemeral || p == nil || p.catalogue == nil {
368+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation is not available"}
369+ }
370+ return p.catalogue.ResolveCreation(true, "", pickerSelectionCreateEphemeral, "", base)
371+}
372+
364373 // ResolveKey revalidates exactly the supplied catalogue key against the latest
365374 // applied snapshot and returns the exact broker stream request. It is the
366375 // commit path: the key captured atomically with the commit decision (see
@@ -361,6 +361,15 @@ func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStre
361 return p.ResolveKey(key, base)361 return p.ResolveKey(key, base)
362 }362 }
363 363
364+// ResolveInitial resolves the one-shot no-argument ephemeral creation through
365+// the current catalogue. It does not inspect mutable picker model state.
366+func (p *pickerController) ResolveInitial(navigation InitialNavigation, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
367+ if navigation != InitialNavigationCreateEphemeral || p == nil || p.catalogue == nil {
368+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation is not available"}
369+ }
370+ return p.catalogue.ResolveCreation(true, "", pickerSelectionCreateEphemeral, "", base)
371+}
372+
364 // ResolveKey revalidates exactly the supplied catalogue key against the latest373 // ResolveKey revalidates exactly the supplied catalogue key against the latest
365 // applied snapshot and returns the exact broker stream request. It is the374 // applied snapshot and returns the exact broker stream request. It is the
366 // commit path: the key captured atomically with the commit decision (see375 // commit path: the key captured atomically with the commit decision (see
M internal/usecase/client/supervisor.go +186 -18
@@ -137,6 +137,9 @@ type State struct {
137137 // Err is the most recent visible connectivity failure, nil while healthy
138138 // and before any failure.
139139 Err error
140+ // ReadyLost is set only when an established ready generation was lost and
141+ // cleared by the replacement's committed publication.
142+ ReadyLost bool
140143 }
141144
142145 // supervisorEventKind is one input to the pure supervisor reducer.
@@ -191,7 +194,12 @@ func reduceSupervisor(state State, event supervisorEvent) State {
191194 state.Connectivity = ConnectivityReady
192195 state.Attempt = 0
193196 state.Err = nil
194- case supervisorTransientFailure, supervisorBrokerLoss:
197+ case supervisorTransientFailure:
198+ state.Connectivity = ConnectivityRetryWait
199+ state.Attempt++
200+ state.Err = event.err
201+ case supervisorBrokerLoss:
202+ state.ReadyLost = state.Connectivity == ConnectivityReady
195203 state.Connectivity = ConnectivityRetryWait
196204 state.Attempt++
197205 state.Err = event.err
@@ -221,6 +229,25 @@ func reduceSupervisor(state State, event supervisorEvent) State {
221229 return state
222230 }
223231
232+// LifecycleNoticeKind is one bounded autonomous lifecycle transition.
233+type LifecycleNoticeKind uint8
234+
235+const (
236+ LifecycleNoticeDetachToPicker LifecycleNoticeKind = iota + 1
237+ LifecycleNoticeDetachAndExit
238+ LifecycleNoticeSessionEnded
239+ LifecycleNoticeDestinationFailed
240+ LifecycleNoticeBrokerLost
241+ LifecycleNoticeBrokerReconnected
242+ LifecycleNoticeOutcomeUnknown
243+)
244+
245+// LifecycleNotice carries classification only; adapter diagnostics and free
246+// text never cross this presentation seam.
247+type LifecycleNotice struct {
248+ Kind LifecycleNoticeKind
249+}
250+
224251 // SupervisorConfig supplies the autonomous client's dependencies. The
225252 // connector, terminal, and clock are required; render, notify, and jitter are
226253 // injectable seams with safe defaults. Render, notify, and jitter must not
@@ -239,6 +266,8 @@ type SupervisorConfig struct {
239266 // Notify surfaces a connectivity failure without leaving the picker.
240267 // Optional; the default notifies nothing.
241268 Notify func(State, error)
269+ // NotifyLifecycle reports typed, bounded lifecycle transitions.
270+ NotifyLifecycle func(LifecycleNotice)
242271 // Jitter returns an equal-jitter fraction in [0,1). It is the only
243272 // randomness seam so tests can pin the retry cadence exactly. Optional; the
244273 // default is a process-local random source.
@@ -253,6 +282,14 @@ type SupervisorConfig struct {
253282 // each attachment Hello. The use case deliberately does not inspect the
254283 // process environment; omitted fields retain their protocol zero values.
255284 AttachmentEnvironment AttachmentEnvironment
285+ // LifecycleActions carries explicit attachment lifecycle requests. A nil
286+ // channel disables external lifecycle actions. The actions are typed so
287+ // detach-to-picker can never be confused with detach-and-exit.
288+ LifecycleActions <-chan AttachmentLifecycleAction
289+ // InitialNavigation optionally requests one broker-native navigation after
290+ // the first committed catalogue publication. It is used by no-argument
291+ // composition to create an ephemeral session without a special attach path.
292+ InitialNavigation *InitialNavigation
256293 }
257294
258295 // AttachmentEnvironment is the composition seam for client environment data
@@ -263,6 +300,28 @@ type AttachmentEnvironment struct {
263300 TrueColor bool
264301 }
265302
303+// InitialNavigation is a one-shot client navigation intent.
304+type InitialNavigation uint8
305+
306+const (
307+ // InitialNavigationCreateEphemeral creates a broker-routed ephemeral session.
308+ InitialNavigationCreateEphemeral InitialNavigation = iota + 1
309+)
310+
311+// AttachmentLifecycleAction is an explicit process/attachment decision.
312+type AttachmentLifecycleActionKind uint8
313+
314+const (
315+ AttachmentDetachToPicker AttachmentLifecycleActionKind = iota + 1
316+ AttachmentDetachAndExit
317+)
318+
319+// AttachmentLifecycleAction targets exactly one granted attachment.
320+type AttachmentLifecycleAction struct {
321+ Token AttachmentToken
322+ Kind AttachmentLifecycleActionKind
323+}
324+
266325 // Supervisor owns one autonomous client process: raw mode, one terminal input
267326 // lifetime, and the broker-connectivity lifecycle.
268327 type Supervisor struct {
@@ -324,6 +383,7 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) {
324383 supervisor.attachments = newAttachmentHost(attachmentHostConfig{
325384 Terminal: cfg.Terminal,
326385 Clock: cfg.Clock,
386+ Actions: cfg.LifecycleActions,
327387 OnAttached: func(AttachmentToken) { supervisor.transition(supervisorEvent{kind: supervisorAttached}) },
328388 })
329389 return supervisor, nil
@@ -357,6 +417,10 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
357417 }()
358418
359419 input := startTerminalInputLifetime(s.cfg.Terminal.In(), s.cfg.Picker)
420+ defer input.stop()
421+ s.attachments.setInput(input.pump)
422+ s.attachments.startGeometry(ctx)
423+ defer s.attachments.stopGeometry()
360424
361425 // The active connection is owned across loop iterations. retire is
362426 // idempotent so every exit path closes exactly the connection it adopted.
@@ -439,7 +503,23 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
439503 // while an established connection already has state.
440504 s.cfg.Picker.ApplySnapshot(service.Snapshot())
441505 }
506+ wasReadyLost := s.State().ReadyLost
442507 s.transition(supervisorEvent{kind: supervisorReady})
508+ if wasReadyLost {
509+ s.notifyLifecycle(LifecycleNoticeBrokerReconnected)
510+ s.mu.Lock()
511+ s.state.ReadyLost = false
512+ s.mu.Unlock()
513+ }
514+ if s.cfg.InitialNavigation != nil {
515+ navigation := *s.cfg.InitialNavigation
516+ s.cfg.InitialNavigation = nil
517+ if terminated, termErr := s.runInitialNavigation(ctx, input, service, navigation); terminated {
518+ retire()
519+ s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr})
520+ return termErr
521+ }
522+ }
443523
444524 // Ready phase: fold publications, admit committed attachments one at a
445525 // time, and return to the picker after each. A committed attachment
@@ -474,6 +554,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
474554 s.transition(supervisorEvent{kind: supervisorTerminal, err: cause})
475555 return cause
476556 }
557+ s.notifyLifecycle(LifecycleNoticeBrokerLost)
477558 if terminated, termErr := s.retryFailure(ctx, input, supervisorBrokerLoss, normalizeUnavailable(lossErr)); terminated {
478559 return termErr
479560 }
@@ -802,6 +883,12 @@ func normalizeUnavailable(err error) error {
802883 }
803884
804885 // notify surfaces a connectivity failure through the optional notifier.
886+func (s *Supervisor) notifyLifecycle(kind LifecycleNoticeKind) {
887+ if s != nil && s.cfg.NotifyLifecycle != nil {
888+ s.cfg.NotifyLifecycle(LifecycleNotice{Kind: kind})
889+ }
890+}
891+
805892 func (s *Supervisor) notify(err error) {
806893 if s.cfg.Notify == nil || err == nil {
807894 return
@@ -831,6 +918,10 @@ type terminalInputLifetime struct {
831918 eof chan error
832919 once sync.Once
833920 consumer pickerInputConsumer
921+ pump *terminalInputPump
922+
923+ mu sync.Mutex
924+ pickerID uint64
834925 }
835926
836927 // startTerminalInputLifetime starts the single terminal read. A nil reader is
@@ -842,28 +933,105 @@ func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *ter
842933 lifetime.finish(io.EOF)
843934 return lifetime
844935 }
936+ lifetime.pump = newTerminalInputPump(in)
937+ lifetime.pump.start()
938+ if consumer != nil {
939+ lifetime.acquirePicker()
940+ }
941+ go lifetime.runPicker()
845942 go func() {
846- buffer := make([]byte, 4096)
847- for {
848- n, err := in.Read(buffer)
849- if err != nil {
850- lifetime.finish(err)
943+ <-lifetime.pump.exited
944+ lifetime.finish(io.EOF)
945+ }()
946+ return lifetime
947+}
948+
949+func (l *terminalInputLifetime) acquirePicker() {
950+ if l == nil || l.pump == nil || l.consumer == nil {
951+ return
952+ }
953+ l.mu.Lock()
954+ if l.pickerID != 0 {
955+ l.mu.Unlock()
956+ return
957+ }
958+ if id, ok := l.pump.tryClaim(); ok {
959+ l.pickerID = id
960+ l.mu.Unlock()
961+ return
962+ }
963+ l.mu.Unlock()
964+ // A finalizing attachment still owns the claim. Retry when that claim is
965+ // actually released rather than permanently abandoning picker input.
966+ go func() {
967+ select {
968+ case <-l.pump.claimChanged:
969+ l.acquirePicker()
970+ case <-l.pump.done:
971+ }
972+ }()
973+}
974+
975+func (l *terminalInputLifetime) releasePicker() {
976+ if l == nil || l.pump == nil {
977+ return
978+ }
979+ l.mu.Lock()
980+ id := l.pickerID
981+ l.pickerID = 0
982+ l.mu.Unlock()
983+ if id != 0 {
984+ l.pump.ack(id)
985+ // Drop picker-owned bytes that were queued but never presented before
986+ // changing owner class; they are not session input.
987+ l.pump.dropOwned(id)
988+ l.pump.revoke(id)
989+ }
990+}
991+
992+func (l *terminalInputLifetime) runPicker() {
993+ if l == nil || l.pump == nil {
994+ return
995+ }
996+ for {
997+ l.mu.Lock()
998+ id := l.pickerID
999+ l.mu.Unlock()
1000+ if id == 0 {
1001+ select {
1002+ case <-l.pump.claimChanged:
1003+ case <-l.pump.done:
8511004 return
8521005 }
853- if n == 0 {
854- // A zero-byte, nil-error read is legal but cannot make
855- // progress; keep reading so a well-behaved reader is never
856- // mistaken for EOF.
857- continue
858- }
859- if lifetime.consumer != nil {
860- // The consumer may retain a partial escape or UTF-8 prefix, so it
861- // gets its own copy rather than the reused read buffer.
862- lifetime.consumer.ConsumeTerminalRead(append([]byte(nil), buffer[:n]...))
1006+ continue
1007+ }
1008+ result, ok := l.pump.take(context.Background(), id)
1009+ if !ok {
1010+ select {
1011+ case <-l.pump.readyFor(id):
1012+ case <-l.pump.claimChanged:
1013+ case <-l.pump.done:
1014+ return
8631015 }
1016+ continue
8641017 }
865- }()
866- return lifetime
1018+ if len(result.data) != 0 {
1019+ l.consumer.ConsumeTerminalRead(result.data)
1020+ }
1021+ l.pump.ack(id)
1022+ if result.err != nil {
1023+ l.finish(result.err)
1024+ return
1025+ }
1026+ }
1027+}
1028+
1029+func (l *terminalInputLifetime) stop() {
1030+ if l == nil || l.pump == nil {
1031+ return
1032+ }
1033+ l.pump.stop()
1034+ l.pump.suspend()
8671035 }
8681036
8691037 // finish publishes the terminal cause exactly once.
@@ -137,6 +137,9 @@ type State struct {
137 // Err is the most recent visible connectivity failure, nil while healthy137 // Err is the most recent visible connectivity failure, nil while healthy
138 // and before any failure.138 // and before any failure.
139 Err error139 Err error
140+ // ReadyLost is set only when an established ready generation was lost and
141+ // cleared by the replacement's committed publication.
142+ ReadyLost bool
140 }143 }
141 144
142 // supervisorEventKind is one input to the pure supervisor reducer.145 // supervisorEventKind is one input to the pure supervisor reducer.
@@ -191,7 +194,12 @@ func reduceSupervisor(state State, event supervisorEvent) State {
191 state.Connectivity = ConnectivityReady194 state.Connectivity = ConnectivityReady
192 state.Attempt = 0195 state.Attempt = 0
193 state.Err = nil196 state.Err = nil
194- case supervisorTransientFailure, supervisorBrokerLoss:197+ case supervisorTransientFailure:
198+ state.Connectivity = ConnectivityRetryWait
199+ state.Attempt++
200+ state.Err = event.err
201+ case supervisorBrokerLoss:
202+ state.ReadyLost = state.Connectivity == ConnectivityReady
195 state.Connectivity = ConnectivityRetryWait203 state.Connectivity = ConnectivityRetryWait
196 state.Attempt++204 state.Attempt++
197 state.Err = event.err205 state.Err = event.err
@@ -221,6 +229,25 @@ func reduceSupervisor(state State, event supervisorEvent) State {
221 return state229 return state
222 }230 }
223 231
232+// LifecycleNoticeKind is one bounded autonomous lifecycle transition.
233+type LifecycleNoticeKind uint8
234+
235+const (
236+ LifecycleNoticeDetachToPicker LifecycleNoticeKind = iota + 1
237+ LifecycleNoticeDetachAndExit
238+ LifecycleNoticeSessionEnded
239+ LifecycleNoticeDestinationFailed
240+ LifecycleNoticeBrokerLost
241+ LifecycleNoticeBrokerReconnected
242+ LifecycleNoticeOutcomeUnknown
243+)
244+
245+// LifecycleNotice carries classification only; adapter diagnostics and free
246+// text never cross this presentation seam.
247+type LifecycleNotice struct {
248+ Kind LifecycleNoticeKind
249+}
250+
224 // SupervisorConfig supplies the autonomous client's dependencies. The251 // SupervisorConfig supplies the autonomous client's dependencies. The
225 // connector, terminal, and clock are required; render, notify, and jitter are252 // connector, terminal, and clock are required; render, notify, and jitter are
226 // injectable seams with safe defaults. Render, notify, and jitter must not253 // injectable seams with safe defaults. Render, notify, and jitter must not
@@ -239,6 +266,8 @@ type SupervisorConfig struct {
239 // Notify surfaces a connectivity failure without leaving the picker.266 // Notify surfaces a connectivity failure without leaving the picker.
240 // Optional; the default notifies nothing.267 // Optional; the default notifies nothing.
241 Notify func(State, error)268 Notify func(State, error)
269+ // NotifyLifecycle reports typed, bounded lifecycle transitions.
270+ NotifyLifecycle func(LifecycleNotice)
242 // Jitter returns an equal-jitter fraction in [0,1). It is the only271 // Jitter returns an equal-jitter fraction in [0,1). It is the only
243 // randomness seam so tests can pin the retry cadence exactly. Optional; the272 // randomness seam so tests can pin the retry cadence exactly. Optional; the
244 // default is a process-local random source.273 // default is a process-local random source.
@@ -253,6 +282,14 @@ type SupervisorConfig struct {
253 // each attachment Hello. The use case deliberately does not inspect the282 // each attachment Hello. The use case deliberately does not inspect the
254 // process environment; omitted fields retain their protocol zero values.283 // process environment; omitted fields retain their protocol zero values.
255 AttachmentEnvironment AttachmentEnvironment284 AttachmentEnvironment AttachmentEnvironment
285+ // LifecycleActions carries explicit attachment lifecycle requests. A nil
286+ // channel disables external lifecycle actions. The actions are typed so
287+ // detach-to-picker can never be confused with detach-and-exit.
288+ LifecycleActions <-chan AttachmentLifecycleAction
289+ // InitialNavigation optionally requests one broker-native navigation after
290+ // the first committed catalogue publication. It is used by no-argument
291+ // composition to create an ephemeral session without a special attach path.
292+ InitialNavigation *InitialNavigation
256 }293 }
257 294
258 // AttachmentEnvironment is the composition seam for client environment data295 // AttachmentEnvironment is the composition seam for client environment data
@@ -263,6 +300,28 @@ type AttachmentEnvironment struct {
263 TrueColor bool300 TrueColor bool
264 }301 }
265 302
303+// InitialNavigation is a one-shot client navigation intent.
304+type InitialNavigation uint8
305+
306+const (
307+ // InitialNavigationCreateEphemeral creates a broker-routed ephemeral session.
308+ InitialNavigationCreateEphemeral InitialNavigation = iota + 1
309+)
310+
311+// AttachmentLifecycleAction is an explicit process/attachment decision.
312+type AttachmentLifecycleActionKind uint8
313+
314+const (
315+ AttachmentDetachToPicker AttachmentLifecycleActionKind = iota + 1
316+ AttachmentDetachAndExit
317+)
318+
319+// AttachmentLifecycleAction targets exactly one granted attachment.
320+type AttachmentLifecycleAction struct {
321+ Token AttachmentToken
322+ Kind AttachmentLifecycleActionKind
323+}
324+
266 // Supervisor owns one autonomous client process: raw mode, one terminal input325 // Supervisor owns one autonomous client process: raw mode, one terminal input
267 // lifetime, and the broker-connectivity lifecycle.326 // lifetime, and the broker-connectivity lifecycle.
268 type Supervisor struct {327 type Supervisor struct {
@@ -324,6 +383,7 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) {
324 supervisor.attachments = newAttachmentHost(attachmentHostConfig{383 supervisor.attachments = newAttachmentHost(attachmentHostConfig{
325 Terminal: cfg.Terminal,384 Terminal: cfg.Terminal,
326 Clock: cfg.Clock,385 Clock: cfg.Clock,
386+ Actions: cfg.LifecycleActions,
327 OnAttached: func(AttachmentToken) { supervisor.transition(supervisorEvent{kind: supervisorAttached}) },387 OnAttached: func(AttachmentToken) { supervisor.transition(supervisorEvent{kind: supervisorAttached}) },
328 })388 })
329 return supervisor, nil389 return supervisor, nil
@@ -357,6 +417,10 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
357 }()417 }()
358 418
359 input := startTerminalInputLifetime(s.cfg.Terminal.In(), s.cfg.Picker)419 input := startTerminalInputLifetime(s.cfg.Terminal.In(), s.cfg.Picker)
420+ defer input.stop()
421+ s.attachments.setInput(input.pump)
422+ s.attachments.startGeometry(ctx)
423+ defer s.attachments.stopGeometry()
360 424
361 // The active connection is owned across loop iterations. retire is425 // The active connection is owned across loop iterations. retire is
362 // idempotent so every exit path closes exactly the connection it adopted.426 // idempotent so every exit path closes exactly the connection it adopted.
@@ -439,7 +503,23 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
439 // while an established connection already has state.503 // while an established connection already has state.
440 s.cfg.Picker.ApplySnapshot(service.Snapshot())504 s.cfg.Picker.ApplySnapshot(service.Snapshot())
441 }505 }
506+ wasReadyLost := s.State().ReadyLost
442 s.transition(supervisorEvent{kind: supervisorReady})507 s.transition(supervisorEvent{kind: supervisorReady})
508+ if wasReadyLost {
509+ s.notifyLifecycle(LifecycleNoticeBrokerReconnected)
510+ s.mu.Lock()
511+ s.state.ReadyLost = false
512+ s.mu.Unlock()
513+ }
514+ if s.cfg.InitialNavigation != nil {
515+ navigation := *s.cfg.InitialNavigation
516+ s.cfg.InitialNavigation = nil
517+ if terminated, termErr := s.runInitialNavigation(ctx, input, service, navigation); terminated {
518+ retire()
519+ s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr})
520+ return termErr
521+ }
522+ }
443 523
444 // Ready phase: fold publications, admit committed attachments one at a524 // Ready phase: fold publications, admit committed attachments one at a
445 // time, and return to the picker after each. A committed attachment525 // time, and return to the picker after each. A committed attachment
@@ -474,6 +554,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
474 s.transition(supervisorEvent{kind: supervisorTerminal, err: cause})554 s.transition(supervisorEvent{kind: supervisorTerminal, err: cause})
475 return cause555 return cause
476 }556 }
557+ s.notifyLifecycle(LifecycleNoticeBrokerLost)
477 if terminated, termErr := s.retryFailure(ctx, input, supervisorBrokerLoss, normalizeUnavailable(lossErr)); terminated {558 if terminated, termErr := s.retryFailure(ctx, input, supervisorBrokerLoss, normalizeUnavailable(lossErr)); terminated {
478 return termErr559 return termErr
479 }560 }
@@ -802,6 +883,12 @@ func normalizeUnavailable(err error) error {
802 }883 }
803 884
804 // notify surfaces a connectivity failure through the optional notifier.885 // notify surfaces a connectivity failure through the optional notifier.
886+func (s *Supervisor) notifyLifecycle(kind LifecycleNoticeKind) {
887+ if s != nil && s.cfg.NotifyLifecycle != nil {
888+ s.cfg.NotifyLifecycle(LifecycleNotice{Kind: kind})
889+ }
890+}
891+
805 func (s *Supervisor) notify(err error) {892 func (s *Supervisor) notify(err error) {
806 if s.cfg.Notify == nil || err == nil {893 if s.cfg.Notify == nil || err == nil {
807 return894 return
@@ -831,6 +918,10 @@ type terminalInputLifetime struct {
831 eof chan error918 eof chan error
832 once sync.Once919 once sync.Once
833 consumer pickerInputConsumer920 consumer pickerInputConsumer
921+ pump *terminalInputPump
922+
923+ mu sync.Mutex
924+ pickerID uint64
834 }925 }
835 926
836 // startTerminalInputLifetime starts the single terminal read. A nil reader is927 // startTerminalInputLifetime starts the single terminal read. A nil reader is
@@ -842,28 +933,105 @@ func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *ter
842 lifetime.finish(io.EOF)933 lifetime.finish(io.EOF)
843 return lifetime934 return lifetime
844 }935 }
936+ lifetime.pump = newTerminalInputPump(in)
937+ lifetime.pump.start()
938+ if consumer != nil {
939+ lifetime.acquirePicker()
940+ }
941+ go lifetime.runPicker()
845 go func() {942 go func() {
846- buffer := make([]byte, 4096)943+ <-lifetime.pump.exited
847- for {944+ lifetime.finish(io.EOF)
848- n, err := in.Read(buffer)945+ }()
849- if err != nil {946+ return lifetime
850- lifetime.finish(err)947+}
948+
949+func (l *terminalInputLifetime) acquirePicker() {
950+ if l == nil || l.pump == nil || l.consumer == nil {
951+ return
952+ }
953+ l.mu.Lock()
954+ if l.pickerID != 0 {
955+ l.mu.Unlock()
956+ return
957+ }
958+ if id, ok := l.pump.tryClaim(); ok {
959+ l.pickerID = id
960+ l.mu.Unlock()
961+ return
962+ }
963+ l.mu.Unlock()
964+ // A finalizing attachment still owns the claim. Retry when that claim is
965+ // actually released rather than permanently abandoning picker input.
966+ go func() {
967+ select {
968+ case <-l.pump.claimChanged:
969+ l.acquirePicker()
970+ case <-l.pump.done:
971+ }
972+ }()
973+}
974+
975+func (l *terminalInputLifetime) releasePicker() {
976+ if l == nil || l.pump == nil {
977+ return
978+ }
979+ l.mu.Lock()
980+ id := l.pickerID
981+ l.pickerID = 0
982+ l.mu.Unlock()
983+ if id != 0 {
984+ l.pump.ack(id)
985+ // Drop picker-owned bytes that were queued but never presented before
986+ // changing owner class; they are not session input.
987+ l.pump.dropOwned(id)
988+ l.pump.revoke(id)
989+ }
990+}
991+
992+func (l *terminalInputLifetime) runPicker() {
993+ if l == nil || l.pump == nil {
994+ return
995+ }
996+ for {
997+ l.mu.Lock()
998+ id := l.pickerID
999+ l.mu.Unlock()
1000+ if id == 0 {
1001+ select {
1002+ case <-l.pump.claimChanged:
1003+ case <-l.pump.done:
851 return1004 return
852 }1005 }
853- if n == 0 {1006+ continue
854- // A zero-byte, nil-error read is legal but cannot make1007+ }
855- // progress; keep reading so a well-behaved reader is never1008+ result, ok := l.pump.take(context.Background(), id)
856- // mistaken for EOF.1009+ if !ok {
857- continue1010+ select {
858- }1011+ case <-l.pump.readyFor(id):
859- if lifetime.consumer != nil {1012+ case <-l.pump.claimChanged:
860- // The consumer may retain a partial escape or UTF-8 prefix, so it1013+ case <-l.pump.done:
861- // gets its own copy rather than the reused read buffer.1014+ return
862- lifetime.consumer.ConsumeTerminalRead(append([]byte(nil), buffer[:n]...))
863 }1015 }
1016+ continue
864 }1017 }
865- }()1018+ if len(result.data) != 0 {
866- return lifetime1019+ l.consumer.ConsumeTerminalRead(result.data)
1020+ }
1021+ l.pump.ack(id)
1022+ if result.err != nil {
1023+ l.finish(result.err)
1024+ return
1025+ }
1026+ }
1027+}
1028+
1029+func (l *terminalInputLifetime) stop() {
1030+ if l == nil || l.pump == nil {
1031+ return
1032+ }
1033+ l.pump.stop()
1034+ l.pump.suspend()
867 }1035 }
868 1036
869 // finish publishes the terminal cause exactly once.1037 // finish publishes the terminal cause exactly once.
M internal/usecase/client/supervisor_attach.go +63 -14
@@ -238,6 +238,16 @@ type attachmentSettlement struct {
238238 // the exact broker stream request the user committed. The stream ID is
239239 // allocated strictly increasing per connection and is consumed even when
240240 // resolution refuses, matching the pool's never-reused ID contract.
241+func (s *Supervisor) resolveInitialStreamRequest(service ports.BrokerService, navigation InitialNavigation) (ports.BrokerOpenStreamRequest, error) {
242+ resolver, ok := s.cfg.Picker.(interface {
243+ ResolveInitial(InitialNavigation, pickerResolveBase) (ports.BrokerOpenStreamRequest, error)
244+ })
245+ if !ok {
246+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation is unavailable"}
247+ }
248+ return resolver.ResolveInitial(navigation, pickerResolveBase{Connection: service.ConnectionID(), Stream: ports.BrokerStreamID(s.nextStream.Add(1))})
249+}
250+
241251 func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) {
242252 base := pickerResolveBase{
243253 Connection: service.ConnectionID(),
@@ -250,9 +260,13 @@ func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService,
250260 // request. The worker receives no endpoint, route, or raw-mode authority: it
251261 // only drives the typed session protocol on the stream the supervisor opened.
252262 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)
263+ geometry, ok := s.attachments.latestGeometry()
264+ if !ok {
265+ var err error
266+ geometry, err = s.cfg.Terminal.Geometry()
267+ if err != nil {
268+ return nil, fmt.Errorf("vev: reading terminal geometry: %w", err)
269+ }
256270 }
257271 environment := s.cfg.AttachmentEnvironment
258272 return newSessionAttachmentWorker(sessionAttachmentConfig{
@@ -272,20 +286,19 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
272286 // the picker. It reports terminated=true only when the run itself must end
273287 // (process cancellation or terminal EOF); every attachment outcome returns to
274288 // 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 {
289+func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, navigation InitialNavigation) (bool, error) {
290+ request, err := s.resolveInitialStreamRequest(service, navigation)
291+ if err != nil {
292+ s.reportAttachmentFailure(err)
278293 return false, nil
279294 }
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)
295+ return s.runResolvedAttachment(ctx, input, service, request)
296+}
288297
298+func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) {
299+ if s.cfg.Picker == nil {
300+ return false, nil
301+ }
289302 request, err := s.resolveCommittedStreamRequest(service, key)
290303 if err != nil {
291304 // A refused selection is a typed presentation notice; it never opens a
@@ -293,7 +306,21 @@ func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminal
293306 s.reportAttachmentFailure(err)
294307 return false, nil
295308 }
309+ return s.runResolvedAttachment(ctx, input, service, request)
310+}
296311
312+func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest) (bool, error) {
313+ picker := s.cfg.Picker
314+ if picker == nil {
315+ return false, nil
316+ }
317+ picker.SetOwnsInput(false)
318+ input.releasePicker()
319+ defer func() {
320+ picker.SetOwnsInput(true)
321+ input.acquirePicker()
322+ }()
323+ s.transition(supervisorEvent{kind: supervisorAttachBegin})
297324 deadline := startAttachmentDeadline(ctx, s.cfg.Clock)
298325 defer deadline.finish()
299326
@@ -351,11 +378,13 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL
351378 }()
352379
353380 var result attachmentSettlement
381+ var brokerLost bool
354382 var terminated bool
355383 var termErr error
356384 select {
357385 case result = <-settled:
358386 case <-service.Done():
387+ brokerLost = true
359388 // The broker connection is gone; the supervisor cancels the run and
360389 // lets the ready loop observe the loss and retire the connection.
361390 run.Cancel()
@@ -379,8 +408,19 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL
379408 s.notifyAttachment(timeout)
380409 return false, nil
381410 }
411+ if errors.Is(result.event.Err, errDetachAndExit) {
412+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
413+ s.notifyLifecycle(LifecycleNoticeDetachAndExit)
414+ return true, nil
415+ }
416+ if errors.Is(result.event.Err, errDetachToPicker) {
417+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
418+ s.notifyLifecycle(LifecycleNoticeDetachToPicker)
419+ return false, nil
420+ }
382421 if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) {
383422 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err})
423+ s.notifyLifecycle(LifecycleNoticeDestinationFailed)
384424 s.notifyAttachment(result.event.Err)
385425 return false, nil
386426 }
@@ -389,12 +429,21 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL
389429 // failed after attachment was lost/failed above, so it can never leave the
390430 // attached presentation showing.
391431 s.transition(supervisorEvent{kind: supervisorAttachEnded})
432+ if !brokerLost {
433+ s.notifyLifecycle(LifecycleNoticeSessionEnded)
434+ }
392435 return false, nil
393436 }
394437
395438 // reportAttachmentFailure returns to the picker with a typed, visible failure.
396439 func (s *Supervisor) reportAttachmentFailure(err error) {
397440 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err})
441+ var brokerErr ports.BrokerError
442+ if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorOutcomeUnknown {
443+ s.notifyLifecycle(LifecycleNoticeOutcomeUnknown)
444+ } else {
445+ s.notifyLifecycle(LifecycleNoticeDestinationFailed)
446+ }
398447 s.notifyAttachment(err)
399448 }
400449
@@ -238,6 +238,16 @@ type attachmentSettlement struct {
238 // the exact broker stream request the user committed. The stream ID is238 // the exact broker stream request the user committed. The stream ID is
239 // allocated strictly increasing per connection and is consumed even when239 // allocated strictly increasing per connection and is consumed even when
240 // resolution refuses, matching the pool's never-reused ID contract.240 // resolution refuses, matching the pool's never-reused ID contract.
241+func (s *Supervisor) resolveInitialStreamRequest(service ports.BrokerService, navigation InitialNavigation) (ports.BrokerOpenStreamRequest, error) {
242+ resolver, ok := s.cfg.Picker.(interface {
243+ ResolveInitial(InitialNavigation, pickerResolveBase) (ports.BrokerOpenStreamRequest, error)
244+ })
245+ if !ok {
246+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "initial navigation is unavailable"}
247+ }
248+ return resolver.ResolveInitial(navigation, pickerResolveBase{Connection: service.ConnectionID(), Stream: ports.BrokerStreamID(s.nextStream.Add(1))})
249+}
250+
241 func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) {251 func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService, key string) (ports.BrokerOpenStreamRequest, error) {
242 base := pickerResolveBase{252 base := pickerResolveBase{
243 Connection: service.ConnectionID(),253 Connection: service.ConnectionID(),
@@ -250,9 +260,13 @@ func (s *Supervisor) resolveCommittedStreamRequest(service ports.BrokerService,
250 // request. The worker receives no endpoint, route, or raw-mode authority: it260 // 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.261 // 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) {262 func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, beforeAttached func() error) (AttachmentWorker, error) {
253- geometry, err := s.cfg.Terminal.Geometry()263+ geometry, ok := s.attachments.latestGeometry()
254- if err != nil {264+ if !ok {
255- return nil, fmt.Errorf("vev: reading terminal geometry: %w", err)265+ var err error
266+ geometry, err = s.cfg.Terminal.Geometry()
267+ if err != nil {
268+ return nil, fmt.Errorf("vev: reading terminal geometry: %w", err)
269+ }
256 }270 }
257 environment := s.cfg.AttachmentEnvironment271 environment := s.cfg.AttachmentEnvironment
258 return newSessionAttachmentWorker(sessionAttachmentConfig{272 return newSessionAttachmentWorker(sessionAttachmentConfig{
@@ -272,20 +286,19 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
272 // the picker. It reports terminated=true only when the run itself must end286 // the picker. It reports terminated=true only when the run itself must end
273 // (process cancellation or terminal EOF); every attachment outcome returns to287 // (process cancellation or terminal EOF); every attachment outcome returns to
274 // the picker in the same process.288 // the picker in the same process.
275-func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) {289+func (s *Supervisor) runInitialNavigation(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, navigation InitialNavigation) (bool, error) {
276- picker := s.cfg.Picker290+ request, err := s.resolveInitialStreamRequest(service, navigation)
277- if picker == nil {291+ if err != nil {
292+ s.reportAttachmentFailure(err)
278 return false, nil293 return false, nil
279 }294 }
280- // Present connecting before releasing picker input, so a slow resolve/open295+ return s.runResolvedAttachment(ctx, input, service, request)
281- // never leaves a visible picker whose keystrokes are silently discarded.296+}
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 297
298+func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, key string) (bool, error) {
299+ if s.cfg.Picker == nil {
300+ return false, nil
301+ }
289 request, err := s.resolveCommittedStreamRequest(service, key)302 request, err := s.resolveCommittedStreamRequest(service, key)
290 if err != nil {303 if err != nil {
291 // A refused selection is a typed presentation notice; it never opens a304 // A refused selection is a typed presentation notice; it never opens a
@@ -293,7 +306,21 @@ func (s *Supervisor) runCommittedAttachment(ctx context.Context, input *terminal
293 s.reportAttachmentFailure(err)306 s.reportAttachmentFailure(err)
294 return false, nil307 return false, nil
295 }308 }
309+ return s.runResolvedAttachment(ctx, input, service, request)
310+}
296 311
312+func (s *Supervisor) runResolvedAttachment(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, request ports.BrokerOpenStreamRequest) (bool, error) {
313+ picker := s.cfg.Picker
314+ if picker == nil {
315+ return false, nil
316+ }
317+ picker.SetOwnsInput(false)
318+ input.releasePicker()
319+ defer func() {
320+ picker.SetOwnsInput(true)
321+ input.acquirePicker()
322+ }()
323+ s.transition(supervisorEvent{kind: supervisorAttachBegin})
297 deadline := startAttachmentDeadline(ctx, s.cfg.Clock)324 deadline := startAttachmentDeadline(ctx, s.cfg.Clock)
298 defer deadline.finish()325 defer deadline.finish()
299 326
@@ -351,11 +378,13 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL
351 }()378 }()
352 379
353 var result attachmentSettlement380 var result attachmentSettlement
381+ var brokerLost bool
354 var terminated bool382 var terminated bool
355 var termErr error383 var termErr error
356 select {384 select {
357 case result = <-settled:385 case result = <-settled:
358 case <-service.Done():386 case <-service.Done():
387+ brokerLost = true
359 // The broker connection is gone; the supervisor cancels the run and388 // The broker connection is gone; the supervisor cancels the run and
360 // lets the ready loop observe the loss and retire the connection.389 // lets the ready loop observe the loss and retire the connection.
361 run.Cancel()390 run.Cancel()
@@ -379,8 +408,19 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL
379 s.notifyAttachment(timeout)408 s.notifyAttachment(timeout)
380 return false, nil409 return false, nil
381 }410 }
411+ if errors.Is(result.event.Err, errDetachAndExit) {
412+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
413+ s.notifyLifecycle(LifecycleNoticeDetachAndExit)
414+ return true, nil
415+ }
416+ if errors.Is(result.event.Err, errDetachToPicker) {
417+ s.transition(supervisorEvent{kind: supervisorAttachEnded})
418+ s.notifyLifecycle(LifecycleNoticeDetachToPicker)
419+ return false, nil
420+ }
382 if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) {421 if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) {
383 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err})422 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err})
423+ s.notifyLifecycle(LifecycleNoticeDestinationFailed)
384 s.notifyAttachment(result.event.Err)424 s.notifyAttachment(result.event.Err)
385 return false, nil425 return false, nil
386 }426 }
@@ -389,12 +429,21 @@ func (s *Supervisor) settleAttachment(ctx context.Context, input *terminalInputL
389 // failed after attachment was lost/failed above, so it can never leave the429 // failed after attachment was lost/failed above, so it can never leave the
390 // attached presentation showing.430 // attached presentation showing.
391 s.transition(supervisorEvent{kind: supervisorAttachEnded})431 s.transition(supervisorEvent{kind: supervisorAttachEnded})
432+ if !brokerLost {
433+ s.notifyLifecycle(LifecycleNoticeSessionEnded)
434+ }
392 return false, nil435 return false, nil
393 }436 }
394 437
395 // reportAttachmentFailure returns to the picker with a typed, visible failure.438 // reportAttachmentFailure returns to the picker with a typed, visible failure.
396 func (s *Supervisor) reportAttachmentFailure(err error) {439 func (s *Supervisor) reportAttachmentFailure(err error) {
397 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err})440 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: err})
441+ var brokerErr ports.BrokerError
442+ if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorOutcomeUnknown {
443+ s.notifyLifecycle(LifecycleNoticeOutcomeUnknown)
444+ } else {
445+ s.notifyLifecycle(LifecycleNoticeDestinationFailed)
446+ }
398 s.notifyAttachment(err)447 s.notifyAttachment(err)
399 }448 }
400 449
M internal/usecase/client/supervisor_attach_test.go +26 -2
@@ -166,6 +166,22 @@ func (p *attachTestPicker) TakeOp() (pickerOp, string) {
166166 return op, key
167167 }
168168
169+func (p *attachTestPicker) ResolveInitial(navigation InitialNavigation, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
170+ if navigation != InitialNavigationCreateEphemeral {
171+ return ports.BrokerOpenStreamRequest{}, errors.New("unexpected initial navigation")
172+ }
173+ p.mu.Lock()
174+ defer p.mu.Unlock()
175+ p.resolved++
176+ request := p.request
177+ request.Admission = ports.BrokerAdmissionCreateEphemeral
178+ request.Target = protocol.ExactSessionTarget{}
179+ request.Local = true
180+ request.Connection = base.Connection
181+ request.Stream = base.Stream
182+ return request, nil
183+}
184+
169185 func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
170186 p.mu.Lock()
171187 defer p.mu.Unlock()
@@ -232,6 +248,10 @@ type attachTestHarness struct {
232248 // startAttachHarness runs one supervisor over a scripted picker and service.
233249 // The caller installs service.setOpenStream before committing a selection.
234250 func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness {
251+ return startAttachHarnessConfig(t, picker, nil)
252+}
253+
254+func startAttachHarnessConfig(t *testing.T, picker pickerHost, configure func(*SupervisorConfig)) *attachTestHarness {
235255 t.Helper()
236256 ctx, cancel := context.WithCancel(context.Background())
237257 clock := newSupervisorTestClock()
@@ -243,13 +263,17 @@ func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness {
243263 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
244264 return service, nil
245265 })
246- sup := mustSupervisor(t, SupervisorConfig{
266+ cfg := SupervisorConfig{
247267 Connector: connector,
248268 Terminal: terminal,
249269 Clock: clock,
250270 Jitter: func() float64 { return 0 },
251271 Picker: picker,
252- })
272+ }
273+ if configure != nil {
274+ configure(&cfg)
275+ }
276+ sup := mustSupervisor(t, cfg)
253277
254278 harness := &attachTestHarness{sup: sup, service: service, clock: clock, terminal: terminal, reader: reader, cancel: cancel, runDone: make(chan struct{})}
255279 go func() {
@@ -166,6 +166,22 @@ func (p *attachTestPicker) TakeOp() (pickerOp, string) {
166 return op, key166 return op, key
167 }167 }
168 168
169+func (p *attachTestPicker) ResolveInitial(navigation InitialNavigation, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
170+ if navigation != InitialNavigationCreateEphemeral {
171+ return ports.BrokerOpenStreamRequest{}, errors.New("unexpected initial navigation")
172+ }
173+ p.mu.Lock()
174+ defer p.mu.Unlock()
175+ p.resolved++
176+ request := p.request
177+ request.Admission = ports.BrokerAdmissionCreateEphemeral
178+ request.Target = protocol.ExactSessionTarget{}
179+ request.Local = true
180+ request.Connection = base.Connection
181+ request.Stream = base.Stream
182+ return request, nil
183+}
184+
169 func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {185 func (p *attachTestPicker) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
170 p.mu.Lock()186 p.mu.Lock()
171 defer p.mu.Unlock()187 defer p.mu.Unlock()
@@ -232,6 +248,10 @@ type attachTestHarness struct {
232 // startAttachHarness runs one supervisor over a scripted picker and service.248 // startAttachHarness runs one supervisor over a scripted picker and service.
233 // The caller installs service.setOpenStream before committing a selection.249 // The caller installs service.setOpenStream before committing a selection.
234 func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness {250 func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness {
251+ return startAttachHarnessConfig(t, picker, nil)
252+}
253+
254+func startAttachHarnessConfig(t *testing.T, picker pickerHost, configure func(*SupervisorConfig)) *attachTestHarness {
235 t.Helper()255 t.Helper()
236 ctx, cancel := context.WithCancel(context.Background())256 ctx, cancel := context.WithCancel(context.Background())
237 clock := newSupervisorTestClock()257 clock := newSupervisorTestClock()
@@ -243,13 +263,17 @@ func startAttachHarness(t *testing.T, picker pickerHost) *attachTestHarness {
243 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {263 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
244 return service, nil264 return service, nil
245 })265 })
246- sup := mustSupervisor(t, SupervisorConfig{266+ cfg := SupervisorConfig{
247 Connector: connector,267 Connector: connector,
248 Terminal: terminal,268 Terminal: terminal,
249 Clock: clock,269 Clock: clock,
250 Jitter: func() float64 { return 0 },270 Jitter: func() float64 { return 0 },
251 Picker: picker,271 Picker: picker,
252- })272+ }
273+ if configure != nil {
274+ configure(&cfg)
275+ }
276+ sup := mustSupervisor(t, cfg)
253 277
254 harness := &attachTestHarness{sup: sup, service: service, clock: clock, terminal: terminal, reader: reader, cancel: cancel, runDone: make(chan struct{})}278 harness := &attachTestHarness{sup: sup, service: service, clock: clock, terminal: terminal, reader: reader, cancel: cancel, runDone: make(chan struct{})}
255 go func() {279 go func() {
M internal/usecase/client/supervisor_test.go +1 -1
@@ -525,7 +525,7 @@ func TestSupervisorReducerTransitions(t *testing.T) {
525525 name: "broker loss returns to picker retry wait",
526526 state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Attempt: 0, Generation: 5},
527527 event: supervisorEvent{kind: supervisorBrokerLoss, err: transient},
528- want: State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 5, Err: transient},
528+ want: State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 5, Err: transient, ReadyLost: true},
529529 },
530530 {
531531 name: "non-retryable failure disconnects and stays on the picker",
@@ -525,7 +525,7 @@ func TestSupervisorReducerTransitions(t *testing.T) {
525 name: "broker loss returns to picker retry wait",525 name: "broker loss returns to picker retry wait",
526 state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Attempt: 0, Generation: 5},526 state: State{Presentation: PresentPicker, Connectivity: ConnectivityReady, Attempt: 0, Generation: 5},
527 event: supervisorEvent{kind: supervisorBrokerLoss, err: transient},527 event: supervisorEvent{kind: supervisorBrokerLoss, err: transient},
528- want: State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 5, Err: transient},528+ want: State{Presentation: PresentPicker, Connectivity: ConnectivityRetryWait, Attempt: 1, Generation: 5, Err: transient, ReadyLost: true},
529 },529 },
530 {530 {
531 name: "non-retryable failure disconnects and stays on the picker",531 name: "non-retryable failure disconnects and stays on the picker",

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