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}
400
401// newAttachmentHost builds the reusable foreground host. A nil clock falls back
402// to the system clock; a nil terminal leaves output refused but still enforces
403// authority.
404func newAttachmentHost(cfg attachmentHostConfig) *attachmentHost {
405 clock := cfg.Clock
406 if supervisorNil(clock) {
407 clock = systemClock{}
408 }
409 ui := cfg.UI
410 resizes := (<-chan domain.Geometry)(nil)
411 var initialGeometry domain.Geometry
412 var initialGeometryValid bool
413 if !supervisorNil(cfg.Terminal) {
414 if supervisorNil(ui) {
415 ui, _ = cfg.Terminal.(ports.UIOutputTransaction)
416 }
417 resizes = cfg.Terminal.ResizeEvents()
418 if geometry, err := cfg.Terminal.Geometry(); err == nil && geometry.Valid() {
419 initialGeometry = geometry.NormalizePixels()
420 initialGeometryValid = true
421 }
422 }
423 return &attachmentHost{
424 term: cfg.Terminal,
425 clock: clock,
426 input: cfg.Input,
427 ui: ui,
428 actionUI: cfg.ActionUI,
429 resizes: resizes,
430 actions: cfg.Actions,
431 onAttach: cfg.OnAttached,
432 geometry: initialGeometry,
433 geometryValid: initialGeometryValid,
434 geometryUpdate: make(chan struct{}, 1),
435 presentationUpdate: make(chan struct{}, 1),
436 navigationRequests: make(chan struct{}, 1),
437 internalActions: make(chan AttachmentLifecycleAction, 1),
438 routeDemand: make(chan struct{}, 1),
439 navigations: make(chan protocol.ServerMessage, 1),
440 overlayActions: make(chan overlayActionConsumed, 1),
441 }
442}
443
444// Begin grants one generation's foreground and starts its worker on a
445// supervisor-joined goroutine. It never closes or otherwise mutates stream, so
446// on false the caller retains sole ownership of it. It returns false when:
447//
448// - the host is nil;
449// - the token is zero (no supervisor generation was assigned);
450// - the worker or stream is nil, in any typed-nil shape;
451// - another foreground is already live, or is finalizing a previous grant;
452// - the shared input pump already has a consumer (a picker or rival
453// foreground owns input).
454//
455// The granted token and event token always retain the caller-supplied
456// supervisor generation. When action UI is configured, its independent
457// generation is used only for UI publication and action fencing. The caller
458// must Wait or Cancel the returned run.
459func (h *attachmentHost) Begin(ctx context.Context, token AttachmentToken, worker AttachmentWorker, stream ports.BrokerLogicalConnection) (*attachmentRun, bool) {
460 if h == nil || token.IsZero() || supervisorNil(worker) || supervisorNil(stream) {
461 return nil, false
462 }
463 if ctx == nil {
464 ctx = context.Background()
465 }
466 workerCtx, cancel := context.WithCancel(ctx)
467 fg := h.newForeground(token, stream)
468 if !h.authority.grant(fg) {
469 cancel()
470 return nil, false
471 }
472 // A request or action left behind by a previous foreground never applies
473 // to this one.
474 h.drainForegroundSignals()
475 if h.actionUI != nil {
476 fg.uiGeneration = h.actionUI.bindForeground(workerCtx, fg.input, fg.consumer)
477 }
478 run := &attachmentRun{
479 host: h,
480 token: token,
481 stream: stream,
482 fg: fg,
483 cancel: cancel,
484 outcome: make(chan AttachmentEvent, 1),
485 done: make(chan struct{}),
486 retired: make(chan struct{}),
487 }
488 go func() {
489 defer close(run.done)
490 event := worker.Run(workerCtx, fg)
491 // The host stamps the granted token: a worker can never report under a
492 // generation it was not granted, and the settlement check is exact.
493 event.Token = token
494 run.outcome <- event
495 }()
496 return run, true
497}
498
499// Run is Begin followed immediately by Wait, for callers that join in place.
500// The second result is false when the run was superseded or cancelled, in which
501// case the worker's late event is discarded.
502func (h *attachmentHost) Run(ctx context.Context, token AttachmentToken, worker AttachmentWorker, stream ports.BrokerLogicalConnection) (AttachmentEvent, bool) {
503 run, ok := h.Begin(ctx, token, worker, stream)
504 if !ok {
505 return AttachmentEvent{}, false
506 }
507 return run.Wait(ctx)
508}
509
510// newForeground allocates a candidate; grant claims input before publishing it.
511func (h *attachmentHost) setInput(input *terminalInputPump) {
512 if h == nil {
513 return
514 }
515 h.input = input
516 h.ownerBoundary = true
517}
518
519func (h *attachmentHost) startGeometry(ctx context.Context) {
520 if h == nil {
521 return
522 }
523 if h.geometryDone != nil {
524 select {
525 case <-h.geometryDone:
526 h.geometryDone = nil
527 h.geometryCancel = nil
528 default:
529 return
530 }
531 }
532 geometryCtx, cancel := context.WithCancel(ctx)
533 h.geometryCancel = cancel
534 h.geometryDone = make(chan struct{})
535 go func() {
536 defer close(h.geometryDone)
537 for {
538 select {
539 case geometry, ok := <-h.resizes:
540 if !ok {
541 return
542 }
543 if !geometry.Valid() {
544 continue
545 }
546 h.geometryMu.Lock()
547 h.geometry = geometry.NormalizePixels()
548 h.geometryValid = true
549 h.geometrySeq++
550 h.geometryMu.Unlock()
551 select {
552 case h.geometryUpdate <- struct{}{}:
553 default:
554 }
555 // Presentation invalidation is deliberately independent from the
556 // attachment geometry sequence/wakeup. The supervisor consumes this
557 // coalesced signal and decides whether a picker presentation may be
558 // repainted; this collector never renders directly.
559 select {
560 case h.presentationUpdate <- struct{}{}:
561 default:
562 }
563 case <-geometryCtx.Done():
564 return
565 }
566 }
567 }()
568}
569
570func (h *attachmentHost) stopGeometry() {
571 if h == nil || h.geometryCancel == nil {
572 return
573 }
574 h.geometryCancel()
575 <-h.geometryDone
576 h.geometryCancel = nil
577 h.geometryDone = nil
578}
579
580func (h *attachmentHost) latestGeometry() (domain.Geometry, bool) {
581 if h == nil {
582 return domain.Geometry{}, false
583 }
584 h.geometryMu.Lock()
585 defer h.geometryMu.Unlock()
586 return h.geometry, h.geometryValid
587}
588
589func (h *attachmentHost) geometryAfter(sequence uint64) (domain.Geometry, uint64, bool) {
590 h.geometryMu.Lock()
591 defer h.geometryMu.Unlock()
592 return h.geometry, h.geometrySeq, h.geometryValid && h.geometrySeq > sequence
593}
594
595func (h *attachmentHost) newForeground(token AttachmentToken, stream ports.BrokerLogicalConnection) *attachmentForeground {
596 return &attachmentForeground{
597 authority: &h.authority,
598 token: token,
599 stream: stream,
600 input: h.input,
601 lease: newForegroundSendLease(),
602 term: h.term,
603 ui: h.ui,
604 resizes: h.resizes,
605 actions: h.actions,
606 host: h,
607 done: make(chan struct{}),
608 decided: true,
609 onAttached: h.onAttach,
610 repaint: make(chan struct{}, 1),
611 tabSelect: make(chan domain.TabStableID, 1),
612 routes: make(chan protocol.RecentRouteSnapshot, 1),
613 replies: make(chan protocol.ClientMessage, attachmentReplyCapacity),
614 }
615}
616
617// drainForegroundSignals drops any navigation request or internal lifecycle
618// action that belonged to an earlier foreground.
619func (h *attachmentHost) drainForegroundSignals() {
620 for {
621 select {
622 case <-h.navigationRequests:
623 case <-h.internalActions:
624 case <-h.routeDemand:
625 case <-h.navigations:
626 case <-h.overlayActions:
627 default:
628 return
629 }
630 }
631}
632
633// NavigationRequests wakes the supervisor when the attached worker asked for
634// the client picker over the live attachment.
635func (h *attachmentHost) NavigationRequests() <-chan struct{} {
636 if h == nil {
637 return nil
638 }
639 return h.navigationRequests
640}
641
642// beginNavigationOverlay composes the supervisor-owned picker over the current
643// foreground: from its return, attachment output is applied and acknowledged
644// but never written, and every authorized input delivery is handed to sink. It
645// reports false when no active foreground can host the overlay.
646func (h *attachmentHost) beginNavigationOverlay(sink pickerInputConsumer) bool {
647 if h == nil || sink == nil {
648 return false
649 }
650 fg := h.authority.foreground()
651 if fg == nil {
652 return false
653 }
654 return fg.setOverlay(attachmentOverlayNavigation, sink)
655}
656
657// endNavigationOverlay returns the terminal to the current foreground and asks
658// its worker for an authoritative repaint over the picker box.
659func (h *attachmentHost) endNavigationOverlay() {
660 if h == nil {
661 return
662 }
663 if fg := h.authority.foreground(); fg != nil {
664 fg.clearOverlay(attachmentOverlayNavigation)
665 }
666}
667
668// committedTarget reports the session the current foreground last committed
669// output for, so the supervisor can tell a commit to the attached session from
670// a commit elsewhere.
671func (h *attachmentHost) committedTarget() (protocol.ExactSessionTarget, bool) {
672 target, _, ok := h.committedView()
673 return target, ok
674}
675
676// committedView reports the session and the tab the current foreground last
677// committed output for.
678func (h *attachmentHost) committedView() (protocol.ExactSessionTarget, domain.TabStableID, bool) {
679 if h == nil {
680 return protocol.ExactSessionTarget{}, "", false
681 }
682 fg := h.authority.foreground()
683 if fg == nil {
684 return protocol.ExactSessionTarget{}, "", false
685 }
686 fg.overlayMu.Lock()
687 defer fg.overlayMu.Unlock()
688 return fg.committed, fg.committedTab, fg.committedKnown
689}
690
691// committedTargetOrZero is committedTarget with an unknown target as zero.
692func (h *attachmentHost) committedTargetOrZero() protocol.ExactSessionTarget {
693 target, ok := h.committedTarget()
694 if !ok {
695 return protocol.ExactSessionTarget{}
696 }
697 return target
698}
699
700// requestTabSelection asks the current foreground's worker to show one tab of
701// its attached session in place. The newest request replaces an unsent one.
702func (h *attachmentHost) requestTabSelection(token AttachmentToken, tab domain.TabStableID) bool {
703 if h == nil || tab == "" {
704 return false
705 }
706 fg := h.authority.foreground()
707 if fg == nil || fg.token != token {
708 return false
709 }
710 for {
711 select {
712 case fg.tabSelect <- tab:
713 return true
714 default:
715 }
716 select {
717 case <-fg.tabSelect:
718 default:
719 }
720 }
721}
722
723// requestDetach asks the current foreground's worker to detach explicitly, so
724// the daemon observes a clean Detach before the supervisor swaps attachments.
725func (h *attachmentHost) requestDetach(token AttachmentToken) {
726 if h == nil {
727 return
728 }
729 select {
730 case h.internalActions <- AttachmentLifecycleAction{Token: token, Kind: AttachmentDetachToPicker}:
731 default:
732 }
733}
734
735// attachmentOverlayKind names the client-composed overlay that owns the
736// terminal over a live attachment.
737type attachmentOverlayKind uint8
738
739const (
740 attachmentOverlayNone attachmentOverlayKind = iota
741 // attachmentOverlayNavigation is the supervisor-owned session picker.
742 attachmentOverlayNavigation
743 // attachmentOverlayMove is the worker-owned move-destination picker the
744 // serving daemon offered.
745 attachmentOverlayMove
746)
747
748// attachmentOverlayForeground is the optional overlay seam of the real
749// foreground. A worker reaches it by assertion so scripted foregrounds may
750// omit it; without it, overlays are simply never presented.
751type attachmentOverlayForeground interface {
752 requestNavigationPicker()
753 setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool
754 clearOverlay(kind attachmentOverlayKind)
755 overlayKind() attachmentOverlayKind
756 divertInput(data []byte, actionID uint64) bool
757 overlayRepaint() <-chan struct{}
758 overlayOutput(uiContext ports.UIContext, data []byte) error
759 noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID)
760 tabSelections() <-chan domain.TabStableID
761 routeSnapshots() <-chan protocol.RecentRouteSnapshot
762 navigationReplies() <-chan protocol.ClientMessage
763 requestNavigation(message protocol.ServerMessage)
764}
765
766var _ attachmentOverlayForeground = (*attachmentForeground)(nil)
767
768// requestNavigationPicker records the daemon's navigation offer for the
769// supervisor. The request is coalesced; the supervisor decides whether the
770// current presentation can host the overlay.
771func (f *attachmentForeground) requestNavigationPicker() {
772 if f == nil || f.host == nil || !f.authority.actionAuthorized(f) {
773 return
774 }
775 select {
776 case f.host.navigationRequests <- struct{}{}:
777 default:
778 }
779}
780
781// setOverlay installs kind under the output lease, so no attachment frame is
782// mid-write when the overlay takes the terminal. An occupied slot refuses a
783// different overlay.
784func (f *attachmentForeground) setOverlay(kind attachmentOverlayKind, sink pickerInputConsumer) bool {
785 if f == nil || kind == attachmentOverlayNone {
786 return false
787 }
788 installed := false
789 f.lease.send(func() bool {
790 if !f.authority.actionAuthorized(f) {
791 return false
792 }
793 f.overlayMu.Lock()
794 defer f.overlayMu.Unlock()
795 if f.overlay != attachmentOverlayNone && f.overlay != kind {
796 return true
797 }
798 f.overlay = kind
799 f.overlaySink = sink
800 installed = true
801 return true
802 })
803 return installed
804}
805
806// clearOverlay releases kind under the output lease and asks the worker for an
807// authoritative repaint: the terminal still shows the picker box and any
808// attachment frames it suppressed were never written.
809func (f *attachmentForeground) clearOverlay(kind attachmentOverlayKind) {
810 if f == nil {
811 return
812 }
813 cleared := false
814 f.lease.send(func() bool {
815 f.overlayMu.Lock()
816 defer f.overlayMu.Unlock()
817 if f.overlay != kind {
818 return true
819 }
820 f.overlay = attachmentOverlayNone
821 f.overlaySink = nil
822 cleared = true
823 return true
824 })
825 if !cleared {
826 // The lease stops at finalization; the overlay dies with the grant.
827 f.overlayMu.Lock()
828 if f.overlay == kind {
829 f.overlay = attachmentOverlayNone
830 f.overlaySink = nil
831 }
832 f.overlayMu.Unlock()
833 return
834 }
835 select {
836 case f.repaint <- struct{}{}:
837 default:
838 }
839}
840
841func (f *attachmentForeground) overlayKind() attachmentOverlayKind {
842 if f == nil {
843 return attachmentOverlayNone
844 }
845 f.overlayMu.Lock()
846 defer f.overlayMu.Unlock()
847 return f.overlay
848}
849
850// divertInput hands one authorized delivery to the supervisor-owned overlay.
851// It reports false when no navigation overlay owns input, so the worker keeps
852// its session path. A driver action consumed here never reaches the daemon, so
853// it is marked before the picker sees the bytes and handed to the supervisor
854// afterwards, which settles it at its own event-loop boundary (or follows it
855// across the swap a commit starts).
856func (f *attachmentForeground) divertInput(data []byte, actionID uint64) bool {
857 if f == nil {
858 return false
859 }
860 f.overlayMu.Lock()
861 sink := f.overlaySink
862 kind := f.overlay
863 f.overlayMu.Unlock()
864 if kind != attachmentOverlayNavigation || sink == nil {
865 return false
866 }
867 tracked := actionID != 0 && f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil
868 if tracked {
869 f.host.actionUI.markOverlayInput(f.uiGeneration, actionID)
870 }
871 sink.ConsumeTerminalRead(data)
872 if tracked {
873 f.host.offerOverlayAction(overlayActionConsumed{generation: f.uiGeneration, actionID: actionID})
874 }
875 return true
876}
877
878// overlayActionConsumed names one driver action the client picker overlay
879// consumed on the given UI generation.
880type overlayActionConsumed struct {
881 generation uint64
882 actionID uint64
883}
884
885// offerOverlayAction hands the latest overlay-consumed action to the
886// supervisor. Automation admits one action at a time and the worker is the only
887// producer, so replacing an unread stale entry never drops a live one.
888func (h *attachmentHost) offerOverlayAction(consumed overlayActionConsumed) {
889 for {
890 select {
891 case h.overlayActions <- consumed:
892 return
893 default:
894 }
895 select {
896 case <-h.overlayActions:
897 default:
898 }
899 }
900}
901
902// OverlayActions wakes the supervisor for each driver action the picker
903// overlay consumed.
904func (h *attachmentHost) OverlayActions() <-chan overlayActionConsumed {
905 if h == nil {
906 return nil
907 }
908 return h.overlayActions
909}
910
911func (f *attachmentForeground) overlayRepaint() <-chan struct{} {
912 if f == nil {
913 return nil
914 }
915 return f.repaint
916}
917
918// committedSnapshot reports the session and tab this foreground last
919// committed output for. It stays readable after the foreground is finished,
920// so a settled attachment can still name what it was showing.
921func (f *attachmentForeground) committedSnapshot() (protocol.ExactSessionTarget, domain.TabStableID, bool) {
922 if f == nil {
923 return protocol.ExactSessionTarget{}, "", false
924 }
925 f.overlayMu.Lock()
926 defer f.overlayMu.Unlock()
927 return f.committed, f.committedTab, f.committedKnown
928}
929
930func (f *attachmentForeground) noteCommitted(target protocol.ExactSessionTarget, tab domain.TabStableID) {
931 if f == nil {
932 return
933 }
934 f.overlayMu.Lock()
935 changed := !f.committedKnown || f.committed != target
936 f.committed = target
937 f.committedTab = tab
938 f.committedKnown = true
939 f.overlayMu.Unlock()
940 if changed {
941 f.demandRoutes()
942 }
943}
944
945// tabSelections carries the supervisor's in-place tab switches to the worker.
946func (f *attachmentForeground) tabSelections() <-chan domain.TabStableID {
947 if f == nil {
948 return nil
949 }
950 return f.tabSelect
951}
952
953// overlayOutput writes one overlay frame through the same lease and UI
954// transaction as attachment output, bypassing only the overlay suppression.
955func (f *attachmentForeground) overlayOutput(uiContext ports.UIContext, data []byte) error {
956 return f.write(uiContext, data, true)
957}
958
959// attachmentQueryForeground is the optional terminal-query seam of the real
960// foreground. Scripted foregrounds may omit it; the worker then skips palette
961// detection but still strips terminal replies from input.
962type attachmentQueryForeground interface {
963 writeTerminalQuery(data []byte) error
964}
965
966var _ attachmentQueryForeground = (*attachmentForeground)(nil)
967
968// writeTerminalQuery writes and flushes terminal query bytes (palette probes)
969// under the output lease. They draw nothing, so neither an overlay nor the UI
970// observation channel is involved.
971func (f *attachmentForeground) writeTerminalQuery(data []byte) error {
972 if f == nil {
973 return errAttachmentForegroundRevoked
974 }
975 var err error
976 ok := f.lease.send(func() bool {
977 if !f.authority.actionAuthorized(f) {
978 return false
979 }
980 if supervisorNil(f.term) || supervisorNil(f.term.Out()) {
981 err = ports.ErrUIUnavailable
982 return true
983 }
984 var n int
985 n, err = f.term.Out().Write(data)
986 if err == nil && n != len(data) {
987 err = io.ErrShortWrite
988 }
989 if err == nil {
990 err = f.term.Flush()
991 }
992 return true
993 })
994 if !ok {
995 return errAttachmentForegroundRevoked
996 }
997 return err
998}
999
1000// attachmentPredictionForeground is the optional predictive-echo seam of the
1001// real foreground. Scripted foregrounds may omit it; the worker then never
1002// predicts.
1003type attachmentPredictionForeground interface {
1004 writePrediction(data []byte) (bool, error)
1005}
1006
1007var _ attachmentPredictionForeground = (*attachmentForeground)(nil)
1008
1009// writePrediction writes and flushes predicted echo under the output lease.
1010// It reports false without writing while an overlay owns the terminal. The
1011// guesses are client-local drawing, so the UI observation channel is not
1012// involved.
1013func (f *attachmentForeground) writePrediction(data []byte) (bool, error) {
1014 if f == nil {
1015 return false, errAttachmentForegroundRevoked
1016 }
1017 var err error
1018 written := false
1019 ok := f.lease.send(func() bool {
1020 if !f.authority.actionAuthorized(f) {
1021 return false
1022 }
1023 if f.overlayKind() != attachmentOverlayNone {
1024 return true
1025 }
1026 if supervisorNil(f.term) || supervisorNil(f.term.Out()) {
1027 err = ports.ErrUIUnavailable
1028 return true
1029 }
1030 var n int
1031 n, err = f.term.Out().Write(data)
1032 if err == nil && n != len(data) {
1033 err = io.ErrShortWrite
1034 }
1035 if err == nil {
1036 err = f.term.Flush()
1037 }
1038 written = err == nil
1039 return true
1040 })
1041 if !ok {
1042 return false, errAttachmentForegroundRevoked
1043 }
1044 return written, err
1045}
1046
1047// attachmentRun is one in-flight worker generation owned by the supervisor.
1048type attachmentRun struct {
1049 host *attachmentHost
1050 token AttachmentToken
1051 stream ports.BrokerLogicalConnection
1052 fg *attachmentForeground
1053 cancel context.CancelFunc
1054 // outcome is capacity one so a worker that settles after the supervisor has
1055 // already moved on can never block on its own return.
1056 outcome chan AttachmentEvent
1057 // done closes when the worker goroutine has returned.
1058 done chan struct{}
1059 joinOnce sync.Once
1060 stateMu sync.Mutex
1061 retired chan struct{}
1062 isRetired bool
1063}
1064
1065// Wait joins the run. It returns the worker's typed event with adopted=true
1066// only when the worker settled on its own and the supervisor had not cancelled;
1067// a run that was cancelled or superseded is joined and its late event discarded
1068// (adopted=false). The caller's ctx stops waiting for an outcome; retirement
1069// then uses the separate bounded worker-join budget.
1070func (r *attachmentRun) Wait(ctx context.Context) (AttachmentEvent, bool) {
1071 if ctx == nil {
1072 ctx = context.Background()
1073 }
1074 select {
1075 case event := <-r.outcome:
1076 // Settlement and external retirement compete at one state boundary.
1077 adopted := r.retire() && ctx.Err() == nil && event.Token == r.token
1078 r.cleanup()
1079 if adopted {
1080 return event, true
1081 }
1082 case <-r.retired:
1083 r.cleanup()
1084 case <-ctx.Done():
1085 r.Cancel()
1086 }
1087 return AttachmentEvent{}, false
1088}
1089
1090// retire is authoritative even when outcome and cancellation are both ready.
1091func (r *attachmentRun) retire() bool {
1092 r.stateMu.Lock()
1093 defer r.stateMu.Unlock()
1094 if r.isRetired {
1095 return false
1096 }
1097 r.isRetired = true
1098 close(r.retired)
1099 return true
1100}
1101
1102// Cancel marks retirement, runs the deterministic final input window, and
1103// returns once the worker is joined or the join bound expires and the retained
1104// consumer and stream are released.
1105func (r *attachmentRun) Cancel() {
1106 r.retire()
1107 r.cleanup()
1108}
1109
1110// cleanup is the ordered teardown. It refuses further output/UI/resize/new input
1111// and closes Done, wakes the worker, then joins within the bound while the pump
1112// consumer stays authorized for Ack/Preserve, so a worker woken by Done can
1113// still save the read it holds. Only after the join (or its timeout) does it
1114// revoke the consumer and foreground slot, then close the owned stream.
1115func (r *attachmentRun) cleanup() {
1116 r.joinOnce.Do(func() {
1117 r.fg.finalize()
1118 r.cancel()
1119 r.join()
1120 r.fg.finish()
1121 _ = r.stream.Close()
1122 })
1123}
1124
1125// join waits for the worker goroutine within the shared join bound. It allocates
1126// a timer only when the worker has not already returned.
1127func (r *attachmentRun) join() {
1128 select {
1129 case <-r.done:
1130 return
1131 default:
1132 }
1133 timer := r.host.clock.NewTimer(attachmentWorkerJoinTimeout)
1134 if supervisorNil(timer) {
1135 timer = systemClock{}.NewTimer(attachmentWorkerJoinTimeout)
1136 }
1137 defer stopSupervisorTimer(timer)
1138 select {
1139 case <-r.done:
1140 case <-timer.C():
1141 }
1142}
1143
1144// attachmentForeground is the supervisor-granted implementation of
1145// AttachmentForeground. It reuses the existing terminal input pump for input
1146// and the existing foreground send lease for output serialization.
1147type attachmentForeground struct {
1148 authority *attachmentAuthority
1149 token AttachmentToken
1150 stream ports.BrokerLogicalConnection
1151 input *terminalInputPump
1152 consumer uint64
1153 lease *foregroundSendLease
1154 term ports.Terminal
1155 ui ports.UIOutputTransaction
1156 uiGeneration uint64
1157 resizes <-chan domain.Geometry
1158 actions <-chan AttachmentLifecycleAction
1159 host *attachmentHost
1160 done chan struct{}
1161 // finalizedOnce and finishedOnce split the single teardown into the
1162 // deterministic final input window: finalize closes Done and stops output
1163 // first, finish revokes the retained consumer only after the join.
1164 finalizedOnce sync.Once
1165 finishedOnce sync.Once
1166 // deliveryMu guards the at-most-once input decision. Input opens the window
1167 // by marking a freshly received delivery undecided; AckInput or PreserveInput
1168 // decides it at most once. decided starts true, so a decision with no
1169 // outstanding delivery is refused instead of silently committing nothing.
1170 deliveryMu sync.Mutex
1171 decided bool
1172 geometrySeq uint64
1173 attached atomic.Bool
1174 onAttached func(AttachmentToken)
1175
1176 // overlayMu guards the overlay slot and the committed target. The slot is
1177 // changed only inside the output lease, so an overlay never takes the
1178 // terminal while an attachment frame is mid-write.
1179 overlayMu sync.Mutex
1180 overlay attachmentOverlayKind
1181 overlaySink pickerInputConsumer
1182 committed protocol.ExactSessionTarget
1183 committedTab domain.TabStableID
1184 committedKnown bool
1185 // repaint wakes the worker to request an authoritative repaint once an
1186 // overlay released the terminal.
1187 repaint chan struct{}
1188 // tabSelect carries one pending in-place tab switch to the worker.
1189 tabSelect chan domain.TabStableID
1190 // routes carries the newest client route snapshot to the worker; replies
1191 // carries the supervisor's navigation failures.
1192 routes chan protocol.RecentRouteSnapshot
1193 replies chan protocol.ClientMessage
1194}
1195
1196// Token is the generation/attempt identity of this grant.
1197func (f *attachmentForeground) Token() AttachmentToken { return f.token }
1198
1199func (f *attachmentForeground) actionableGeneration() uint64 {
1200 if f.uiGeneration != 0 {
1201 return f.uiGeneration
1202 }
1203 return f.token.Generation
1204}
1205
1206func (f *attachmentForeground) uiPublished() {
1207 if f.uiGeneration == 0 || f.host == nil || f.host.actionUI == nil {
1208 return
1209 }
1210 f.host.actionUI.published(f.uiGeneration)
1211}
1212
1213func (f *attachmentForeground) uiReceipt(receipt protocol.UIReceipt) {
1214 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1215 f.host.actionUI.receipt(f.uiGeneration, receipt)
1216 }
1217}
1218
1219// uiFollowSamePeer keeps the driver action that caused a same-peer offer
1220// pending until the in-place destination commits.
1221func (f *attachmentForeground) uiFollowSamePeer(actionID uint64) {
1222 if actionID != 0 && f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1223 f.host.actionUI.follow(f.uiGeneration, actionID)
1224 }
1225}
1226
1227// uiSamePeerArrived runs before the destination's first output is published.
1228func (f *attachmentForeground) uiSamePeerArrived() {
1229 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1230 f.host.actionUI.arriveInPlace(f.uiGeneration)
1231 }
1232}
1233
1234// uiSamePeerFailed fails the followed action of a refused same-peer switch.
1235func (f *attachmentForeground) uiSamePeerFailed() {
1236 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1237 f.host.actionUI.failInPlace(f.uiGeneration)
1238 }
1239}
1240
1241// Stream is the supervisor-admitted logical stream this worker owns.
1242func (f *attachmentForeground) Stream() ports.BrokerLogicalConnection { return f.stream }
1243
1244// Done is closed when the supervisor starts finalizing this grant. It stops
1245// Input and Resize and wakes a worker so it can still Ack/Preserve the read it
1246// holds; the output gate is stopped at the same time.
1247func (f *attachmentForeground) Done() <-chan struct{} { return f.done }
1248
1249// finalize opens the final input window exactly once: it moves the grant into
1250// the finalizing authority state, stops the output gate (waiting for any
1251// in-flight output transaction to end), and closes Done so Input/Resize refuse
1252// new work and the worker wakes. The pump consumer and foreground slot stay
1253// owned, so the worker may still Ack/Preserve the read it holds.
1254// Lock order is authority -> send lease; beginFinalize releases authority before
1255// waiting on the lease, so output never observes authority while it holds the
1256// lease.
1257func (f *attachmentForeground) finalize() {
1258 if f == nil {
1259 return
1260 }
1261 f.finalizedOnce.Do(func() {
1262 f.authority.beginFinalize(f)
1263 if f.lease != nil {
1264 f.lease.stop()
1265 }
1266 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1267 f.host.actionUI.releaseForeground(f.uiGeneration)
1268 }
1269 close(f.done)
1270 })
1271}
1272
1273// finish revokes the retained consumer and clears the foreground slot after the
1274// bounded join. It runs before the stream is closed, so undecided input either
1275// reached the pump during finalization or is dropped with the revocation.
1276func (f *attachmentForeground) finish() {
1277 if f == nil {
1278 return
1279 }
1280 f.finishedOnce.Do(func() {
1281 // Finish is lossless: input kept for a resume must survive it. The
1282 // supervisor drops unclaimed bytes (dropUnclaimed) when the terminal
1283 // returns to the picker, the owner-class boundary no attachment byte
1284 // may cross.
1285 f.authority.revoke(f)
1286 })
1287}
1288
1289// beginDelivery opens the decision window for the delivery the worker just
1290// received: the next AckInput or PreserveInput decides it, and a further
1291// decision is refused until Input receives another delivery.
1292func (f *attachmentForeground) beginDelivery() {
1293 f.deliveryMu.Lock()
1294 f.decided = false
1295 f.deliveryMu.Unlock()
1296}
1297
1298// decideDelivery reports whether this is the first decision for the current
1299// delivery and marks it decided. A second Ack/Preserve for the same delivery,
1300// or a decision with no outstanding delivery, is refused.
1301func (f *attachmentForeground) decideDelivery() bool {
1302 f.deliveryMu.Lock()
1303 defer f.deliveryMu.Unlock()
1304 if f.decided {
1305 return false
1306 }
1307 f.decided = true
1308 return true
1309}
1310
1311// Input leases the next authorized terminal read. It waits on the pump's ready
1312// signal exactly like the attach scanner, so a shared single reader is never
1313// duplicated.
1314func (f *attachmentForeground) Input(ctx context.Context) (AttachmentInputEvent, bool) {
1315 if ctx == nil {
1316 ctx = context.Background()
1317 }
1318 for {
1319 if !f.authority.actionAuthorized(f) || f.input == nil {
1320 return AttachmentInputEvent{}, false
1321 }
1322 result, ok := f.input.take(ctx, f.consumer)
1323 if ok {
1324 f.beginDelivery()
1325 return AttachmentInputEvent{Data: result.data, Err: result.err, actionID: result.actionID}, true
1326 }
1327 if ctx.Err() != nil {
1328 return AttachmentInputEvent{}, false
1329 }
1330 select {
1331 case <-ctx.Done():
1332 return AttachmentInputEvent{}, false
1333 case <-f.done:
1334 return AttachmentInputEvent{}, false
1335 case request := <-f.input.automation:
1336 f.input.mu.Lock()
1337 clean := f.input.consumer == f.consumer && request.consumer == f.consumer && f.input.pending == nil && len(f.input.residual) == 0 && f.input.delivering == 0
1338 f.input.mu.Unlock()
1339 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)
1340 if clean && request.owner != nil {
1341 clean = request.owner.accept(request.record.actionID, request.record.generation)
1342 }
1343 request.admitted <- clean
1344 if !clean {
1345 continue
1346 }
1347 request.dispatched <- true
1348 return AttachmentInputEvent{Data: append([]byte(nil), request.record.data...), actionID: request.record.actionID}, true
1349 case <-f.input.readyFor(f.consumer):
1350 }
1351 }
1352}
1353
1354// AckInput commits the last delivery so the shared reader may publish the next.
1355// It decides that delivery, so a duplicate Ack/Preserve for it is a no-op.
1356func (f *attachmentForeground) AckInput() {
1357 if f == nil || f.input == nil {
1358 return
1359 }
1360 if !f.decideDelivery() {
1361 return
1362 }
1363 f.authority.retain(f, func() { f.input.ack(f.consumer) })
1364}
1365
1366// PreserveInput re-queues the undecided bytes of the last delivery for the next
1367// authorized attempt. It decides that delivery, so a duplicate Ack/Preserve for
1368// it is a no-op and can never re-queue different bytes. It stays authorized
1369// while the supervisor finalizes the grant.
1370func (f *attachmentForeground) PreserveInput(data []byte) {
1371 if f == nil || f.input == nil {
1372 return
1373 }
1374 if !f.decideDelivery() {
1375 return
1376 }
1377 f.authority.retain(f, func() {
1378 f.input.ack(f.consumer)
1379 f.input.preserveResidual(f.consumer, data)
1380 })
1381}
1382
1383// Resize waits for the next authorized terminal resize.
1384func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecycleActionKind, bool) {
1385 if ctx == nil {
1386 ctx = context.Background()
1387 }
1388 for {
1389 if !f.authority.actionAuthorized(f) {
1390 return 0, false
1391 }
1392 var internal <-chan AttachmentLifecycleAction
1393 if f.host != nil {
1394 internal = f.host.internalActions
1395 }
1396 select {
1397 case action, ok := <-f.actions:
1398 if !ok || !f.authority.actionAuthorized(f) {
1399 return 0, false
1400 }
1401 if action.Token != f.token {
1402 continue
1403 }
1404 return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit
1405 case action := <-internal:
1406 if action.Token != f.token || !f.authority.actionAuthorized(f) {
1407 continue
1408 }
1409 return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit
1410 case <-ctx.Done():
1411 return 0, false
1412 case <-f.done:
1413 return 0, false
1414 }
1415 }
1416}
1417
1418func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) {
1419 if ctx == nil {
1420 ctx = context.Background()
1421 }
1422 if !f.authority.actionAuthorized(f) || f.host == nil {
1423 return domain.Geometry{}, false
1424 }
1425 if !f.host.ownerBoundary {
1426 resizes := f.resizes
1427 for {
1428 select {
1429 case geometry, ok := <-resizes:
1430 if !ok {
1431 resizes = nil
1432 continue
1433 }
1434 if !f.authority.actionAuthorized(f) {
1435 return domain.Geometry{}, false
1436 }
1437 return geometry, true
1438 case <-ctx.Done():
1439 return domain.Geometry{}, false
1440 case <-f.done:
1441 return domain.Geometry{}, false
1442 }
1443 }
1444 }
1445 for {
1446 if geometry, next, ok := f.host.geometryAfter(f.geometrySeq); ok {
1447 f.geometrySeq = next
1448 if f.authority.actionAuthorized(f) {
1449 return geometry, true
1450 }
1451 return domain.Geometry{}, false
1452 }
1453 select {
1454 case <-ctx.Done():
1455 return domain.Geometry{}, false
1456 case <-f.done:
1457 return domain.Geometry{}, false
1458 case <-f.host.geometryUpdate:
1459 }
1460 }
1461}
1462
1463// Output holds the same send lease across the entire UI observation boundary.
1464// UIOutputTransaction is an observation channel, not attachment commit
1465// authority: an unavailable or closed observation channel does not stop the
1466// terminal frame. Other publication failures abort. Successful terminal write,
1467// flush, and EndOutput(true) commit the frame; any failed or short write or
1468// failed flush leaves EndOutput(false).
1469func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error {
1470 return f.write(uiContext, data, false)
1471}
1472
1473// write is the shared output transaction. While an overlay owns the terminal an
1474// attachment frame (overlay=false) is accepted without being written or
1475// published: the caller still applies and acknowledges it, and the overlay's
1476// release requests an authoritative repaint. Overlay frames bypass only that
1477// suppression.
1478func (f *attachmentForeground) write(uiContext ports.UIContext, data []byte, overlay bool) error {
1479 if f == nil {
1480 return errAttachmentForegroundRevoked
1481 }
1482 var outputErr error
1483 ok := f.lease.send(func() bool {
1484 if !f.authority.actionAuthorized(f) {
1485 return false
1486 }
1487 if !overlay && f.overlayKind() != attachmentOverlayNone {
1488 // Applied, not written: remember its committed boundary so a
1489 // daemon receipt fenced on it still settles its action.
1490 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1491 f.host.actionUI.suppressedOutput(f.uiGeneration, f.presentationContext(uiContext))
1492 }
1493 return true
1494 }
1495 if supervisorNil(f.term) {
1496 outputErr = ports.ErrUIUnavailable
1497 return true
1498 }
1499 writer := f.term.Out()
1500 if supervisorNil(writer) {
1501 outputErr = ports.ErrUIUnavailable
1502 return true
1503 }
1504 uiContext = f.presentationContext(uiContext)
1505 hasUI := !supervisorNil(f.ui)
1506 if hasUI {
1507 f.ui.BeginOutput(uiContext)
1508 }
1509 success := false
1510 defer func() {
1511 if hasUI {
1512 f.ui.EndOutput(success)
1513 if success {
1514 f.uiPublished()
1515 }
1516 }
1517 }()
1518 // An unavailable capture means the optional UI observation channel is
1519 // disabled or closed, so the terminal frame still writes and flushes.
1520 // Any other publication error aborts before the frame is written.
1521 if hasUI {
1522 if err := f.ui.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
1523 outputErr = err
1524 return true
1525 }
1526 }
1527 var n int
1528 n, outputErr = writer.Write(data)
1529 if outputErr == nil && n != len(data) {
1530 outputErr = io.ErrShortWrite
1531 }
1532 if outputErr != nil {
1533 return true
1534 }
1535 outputErr = f.term.Flush()
1536 success = outputErr == nil
1537 return true
1538 })
1539 if !ok {
1540 return errAttachmentForegroundRevoked
1541 }
1542 return outputErr
1543}
1544
1545// presentationContext applies the published-context shape rule to one foreground
1546// transaction. Only an Attached transaction carries the real actionable
1547// generation and the committed session metadata; any other presentation carries
1548// the run's stable UI handle and its status with every other field zeroed. A
1549// pre-attach Connecting frame therefore never publishes an actionable
1550// generation or session identity, and no caller can fence an action against a
1551// generation the attachment has not committed.
1552func (f *attachmentForeground) presentationContext(identity ports.UIContext) ports.UIContext {
1553 handle := identity.AttachmentHandle
1554 if f.host != nil && f.host.actionUI != nil {
1555 handle = f.host.actionUI.Handle()
1556 }
1557 if identity.Status != ports.UIStatusAttached {
1558 return ports.UIContext{AttachmentHandle: handle, Status: identity.Status}
1559 }
1560 identity.Generation = f.actionableGeneration()
1561 identity.AttachmentHandle = handle
1562 return identity
1563}
1564
1565// PublishAttached publishes the committed attached presentation. It takes the
1566// same send lease and the same authorization fence as Output, so it can never
1567// race a finalization or a revoked generation, and it writes no bytes: the
1568// attached presentation describes the frame that already drained. A closed UI
1569// observation channel is tolerated exactly as Output tolerates it, while any
1570// other publication failure is reported and leaves the run unattached.
1571func (f *attachmentForeground) PublishAttached(uiContext ports.UIContext) error {
1572 if f == nil {
1573 return errAttachmentForegroundRevoked
1574 }
1575 var outputErr error
1576 ok := f.lease.send(func() bool {
1577 if !f.authority.actionAuthorized(f) {
1578 return false
1579 }
1580 if supervisorNil(f.ui) {
1581 return true
1582 }
1583 uiContext = f.presentationContext(uiContext)
1584 f.ui.BeginOutput(uiContext)
1585 publishErr := f.ui.PublishContext(uiContext)
1586 if publishErr != nil && !errors.Is(publishErr, ports.ErrUIUnavailable) {
1587 outputErr = publishErr
1588 f.ui.EndOutput(false)
1589 return true
1590 }
1591 f.ui.EndOutput(true)
1592 f.uiPublished()
1593 return true
1594 })
1595 if !ok {
1596 return errAttachmentForegroundRevoked
1597 }
1598 return outputErr
1599}
1600
1601// MarkAttached records the attached transition atomically with respect to
1602// finalization and revocation.
1603func (f *attachmentForeground) MarkAttached() bool {
1604 if f == nil {
1605 return false
1606 }
1607 set := f.authority.act(f, func() { f.attached.Store(true) })
1608 if !set {
1609 return false
1610 }
1611 if f.onAttached != nil {
1612 f.onAttached(f.token)
1613 }
1614 return true
1615}
1616
1617// Attached reports whether MarkAttached has succeeded.
1618func (f *attachmentForeground) Attached() bool {
1619 return f != nil && f.attached.Load()
1620}