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// attachmentRun is one in-flight worker generation owned by the supervisor.
1001type attachmentRun struct {
1002 host *attachmentHost
1003 token AttachmentToken
1004 stream ports.BrokerLogicalConnection
1005 fg *attachmentForeground
1006 cancel context.CancelFunc
1007 // outcome is capacity one so a worker that settles after the supervisor has
1008 // already moved on can never block on its own return.
1009 outcome chan AttachmentEvent
1010 // done closes when the worker goroutine has returned.
1011 done chan struct{}
1012 joinOnce sync.Once
1013 stateMu sync.Mutex
1014 retired chan struct{}
1015 isRetired bool
1016}
1017
1018// Wait joins the run. It returns the worker's typed event with adopted=true
1019// only when the worker settled on its own and the supervisor had not cancelled;
1020// a run that was cancelled or superseded is joined and its late event discarded
1021// (adopted=false). The caller's ctx stops waiting for an outcome; retirement
1022// then uses the separate bounded worker-join budget.
1023func (r *attachmentRun) Wait(ctx context.Context) (AttachmentEvent, bool) {
1024 if ctx == nil {
1025 ctx = context.Background()
1026 }
1027 select {
1028 case event := <-r.outcome:
1029 // Settlement and external retirement compete at one state boundary.
1030 adopted := r.retire() && ctx.Err() == nil && event.Token == r.token
1031 r.cleanup()
1032 if adopted {
1033 return event, true
1034 }
1035 case <-r.retired:
1036 r.cleanup()
1037 case <-ctx.Done():
1038 r.Cancel()
1039 }
1040 return AttachmentEvent{}, false
1041}
1042
1043// retire is authoritative even when outcome and cancellation are both ready.
1044func (r *attachmentRun) retire() bool {
1045 r.stateMu.Lock()
1046 defer r.stateMu.Unlock()
1047 if r.isRetired {
1048 return false
1049 }
1050 r.isRetired = true
1051 close(r.retired)
1052 return true
1053}
1054
1055// Cancel marks retirement, runs the deterministic final input window, and
1056// returns once the worker is joined or the join bound expires and the retained
1057// consumer and stream are released.
1058func (r *attachmentRun) Cancel() {
1059 r.retire()
1060 r.cleanup()
1061}
1062
1063// cleanup is the ordered teardown. It refuses further output/UI/resize/new input
1064// and closes Done, wakes the worker, then joins within the bound while the pump
1065// consumer stays authorized for Ack/Preserve, so a worker woken by Done can
1066// still save the read it holds. Only after the join (or its timeout) does it
1067// revoke the consumer and foreground slot, then close the owned stream.
1068func (r *attachmentRun) cleanup() {
1069 r.joinOnce.Do(func() {
1070 r.fg.finalize()
1071 r.cancel()
1072 r.join()
1073 r.fg.finish()
1074 _ = r.stream.Close()
1075 })
1076}
1077
1078// join waits for the worker goroutine within the shared join bound. It allocates
1079// a timer only when the worker has not already returned.
1080func (r *attachmentRun) join() {
1081 select {
1082 case <-r.done:
1083 return
1084 default:
1085 }
1086 timer := r.host.clock.NewTimer(attachmentWorkerJoinTimeout)
1087 if supervisorNil(timer) {
1088 timer = systemClock{}.NewTimer(attachmentWorkerJoinTimeout)
1089 }
1090 defer stopSupervisorTimer(timer)
1091 select {
1092 case <-r.done:
1093 case <-timer.C():
1094 }
1095}
1096
1097// attachmentForeground is the supervisor-granted implementation of
1098// AttachmentForeground. It reuses the existing terminal input pump for input
1099// and the existing foreground send lease for output serialization.
1100type attachmentForeground struct {
1101 authority *attachmentAuthority
1102 token AttachmentToken
1103 stream ports.BrokerLogicalConnection
1104 input *terminalInputPump
1105 consumer uint64
1106 lease *foregroundSendLease
1107 term ports.Terminal
1108 ui ports.UIOutputTransaction
1109 uiGeneration uint64
1110 resizes <-chan domain.Geometry
1111 actions <-chan AttachmentLifecycleAction
1112 host *attachmentHost
1113 done chan struct{}
1114 // finalizedOnce and finishedOnce split the single teardown into the
1115 // deterministic final input window: finalize closes Done and stops output
1116 // first, finish revokes the retained consumer only after the join.
1117 finalizedOnce sync.Once
1118 finishedOnce sync.Once
1119 // deliveryMu guards the at-most-once input decision. Input opens the window
1120 // by marking a freshly received delivery undecided; AckInput or PreserveInput
1121 // decides it at most once. decided starts true, so a decision with no
1122 // outstanding delivery is refused instead of silently committing nothing.
1123 deliveryMu sync.Mutex
1124 decided bool
1125 geometrySeq uint64
1126 attached atomic.Bool
1127 onAttached func(AttachmentToken)
1128
1129 // overlayMu guards the overlay slot and the committed target. The slot is
1130 // changed only inside the output lease, so an overlay never takes the
1131 // terminal while an attachment frame is mid-write.
1132 overlayMu sync.Mutex
1133 overlay attachmentOverlayKind
1134 overlaySink pickerInputConsumer
1135 committed protocol.ExactSessionTarget
1136 committedTab domain.TabStableID
1137 committedKnown bool
1138 // repaint wakes the worker to request an authoritative repaint once an
1139 // overlay released the terminal.
1140 repaint chan struct{}
1141 // tabSelect carries one pending in-place tab switch to the worker.
1142 tabSelect chan domain.TabStableID
1143 // routes carries the newest client route snapshot to the worker; replies
1144 // carries the supervisor's navigation failures.
1145 routes chan protocol.RecentRouteSnapshot
1146 replies chan protocol.ClientMessage
1147}
1148
1149// Token is the generation/attempt identity of this grant.
1150func (f *attachmentForeground) Token() AttachmentToken { return f.token }
1151
1152func (f *attachmentForeground) actionableGeneration() uint64 {
1153 if f.uiGeneration != 0 {
1154 return f.uiGeneration
1155 }
1156 return f.token.Generation
1157}
1158
1159func (f *attachmentForeground) uiPublished() {
1160 if f.uiGeneration == 0 || f.host == nil || f.host.actionUI == nil {
1161 return
1162 }
1163 f.host.actionUI.published(f.uiGeneration)
1164}
1165
1166func (f *attachmentForeground) uiReceipt(receipt protocol.UIReceipt) {
1167 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1168 f.host.actionUI.receipt(f.uiGeneration, receipt)
1169 }
1170}
1171
1172// uiFollowSamePeer keeps the driver action that caused a same-peer offer
1173// pending until the in-place destination commits.
1174func (f *attachmentForeground) uiFollowSamePeer(actionID uint64) {
1175 if actionID != 0 && f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1176 f.host.actionUI.follow(f.uiGeneration, actionID)
1177 }
1178}
1179
1180// uiSamePeerArrived runs before the destination's first output is published.
1181func (f *attachmentForeground) uiSamePeerArrived() {
1182 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1183 f.host.actionUI.arriveInPlace(f.uiGeneration)
1184 }
1185}
1186
1187// uiSamePeerFailed fails the followed action of a refused same-peer switch.
1188func (f *attachmentForeground) uiSamePeerFailed() {
1189 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1190 f.host.actionUI.failInPlace(f.uiGeneration)
1191 }
1192}
1193
1194// Stream is the supervisor-admitted logical stream this worker owns.
1195func (f *attachmentForeground) Stream() ports.BrokerLogicalConnection { return f.stream }
1196
1197// Done is closed when the supervisor starts finalizing this grant. It stops
1198// Input and Resize and wakes a worker so it can still Ack/Preserve the read it
1199// holds; the output gate is stopped at the same time.
1200func (f *attachmentForeground) Done() <-chan struct{} { return f.done }
1201
1202// finalize opens the final input window exactly once: it moves the grant into
1203// the finalizing authority state, stops the output gate (waiting for any
1204// in-flight output transaction to end), and closes Done so Input/Resize refuse
1205// new work and the worker wakes. The pump consumer and foreground slot stay
1206// owned, so the worker may still Ack/Preserve the read it holds.
1207// Lock order is authority -> send lease; beginFinalize releases authority before
1208// waiting on the lease, so output never observes authority while it holds the
1209// lease.
1210func (f *attachmentForeground) finalize() {
1211 if f == nil {
1212 return
1213 }
1214 f.finalizedOnce.Do(func() {
1215 f.authority.beginFinalize(f)
1216 if f.lease != nil {
1217 f.lease.stop()
1218 }
1219 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1220 f.host.actionUI.releaseForeground(f.uiGeneration)
1221 }
1222 close(f.done)
1223 })
1224}
1225
1226// finish revokes the retained consumer and clears the foreground slot after the
1227// bounded join. It runs before the stream is closed, so undecided input either
1228// reached the pump during finalization or is dropped with the revocation.
1229func (f *attachmentForeground) finish() {
1230 if f == nil {
1231 return
1232 }
1233 f.finishedOnce.Do(func() {
1234 // Autonomous supervisor foregrounds have a host-lifetime geometry
1235 // collector. At that owner-class boundary, no attachment byte may reach
1236 // the picker. Attachment-to-attachment replacement instead delivers pending input.
1237 if f.input != nil && f.host != nil && f.host.ownerBoundary {
1238 f.input.dropOwned(f.consumer)
1239 }
1240 f.authority.revoke(f)
1241 })
1242}
1243
1244// beginDelivery opens the decision window for the delivery the worker just
1245// received: the next AckInput or PreserveInput decides it, and a further
1246// decision is refused until Input receives another delivery.
1247func (f *attachmentForeground) beginDelivery() {
1248 f.deliveryMu.Lock()
1249 f.decided = false
1250 f.deliveryMu.Unlock()
1251}
1252
1253// decideDelivery reports whether this is the first decision for the current
1254// delivery and marks it decided. A second Ack/Preserve for the same delivery,
1255// or a decision with no outstanding delivery, is refused.
1256func (f *attachmentForeground) decideDelivery() bool {
1257 f.deliveryMu.Lock()
1258 defer f.deliveryMu.Unlock()
1259 if f.decided {
1260 return false
1261 }
1262 f.decided = true
1263 return true
1264}
1265
1266// Input leases the next authorized terminal read. It waits on the pump's ready
1267// signal exactly like the attach scanner, so a shared single reader is never
1268// duplicated.
1269func (f *attachmentForeground) Input(ctx context.Context) (AttachmentInputEvent, bool) {
1270 if ctx == nil {
1271 ctx = context.Background()
1272 }
1273 for {
1274 if !f.authority.actionAuthorized(f) || f.input == nil {
1275 return AttachmentInputEvent{}, false
1276 }
1277 result, ok := f.input.take(ctx, f.consumer)
1278 if ok {
1279 f.beginDelivery()
1280 return AttachmentInputEvent{Data: result.data, Err: result.err, actionID: result.actionID}, true
1281 }
1282 if ctx.Err() != nil {
1283 return AttachmentInputEvent{}, false
1284 }
1285 select {
1286 case <-ctx.Done():
1287 return AttachmentInputEvent{}, false
1288 case <-f.done:
1289 return AttachmentInputEvent{}, false
1290 case request := <-f.input.automation:
1291 f.input.mu.Lock()
1292 clean := f.input.consumer == f.consumer && request.consumer == f.consumer && f.input.pending == nil && len(f.input.residual) == 0 && f.input.delivering == 0
1293 f.input.mu.Unlock()
1294 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)
1295 if clean && request.owner != nil {
1296 clean = request.owner.accept(request.record.actionID, request.record.generation)
1297 }
1298 request.admitted <- clean
1299 if !clean {
1300 continue
1301 }
1302 request.dispatched <- true
1303 return AttachmentInputEvent{Data: append([]byte(nil), request.record.data...), actionID: request.record.actionID}, true
1304 case <-f.input.readyFor(f.consumer):
1305 }
1306 }
1307}
1308
1309// AckInput commits the last delivery so the shared reader may publish the next.
1310// It decides that delivery, so a duplicate Ack/Preserve for it is a no-op.
1311func (f *attachmentForeground) AckInput() {
1312 if f == nil || f.input == nil {
1313 return
1314 }
1315 if !f.decideDelivery() {
1316 return
1317 }
1318 f.authority.retain(f, func() { f.input.ack(f.consumer) })
1319}
1320
1321// PreserveInput re-queues the undecided bytes of the last delivery for the next
1322// authorized attempt. It decides that delivery, so a duplicate Ack/Preserve for
1323// it is a no-op and can never re-queue different bytes. It stays authorized
1324// while the supervisor finalizes the grant.
1325func (f *attachmentForeground) PreserveInput(data []byte) {
1326 if f == nil || f.input == nil {
1327 return
1328 }
1329 if !f.decideDelivery() {
1330 return
1331 }
1332 f.authority.retain(f, func() {
1333 f.input.ack(f.consumer)
1334 f.input.preserveResidual(f.consumer, data)
1335 })
1336}
1337
1338// Resize waits for the next authorized terminal resize.
1339func (f *attachmentForeground) Lifecycle(ctx context.Context) (AttachmentLifecycleActionKind, bool) {
1340 if ctx == nil {
1341 ctx = context.Background()
1342 }
1343 for {
1344 if !f.authority.actionAuthorized(f) {
1345 return 0, false
1346 }
1347 var internal <-chan AttachmentLifecycleAction
1348 if f.host != nil {
1349 internal = f.host.internalActions
1350 }
1351 select {
1352 case action, ok := <-f.actions:
1353 if !ok || !f.authority.actionAuthorized(f) {
1354 return 0, false
1355 }
1356 if action.Token != f.token {
1357 continue
1358 }
1359 return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit
1360 case action := <-internal:
1361 if action.Token != f.token || !f.authority.actionAuthorized(f) {
1362 continue
1363 }
1364 return action.Kind, action.Kind == AttachmentDetachToPicker || action.Kind == AttachmentDetachAndExit
1365 case <-ctx.Done():
1366 return 0, false
1367 case <-f.done:
1368 return 0, false
1369 }
1370 }
1371}
1372
1373func (f *attachmentForeground) Resize(ctx context.Context) (domain.Geometry, bool) {
1374 if ctx == nil {
1375 ctx = context.Background()
1376 }
1377 if !f.authority.actionAuthorized(f) || f.host == nil {
1378 return domain.Geometry{}, false
1379 }
1380 if !f.host.ownerBoundary {
1381 resizes := f.resizes
1382 for {
1383 select {
1384 case geometry, ok := <-resizes:
1385 if !ok {
1386 resizes = nil
1387 continue
1388 }
1389 if !f.authority.actionAuthorized(f) {
1390 return domain.Geometry{}, false
1391 }
1392 return geometry, true
1393 case <-ctx.Done():
1394 return domain.Geometry{}, false
1395 case <-f.done:
1396 return domain.Geometry{}, false
1397 }
1398 }
1399 }
1400 for {
1401 if geometry, next, ok := f.host.geometryAfter(f.geometrySeq); ok {
1402 f.geometrySeq = next
1403 if f.authority.actionAuthorized(f) {
1404 return geometry, true
1405 }
1406 return domain.Geometry{}, false
1407 }
1408 select {
1409 case <-ctx.Done():
1410 return domain.Geometry{}, false
1411 case <-f.done:
1412 return domain.Geometry{}, false
1413 case <-f.host.geometryUpdate:
1414 }
1415 }
1416}
1417
1418// Output holds the same send lease across the entire UI observation boundary.
1419// UIOutputTransaction is an observation channel, not attachment commit
1420// authority: an unavailable or closed observation channel does not stop the
1421// terminal frame. Other publication failures abort. Successful terminal write,
1422// flush, and EndOutput(true) commit the frame; any failed or short write or
1423// failed flush leaves EndOutput(false).
1424func (f *attachmentForeground) Output(uiContext ports.UIContext, data []byte) error {
1425 return f.write(uiContext, data, false)
1426}
1427
1428// write is the shared output transaction. While an overlay owns the terminal an
1429// attachment frame (overlay=false) is accepted without being written or
1430// published: the caller still applies and acknowledges it, and the overlay's
1431// release requests an authoritative repaint. Overlay frames bypass only that
1432// suppression.
1433func (f *attachmentForeground) write(uiContext ports.UIContext, data []byte, overlay bool) error {
1434 if f == nil {
1435 return errAttachmentForegroundRevoked
1436 }
1437 var outputErr error
1438 ok := f.lease.send(func() bool {
1439 if !f.authority.actionAuthorized(f) {
1440 return false
1441 }
1442 if !overlay && f.overlayKind() != attachmentOverlayNone {
1443 // Applied, not written: remember its committed boundary so a
1444 // daemon receipt fenced on it still settles its action.
1445 if f.uiGeneration != 0 && f.host != nil && f.host.actionUI != nil {
1446 f.host.actionUI.suppressedOutput(f.uiGeneration, f.presentationContext(uiContext))
1447 }
1448 return true
1449 }
1450 if supervisorNil(f.term) {
1451 outputErr = ports.ErrUIUnavailable
1452 return true
1453 }
1454 writer := f.term.Out()
1455 if supervisorNil(writer) {
1456 outputErr = ports.ErrUIUnavailable
1457 return true
1458 }
1459 uiContext = f.presentationContext(uiContext)
1460 hasUI := !supervisorNil(f.ui)
1461 if hasUI {
1462 f.ui.BeginOutput(uiContext)
1463 }
1464 success := false
1465 defer func() {
1466 if hasUI {
1467 f.ui.EndOutput(success)
1468 if success {
1469 f.uiPublished()
1470 }
1471 }
1472 }()
1473 // An unavailable capture means the optional UI observation channel is
1474 // disabled or closed, so the terminal frame still writes and flushes.
1475 // Any other publication error aborts before the frame is written.
1476 if hasUI {
1477 if err := f.ui.PublishContext(uiContext); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
1478 outputErr = err
1479 return true
1480 }
1481 }
1482 var n int
1483 n, outputErr = writer.Write(data)
1484 if outputErr == nil && n != len(data) {
1485 outputErr = io.ErrShortWrite
1486 }
1487 if outputErr != nil {
1488 return true
1489 }
1490 outputErr = f.term.Flush()
1491 success = outputErr == nil
1492 return true
1493 })
1494 if !ok {
1495 return errAttachmentForegroundRevoked
1496 }
1497 return outputErr
1498}
1499
1500// presentationContext applies the published-context shape rule to one foreground
1501// transaction. Only an Attached transaction carries the real actionable
1502// generation and the committed session metadata; any other presentation carries
1503// the run's stable UI handle and its status with every other field zeroed. A
1504// pre-attach Connecting frame therefore never publishes an actionable
1505// generation or session identity, and no caller can fence an action against a
1506// generation the attachment has not committed.
1507func (f *attachmentForeground) presentationContext(identity ports.UIContext) ports.UIContext {
1508 handle := identity.AttachmentHandle
1509 if f.host != nil && f.host.actionUI != nil {
1510 handle = f.host.actionUI.Handle()
1511 }
1512 if identity.Status != ports.UIStatusAttached {
1513 return ports.UIContext{AttachmentHandle: handle, Status: identity.Status}
1514 }
1515 identity.Generation = f.actionableGeneration()
1516 identity.AttachmentHandle = handle
1517 return identity
1518}
1519
1520// PublishAttached publishes the committed attached presentation. It takes the
1521// same send lease and the same authorization fence as Output, so it can never
1522// race a finalization or a revoked generation, and it writes no bytes: the
1523// attached presentation describes the frame that already drained. A closed UI
1524// observation channel is tolerated exactly as Output tolerates it, while any
1525// other publication failure is reported and leaves the run unattached.
1526func (f *attachmentForeground) PublishAttached(uiContext ports.UIContext) error {
1527 if f == nil {
1528 return errAttachmentForegroundRevoked
1529 }
1530 var outputErr error
1531 ok := f.lease.send(func() bool {
1532 if !f.authority.actionAuthorized(f) {
1533 return false
1534 }
1535 if supervisorNil(f.ui) {
1536 return true
1537 }
1538 uiContext = f.presentationContext(uiContext)
1539 f.ui.BeginOutput(uiContext)
1540 publishErr := f.ui.PublishContext(uiContext)
1541 if publishErr != nil && !errors.Is(publishErr, ports.ErrUIUnavailable) {
1542 outputErr = publishErr
1543 f.ui.EndOutput(false)
1544 return true
1545 }
1546 f.ui.EndOutput(true)
1547 f.uiPublished()
1548 return true
1549 })
1550 if !ok {
1551 return errAttachmentForegroundRevoked
1552 }
1553 return outputErr
1554}
1555
1556// MarkAttached records the attached transition atomically with respect to
1557// finalization and revocation.
1558func (f *attachmentForeground) MarkAttached() bool {
1559 if f == nil {
1560 return false
1561 }
1562 set := f.authority.act(f, func() { f.attached.Store(true) })
1563 if !set {
1564 return false
1565 }
1566 if f.onAttached != nil {
1567 f.onAttached(f.token)
1568 }
1569 return true
1570}
1571
1572// Attached reports whether MarkAttached has succeeded.
1573func (f *attachmentForeground) Attached() bool {
1574 return f != nil && f.attached.Load()
1575}