attachment_worker.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close