1package client
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "sync"
9 "sync/atomic"
10 "time"
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// Attachment worker mechanisms.
18//
19// This file defines the attachment mechanisms owned by the autonomous
20// supervisor. It reuses the terminal input pump (terminalInputPump) and
21// foreground output gate (foregroundSendLease) rather than duplicating them.
22//
23// Ownership split:
24//
25// - The supervisor owns the terminal: raw mode, the terminal writer, the
26// single input pump, the UI publication transaction, resize events, and the
27// process lifecycle. None of those move into a worker.
28// - One AttachmentWorker owns exactly one broker logical stream and the typed
29// session I/O on it. It cannot enter or restore raw mode, choose an
30// endpoint, restore a source route, retain an attachment in the cache, or
31// write the terminal directly.
32// - Every terminal-facing action a worker performs crosses the
33// supervisor-granted AttachmentForeground. The supervisor grants exactly
34// one foreground at a time; on cancellation or supersession it finalizes
35// that grant (closing Done and refusing output, UI, resize, and new input),
36// lets the woken worker Ack/Preserve the read it holds through a bounded
37// join, then revokes input authority before closing the owned stream.
38// - Every worker result carries the AttachmentToken (generation/attempt) the
39// supervisor granted, so a late result from a canceled or superseded
40// generation is discarded rather than applied.
41
42// Picker and attachment consumers use the same terminal input pump; they must
43// never start rival readers.
44//
45// UI action admission is optional and reuses this host's single pump claim.
46// Binding occurs after grant claims the pump and before the worker starts;
47// release occurs when finalization starts, before the claim can return to the
48// picker. The UI-generated generation is authoritative and is stamped onto the
49// attachment token and every publication.
50
51// attachmentWorkerJoinTimeout bounds the supervisor's join of a cancelled
52// worker during the final input window. A worker that honors neither
53// cancellation nor a closed Done is abandoned rather than allowed to pin the
54// supervisor; the supervisor then revokes the retained consumer and closes the
55// stream anyway. It mirrors the attachment retirement join budget.
56const attachmentWorkerJoinTimeout = 5 * time.Second
57
58// errAttachmentForegroundRevoked reports a worker action refused because the
59// supervisor revoked its input/output authority.
60var errAttachmentForegroundRevoked = errors.New("client: attachment foreground authority revoked")
61
62// AttachmentToken identifies one attachment worker run. Generation is the
63// supervisor-assigned foreground generation. The host never allocates it while
64// UI action binding is deferred, so the supervisor supplies it and the host
65// stamps it onto the event and every publication; Attempt is the worker
66// identity, not a UI generation. A zero Generation is never granted.
67type AttachmentToken struct {
68 Generation uint64
69 Attempt uint64
70}
71
72// IsZero reports whether the token was never assigned by a supervisor.
73func (t AttachmentToken) IsZero() bool { return t.Generation == 0 }
74
75// String renders the token for diagnostics.
76func (t AttachmentToken) String() string {
77 return fmt.Sprintf("gen=%d attempt=%d", t.Generation, t.Attempt)
78}
79
80// AttachmentEventKind classifies one worker's terminal outcome. Every kind
81// returns the client to the picker: a worker event never terminates the process
82// by itself. Only the supervisor's own state machine decides that.
83type AttachmentEventKind uint8
84
85const (
86 // AttachmentEventEnded is an orderly end: the logical stream reached EOF or
87 // the worker detached without failure.
88 AttachmentEventEnded AttachmentEventKind = iota + 1
89 // AttachmentEventLost is a transport or logical-stream loss. The logical
90 // stream's own Done/Err carries the stable cause.
91 AttachmentEventLost
92 // AttachmentEventFailed is a typed protocol or application failure on the
93 // stream, distinct from a carriage loss.
94 AttachmentEventFailed
95)
96
97// String renders the event kind for diagnostics.
98func (k AttachmentEventKind) String() string {
99 switch k {
100 case AttachmentEventEnded:
101 return "ended"
102 case AttachmentEventLost:
103 return "lost"
104 case AttachmentEventFailed:
105 return "failed"
106 default:
107 return "unknown"
108 }
109}
110
111// AttachmentEvent is the typed result of one worker run. Token is always the
112// token the supervisor granted: the host stamps it, so a worker cannot report
113// under another generation.
114type AttachmentEvent struct {
115 Token AttachmentToken
116 Kind AttachmentEventKind
117 Err error
118}
119
120// AttachmentInputEvent is one authorized terminal input delivery. Err carries a
121// terminal read failure (for example io.EOF) alongside any final bytes.
122type AttachmentInputEvent struct {
123 Data []byte
124 Err error
125 actionID uint64
126}
127
128// AttachmentForeground is the supervisor-granted handle through which a worker
129// touches the terminal. It is the worker's only path to input, resize, output,
130// UI publication, and the attached transition. Every method is refused once the
131// supervisor revokes the grant, so a canceled or superseded generation cannot
132// read input, resize, write output, publish UI, or mark itself attached.
133type AttachmentForeground interface {
134 // Token is the generation/attempt identity of this grant.
135 Token() AttachmentToken
136 // Stream is the supervisor-admitted logical stream this worker owns for its
137 // lifetime. The supervisor retains close authority.
138 Stream() ports.BrokerLogicalConnection
139 // Input blocks until the next authorized terminal input delivery. It returns
140 // false once authority is revoked or ctx ends.
141 Input(ctx context.Context) (AttachmentInputEvent, bool)
142 // AckInput commits the last Input delivery so the shared reader may accept
143 // the next one. It decides that delivery: at most one of AckInput and
144 // PreserveInput may decide a delivery, and a second decision for the same
145 // delivery is rejected as a no-op. It is a no-op once authority is revoked.
146 AckInput()
147 // PreserveInput re-queues the undecided bytes of the last Input delivery so
148 // the next authorized attempt can replay them exactly once. It decides that
149 // delivery: a duplicate decision (a later AckInput or PreserveInput for the
150 // same delivery, or a call with no outstanding delivery) is rejected as a
151 // no-op instead of re-queuing different bytes. It stays authorized while the
152 // supervisor finalizes this grant (after Done closes and before revocation),
153 // so a cancelled worker can still save input; it is a no-op once authority is
154 // revoked.
155 PreserveInput(data []byte)
156 // Resize blocks until the latest authorized terminal resize for this
157 // attachment. It returns false once authority is revoked or ctx ends.
158 Resize(ctx context.Context) (domain.Geometry, bool)
159 // Lifecycle blocks for one explicit typed lifecycle action.
160 Lifecycle(ctx context.Context) (AttachmentLifecycleActionKind, bool)
161 // Output publishes context and writes and flushes bytes in one authorized
162 // transaction. Missing UI or terminal returns ports.ErrUIUnavailable.
163 Output(uiContext ports.UIContext, data []byte) error
164 // PublishAttached publishes the committed attached presentation once the
165 // first frame was written, flushed, and committed. It is fenced exactly like
166 // Output and writes no terminal bytes: the screen it publishes is the frame
167 // that already drained, so no second writer and no extra flush is needed.
168 PublishAttached(uiContext ports.UIContext) error
169 // MarkAttached records the attached transition. It is refused once authority
170 // is revoked.
171 MarkAttached() bool
172 // Attached reports whether MarkAttached has succeeded.
173 Attached() bool
174 // Done is closed when the supervisor finalizes this grant. It stops new
175 // input, resize, output, and UI publication and wakes the worker, while
176 // AckInput and PreserveInput stay authorized for the read the worker already
177 // holds until the supervisor revokes the grant.
178 Done() <-chan struct{}
179}
180
181// AttachmentWorker is the contract for one broker logical stream's typed
182// session I/O. Run owns the stream returned by Stream (via the foreground) and
183// must honor ctx cancellation: the supervisor closes the stream after revoking
184// authority, which must unblock receive/send. Run must never enter or restore
185// raw mode, choose an endpoint, restore a source route, or touch the attachment
186// cache; none of those authorities are reachable from its arguments.
187type AttachmentWorker interface {
188 Run(ctx context.Context, fg AttachmentForeground) AttachmentEvent
189}
190
191// attachmentAuthorityState is the explicit lifecycle of the single foreground
192// slot. Finalization is a first-class state rather than a side effect of a
193// closed Done channel: while finalizing, only Ack/Preserve remain authorized,
194// every other action and a competing grant are refused.
195type attachmentAuthorityState uint8
196
197const (
198 // authorityIdle has no foreground owner.
199 authorityIdle attachmentAuthorityState = iota
200 // authorityActive grants the foreground every AttachmentForeground action.
201 authorityActive
202 // authorityFinalizing retains the same foreground only for Ack/Preserve
203 // while the supervisor joins a cancelled worker. New input, resize, output,
204 // UI publication, MarkAttached, and a competing grant are all refused.
205 authorityFinalizing
206)
207
208// attachmentAuthority owns the single supervisor foreground slot. It is safe for
209// concurrent use: the supervisor finalizes and revokes from its own goroutine
210// while a worker acts from its run goroutine.
211type attachmentAuthority struct {
212 mu sync.Mutex
213 current *attachmentForeground
214 state attachmentAuthorityState
215}
216
217// grant installs fg as the sole foreground in the active state. It fails while
218// another grant is live or while the shared input pump already has a consumer,
219// which is what keeps exactly one foreground owner.
220func (a *attachmentAuthority) grant(fg *attachmentForeground) bool {
221 if a == nil || fg == nil {
222 return false
223 }
224 a.mu.Lock()
225 defer a.mu.Unlock()
226 if a.current != nil {
227 return false
228 }
229 if fg.input != nil {
230 var ok bool
231 fg.consumer, ok = fg.input.tryClaim()
232 if !ok {
233 return false
234 }
235 }
236 a.current = fg
237 a.state = authorityActive
238 return true
239}
240
241// beginFinalize moves fg into the finalizing state: the worker keeps the slot
242// and its pump consumer for Ack/Preserve, but every other action is refused.
243// It reports false when fg no longer holds the slot.
244func (a *attachmentAuthority) beginFinalize(fg *attachmentForeground) bool {
245 if a == nil || fg == nil {
246 return false
247 }
248 a.mu.Lock()
249 defer a.mu.Unlock()
250 if a.current != fg {
251 return false
252 }
253 a.state = authorityFinalizing
254 return true
255}
256
257// revoke clears fg if it is still the foreground, releasing the pump consumer
258// and any future UI binding, and reports whether it was. It runs after the
259// bounded join. Input release and clearing the slot are atomic with respect to
260// the next grant.
261func (a *attachmentAuthority) revoke(fg *attachmentForeground) bool {
262 if a == nil || fg == nil {
263 return false
264 }
265 a.mu.Lock()
266 defer a.mu.Unlock()
267 if a.current != fg {
268 return false
269 }
270 if fg.input != nil && fg.consumer != 0 {
271 fg.input.revoke(fg.consumer)
272 }
273 a.current = nil
274 a.state = authorityIdle
275 return true
276}
277
278// foreground is the sole synchronized ownership accessor. It returns the
279// owner in both the active and finalizing states.
280func (a *attachmentAuthority) foreground() *attachmentForeground {
281 if a == nil {
282 return nil
283 }
284 a.mu.Lock()
285 defer a.mu.Unlock()
286 return a.current
287}
288
289// actionAuthorized reports whether fg may take a terminal-facing action now: it
290// must hold the slot and the slot must not be finalizing.
291func (a *attachmentAuthority) actionAuthorized(fg *attachmentForeground) bool {
292 if a == nil || fg == nil {
293 return false
294 }
295 a.mu.Lock()
296 defer a.mu.Unlock()
297 return a.current == fg && a.state == authorityActive
298}
299
300// act runs fn under the authority lock exactly when fg may still act, so an
301// action or state transition (the attached marker) is atomic with respect to
302// finalization and revocation.
303func (a *attachmentAuthority) act(fg *attachmentForeground, fn func()) bool {
304 if a == nil || fg == nil {
305 return false
306 }
307 a.mu.Lock()
308 defer a.mu.Unlock()
309 if a.current != fg || a.state != authorityActive {
310 return false
311 }
312 fn()
313 return true
314}
315
316// retain runs fn under the authority lock exactly when fg still owns the slot,
317// including while finalizing. It is the Ack/Preserve path: those only commit or
318// re-queue input the worker already held, so a cancelled generation may still
319// save it during the final input window. Every other action uses act or
320// actionAuthorized.
321func (a *attachmentAuthority) retain(fg *attachmentForeground, fn func()) bool {
322 if a == nil || fg == nil {
323 return false
324 }
325 a.mu.Lock()
326 defer a.mu.Unlock()
327 if a.current != fg {
328 return false
329 }
330 fn()
331 return true
332}
333
334// attachmentHostConfig supplies the supervisor-owned dependencies. Terminal,
335// clock, and input belong to the supervisor; UI seams and OnAttached are
336// optional.
337type attachmentHostConfig struct {
338 // Terminal is the controlling terminal the supervisor owns. Optional only
339 // so a mechanism test can omit output entirely; production supplies it.
340 Terminal ports.Terminal
341 // Clock supplies the bounded-join timer. Use ports.Clock, not the wall
342 // clock.
343 Clock ports.Clock
344 // Input is the supervisor-owned single terminal reader. The host claims one
345 // consumer per foreground and revokes it after finalization. Optional.
346 Input *terminalInputPump
347 // Actions carries explicit lifecycle decisions for the current attachment.
348 Actions <-chan AttachmentLifecycleAction
349 // UI is the supervisor-owned UI publication transaction. When nil it is
350 // taken from Terminal when that implements ports.UIOutputTransaction.
351 UI ports.UIOutputTransaction
352 // ActionUI optionally binds automation to the same claimed input consumer.
353 ActionUI *UI
354 // OnAttached observes a successful MarkAttached. Optional.
355 OnAttached func(AttachmentToken)
356}
357
358// attachmentHost is the supervisor-side owner of the terminal foreground. It
359// grants one worker generation at a time input/output authority over a single
360// admitted logical stream, joins that worker within a bound, and discards late
361// results. It owns no raw mode and restores no terminal: those stay with the
362// supervisor.
363type attachmentHost struct {
364 term ports.Terminal
365 clock ports.Clock
366 input *terminalInputPump
367 ui ports.UIOutputTransaction
368 actionUI *UI
369 resizes <-chan domain.Geometry
370 actions <-chan AttachmentLifecycleAction
371 onAttach func(AttachmentToken)
372 authority attachmentAuthority
373
374 geometryMu sync.Mutex
375 geometry domain.Geometry
376 geometryValid bool
377 geometrySeq uint64
378 geometryUpdate chan struct{}
379 presentationUpdate chan struct{}
380 geometryCancel context.CancelFunc
381 geometryDone chan struct{}
382 ownerBoundary bool
383
384 // navigationRequests carries a coalesced request from the attached worker
385 // to compose the client's session picker over the live attachment (the
386 // daemon's navigation PickerOffer). Only the supervisor consumes it.
387 navigationRequests chan struct{}
388 // internalActions carries supervisor-originated lifecycle decisions for the
389 // current foreground, such as the detach that precedes an attachment swap.
390 internalActions chan AttachmentLifecycleAction
391 // routeDemand wakes the supervisor to publish routes when the committed
392 // session changes; navigations carries the daemon's navigation requests
393 // Only the supervisor consumes these route and navigation channels.
394 routeDemand chan struct{}
395 navigations chan protocol.ServerMessage
396 // overlayActions carries driver actions the client picker overlay
397 // consumed to the supervisor, which settles them.
398 overlayActions chan overlayActionConsumed
399 // inPlaceResults carries the outcome of each in-place switch the
400 // supervisor requested on the current foreground.
401 inPlaceResults chan inPlaceResult
402}
403
404// newAttachmentHost builds the reusable foreground host. A nil clock falls back
405// to the system clock; a nil terminal leaves output refused but still enforces
406// authority.
407func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost {
408 clock := cfg.Clock
409 if supervisorNil(clock) {
410 clock = systemClock{}
411 }
412 ui := cfg.UI
413 resizes := (<-chan domain.Geometry)(nil)
414 var initialGeometry domain.Geometry
415 var initialGeometryValid bool
416 if !supervisorNil(cfg.Terminal) {
417 if supervisorNil(ui) {
418 ui, _ = cfg.Terminal.(ports.UIOutputTransaction)
419 }
420 resizes = cfg.Terminal.ResizeEvents()
421 if geometry, err := cfg.Terminal.Geometry(); err == nil && geometry.Valid() {
422 initialGeometry = geometry.NormalizePixels()
423 initialGeometryValid = true
424 }
425 }
426 return &attachmentHost{
427 term: cfg.Terminal,
428 clock: clock,
429 input: cfg.Input,
430 ui: ui,
431 actionUI: cfg.ActionUI,
432 resizes: resizes,
433 actions: cfg.Actions,
434 onAttach: cfg.OnAttached,
435 geometry: initialGeometry,
436 geometryValid: initialGeometryValid,
437 geometryUpdate: make(chan struct{}, 1),
438 presentationUpdate: make(chan struct{}, 1),
439 navigationRequests: make(chan struct{}, 1),
440 internalActions: make(chan AttachmentLifecycleAction, 1),
441 routeDemand: make(chan struct{}, 1),
442 navigations: make(chan protocol.ServerMessage, 1),
443 overlayActions: make(chan overlayActionConsumed, 1),
444 inPlaceResults: make(chan inPlaceResult, 1),
445 }
446}
447
448// Begin grants one generation's foreground and starts its worker on a
449// supervisor-joined goroutine. It never closes or otherwise mutates stream, so
450// on false the caller retains sole ownership of it. It returns false when:
451//
452// - the host is nil;
453// - the token is zero (no supervisor generation was assigned);
454// - the worker or stream is nil, in any typed-nil shape;
455// - another foreground is already live, or is finalizing a previous grant;
456// - the shared input pump already has a consumer (a picker or rival
457// foreground owns input).
458//
459// The granted token and event token always retain the caller-supplied
460// supervisor generation. When action UI is configured, its independent
461// generation is used only for UI publication and action fencing. The caller
462// must Wait or Cancel the returned run.
463func (h *attachmentHost) Begin(ctx context.Context, token AttachmentToken, worker AttachmentWorker, stream ports.BrokerLogicalConnection) (*attachmentRun, bool) {
464 if h == nil || token.IsZero() || supervisorNil(worker) || supervisorNil(stream) {
465 return nil, false
466 }
467 if ctx == nil {
468 ctx = context.Background()
469 }
470 workerCtx, cancel := context.WithCancel(ctx)
471 fg := h.newForeground(token, stream)
472 if !h.authority.grant(fg) {
473 cancel()
474 return nil, false
475 }
476 // A request or action left behind by a previous foreground never applies
477 // to this one.
478 h.drainForegroundSignals()
479 if h.actionUI != nil {
480 fg.uiGeneration = h.actionUI.bindForeground(workerCtx, fg.input, fg.consumer)
481 }
482 run := &attachmentRun{
483 host: h,
484 token: token,
485 stream: stream,
486 fg: fg,
487 cancel: cancel,
488 outcome: make(chan AttachmentEvent, 1),
489 done: make(chan struct{}),
490 retired: make(chan struct{}),
491 }
492 go func() {
493 defer close(run.done)
494 event := worker.Run(workerCtx, fg)
495 // The host stamps the granted token: a worker can never report under a
496 // generation it was not granted, and the settlement check is exact.
497 event.Token = token
498 run.outcome <- event
499 }()
500 return run, true
501}
502
503// Run is Begin followed immediately by Wait, for callers that join in place.
504// The second result is false when the run was superseded or cancelled, in which
505// case the worker's late event is discarded.
506func (h *attachmentHost) Run(ctx context.Context, token AttachmentToken, worker AttachmentWorker, stream ports.BrokerLogicalConnection) (AttachmentEvent, bool) {
507 run, ok := h.Begin(ctx, token, worker, stream)
508 if !ok {
509 return AttachmentEvent{}, false
510 }
511 return run.Wait(ctx)
512}
513
514// newForeground allocates a candidate; grant claims input before publishing it.
515func (h *attachmentHost) setInput(input *terminalInputPump) {
516 if h == nil {
517 return
518 }
519 h.input = input
520 h.ownerBoundary = true
521}
522
523func (h *attachmentHost) startGeometry(ctx context.Context) {
524 if h == nil {
525 return
526 }
527 if h.geometryDone != nil {
528 select {
529 case <-h.geometryDone:
530 h.geometryDone = nil
531 h.geometryCancel = nil
532 default:
533 return
534 }
535 }
536 geometryCtx, cancel := context.WithCancel(ctx)
537 h.geometryCancel = cancel
538 h.geometryDone = make(chan struct{})
539 go func() {
540 defer close(h.geometryDone)
541 for {
542 select {
543 case geometry, ok := <-h.resizes:
544 if !ok {
545 return
546 }
547 if !geometry.Valid() {
548 continue
549 }
550 h.geometryMu.Lock()
551 h.geometry = geometry.NormalizePixels()
552 h.geometryValid = true
553 h.geometrySeq++
554 h.geometryMu.Unlock()
555 select {
556 case h.geometryUpdate <- struct{}{}:
557 default:
558 }
559 // Presentation invalidation is deliberately independent from the
560 // attachment geometry sequence/wakeup. The supervisor consumes this
561 // coalesced signal and decides whether a picker presentation may be
562 // repainted; this collector never renders directly.
563 select {
564 case h.presentationUpdate <- struct{}{}:
565 default:
566 }
567 case <-geometryCtx.Done():
568 return
569 }
570 }
571 }()
572}
573
574func (h *attachmentHost) stopGeometry() {
575 if h == nil || h.geometryCancel == nil {
576 return
577 }
578 h.geometryCancel()
579 <-h.geometryDone
580 h.geometryCancel = nil
581 h.geometryDone = nil
582}
583
584func (h *attachmentHost) latestGeometry() (domain.Geometry, bool) {
585 if h == nil {
586 return domain.Geometry{}, false
587 }
588 h.geometryMu.Lock()
589 defer h.geometryMu.Unlock()
590 return h.geometry, h.geometryValid
591}
592
593func (h *attachmentHost) geometryAfter(sequence uint64) (domain.Geometry, uint64, bool) {
594 h.geometryMu.Lock()
595 defer h.geometryMu.Unlock()
596 return h.geometry, h.geometrySeq, h.geometryValid && h.geometrySeq > sequence
597}
598
599func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.BrokerLogicalConnection) *attachmentForeground {
600 return &attachmentForeground{
601 authority: &h.authority,
602 token: token,
603 stream: stream,
604 input: h.input,
605 lease: newForegroundSendLease(),
606 term: h.term,
607 ui: h.ui,
608 resizes: h.resizes,
609 actions: h.actions,
610 host: h,
611 done: make(chan struct{}),
612 decided: true,
613 onAttached: h.onAttach,
614 repaint: make(chan struct{}, 1),
615 tabSelect: make(chan domain.TabStableID, 1),
616 routes: make(chan protocol.RecentRouteSnapshot, 1),
617 replies: make(chan protocol.ClientMessage, attachmentReplyCapacity),
618 inPlace: make(chan inPlaceSwitch, 1),
619 }
620}
621
622// drainForegroundSignals drops any navigation request or internal lifecycle
623// action that belonged to an earlier foreground.
624func (h *attachmentHost) drainForegroundSignals() {
625 for {
626 select {
627 case <-h.navigationRequests:
628 case <-h.internalActions:
629 case <-h.routeDemand:
630 case <-h.navigations:
631 case <-h.overlayActions:
632 case <-h.inPlaceResults:
633 default:
634 return
635 }
636 }
637}
638
639// NavigationRequests wakes the supervisor when the attached worker asked for
640// the client picker over the live attachment.
641func (h *attachmentHost) NavigationRequests() <-chan struct{} {
642 if h == nil {
643 return nil
644 }
645 return h.navigationRequests
646}
647
648// beginNavigationOverlay composes the supervisor-owned picker over the current
649// foreground: from its return, attachment output is applied and acknowledged
650// but never written, and every authorized input delivery is handed to sink. It
651// reports false when no active foreground can host the overlay.
652func (h *attachmentHost) beginNavigationOverlay(sink pickerInputConsumer) bool {
653 if h == nil || sink == nil {
654 return false
655 }
656 fg := h.authority.foreground()
657 if fg == nil {
658 return false
659 }
660 return fg.setOverlay(attachmentOverlayNavigation, sink)
661}
662
663// endNavigationOverlay returns the terminal to the current foreground. With
664// repaint it asks the worker for an authoritative repaint over the picker box.
665func (h *attachmentHost) endNavigationOverlay(repaint bool) {
666 if h == nil {
667 return
668 }
669 if fg := h.authority.foreground(); fg != nil {
670 fg.releaseOverlay(attachmentOverlayNavigation, repaint)
671 }
672}
673
674// committedTarget reports the session the current foreground last committed
675// output for, so the supervisor can tell a commit to the attached session from
676// a commit elsewhere.
677func (h *attachmentHost) committedTarget() (protocol.ExactSessionTarget, bool) {
678 target, _, ok := h.committedView()
679 return target, ok
680}
681
682// committedView reports the session and the tab the current foreground last
683// committed output for.
684func (h *attachmentHost) committedView() (protocol.ExactSessionTarget, domain.TabStableID, bool) {
685 if h == nil {
686 return protocol.ExactSessionTarget{}, "", false
687 }
688 fg := h.authority.foreground()
689 if fg == nil {
690 return protocol.ExactSessionTarget{}, "", false
691 }
692 fg.overlayMu.Lock()
693 defer fg.overlayMu.Unlock()
694 return fg.committed, fg.committedTab, fg.committedKnown
695}
696
697// committedTargetOrZero is committedTarget with an unknown target as zero.
698func (h *attachmentHost) committedTargetOrZero() protocol.ExactSessionTarget {
699 target, ok := h.committedTarget()
700 if !ok {
701 return protocol.ExactSessionTarget{}
702 }
703 return target
704}
705
706// requestTabSelection asks the current foreground's worker to show one tab of
707// its attached session in place. The newest request replaces an unsent one.
708func (h *attachmentHost) requestTabSelection(token AttachmentToken, tab domain.TabStableID) bool {
709 if h == nil || tab == "" {
710 return false
711 }
712 fg := h.authority.foreground()
713 if fg == nil || fg.token != token {
714 return false
715 }
716 for {
717 select {
718 case fg.tabSelect <- tab:
719 return true
720 default:
721 }
722 select {
723 case <-fg.tabSelect:
724 default:
725 }
726 }
727}
728
729// requestDetach asks the current foreground's worker to detach explicitly, so
730// the daemon observes a clean Detach before the supervisor swaps attachments.
731func (h *attachmentHost) requestDetach(token AttachmentToken) {
732 if h == nil {
733 return
734 }
735 select {
736 case h.internalActions <- AttachmentLifecycleAction{Token: token, Kind: AttachmentDetachToPicker}:
737 default:
738 }
739}
740
741// attachmentOverlayKind names the client-composed overlay that owns the
742// terminal over a live attachment.
743type attachmentOverlayKind uint8
744
745const (
746 attachmentOverlayNone attachmentOverlayKind = iota
747 // attachmentOverlayNavigation is the supervisor-owned session picker.
748 attachmentOverlayNavigation
749 // attachmentOverlayMove is the worker-owned move-destination picker the
750 // serving daemon offered.
751 attachmentOverlayMove
752)
753
754// attachmentOverlayForeground is the optional overlay seam of the real
755// foreground. A worker reaches it by assertion so scripted foregrounds may
756// omit it; without it, overlays are simply never presented.
757type attachmentOverlayForeground interface {
758 requestNavigationPicker()
759 setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool
760 clearOverlay(kind attachmentOverlayKind)
761 overlayKind() attachmentOverlayKind
762 divertInput(data []byte, actionID uint64) bool
763 overlayRepaint() <-chan struct{}
764 overlayOutput(uiContext ports.UIContext, data []byte) error
765 noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID)
766 tabSelections() <-chan domain.TabStableID
767 routeSnapshots() <-chan protocol.RecentRouteSnapshot
768 navigationReplies() <-chan protocol.ClientMessage
769 requestNavigation(message protocol.ServerMessage)
770 inPlaceSwitches() <-chan inPlaceSwitch
771 reportInPlace(seq uint64, outcome inPlaceOutcome)
772}
773
774var _ attachmentOverlayForeground = (*attachmentForeground)(nil)
775
776// requestNavigationPicker records the daemon's navigation offer for the
777// supervisor. The request is coalesced; the supervisor decides whether the
778// current presentation can host the overlay.
779func (f *attachmentForeground) requestNavigationPicker() {
780 if f == nil || f.host == nil || !f.authority.actionAuthorized(f) {
781 return
782 }
783 select {
784 case f.host.navigationRequests <- struct{}{}:
785 default:
786 }
787}
788
789// setOverlay installs kind under the output lease, so no attachment frame is
790// mid-write when the overlay takes the terminal. An occupied slot refuses a
791// different overlay.
792func (f *attachmentForeground) setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool {
793 if f == nil || kind == attachmentOverlayNone {
794 return false
795 }
796 installed := false
797 f.lease.send(func() bool {
798 if !f.authority.actionAuthorized(f) {
799 return false
800 }
801 f.overlayMu.Lock()
802 defer f.overlayMu.Unlock()
803 if f.overlay != attachmentOverlayNone && f.overlay != kind {
804 return true
805 }
806 f.overlay = kind
807 f.overlaySink = sink
808 installed = true
809 return true
810 })
811 return installed
812}
813
814// clearOverlay releases kind under the output lease and asks the worker for an
815// authoritative repaint: the terminal still shows the picker box and any
816// attachment frames it suppressed were never written.
817func (f *attachmentForeground) clearOverlay(kind attachmentOverlayKind) {
818 f.releaseOverlay(kind, true)
819}
820
821// releaseOverlay is clearOverlay with the repaint request optional.
822func (f *attachmentForeground) releaseOverlay(kind attachmentOverlayKind, repaint bool) {
823 if f == nil {
824 return
825 }
826 cleared := false
827 f.lease.send(func() bool {
828 f.overlayMu.Lock()
829 defer f.overlayMu.Unlock()
830 if f.overlay != kind {
831 return true
832 }
833 f.overlay = attachmentOverlayNone
834 f.overlaySink = nil
835 cleared = true
836 return true
837 })
838 if !cleared {
839 // The lease stops at finalization; the overlay dies with the grant.
840 f.overlayMu.Lock()
841 if f.overlay == kind {
842 f.overlay = attachmentOverlayNone
843 f.overlaySink = nil
844 }
845 f.overlayMu.Unlock()
846 return
847 }
848 if !repaint {
849 return
850 }
851 select {
852 case f.repaint <- struct{}{}:
853 default:
854 }
855}
856
857func (f *attachmentForeground) overlayKind() attachmentOverlayKind {
858 if f == nil {
859 return attachmentOverlayNone
860 }
861 f.overlayMu.Lock()
862 defer f.overlayMu.Unlock()
863 return f.overlay
864}
865
866// divertInput hands one authorized delivery to the supervisor-owned overlay.
867// It reports false when no navigation overlay owns input, so the worker keeps
868// its session path. A driver action consumed here never reaches the daemon, so
869// it is marked before the picker sees the bytes and handed to the supervisor
870// afterwards, which settles it at its own event-loop boundary (or follows it
871// across the swap a commit starts).
872func (f *attachmentForeground) divertInput(data []byte, actionID uint64) bool {
873 if f == nil {
874 return false
875 }
876 f.overlayMu.Lock()
877 sink := f.overlaySink
878 kind := f.overlay
879 f.overlayMu.Unlock()
880 if kind != attachmentOverlayNavigation || sink == nil {
881 return false
882 }
883 tracked := actionID != 0 && f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil
884 if tracked {
885 f.host.actionUI.markOverlayInput(f.uiGeneration, actionID)
886 }
887 sink.ConsumeTerminalRead(data)
888 if tracked {
889 f.host.offerOverlayAction(overlayActionConsumed{generation: f.uiGeneration, actionID: actionID})
890 }
891 return true
892}
893
894// overlayActionConsumed names one driver action the client picker overlay
895// consumed on the given UI generation.
896type overlayActionConsumed struct {
897 generation uint64
898 actionID uint64
899}
900
901// offerOverlayAction hands the latest overlay-consumed action to the
902// supervisor. Automation admits one action at a time and the worker is the only
903// producer, so replacing an unread stale entry never drops a live one.
904func (h *attachmentHost) offerOverlayAction(consumed overlayActionConsumed) {
905 for {
906 select {
907 case h.overlayActions <- consumed:
908 return
909 default:
910 }
911 select {
912 case <-h.overlayActions:
913 default:
914 }
915 }
916}
917
918// OverlayActions wakes the supervisor for each driver action the picker
919// overlay consumed.
920func (h *attachmentHost) OverlayActions() <-chan overlayActionConsumed {
921 if h == nil {
922 return nil
923 }
924 return h.overlayActions
925}
926
927func (f *attachmentForeground) overlayRepaint() <-chan struct{} {
928 if f == nil {
929 return nil
930 }
931 return f.repaint
932}
933
934// committedSnapshot reports the session and tab this foreground last
935// committed output for. It stays readable after the foreground is finished,
936// so a settled attachment can still name what it was showing.
937func (f *attachmentForeground) committedSnapshot() (protocol.ExactSessionTarget, domain.TabStableID, bool) {
938 if f == nil {
939 return protocol.ExactSessionTarget{}, "", false
940 }
941 f.overlayMu.Lock()
942 defer f.overlayMu.Unlock()
943 return f.committed, f.committedTab, f.committedKnown
944}
945
946func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) {
947 if f == nil {
948 return
949 }
950 f.overlayMu.Lock()
951 changed := !f.committedKnown || f.committed != target
952 f.committed = target
953 f.committedTab = tab
954 f.committedKnown = true
955 f.overlayMu.Unlock()
956 if changed {
957 f.demandRoutes()
958 }
959}
960
961// tabSelections carries the supervisor's in-place tab switches to the worker.
962func (f *attachmentForeground) tabSelections() <-chan domain.TabStableID {
963 if f == nil {
964 return nil
965 }
966 return f.tabSelect
967}
968
969// overlayOutput writes one overlay frame through the same lease and UI
970// transaction as attachment output, bypassing only the overlay suppression.
971func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []byte) error {
972 return f.write(uiContext, data, true)
973}
974
975// attachmentQueryForeground is the optional terminal-query seam of the real
976// foreground. Scripted foregrounds may omit it; the worker then skips palette
977// detection but still strips terminal replies from input.
978type attachmentQueryForeground interface {
979 writeTerminalQuery(data []byte) error
980}
981
982var _ attachmentQueryForeground = (*attachmentForeground)(nil)
983
984// writeTerminalQuery writes and flushes terminal query bytes (palette probes)
985// under the output lease. They draw nothing, so neither an overlay nor the UI
986// observation channel is involved.
987func (f *attachmentForeground) writeTerminalQuery(data []byte) error {
988 if f == nil {
989 return errAttachmentForegroundRevoked
990 }
991 var err error
992 ok := f.lease.send(func() bool {
993 if !f.authority.actionAuthorized(f) {
994 return false
995 }
996 if supervisorNil(f.term) || supervisorNil(f.term.Out()) {
997 err = ports.ErrUIUnavailable
998 return true
999 }
1000 var n int
1001 n, err = f.term.Out().Write(data)
1002 if err == nil && n != len(data) {
1003 err = io.ErrShortWrite
1004 }
1005 if err == nil {
1006 err = f.term.Flush()
1007 }
1008 return true
1009 })
1010 if !ok {
1011 return errAttachmentForegroundRevoked
1012 }
1013 return err
1014}
1015
1016// attachmentPredictionForeground is the optional predictive-echo seam of the
1017// real foreground. Scripted foregrounds may omit it; the worker then never
1018// predicts.
1019type attachmentPredictionForeground interface {
1020 writePrediction(data []byte) (bool, error)
1021}
1022
1023var _ attachmentPredictionForeground = (*attachmentForeground)(nil)
1024
1025// writePrediction writes and flushes predicted echo under the output lease.
1026// It reports false without writing while an overlay owns the terminal. The
1027// guesses are client-local drawing, so the UI observation channel is not
1028// involved.
1029func (f *attachmentForeground) writePrediction(data []byte) (bool, error) {
1030 if f == nil {
1031 return false, errAttachmentForegroundRevoked
1032 }
1033 var err error
1034 written := false
1035 ok := f.lease.send(func() bool {
1036 if !f.authority.actionAuthorized(f) {
1037 return false
1038 }
1039 if f.overlayKind() != attachmentOverlayNone {
1040 return true
1041 }
1042 if supervisorNil(f.term) || supervisorNil(f.term.Out()) {
1043 err = ports.ErrUIUnavailable
1044 return true
1045 }
1046 var n int
1047 n, err = f.term.Out().Write(data)
1048 if err == nil && n != len(data) {
1049 err = io.ErrShortWrite
1050 }
1051 if err == nil {
1052 err = f.term.Flush()
1053 }
1054 written = err == nil
1055 return true
1056 })
1057 if !ok {
1058 return false, errAttachmentForegroundRevoked
1059 }
1060 return written, err
1061}
1062
1063// attachmentRun is one in-flight worker generation owned by the supervisor.
1064type attachmentRun struct {
1065 host *attachmentHost
1066 token AttachmentToken
1067 stream ports.BrokerLogicalConnection
1068 fg *attachmentForeground
1069 cancel context.CancelFunc
1070 // outcome is capacity one so a worker that settles after the supervisor has
1071 // already moved on can never block on its own return.
1072 outcome chan AttachmentEvent
1073 // done closes when the worker goroutine has returned.
1074 done chan struct{}
1075 joinOnce sync.Once
1076 stateMu sync.Mutex
1077 retired chan struct{}
1078 isRetired bool
1079}
1080
1081// Wait joins the run. It returns the worker's typed event with adopted=true
1082// only when the worker settled on its own and the supervisor had not cancelled;
1083// a run that was cancelled or superseded is joined and its late event discarded
1084// (adopted=false). The caller's ctx stops waiting for an outcome; retirement
1085// then uses the separate bounded worker-join budget.
1086func (r *attachmentRun) Wait(ctx context.Context) (AttachmentEvent, bool) {
1087 if ctx == nil {
1088 ctx = context.Background()
1089 }
1090 select {
1091 case event := <-r.outcome:
1092 // Settlement and external retirement compete at one state boundary.
1093 adopted := r.retire() && ctx.Err() == nil && event.Token == r.token
1094 r.cleanup()
1095 if adopted {
1096 return event, true
1097 }
1098 case <-r.retired:
1099 r.cleanup()
1100 case <-ctx.Done():
1101 r.Cancel()
1102 }
1103 return AttachmentEvent{}, false
1104}
1105
1106// retire is authoritative even when outcome and cancellation are both ready.
1107func (r *attachmentRun) retire() bool {
1108 r.stateMu.Lock()
1109 defer r.stateMu.Unlock()
1110 if r.isRetired {
1111 return false
1112 }
1113 r.isRetired = true
1114 close(r.retired)
1115 return true
1116}
1117
1118// Cancel marks retirement, runs the deterministic final input window, and
1119// returns once the worker is joined or the join bound expires and the retained
1120// consumer and stream are released.
1121func (r *attachmentRun) Cancel() {
1122 r.retire()
1123 r.cleanup()
1124}
1125
1126// cleanup is the ordered teardown. It refuses further output/UI/resize/new input
1127// and closes Done, wakes the worker, then joins within the bound while the pump
1128// consumer stays authorized for Ack/Preserve, so a worker woken by Done can
1129// still save the read it holds. Only after the join (or its timeout) does it
1130// revoke the consumer and foreground slot, then close the owned stream.
1131func (r *attachmentRun) cleanup() {
1132 r.joinOnce.Do(func() {
1133 r.fg.finalize()
1134 r.cancel()
1135 r.join()
1136 r.fg.finish()
1137 _ = r.stream.Close()
1138 })
1139}
1140
1141// join waits for the worker goroutine within the shared join bound. It allocates
1142// a timer only when the worker has not already returned.
1143func (r *attachmentRun) join() {
1144 select {
1145 case <-r.done:
1146 return
1147 default:
1148 }
1149 timer := r.host.clock.NewTimer(attachmentWorkerJoinTimeout)
1150 if supervisorNil(timer) {
1151 timer = systemClock{}.NewTimer(attachmentWorkerJoinTimeout)
1152 }
1153 defer stopSupervisorTimer(timer)
1154 select {
1155 case <-r.done:
1156 case <-timer.C():
1157 }
1158}
1159
1160// attachmentForeground is the supervisor-granted implementation of
1161// AttachmentForeground. It reuses the existing terminal input pump for input
1162// and the existing foreground send lease for output serialization.
1163type attachmentForeground struct {
1164 authority *attachmentAuthority
1165 token AttachmentToken
1166 stream ports.BrokerLogicalConnection
1167 input *terminalInputPump
1168 consumer uint64
1169 lease *foregroundSendLease
1170 term ports.Terminal
1171 ui ports.UIOutputTransaction
1172 uiGeneration uint64
1173 resizes <-chan domain.Geometry
1174 actions <-chan AttachmentLifecycleAction
1175 host *attachmentHost
1176 done chan struct{}
1177 // finalizedOnce and finishedOnce split the single teardown into the
1178 // deterministic final input window: finalize closes Done and stops output
1179 // first, finish revokes the retained consumer only after the join.
1180 finalizedOnce sync.Once
1181 finishedOnce sync.Once
1182 // deliveryMu guards the at-most-once input decision. Input opens the window
1183 // by marking a freshly received delivery undecided; AckInput or PreserveInput
1184 // decides it at most once. decided starts true, so a decision with no
1185 // outstanding delivery is refused instead of silently committing nothing.
1186 deliveryMu sync.Mutex
1187 decided bool
1188 geometrySeq uint64
1189 attached atomic.Bool
1190 onAttached func(AttachmentToken)
1191
1192 // overlayMu guards the overlay slot and the committed target. The slot is
1193 // changed only inside the output lease, so an overlay never takes the
1194 // terminal while an attachment frame is mid-write.
1195 overlayMu sync.Mutex
1196 overlay attachmentOverlayKind
1197 overlaySink pickerInputConsumer
1198 committed protocol.ExactSessionTarget
1199 committedTab domain.TabStableID
1200 committedKnown bool
1201 // repaint wakes the worker to request an authoritative repaint once an
1202 // overlay released the terminal.
1203 repaint chan struct{}
1204 // tabSelect carries one pending in-place tab switch to the worker.
1205 tabSelect chan domain.TabStableID
1206 // routes carries the newest client route snapshot to the worker; replies
1207 // carries the supervisor's navigation failures.
1208 routes chan protocol.RecentRouteSnapshot
1209 replies chan protocol.ClientMessage
1210 // inPlace carries one pending supervisor in-place switch to the worker.
1211 inPlace chan inPlaceSwitch
1212}
1213
1214// Token is the generation/attempt identity of this grant.
1215func (f *attachmentForeground) Token() AttachmentToken { return f.token }
1216
1217func (f *attachmentForeground) actionableGeneration() uint64 {
1218 if f.uiGeneration != 0 {
1219 return f.uiGeneration
1220 }
1221 return f.token.Generation
1222}
1223
1224func (f *attachmentForeground) uiPublished() {
1225 if f.uiGeneration == 0 || f.host == nil || f.host.actionUI == nil {
1226 return
1227 }
1228 f.host.actionUI.published(f.uiGeneration)
1229}
1230
1231func (f *attachmentForeground) uiReceipt(receipt protocol.UIReceipt) {
1232 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1233 f.host.actionUI.receipt(f.uiGeneration, receipt)
1234 }
1235}
1236
1237// uiFollowSamePeer keeps the driver action that caused a same-peer offer
1238// pending until the in-place destination commits.
1239func (f *attachmentForeground) uiFollowSamePeer(actionID uint64) {
1240 if actionID != 0 && f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1241 f.host.actionUI.follow(f.uiGeneration, actionID)
1242 }
1243}
1244
1245// uiSamePeerArrived runs before the destination's first output is published.
1246func (f *attachmentForeground) uiSamePeerArrived() {
1247 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1248 f.host.actionUI.arriveInPlace(f.uiGeneration)
1249 }
1250}
1251
1252// uiSamePeerFailed fails the followed action of a refused same-peer switch.
1253func (f *attachmentForeground) uiSamePeerFailed() {
1254 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1255 f.host.actionUI.failInPlace(f.uiGeneration)
1256 }
1257}
1258
1259// Stream is the supervisor-admitted logical stream this worker owns.
1260func (f *attachmentForeground) Stream() ports.BrokerLogicalConnection { return f.stream }
1261
1262// Done is closed when the supervisor starts finalizing this grant. It stops
1263// Input and Resize and wakes a worker so it can still Ack/Preserve the read it
1264// holds; the output gate is stopped at the same time.
1265func (f *attachmentForeground) Done() <-chan struct{} { return f.done }
1266
1267// finalize opens the final input window exactly once: it moves the grant into
1268// the finalizing authority state, stops the output gate (waiting for any
1269// in-flight output transaction to end), and closes Done so Input/Resize refuse
1270// new work and the worker wakes. The pump consumer and foreground slot stay
1271// owned, so the worker may still Ack/Preserve the read it holds.
1272// Lock order is authority -> send lease; beginFinalize releases authority before
1273// waiting on the lease, so output never observes authority while it holds the
1274// lease.
1275func (f *attachmentForeground) finalize() {
1276 if f == nil {
1277 return
1278 }
1279 f.finalizedOnce.Do(func() {
1280 f.authority.beginFinalize(f)
1281 if f.lease != nil {
1282 f.lease.stop()
1283 }
1284 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1285 f.host.actionUI.releaseForeground(f.uiGeneration)
1286 }
1287 close(f.done)
1288 })
1289}
1290
1291// finish revokes the retained consumer and clears the foreground slot after the
1292// bounded join. It runs before the stream is closed, so undecided input either
1293// reached the pump during finalization or is dropped with the revocation.
1294func (f *attachmentForeground) finish() {
1295 if f == nil {
1296 return
1297 }
1298 f.finishedOnce.Do(func() {
1299 // Finish is lossless: input kept for a resume must survive it. The
1300 // supervisor drops unclaimed bytes (dropUnclaimed) when the terminal
1301 // returns to the picker, the owner-class boundary no attachment byte
1302 // may cross.
1303 f.authority.revoke(f)
1304 })
1305}
1306
1307// beginDelivery opens the decision window for the delivery the worker just
1308// received: the next AckInput or PreserveInput decides it, and a further
1309// decision is refused until Input receives another delivery.
1310func (f *attachmentForeground) beginDelivery() {
1311 f.deliveryMu.Lock()
1312 f.decided = false
1313 f.deliveryMu.Unlock()
1314}
1315
1316// decideDelivery reports whether this is the first decision for the current
1317// delivery and marks it decided. A second Ack/Preserve for the same delivery,
1318// or a decision with no outstanding delivery, is refused.
1319func (f *attachmentForeground) decideDelivery() bool {
1320 f.deliveryMu.Lock()
1321 defer f.deliveryMu.Unlock()
1322 if f.decided {
1323 return false
1324 }
1325 f.decided = true
1326 return true
1327}
1328
1329// Input leases the next authorized terminal read. It waits on the pump's ready
1330// signal exactly like the attach scanner, so a shared single reader is never
1331// duplicated.
1332func (f *attachmentForeground) Input(ctx context.Context) (AttachmentInputEvent, bool) {
1333 if ctx == nil {
1334 ctx = context.Background()
1335 }
1336 for {
1337 if !f.authority.actionAuthorized(f) || f.input == nil {
1338 return AttachmentInputEvent{}, false
1339 }
1340 result, ok := f.input.take(ctx, f.consumer)
1341 if ok {
1342 f.beginDelivery()
1343 return AttachmentInputEvent{Data: result.data, Err: result.err, actionID: result.actionID}, true
1344 }
1345 if ctx.Err() != nil {
1346 return AttachmentInputEvent{}, false
1347 }
1348 select {
1349 case <-ctx.Done():
1350 return AttachmentInputEvent{}, false
1351 case <-f.done:
1352 return AttachmentInputEvent{}, false
1353 case request := <-f.input.automation:
1354 f.input.mu.Lock()
1355 clean := f.input.consumer == f.consumer && request.consumer == f.consumer && f.input.pending == nil && len(f.input.residual) == 0 && f.input.delivering == 0
1356 f.input.mu.Unlock()
1357 clean = clean && request.ctx.Err() == nil && request.record.source == terminalInputAutomation && request.record.actionID != 0 && request.record.generation == f.uiGeneration && request.record.endBatch && len(request.record.data) > 0 && len(request.record.data) <= uiMaxInputBytes && validUIKeyBatch(request.record.data)
1358 if clean && request.owner != nil {
1359 clean = request.owner.accept(request.record.actionID, request.record.generation)
1360 }
1361 request.admitted <- clean
1362 if !clean {
1363 continue
1364 }
1365 request.dispatched <- true
1366 return AttachmentInputEvent{Data: append([]byte(nil), request.record.data...), actionID: request.record.actionID}, true
1367 case <-f.input.readyFor(f.consumer):
1368 }
1369 }
1370}
1371
1372// AckInput commits the last delivery so the shared reader may publish the next.
1373// It decides that delivery, so a duplicate Ack/Preserve for it is a no-op.
1374func (f *attachmentForeground) AckInput() {
1375 if f == nil || f.input == nil {
1376 return
1377 }
1378 if !f.decideDelivery() {
1379 return
1380 }
1381 f.authority.retain(f, func() { f.input.ack(f.consumer) })
1382}
1383
1384// PreserveInput re-queues the undecided bytes of the last delivery for the next
1385// authorized attempt. It decides that delivery, so a duplicate Ack/Preserve for
1386// it is a no-op and can never re-queue different bytes. It stays authorized
1387// while the supervisor finalizes the grant.
1388func (f *attachmentForeground) PreserveInput(data []byte) {
1389 if f == nil || f.input == nil {
1390 return
1391 }
1392 if !f.decideDelivery() {
1393 return
1394 }
1395 f.authority.retain(f, func() {
1396 f.input.ack(f.consumer)
1397 f.input.preserveResidual(f.consumer, data)
1398 })
1399}
1400
1401// Resize waits for the next authorized terminal resize.
1402func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecycleActionKind, bool) {
1403 if ctx == nil {
1404 ctx = context.Background()
1405 }
1406 for {
1407 if !f.authority.actionAuthorized(f) {
1408 return 0, false
1409 }
1410 var internal <-chan AttachmentLifecycleAction
1411 if f.host != nil {
1412 internal = f.host.internalActions
1413 }
1414 select {
1415 case action, ok := <-f.actions:
1416 if !ok || !f.authority.actionAuthorized(f) {
1417 return 0, false
1418 }
1419 if action.Token != f.token {
1420 continue
1421 }
1422 return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit
1423 case action := <-internal:
1424 if action.Token != f.token || !f.authority.actionAuthorized(f) {
1425 continue
1426 }
1427 return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit
1428 case <-ctx.Done():
1429 return 0, false
1430 case <-f.done:
1431 return 0, false
1432 }
1433 }
1434}
1435
1436func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) {
1437 if ctx == nil {
1438 ctx = context.Background()
1439 }
1440 if !f.authority.actionAuthorized(f) || f.host == nil {
1441 return domain.Geometry{}, false
1442 }
1443 if !f.host.ownerBoundary {
1444 resizes := f.resizes
1445 for {
1446 select {
1447 case geometry, ok := <-resizes:
1448 if !ok {
1449 resizes = nil
1450 continue
1451 }
1452 if !f.authority.actionAuthorized(f) {
1453 return domain.Geometry{}, false
1454 }
1455 return geometry, true
1456 case <-ctx.Done():
1457 return domain.Geometry{}, false
1458 case <-f.done:
1459 return domain.Geometry{}, false
1460 }
1461 }
1462 }
1463 for {
1464 if geometry, next, ok := f.host.geometryAfter(f.geometrySeq); ok {
1465 f.geometrySeq = next
1466 if f.authority.actionAuthorized(f) {
1467 return geometry, true
1468 }
1469 return domain.Geometry{}, false
1470 }
1471 select {
1472 case <-ctx.Done():
1473 return domain.Geometry{}, false
1474 case <-f.done:
1475 return domain.Geometry{}, false
1476 case <-f.host.geometryUpdate:
1477 }
1478 }
1479}
1480
1481// Output holds the same send lease across the entire UI observation boundary.
1482// UIOutputTransaction is an observation channel, not attachment commit
1483// authority: an unavailable or closed observation channel does not stop the
1484// terminal frame. Other publication failures abort. Successful terminal write,
1485// flush, and EndOutput(true) commit the frame; any failed or short write or
1486// failed flush leaves EndOutput(false).
1487func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error {
1488 return f.write(uiContext, data, false)
1489}
1490
1491// write is the shared output transaction. While an overlay owns the terminal an
1492// attachment frame (overlay=false) is accepted without being written or
1493// published: the caller still applies and acknowledges it, and the overlay's
1494// release requests an authoritative repaint. Overlay frames bypass only that
1495// suppression.
1496func (f *attachmentForeground) write(uiContext ports.UIContext, data []byte, overlay bool) error {
1497 if f == nil {
1498 return errAttachmentForegroundRevoked
1499 }
1500 var outputErr error
1501 ok := f.lease.send(func() bool {
1502 if !f.authority.actionAuthorized(f) {
1503 return false
1504 }
1505 if !overlay && f.overlayKind() != attachmentOverlayNone {
1506 // Applied, not written: remember its committed boundary so a
1507 // daemon receipt fenced on it still settles its action.
1508 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1509 f.host.actionUI.suppressedOutput(f.uiGeneration, f.presentationContext(uiContext))
1510 }
1511 return true
1512 }
1513 if supervisorNil(f.term) {
1514 outputErr = ports.ErrUIUnavailable
1515 return true
1516 }
1517 writer := f.term.Out()
1518 if supervisorNil(writer) {
1519 outputErr = ports.ErrUIUnavailable
1520 return true
1521 }
1522 uiContext = f.presentationContext(uiContext)
1523 hasUI := !supervisorNil(f.ui)
1524 if hasUI {
1525 f.ui.BeginOutput(uiContext)
1526 }
1527 success := false
1528 defer func() {
1529 if hasUI {
1530 f.ui.EndOutput(success)
1531 if success {
1532 f.uiPublished()
1533 }
1534 }
1535 }()
1536 // An unavailable capture means the optional UI observation channel is
1537 // disabled or closed, so the terminal frame still writes and flushes.
1538 // Any other publication error aborts before the frame is written.
1539 if hasUI {
1540 if err := f.ui.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
1541 outputErr = err
1542 return true
1543 }
1544 }
1545 var n int
1546 n, outputErr = writer.Write(data)
1547 if outputErr == nil && n != len(data) {
1548 outputErr = io.ErrShortWrite
1549 }
1550 if outputErr != nil {
1551 return true
1552 }
1553 outputErr = f.term.Flush()
1554 success = outputErr == nil
1555 return true
1556 })
1557 if !ok {
1558 return errAttachmentForegroundRevoked
1559 }
1560 return outputErr
1561}
1562
1563// presentationContext applies the published-context shape rule to one foreground
1564// transaction. Only an Attached transaction carries the real actionable
1565// generation and the committed session metadata; any other presentation carries
1566// the run's stable UI handle and its status with every other field zeroed. A
1567// pre-attach Connecting frame therefore never publishes an actionable
1568// generation or session identity, and no caller can fence an action against a
1569// generation the attachment has not committed.
1570func (f *attachmentForeground) presentationContext(identity ports.UIContext) ports.UIContext {
1571 handle := identity.AttachmentHandle
1572 if f.host != nil && f.host.actionUI != nil {
1573 handle = f.host.actionUI.Handle()
1574 }
1575 if identity.Status != ports.UIStatusAttached {
1576 return ports.UIContext{AttachmentHandle: handle, Status: identity.Status}
1577 }
1578 identity.Generation = f.actionableGeneration()
1579 identity.AttachmentHandle = handle
1580 return identity
1581}
1582
1583// PublishAttached publishes the committed attached presentation. It takes the
1584// same send lease and the same authorization fence as Output, so it can never
1585// race a finalization or a revoked generation, and it writes no bytes: the
1586// attached presentation describes the frame that already drained. A closed UI
1587// observation channel is tolerated exactly as Output tolerates it, while any
1588// other publication failure is reported and leaves the run unattached.
1589func (f *attachmentForeground) PublishAttached(uiContext ports.UIContext) error {
1590 if f == nil {
1591 return errAttachmentForegroundRevoked
1592 }
1593 var outputErr error
1594 ok := f.lease.send(func() bool {
1595 if !f.authority.actionAuthorized(f) {
1596 return false
1597 }
1598 if supervisorNil(f.ui) {
1599 return true
1600 }
1601 uiContext = f.presentationContext(uiContext)
1602 f.ui.BeginOutput(uiContext)
1603 publishErr := f.ui.PublishContext(uiContext)
1604 if publishErr != nil && !errors.Is(publishErr, ports.ErrUIUnavailable) {
1605 outputErr = publishErr
1606 f.ui.EndOutput(false)
1607 return true
1608 }
1609 f.ui.EndOutput(true)
1610 f.uiPublished()
1611 return true
1612 })
1613 if !ok {
1614 return errAttachmentForegroundRevoked
1615 }
1616 return outputErr
1617}
1618
1619// MarkAttached records the attached transition atomically with respect to
1620// finalization and revocation.
1621func (f *attachmentForeground) MarkAttached() bool {
1622 if f == nil {
1623 return false
1624 }
1625 set := f.authority.act(f, func() { f.attached.Store(true) })
1626 if !set {
1627 return false
1628 }
1629 if f.onAttached != nil {
1630 f.onAttached(f.token)
1631 }
1632 return true
1633}
1634
1635// Attached reports whether MarkAttached has succeeded.
1636func (f *attachmentForeground) Attached() bool {
1637 return f != nil && f.attached.Load()
1638}