v0.7.0 4c16dd8

daemon.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5	"context"
6	cryptorand "crypto/rand"
7	"encoding/binary"
8	"errors"
9	"log/slog"
10	"math"
11	"os"
12	"reflect"
13	"sort"
14	"sync"
15	"sync/atomic"
16	"time"
17
18	"github.com/bnema/vev/internal/domain"
19	"github.com/bnema/vev/internal/domain/terminalcap"
20	"github.com/bnema/vev/internal/ports"
21	"github.com/bnema/vev/internal/protocol"
22	"github.com/bnema/vev/internal/usecase/keys"
23	recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
24)
25
26// Scheduler debounce bounds. Idle updates use the minimum for low latency;
27// sustained floods step toward the maximum to reduce frame/syscall pressure.
28const (
29	minDebounceInterval   = 2 * time.Millisecond
30	maxSyncUpdateDuration = 500 * time.Millisecond
31)
32
33const debounceInterval = minDebounceInterval
34
35// ptyReadBufSize is the PTY reader's read buffer.
36const ptyReadBufSize = 32 * 1024
37
38// New and restored panes have independent line and uncompressed logical-byte
39// ceilings. Visible primary and alternate screens are outside this budget.
40const (
41	defaultScrollbackRows  = domain.DefaultScrollbackLines
42	defaultScrollbackBytes = domain.DefaultScrollbackMegabytes * 1_000_000
43)
44
45// detachNotifyTimeout bounds the best-effort Detached notification on the
46// detach/kill/shutdown paths: if a wedged client (full kernel send buffer)
47// blocks the write for this long, its transport is force-closed, which fails
48// the in-flight send. Teardown is never gated on a client draining its socket.
49const detachNotifyTimeout = time.Second
50
51// maxUnackedOutputStates caps how many output states may be in flight (sent but
52// not yet acked by the client) before paint defers rather than composing
53// another diff. It bounds the daemon's paint rate to the client's ack rate, so
54// heavy output degrades to lower fps on a slow link instead of overflowing the
55// transport. It must stay well under the datagram carriage's 32-frame client
56// window so its reliable queue never fills from painting alone.
57const maxUnackedOutputStates = protocol.MaxOutputWindow
58
59// normalizeOutputWindow bounds the untrusted Hello value. Zero deliberately
60// means the legacy/default window, so malformed or absent values remain safe.
61func normalizeOutputWindow(window uint8) uint8 {
62	if window == 0 || window > maxUnackedOutputStates {
63		return maxUnackedOutputStates
64	}
65	return window
66}
67
68const defaultResumeParkGrace = 15 * time.Minute
69
70// defaultSuspendedSafetyExpiry bounds how long the daemon retains a suspended
71// attachment. Suspension keeps transport ownership and session membership but
72// removes interactive authority, so a client that never returns must not keep
73// the underlying process alive indefinitely. Expiry is a terminal, no-notice
74// eviction that never mints a resume credential.
75const defaultSuspendedSafetyExpiry = 24 * time.Hour
76
77// defaultSize is retained for headless layout helpers that have no client
78// window; Hello routing rejects invalid dimensions instead of using it.
79var defaultSize = domain.Size{Cols: 80, Rows: 24}
80
81type Daemon struct {
82	mu       sync.Mutex
83	sessions map[domain.SessionID]*session
84	inactive map[string]inactiveSession
85	// creating reserves names while durable creation I/O runs without mu.
86	creating map[string]struct{}
87	nextID   uint64
88	// lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
89	// mark. It is guarded by mu and prevents a wall-clock regression from
90	// reusing a lifecycle identity.
91	lastAllocatedCreatedAt int64
92	mruSeq                 atomic.Uint64
93	creationRequestSeq     atomic.Uint64
94	// closing marks that explicit daemon shutdown (KillDaemon/process
95	// cancellation) has irreversibly begun. It is set under mu when
96	// shutdownAll starts, and checked by route under the same mutex — so a Hello
97	// racing shutdown can never insert a new session that nobody would tear
98	// down. Neither an empty registry (final session removal) nor a KillAll
99	// purge sets it: the daemon survives empty→occupied→empty and survives a
100	// purge, and only an explicit stop/process cancellation ends it.
101	closing bool
102
103	// moveLifecycleMu is the daemon-level admission gate for transferable
104	// ownership changes. It is acquired before any session teardownMu, and is
105	// never held while a move, teardown, or external operation runs.
106	moveLifecycleMu      sync.Mutex
107	moveLifecycleClosing bool
108	moveLifecycleActive  uint
109	moveLifecycleChanged chan struct{}
110	// paneProcessCtx roots transferable pane processes outside any one session.
111	// Shutdown cancels it only after the global move gate drains.
112	paneProcessCtx    context.Context
113	paneProcessCancel context.CancelFunc
114
115	// purgeAllMu grants one daemon-wide purge (KillAll) owner at a time so two
116	// concurrent kill-all requests never interleave their exact lifecycle sets.
117	// It is not an admission gate; see purgeAdmissionClosing below.
118	purgeAllMu sync.Mutex
119	// purgeAdmissionClosing is the transient KillAll admission gate. Unlike
120	// closing and moveLifecycleClosing it is re-openable: purgeAllSessions sets
121	// it under mu, removes exactly the live and stopped/broken/durable records
122	// captured at that instant, then clears it so the daemon keeps serving.
123	// Creation and restoration wait on the changed channel (a blocked writer
124	// keeps new admissions out), while moves and internal creations reject, so
125	// no transition can be inserted into the purge's exact lifecycle set.
126	// purgeAdmissionActive counts admitted transitions the purge must drain.
127	// purgeEpoch increments once at each purge that acquires the gate (after the
128	// drain) so a restoration worker blocked on admission can detect a purge
129	// that completed while it waited and must not resurrect the records captured
130	// before that purge.
131	// All three fields are guarded by mu.
132	purgeAdmissionClosing bool
133	purgeAdmissionActive  uint
134	purgeAdmissionChanged chan struct{}
135	purgeEpoch            uint64
136
137	// notifies holds one completion channel per in-flight async Detached
138	// notification (guarded by mu, pruned on insert). Channels rather than a
139	// WaitGroup: notifications are spawned from arbitrary goroutines while
140	// Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent
141	// with Wait.
142	notifies []chan struct{}
143	// resume owns parked and in-flight parking resume credentials. Guarded by mu.
144	resume resumeCredentials
145	// suspended retains one daemon-owned safety expiry per suspended
146	// attachment. Expiry is exact: the record carries the suspension's
147	// lifecycle generation and transport incarnation, so a later activation,
148	// re-suspension, detach, or session teardown can never be retired by a
149	// stale timer. Guarded by mu.
150	suspended map[*attachedClient]*suspendedAttachmentRetention
151	// graphicsNamespaces reserves deterministic, attachment/session-scoped Kitty
152	// ID blocks. Once a block may have reached an outer terminal it remains in
153	// this bounded table for the daemon lifetime: side-effect Output frames have
154	// no terminal ACK. Pool exhaustion disables graphics for new attachments and
155	// leaves their ordinary text output intact.
156	graphicsNamespaces           map[uint64]struct{}
157	graphicsNamespaceFences      map[uint64]uint64
158	graphicsNamespaceQuarantines map[uint64]*graphicsNamespaceQuarantine
159	// graphicsNamespaceSalt separates daemon lifetimes before attachment keys
160	// are hashed. Kitty IDs are terminal-global, so a restarted or neighboring
161	// daemon must not deterministically reopen the previous daemon's block.
162	graphicsNamespaceSalt uint64
163
164	attnMu    sync.Mutex
165	animFrame int
166	animWake  chan struct{}
167
168	paletteRecentMu sync.Mutex
169	paletteRecent   []string
170	// daemonTestHooks holds deterministic test seams; production leaves every
171	// hook nil.
172	daemonTestHooks
173	ptys                     ports.PTYFactory
174	clock                    ports.Clock
175	log                      *slog.Logger
176	runtimeObserver          ports.RuntimeObserver
177	baseEnv                  []string
178	shell                    string
179	shellArgs                []string
180	shellOverride            bool
181	persistEnabled           bool
182	catalogue                ports.Catalogue
183	catalogueRecords         []domain.CatalogueRecord
184	catalogueRecordsProvided bool
185	// snapshotRepository is the sole checkpoint storage contract.
186	snapshotRepository        ports.SnapshotRepository
187	recovery                  *recoveryusecase.Coordinator
188	snapshotGarbageCollection bool
189	maintenanceWorkerCancel   context.CancelFunc
190	maintenanceWorkerDone     chan struct{}
191	// restoreWorkerDone is the restoration goroutine's ownership signal. Startup
192	// restoration reconciles durable checkpoints, so it is a durable writer and
193	// is guarded by snapshotWorkerMu with the other two.
194	restoreWorkerDone chan struct{}
195	snapsEnabled      bool
196	noticeStore       ports.NoticeStore
197	snapshotJobs      chan *snapshotCapture
198	// snapshotAdmitted contains every capture accepted by either worker queue,
199	// including captures buffered in snapshotJobs. Guarded by snapshotWorkerMu.
200	snapshotAdmitted map[*snapshotCapture]struct{}
201	// snapshotWake wakes the repository scheduler when a session becomes dirty
202	// or an attempt completes. It is never closed and producers only send
203	// non-blockingly.
204	snapshotWake            chan struct{}
205	snapshotWorkerMu        sync.Mutex
206	snapshotWorkerID        uint64
207	snapshotWorkerCtx       context.Context
208	snapshotWorkerCancel    context.CancelFunc
209	snapshotWorkerDone      chan struct{}
210	snapshotWorkerFlush     chan struct{}
211	snapshotWorkerFinalWake chan struct{}
212	// snapshotFinalJobs coalesces terminal captures by session when the bounded
213	// regular queue is full. It retains at most snapshotFinalQueueCapacity named
214	// sessions, each with only its newest terminal state while the worker blocks.
215	snapshotFinalJobs      map[*session]*snapshotCapture
216	snapshotFinalOrder     []*session
217	snapshotWorkerClosing  bool
218	snapshotWorkerInFlight *snapshotCapture
219	// snapshotNoticeMu guards the active global persistence failure signature.
220	// It is separate from snapshotWorkerMu so notice routing cannot block a
221	// producer or a repository worker.
222	snapshotNoticeMu               sync.Mutex
223	snapshotActiveFailureSignature string
224	shutdownNoticeMu               sync.Mutex
225	shutdownNoticedSessions        map[string]struct{}
226	restoreDone                    chan struct{}
227	restoreOnce                    sync.Once
228	procCwd                        func(int) (string, error)
229	procComm                       func(int) (string, error)
230	procArgv                       func(int) ([]string, error)
231	procGroupArgv                  func(int, int) ([]string, error)
232	dirOrHome                      func(string) string
233	bindings                       atomic.Pointer[keys.Bindings]
234	codeOverrides                  atomic.Pointer[map[string]string]
235	restoreProcessAllowlist        atomic.Pointer[map[string]struct{}]
236	floatingConfig                 atomic.Pointer[domain.FloatingConfig]
237	copyConfig                     atomic.Pointer[domain.CopyConfig]
238	paletteConfig                  atomic.Pointer[domain.PaletteConfig]
239	navConfig                      atomic.Pointer[domain.NavConfig]
240	tabsConfig                     atomic.Pointer[domain.TabsConfig]
241	ephemeralConfig                atomic.Pointer[domain.EphemeralConfig]
242	scrollbackConfig               atomic.Pointer[domain.ScrollbackConfig]
243	themeConfig                    atomic.Pointer[themeConfigSnapshot]
244	barScripts                     *barScriptState
245	notices                        *noticeCenter
246	resumeParkGrace                time.Duration
247	suspendedSafetyExpiry          time.Duration
248	// tempDir overrides os.TempDir() for clipboard-image-transfer writes
249	// (see clipboard.go); empty means use os.TempDir().
250	tempDir string
251
252	serveCtx    context.Context
253	serveCancel context.CancelFunc
254
255	// hardCtx force-closes connection transports on shutdown, but only after
256	// shutdownAll has delivered graceful Detached notices — keeping it separate
257	// from serveCtx so a parent-context cancel never races the notice.
258	hardCtx    context.Context
259	hardCancel context.CancelFunc
260
261	// done is closed exactly once by an explicit daemon stop (shutdownAll) to
262	// break Serve's accept loop. It is deliberately not closed by final session
263	// removal or by a KillAll purge: an empty registry and a purged registry
264	// both keep the daemon serving.
265	done     chan struct{}
266	doneOnce sync.Once
267
268	// sessWg owns attention animation, bar-script polling, CWD sampling,
269	// snapshot save/restore, and floating-session launch goroutines.
270	sessWg sync.WaitGroup
271	connWg sync.WaitGroup // per-connection handler goroutines
272	// attachmentCleanupWg owns replacement transport closes and retired render
273	// worker joins. Only connection handlers add work; Serve waits for those
274	// handlers before joining this group, so no Add races its terminal Wait.
275	attachmentCleanupWg sync.WaitGroup
276}
277
278// daemonTestHooks are deterministic seams that let tests pause or observe the
279// daemon inside narrow concurrency windows. Production never sets them, and no
280// hook may perform external work while locks are held.
281type daemonTestHooks struct {
282	// beforeRecentSessionHandoff is a deterministic test seam for the narrow
283	// interval between JRS validation and its committed hand-off.
284	beforeRecentSessionHandoff func()
285	// beforeCopyModeRevalidate is a deterministic test seam between staging a
286	// copy-mode candidate and revalidating its pane membership.
287	beforeCopyModeRevalidate func()
288	// beforeCopyMouseMap is a deterministic seam after an immutable copy-input
289	// snapshot is captured and before its mapped position is applied.
290	beforeCopyMouseMap func()
291	// beforeSessionResizePublication runs after final external PTY validation
292	// and before coordinator epoch admission. It is a deterministic regression
293	// seam for stale resize publication.
294	beforeSessionResizePublication func()
295	// beforeResizeOwnerPostEffect pauses after a resize's optimistic owner check
296	// and immediately before a post-commit effect is published. Tests use it to
297	// move a pane through the ordered resize fences at the former TOCTOU window.
298	beforeResizeOwnerPostEffect func(resizeOwnerPostEffect)
299	// afterConnectionSessionSnapshot is a deterministic test seam after the
300	// connection loop reads its current session and before it captures that role.
301	afterConnectionSessionSnapshot func(*session)
302	// afterAttachmentFrameDispatch is a deterministic test seam after the
303	// connection loop snapshots a token and before a decoded frame takes effect.
304	afterAttachmentFrameDispatch func(attachmentCapability)
305	// afterAttachmentEffectParticipantsSnapshotted observes immutable role-gate
306	// participants after architecture preflight locks are released and before
307	// the globally ordered freeze begins.
308	afterAttachmentEffectParticipantsSnapshotted func(string, []*attachedClient)
309	// afterAttachmentEffectGateFrozen observes each participant after it is frozen in
310	// immutable identity order and before drain/publication continues.
311	afterAttachmentEffectGateFrozen func(string, *attachedClient)
312	// afterAttachmentEffectsFrozen observes the lock-free boundary after all affected
313	// attachment gates are frozen and drained, before architecture publication.
314	afterAttachmentEffectsFrozen func()
315	// afterPurgeAdmission is a deterministic test seam that runs after a KillAll
316	// purge has closed purge admission and taken the closing fence, while it
317	// still owns purgeAllMu. It lets tests observe purge serialization.
318	afterPurgeAdmission func()
319	// beforePurgeLiveSessionKill is a deterministic test seam that runs after a
320	// KillAll purge's per-unit shutdown precheck and before that live unit
321	// attempts teardown ownership. Tests use it to let an explicit stop win that
322	// exact window, proving a stop-owned unit is never reported as a success.
323	beforePurgeLiveSessionKill func(*session)
324	// afterSessionKillQuarantine is a deterministic test seam that runs while a
325	// teardown owns the session and its snapshot coordinator is quarantined, but
326	// before the pre-publication revalidation. Tests use it to change the
327	// participant set in that window and prove a post-quarantine abort rolls the
328	// quarantine back so a surviving session keeps checkpointing.
329	afterSessionKillQuarantine func(*session)
330	// beforeAttachmentTransitionIdentityAdmission is a deterministic test seam
331	// after a ready transition published its capability and before the committed
332	// route identity is admitted, so tests can supersede that exact capability in
333	// the window before the transition's first paint and completion.
334	beforeAttachmentTransitionIdentityAdmission func(attachmentCapability)
335	// afterAttachmentTransitionCoordinatorsLocked is a deterministic lock-order
336	// seam used by transition validation tests.
337	afterAttachmentTransitionCoordinatorsLocked func()
338	// beforeMoveDispatch, afterMoveLifecycleReserved, and
339	// afterMovePaneSourceSnapshot are test-only seams. They run before dispatch
340	// admission, after lifecycle reservation, and after the pre-fence source
341	// attachment snapshot respectively. beforeMovePaneCommit runs inside the
342	// non-failing publication section. None is set in production, and none may
343	// perform external work while locks are held.
344	beforeMoveDispatch                        func()
345	afterMoveLifecycleReserved                func()
346	afterMoveLifecycleGateBeforeTeardownLocks func()
347	afterMovePaneSourceSnapshot               func()
348	afterMoveTabSourceSnapshot                func()
349	beforeMovePaneCommit                      func()
350	beforeMoveTabCommit                       func()
351	// beforeMoveFollowCompletion is a deterministic test seam after the composite
352	// follow's first paint and before the postcommit admits the capability that
353	// reports completion, so tests can supersede the published capability.
354	beforeMoveFollowCompletion func(attachmentCapability)
355	// afterDetachAttachmentEffectsFrozen observes terminal detach after it wins the
356	// attachment gate but before it checks session ownership.
357	afterDetachAttachmentEffectsFrozen func()
358	// beforeClientGoneDetach pauses clientGone after the stale-transport
359	// precheck and before exact transport/incarnation detach validation.
360	beforeClientGoneDetach func()
361	// beforeMarkParkingInFlight pauses resumeLiveAttachment after the live
362	// credential matches and before markParkingInFlight, so tests can win
363	// explicit detach or session removal before late marker publication.
364	beforeMarkParkingInFlight func()
365	// afterClientGoneDetach pauses finishClientGone after detach won and before
366	// parkAttachment. The parking-in-flight marker must already be published.
367	afterClientGoneDetach func()
368	// afterResumeLiveDetach pauses resumeLiveAttachment after detach won and
369	// before parkAttachment. The parking-in-flight marker must already be
370	// published so a concurrent same-token resume can wait the gap.
371	afterResumeLiveDetach func()
372	// afterParkingWaitArmed observes waitParkingInFlight after it has resolved a
373	// matching in-flight entry and before it blocks on done.
374	afterParkingWaitArmed func()
375	// beforeResumeParkedSendMu pauses resumeParked after the initial parked
376	// lookup and before the attachment send lock, so tests can consume or
377	// replace the credential while the handshake waits.
378	beforeResumeParkedSendMu func()
379	// afterAttachmentEffectAdmitted is a deterministic test seam after a frame/paint
380	// reserves its exact capability and before its first observable mutation.
381	afterAttachmentEffectAdmitted func(attachmentCapability)
382	// beforeFirstPaintSendWait is a test-only seam immediately before a
383	// transition first paint waits for the attachment send lock.
384	beforeFirstPaintSendWait func(attachmentCapability)
385	// afterDelayedKeyEffectAttempt observes whether a timer callback acquired a
386	// fresh exact capability before producing PTY, action, or overlay effects.
387	afterDelayedKeyEffectAttempt func(bool)
388	// afterActionAttachmentEffectEnded observes action-specific admission release. It
389	// is a deterministic seam for proving no role-bound mutation follows release.
390	afterActionAttachmentEffectEnded func(string)
391	// beforeAttachmentSendErrorCleanup pauses asynchronous render-failure retirement
392	// after the render ticket ends and before exact lifecycle validation.
393	beforeAttachmentSendErrorCleanup func(attachmentCapability)
394	afterAttachmentSendErrorCleanup  func()
395	// beforeSuspendedExpiryFreeze pauses the safety expiry after its timer fired
396	// and before it freezes the attachment effect gate, so tests can win
397	// activation, disconnect, or replacement first. It is a test-only seam.
398	beforeSuspendedExpiryFreeze func(*attachedClient)
399}
400
401type parkedAttachment struct {
402	sess              *session
403	ac                *attachedClient
404	pickerGeneration  uint64
405	paletteGeneration uint64
406	timer             ports.Timer
407	claimed           bool
408	done              chan struct{}
409	doneOnce          sync.Once
410}
411
412// parkingAttachment is the observable detach→park lifecycle for one resume
413// credential. done closes when the token is published into parked or parking
414// is abandoned.
415type parkingAttachment struct {
416	sess     *session
417	ac       *attachedClient
418	done     chan struct{}
419	doneOnce sync.Once
420}
421
422func (p *parkingAttachment) closeDone() {
423	if p == nil || p.done == nil {
424		return
425	}
426	p.doneOnce.Do(func() { close(p.done) })
427}
428
429// inactiveSession is the durable authority for a named session without a live
430// runtime. It remains present while healthy-down, restoring, broken, degraded,
431// or purge-fenced; callers must use its predicates instead of inferring those
432// states from map membership.
433type inactiveSession struct {
434	name        string
435	cwd         string
436	createdAt   int64
437	incarnation domain.IncarnationID
438	lastUsedSeq uint64
439	tabNames    []string
440	tabRecords  []domain.CatalogueTabRecord
441	purging     bool
442	record      domain.CatalogueRecord
443	state       protocol.SessionState
444	restoreDone chan struct{}
445}
446
447func (s inactiveSession) restorePending() bool {
448	if s.restoreDone == nil {
449		return false
450	}
451	select {
452	case <-s.restoreDone:
453		return false
454	default:
455		return true
456	}
457}
458
459func (s inactiveSession) broken() bool {
460	return s.state == protocol.SessionBroken || s.record.DegradedReason != ""
461}
462
463func (s inactiveSession) visible() bool {
464	return !s.purging
465}
466
467func (s inactiveSession) canResume() bool {
468	return s.visible() && s.state == protocol.SessionDown && !s.broken()
469}
470
471func (s inactiveSession) sameLifecycle(other inactiveSession) bool {
472	return s.name == other.name && s.createdAt == other.createdAt && s.incarnation == other.incarnation
473}
474
475type Option func(*Daemon)
476
477// WithRuntimeObserver accepts only a composition-root serialized observer.
478// The application owns its lifecycle; the daemon never creates or closes a
479// second reporting worker around it.
480func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
481	return func(d *Daemon) { d.runtimeObserver = observer }
482}
483
484// WithShell overrides the command (and its args) each session spawns. The
485// default is $SHELL (or /bin/sh) with no arguments; tests use this to run a
486// deterministic program.
487func WithShell(cmd string, args []string) Option {
488	return func(d *Daemon) {
489		d.shell = cmd
490		d.shellArgs = args
491		d.shellOverride = true
492	}
493}
494
495// WithCatalogue installs the singular catalogue and the strictly opened
496// records that define the daemon's expected-session registry at startup.
497func WithCatalogue(catalogue ports.Catalogue, records []domain.CatalogueRecord) Option {
498	return func(d *Daemon) {
499		d.catalogue = catalogue
500		d.persistEnabled = catalogue != nil
501		d.catalogueRecords = append([]domain.CatalogueRecord(nil), records...)
502		d.catalogueRecordsProvided = true
503	}
504}
505
506func (d *Daemon) catalogueRecord(name string) (domain.CatalogueRecord, bool, error) {
507	if d == nil || d.catalogue == nil {
508		return domain.CatalogueRecord{}, false, nil
509	}
510	return d.catalogue.Record(name)
511}
512
513// markCatalogueDirty buffers metadata. The periodic catalogue timer, the next
514// identity write, or shutdown provides the durability barrier.
515func (d *Daemon) markCatalogueDirty(update domain.CatalogueMetadataUpdate) {
516	if d == nil || d.catalogue == nil {
517		return
518	}
519	if err := d.catalogue.UpdateMetadata(update); err != nil {
520		d.log.Warn("buffering session metadata failed", "err", err, "session", update.Name)
521	}
522}
523
524func (d *Daemon) flushCatalogue() error {
525	if d == nil || d.catalogue == nil {
526		return nil
527	}
528	return d.catalogue.Sync()
529}
530
531// WithRecoveryCoordinator installs the durable recovery coordinator.
532func WithRecoveryCoordinator(coordinator *recoveryusecase.Coordinator) Option {
533	return func(d *Daemon) {
534		d.recovery = coordinator
535	}
536}
537
538// WithSnapshotRepository enables content-addressed incremental snapshots.
539func WithSnapshotRepository(repository ports.SnapshotRepository) Option {
540	return func(d *Daemon) {
541		if isNilSnapshotRepository(repository) {
542			return
543		}
544		d.snapshotRepository = repository
545		d.snapsEnabled = true
546	}
547}
548
549func isNilSnapshotRepository(repository ports.SnapshotRepository) bool {
550	if repository == nil {
551		return true
552	}
553	value := reflect.ValueOf(repository)
554	switch value.Kind() {
555	case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
556		return value.IsNil()
557	default:
558		return false
559	}
560}
561
562// WithNoticeStore enables persisting undeliverable notices across daemon
563// restarts. A nil store keeps the daemon in no-op notice-persistence mode.
564func WithNoticeStore(store ports.NoticeStore) Option {
565	return func(d *Daemon) { d.noticeStore = store }
566}
567
568// WithSnapshotGarbageCollection runs background snapshot GC through the
569// recovery coordinator installed by WithRecoveryCoordinator.
570func WithSnapshotGarbageCollection() Option {
571	return func(d *Daemon) { d.snapshotGarbageCollection = true }
572}
573
574// WithCwdReader overrides the process cwd reader used for persistence tests.
575func WithCwdReader(fn func(int) (string, error)) Option {
576	return func(d *Daemon) {
577		if fn != nil {
578			d.procCwd = fn
579		}
580	}
581}
582
583// WithProcessInspector installs the platform process-inspection implementation.
584func WithProcessInspector(ins ports.ProcessInspector) Option {
585	return func(d *Daemon) {
586		if ins == nil {
587			return
588		}
589		d.procCwd = ins.Cwd
590		d.procComm = ins.Comm
591		d.procArgv = ins.Argv
592		d.procGroupArgv = ins.GroupArgv
593	}
594}
595
596// WithDirOrHome installs path fallback behavior from the application layer.
597func WithDirOrHome(fn func(string) string) Option {
598	return func(d *Daemon) {
599		if fn != nil {
600			d.dirOrHome = fn
601		}
602	}
603}
604
605// WithTempDir overrides the directory clipboard-image-transfer writes temp
606// files into (production default: os.TempDir()); tests use this with
607// t.TempDir() so writes are isolated and auto-cleaned.
608func WithTempDir(dir string) Option {
609	return func(d *Daemon) {
610		d.tempDir = dir
611	}
612}
613
614// WithBarScriptCommandRunner installs the shell command runner used by bar scripts.
615func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option {
616	return func(d *Daemon) {
617		if runner != nil {
618			d.barScripts.runner = barScriptRunner{runner: runner}
619		}
620	}
621}
622
623// WithResumeParkGrace overrides how long detached resume-capable clients stay
624// parked for reconnection. Non-positive durations keep the default.
625func WithResumeParkGrace(grace time.Duration) Option {
626	return func(d *Daemon) {
627		if grace > 0 {
628			d.resumeParkGrace = grace
629		}
630	}
631}
632
633// WithSuspendedSafetyExpiry overrides how long the daemon retains a suspended
634// attachment before terminal eviction. Non-positive durations keep the default.
635// Fake-clock tests set a short duration so the real timer path stays under test.
636func WithSuspendedSafetyExpiry(expiry time.Duration) Option {
637	return func(d *Daemon) {
638		if expiry > 0 {
639			d.suspendedSafetyExpiry = expiry
640		}
641	}
642}
643
644// WithConfig applies the initial user configuration.
645func WithConfig(cfg domain.Config) Option {
646	return func(d *Daemon) {
647		d.ApplyConfig(cfg)
648	}
649}
650
651// allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller
652// holds d.mu. The persisted high-water mark is loaded by New before any named
653// session can be resumed or created.
654func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) {
655	now := d.nowUnixNano()
656	if now <= d.lastAllocatedCreatedAt {
657		if d.lastAllocatedCreatedAt == math.MaxInt64 {
658			return 0, errors.New("daemon: lifecycle identities exhausted")
659		}
660		now = d.lastAllocatedCreatedAt + 1
661	}
662	d.lastAllocatedCreatedAt = now
663	return now, nil
664}
665
666func (d *Daemon) nowUnixNano() int64 {
667	return d.daemonNow().UnixNano()
668}
669
670// daemonNow reports the daemon clock. New defaults a nil clock to the
671// system clock, so the fallback below only serves hand-built fixtures
672// and stays explicit about its provenance instead of hiding a bare
673// wall-clock read.
674func (d *Daemon) daemonNow() time.Time {
675	if d == nil || d.clock == nil {
676		return systemClock{}.Now()
677	}
678	return d.clock.Now()
679}
680
681type systemClock struct{}
682type systemTimer struct{ *time.Timer }
683
684var graphicsNamespaceFallbackSalt atomic.Uint64
685
686func newGraphicsNamespaceSalt() uint64 {
687	var bytes [8]byte
688	if _, err := cryptorand.Read(bytes[:]); err == nil {
689		if salt := binary.BigEndian.Uint64(bytes[:]); salt != 0 {
690			return salt
691		}
692	}
693	for {
694		salt := graphicsNamespaceFallbackSalt.Add(1)
695		if salt != 0 {
696			return salt
697		}
698	}
699}
700
701func (systemClock) Now() time.Time { return time.Now() }
702func (systemClock) NewTimer(delay time.Duration) ports.Timer {
703	return systemTimer{Timer: time.NewTimer(delay)}
704}
705func (t systemTimer) C() <-chan time.Time { return t.Timer.C }
706
707// New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
708// render debounce, and log receives diagnostics (defaults to slog.Default).
709func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
710	if log == nil {
711		log = slog.Default()
712	}
713	if clock == nil {
714		clock = systemClock{}
715	}
716	paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
717	d := &Daemon{
718		sessions:                     make(map[domain.SessionID]*session),
719		inactive:                     make(map[string]inactiveSession),
720		creating:                     make(map[string]struct{}),
721		resume:                       newResumeCredentials(),
722		suspended:                    make(map[*attachedClient]*suspendedAttachmentRetention),
723		graphicsNamespaces:           make(map[uint64]struct{}),
724		graphicsNamespaceFences:      make(map[uint64]uint64),
725		graphicsNamespaceQuarantines: make(map[uint64]*graphicsNamespaceQuarantine),
726		graphicsNamespaceSalt:        newGraphicsNamespaceSalt(),
727		paneProcessCtx:               paneProcessCtx,
728		paneProcessCancel:            paneProcessCancel,
729		ptys:                         ptys,
730		clock:                        clock,
731		log:                          log,
732		baseEnv:                      os.Environ(),
733		shell:                        defaultShellCommand,
734		dirOrHome:                    dirOrHome,
735		done:                         make(chan struct{}),
736		restoreDone:                  make(chan struct{}),
737		animWake:                     make(chan struct{}, 1),
738		snapshotJobs:                 make(chan *snapshotCapture, snapshotQueueCapacity),
739		snapshotAdmitted:             make(map[*snapshotCapture]struct{}),
740		snapshotWake:                 make(chan struct{}, 1),
741		notices:                      newNoticeCenter(),
742		resumeParkGrace:              defaultResumeParkGrace,
743		suspendedSafetyExpiry:        defaultSuspendedSafetyExpiry,
744		barScripts: &barScriptState{
745			cfg:         barConfigFromDomain(domain.Defaults().Bar),
746			outputs:     make(map[domain.SessionID]barScriptOutputs),
747			lastRefresh: make(map[domain.SessionID]time.Time),
748			lastContext: make(map[domain.SessionID]barScriptContext),
749			running:     make(map[domain.SessionID]bool),
750			reload:      make(chan struct{}, 1),
751		},
752	}
753	defaults := domain.Defaults()
754	defaultFloating := defaults.Floating
755	d.floatingConfig.Store(&defaultFloating)
756	defaultCopy := defaults.Copy
757	d.copyConfig.Store(&defaultCopy)
758	defaultPalette := defaults.Palette
759	d.paletteConfig.Store(&defaultPalette)
760	defaultTabs := defaults.Tabs
761	d.tabsConfig.Store(&defaultTabs)
762	for _, o := range opts {
763		o(d)
764	}
765	if d.dirOrHome == nil {
766		d.dirOrHome = dirOrHome
767	}
768	if d.bindings.Load() == nil {
769		d.bindings.Store(keys.DefaultBindings())
770	}
771	if d.codeOverrides.Load() == nil {
772		empty := map[string]string{}
773		d.codeOverrides.Store(&empty)
774	}
775	if d.restoreProcessAllowlist.Load() == nil {
776		allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
777		d.restoreProcessAllowlist.Store(&allow)
778	}
779	records := d.catalogueRecords
780	var maxSeq uint64
781	var maxCreatedAt int64
782	hasCreatedAt := false
783	for _, r := range records {
784		if d.catalogueRecordsProvided {
785			state, done := initialSessionState(r)
786			d.inactive[r.Name] = inactiveSessionFromRecord(r, state, done)
787		} else {
788			d.inactive[r.Name] = inactiveSessionFromRecord(r, protocol.SessionDown, nil)
789		}
790		if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
791			maxCreatedAt = r.CreatedAt
792			hasCreatedAt = true
793		}
794		if r.LastUsedSeq > maxSeq {
795			maxSeq = r.LastUsedSeq
796		}
797	}
798	if hasCreatedAt {
799		d.lastAllocatedCreatedAt = maxCreatedAt
800	}
801	d.mruSeq.Store(maxSeq)
802	return d
803}
804
805// Serve runs the accept loop over l, owning it for the loop's lifetime. It
806// returns only on ctx cancellation or an explicit daemon shutdown request
807// (KillDaemon), never when the session registry drains to empty: final
808// session removal is independent of daemon shutdown so the daemon survives
809// empty→occupied→empty and can accept fresh sessions again. A KillAll purge
810// leaves Serve running too. On the ctx path
811// attached clients are detached with ReasonServerShutdown.
812func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
813	d.serveCtx, d.serveCancel = context.WithCancel(ctx)
814	defer d.serveCancel()
815	d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
816	defer d.hardCancel()
817
818	d.sessWg.Go(func() {
819		d.attentionAnimator(d.serveCtx)
820	})
821	d.sessWg.Go(func() {
822		d.barScriptPoller(d.serveCtx)
823	})
824	d.sessWg.Go(func() {
825		d.historyMaintenance(d.serveCtx)
826	})
827	if d.persistEnabled {
828		d.sessWg.Go(func() {
829			d.cwdSampler(d.serveCtx)
830		})
831	}
832	if d.snapsEnabled {
833		d.startSnapshotEncodeWorker()
834		d.sessWg.Go(func() {
835			d.snapshotRepositorySaver(d.serveCtx)
836		})
837		d.startSnapshotRestoration()
838		d.startDurableMaintenance()
839	} else {
840		d.closeRestoreDone()
841	}
842	// Break the accept loop only on an explicit shutdown: the parent context
843	// being cancelled or an explicit daemon stop request (d.done). An empty
844	// registry never closes the listener, so the daemon keeps accepting.
845	go func() {
846		select {
847		case <-d.serveCtx.Done():
848		case <-d.done:
849		}
850		_ = l.Close()
851	}()
852
853	for {
854		tr, err := l.Accept()
855		if err != nil {
856			if d.serveCtx.Err() != nil {
857				d.log.Info("accept loop exiting", "err", err, "reason", "context canceled")
858			} else {
859				d.log.Warn("accept loop exiting", "err", err)
860			}
861			break
862		}
863		d.connWg.Go(func() {
864			d.handleConn(tr)
865		})
866	}
867
868	// Tear down. shutdownAll marks the daemon closing (route now rejects any
869	// racing Hello) and kills every session: PTYs are closed and contexts
870	// cancelled unconditionally, with the Detached notices sent asynchronously
871	// under a deadline. Wait for those notices (bounded — a wedged client's
872	// transport is force-closed by the deadline) before hard-closing the
873	// remaining parked connections, so graceful notices are never raced by the
874	// force-close. Finally drain the conn handlers, run one defensive sweep in
875	// case any ordering ever leaves a session behind, and join the session
876	// goroutines (readers unblock via pty.Close, coordinators via ctx cancel).
877	// One deadline owns every terminal checkpoint wait and the final worker
878	// join. A repository is allowed to ignore cancellation, so spending a fresh
879	// interval at either stage would make shutdown exceed its documented bound.
880	var snapshotDeadline *snapshotShutdownDeadline
881	if d.snapsEnabled {
882		snapshotDeadline = newSnapshotShutdownDeadline(d.clock)
883		defer snapshotDeadline.stop()
884	}
885	d.terminateAllForShutdown(protocol.ReasonServerShutdown, snapshotDeadline)
886	d.waitNotifies()
887	d.hardCancel()
888	d.serveCancel()
889	// Wait for handlers before flushing snapshots: a handler that already
890	// entered killSession may still submit its terminal capture after the
891	// registry snapshot has been removed.
892	d.connWg.Wait()
893	d.attachmentCleanupWg.Wait()
894	// Forced terminal checkpoints run before shutdown stops producers or the
895	// worker. StopDurableWriters reports every session whose final capture still
896	// owns work when the shared checkpoint budget expires. Persist one notice per
897	// affected session, then retain ownership until every writer actually exits.
898	stopCtx, stopCancel := snapshotStopContext(snapshotDeadline)
899	timedOutSessions := d.StopDurableWriters(stopCtx)
900	stopCancel()
901	for _, name := range timedOutSessions {
902		d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)
903	}
904	d.WaitDurableWriters()
905	d.terminateAllForShutdown(protocol.ReasonServerShutdown, snapshotDeadline)
906	d.waitSessionWorkersWithSnapshotDeadline(snapshotDeadline)
907	d.waitNotifies()
908	if err := d.flushCatalogue(); err != nil {
909		d.log.Warn("flushing session catalogue at shutdown failed", "err", err)
910	}
911	if d.catalogue != nil {
912		if err := d.catalogue.Close(); err != nil {
913			d.log.Warn("closing session catalogue failed", "err", err)
914		}
915	}
916	return nil
917}
918
919// shutdownAll is the explicit daemon-stop operation (wire KillDaemon). It is
920// the only caller that irreversibly ends the daemon: it closes move admission,
921// marks closing, reserves shutdown preservation for every live session, then
922// cancels the daemon-wide pane process context, preserves every live session as
923// stopped durable authority, and closes done so Serve returns.
924// KillAll never reaches this path. Setting closing under the same lock as the
925// snapshot guarantees no session can be inserted after the snapshot: route
926// rejects once closing is set, and both run under d.mu. killSession (which
927// relocks) runs after the lock is released.
928func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) {
929	return d.terminateAllForShutdown(reason, nil)
930}
931
932// terminateAllForShutdown performs the irreversible daemon teardown shared by
933// shutdownAll and Serve's exit path. It is separate from purgeAllSessions,
934// which leaves every daemon-wide shutdown fact untouched.
935func (d *Daemon) terminateAllForShutdown(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
936	// Stop background GC first so it releases the recovery fence before final
937	// checkpoints are published under the shutdown deadline.
938	d.cancelDurableMaintenance()
939	d.closeMoveLifecycles()
940	d.mu.Lock()
941	d.closing = true
942	// Wake any KillAll waiting on purge admission so it defers to this stop
943	// instead of holding the gate until its bounded drain deadline expires.
944	d.signalPurgeAdmissionChangedLocked()
945	d.purgeAllParkingLocked()
946	parkedRetirements := d.purgeAllParkedLocked()
947	d.purgeAllSuspendedLocked()
948	snapshot := d.sessionsSnapshotLocked()
949	d.mu.Unlock()
950	d.finishParkedAttachmentRetirements(parkedRetirements)
951	// Publish preservation policy for the complete registry snapshot before
952	// cancelling any PTY. Cancellation-driven EOF is allowed to own teardown,
953	// but its disposition must remain daemon shutdown rather than session purge.
954	// closeMoveLifecycles therefore leaves pane processes running; only this
955	// ordering, reserve then cancel, keeps a named session durable.
956	for _, s := range snapshot {
957		s.reserveShutdownTeardown()
958	}
959	d.cancelPaneProcesses()
960	d.log.Info("session termination begin", "reason", reason, "live_sessions", len(snapshot))
961	for _, s := range snapshot {
962		// Cancellation and PTY closure must not wait behind a teardown owner that
963		// is blocked in snapshot publication or purge work.
964		s.mu.Lock()
965		name := s.name
966		ephemeral := s.ephemeral
967		s.mu.Unlock()
968		s.stopInMemoryLifecycle()
969		if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline, nil); err != nil {
970			checkpointIncomplete = true
971			d.log.Error("closing session with unpersisted terminal state", "err", err)
972		}
973		if !ephemeral && deadline != nil {
974			select {
975			case <-deadline.Done():
976				checkpointIncomplete = true
977				d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)
978			default:
979			}
980		}
981	}
982	// Signal Serve even when a concurrent role transition made this initial
983	// pass abort; Serve owns the bounded defensive teardown passes.
984	d.doneOnce.Do(func() { close(d.done) })
985	return checkpointIncomplete
986}
987
988// waitSessionWorkersWithSnapshotDeadline joins in-memory workers when possible,
989// but never extends Serve beyond the shared repository shutdown budget. By this
990// point connection handlers are joined and every session has been cancelled, so
991// no new session worker can be registered while the waiter is active.
992func (d *Daemon) waitSessionWorkersWithSnapshotDeadline(deadline *snapshotShutdownDeadline) {
993	done := make(chan struct{})
994	go func() {
995		d.sessWg.Wait()
996		close(done)
997	}()
998	if deadline == nil {
999		<-done
1000		return
1001	}
1002	select {
1003	case <-done:
1004	case <-deadline.Done():
1005	}
1006}
1007
1008// snapshotShutdownDeadline is a single-use shutdown budget shared by forced
1009// checkpoints and the final snapshot worker join. Its done channel is never
1010// closed by callers, so a detached worker can safely retain session state until
1011// an uncooperative repository call returns.
1012type snapshotShutdownDeadline struct {
1013	done     chan struct{}
1014	stopCh   chan struct{}
1015	finished chan struct{}
1016	stopOnce sync.Once
1017}
1018
1019func newSnapshotShutdownDeadline(clock ports.Clock) *snapshotShutdownDeadline {
1020	deadline := &snapshotShutdownDeadline{done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{})}
1021	timer := clock.NewTimer(snapshotFinalFlushTimeout)
1022	go func() {
1023		defer close(deadline.finished)
1024		select {
1025		case <-timer.C():
1026			close(deadline.done)
1027		case <-deadline.stopCh:
1028			timer.Stop()
1029		}
1030	}()
1031	return deadline
1032}
1033
1034func (d *snapshotShutdownDeadline) stop() {
1035	if d != nil {
1036		d.stopOnce.Do(func() {
1037			close(d.stopCh)
1038			<-d.finished
1039		})
1040	}
1041}
1042
1043func (d *snapshotShutdownDeadline) Done() <-chan struct{} {
1044	if d == nil {
1045		return nil
1046	}
1047	return d.done
1048}
1049
1050func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, context.CancelFunc) {
1051	ctx, cancel := context.WithCancel(context.Background())
1052	if deadline == nil {
1053		return ctx, cancel
1054	}
1055	select {
1056	case <-deadline.Done():
1057		cancel()
1058		return ctx, cancel
1059	default:
1060	}
1061	stop := make(chan struct{})
1062	go func() {
1063		select {
1064		case <-deadline.Done():
1065			cancel()
1066		case <-stop:
1067		}
1068	}()
1069	var stopOnce sync.Once
1070	return ctx, func() {
1071		stopOnce.Do(func() { close(stop) })
1072		cancel()
1073	}
1074}
1075
1076// registerSessionLocked publishes an exact attachment-session identity. Caller
1077// holds d.mu.
1078func (d *Daemon) registerSessionLocked(entry *session) bool {
1079	if entry == nil {
1080		return false
1081	}
1082	core := entry.core()
1083	if core == nil || core.id == "" || d.sessions[core.id] != nil {
1084		return false
1085	}
1086	d.sessions[core.id] = entry
1087	return true
1088}
1089
1090// unregisterSessionLocked removes only the exact registered identity. Caller
1091// holds d.mu.
1092func (d *Daemon) unregisterSessionLocked(entry *session) bool {
1093	if entry == nil {
1094		return false
1095	}
1096	core := entry.core()
1097	if core == nil || d.sessions[core.id] != entry {
1098		return false
1099	}
1100	delete(d.sessions, core.id)
1101	return true
1102}
1103
1104func (d *Daemon) sessionsSnapshotLocked() []*session {
1105	return sessionsSnapshot(d.sessions)
1106}
1107
1108// handleConn reads the first typed message off a fresh connection and routes it.
1109// A context watcher closes the connection on cancellation or handshake timeout.
1110func (d *Daemon) handleConn(tr ports.ServerConnection) {
1111	handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(d.hardCtx, acceptedHandshakeDeadline(tr))
1112	stopTransport := watchHandshakeTransport(handshakeCtx, tr)
1113	defer finishHandshake()
1114	defer stopTransport()
1115
1116	var first protocol.ClientMessage
1117	err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1118		var receiveErr error
1119		first, receiveErr = tr.ReceiveClient()
1120		return receiveErr
1121	})
1122	if err != nil {
1123		var failure *protocol.DecodeFailure
1124		if errors.As(err, &failure) {
1125			d.handleInitialDecodeFailure(handshakeCtx, tr, failure)
1126		} else {
1127			err = handshakeContextError(d.hardCtx, timedOut, err)
1128			d.log.Warn("connection closed before hello", "err", err)
1129		}
1130		_ = tr.Close()
1131		return
1132	}
1133	switch message := first.(type) {
1134	case protocol.List:
1135		stopTransport()
1136		finishHandshake()
1137		d.handleList(tr)
1138	case protocol.CommandRequest:
1139		stopTransport()
1140		finishHandshake()
1141		if err := d.handleCommand(tr, message); err != nil {
1142			d.log.Warn("command handler failed", "err", err)
1143		}
1144	case protocol.RemotePreviewWatch:
1145		stopTransport()
1146		finishHandshake()
1147		d.serveRemotePreviewWatch(tr, message)
1148	case protocol.NavigationInventoryRequest:
1149		stopTransport()
1150		finishHandshake()
1151		d.handleNavigationInventory(tr, message)
1152	case protocol.Kill:
1153		stopTransport()
1154		finishHandshake()
1155		d.handleKill(tr, message)
1156	case protocol.Hello:
1157		d.handleHelloWithContext(handshakeCtx, timedOut, stopTransport, finishHandshake, tr, message)
1158	default:
1159		d.log.Warn("hello rejected", "err", "expected hello")
1160		_ = boundedHandshakeOperation(handshakeCtx, tr, func() error {
1161			return tr.SendServer(serverError(protocol.ErrInternal, "expected hello"))
1162		})
1163		stopTransport()
1164		finishHandshake()
1165		_ = tr.Close()
1166	}
1167}
1168
1169func (d *Daemon) handleInitialDecodeFailure(ctx context.Context, tr ports.ServerConnection, failure *protocol.DecodeFailure) {
1170	send := func(message protocol.ServerMessage) {
1171		_ = boundedHandshakeOperation(ctx, tr, func() error { return tr.SendServer(message) })
1172		_ = tr.Close()
1173	}
1174	switch failure.Kind {
1175	case protocol.DecodeMessageHello:
1176		if failure.Version != 0 && failure.Version != protocol.Version {
1177			send(serverError(protocol.ErrVersionMismatch, "protocol version mismatch"))
1178			return
1179		}
1180		send(serverError(protocol.ErrInternal, "malformed hello"))
1181	case protocol.DecodeMessageCommand:
1182		if !failure.HasRequestID {
1183			return
1184		}
1185		code, text := protocol.ErrInternal, "malformed command request"
1186		if failure.Version != 0 && failure.Version != protocol.Version {
1187			code, text = protocol.ErrVersionMismatch, "protocol version mismatch"
1188		}
1189		send(protocol.CommandResult{RequestID: failure.RequestID, Outcome: protocol.CommandFailed, Code: code, Text: text})
1190	case protocol.DecodeMessageKill:
1191		send(serverError(protocol.ErrInternal, "malformed kill request"))
1192	case protocol.DecodeMessageRemotePreview:
1193		send(protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewMalformed})
1194	case protocol.DecodeMessageNavigationInventory:
1195		if !failure.HasRequestID {
1196			return
1197		}
1198		status := protocol.NavigationInventoryInvalid
1199		if failure.Version != 0 && failure.Version != protocol.Version {
1200			status = protocol.NavigationInventoryVersionMismatch
1201		}
1202		send(protocol.NavigationInventoryResponse{RequestID: failure.RequestID, Operation: protocol.NavigationInventorySnapshot, Status: status})
1203	default:
1204		send(serverError(protocol.ErrInternal, "expected hello"))
1205	}
1206}
1207
1208// handleList replies with the current session listing and closes the (one-shot
1209// control) connection.
1210func (d *Daemon) handleList(tr ports.ServerConnection) {
1211	defer func() { _ = tr.Close() }()
1212
1213	d.mu.Lock()
1214	infos := make([]protocol.SessionInfo, 0, len(d.sessions)+len(d.inactive))
1215	liveNames := make(map[string]struct{}, len(d.sessions))
1216	for _, s := range d.sessions {
1217		if s == nil {
1218			continue
1219		}
1220		s.mu.Lock()
1221		info := protocol.SessionInfo{
1222			SessionID: string(s.id),
1223			Name:      s.name,
1224			State:     protocol.SessionUp,
1225			Ephemeral: s.ephemeral,
1226			Tabs:      uint16(len(s.tabs)),
1227			// Attached means registered interactive presence, not mere transport
1228			// membership: a suspended-only session is headless to observers.
1229			Attached: sessionInteractivelyAttachedLocked(s),
1230		}
1231		liveNames[s.name] = struct{}{}
1232		s.mu.Unlock()
1233		infos = append(infos, info)
1234	}
1235	for name, inactive := range d.inactive {
1236		if _, live := liveNames[name]; live || !inactive.visible() {
1237			continue
1238		}
1239		state := protocol.SessionDown
1240		if inactive.broken() {
1241			state = protocol.SessionBroken
1242		}
1243		infos = append(infos, protocol.SessionInfo{Name: name, State: state})
1244	}
1245	d.mu.Unlock()
1246
1247	sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })
1248	_ = d.boundedControlSend(tr, serverSessions(infos))
1249}
1250
1251// handleKill terminates the requested live session or stopped named session,
1252// purges every session (KillAll), or accepts daemon shutdown (KillDaemon).
1253// Every normally decoded request receives exactly one correlated result before
1254// the control connection closes. KillAll leaves the daemon active.
1255func (d *Daemon) handleKill(tr ports.ServerConnection, request protocol.Kill) {
1256	defer func() { _ = tr.Close() }()
1257	send := func(result protocol.KillResult) {
1258		result.RequestID = request.RequestID
1259		d.logKillResultSendFailure(result, d.boundedControlSend(tr, result))
1260	}
1261
1262	switch request.Scope {
1263	case protocol.KillAll:
1264		result := d.purgeAllSessions(protocol.ReasonSessionKilled)
1265		if result.Superseded() {
1266			send(protocol.KillResult{Outcome: protocol.KillOutcomeUnknown, Code: protocol.ErrServerShutdown, Text: "daemon is shutting down"})
1267			return
1268		}
1269		if result.Failed() {
1270			failures := make([]protocol.KillFailure, 0, min(len(result.Failures), purgeSummaryFailureLimit))
1271			for _, failure := range result.Failures[:min(len(result.Failures), purgeSummaryFailureLimit)] {
1272				failures = append(failures, protocol.KillFailure{Class: failure.Class.String(), Name: failure.Name, Text: failure.Err.Error()})
1273			}
1274			send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: result.summary(), Failures: failures})
1275			return
1276		}
1277		send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1278		return
1279	case protocol.KillDaemon:
1280		send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1281		d.shutdownAll(protocol.ReasonServerShutdown)
1282		return
1283	case protocol.KillSession:
1284	default:
1285		send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "invalid kill scope"})
1286		return
1287	}
1288
1289	d.mu.Lock()
1290	target := d.findByNameLocked(request.Name)
1291	if target == nil {
1292		if _, ok := d.inactive[request.Name]; ok {
1293			d.mu.Unlock()
1294			// Stopped sessions use the same catalogue-first, incarnation-second
1295			// deletion order as live and offline purges.
1296			if err := d.retryStoppedPurge(request.Name); err != nil {
1297				d.log.Warn("deleting stopped session failed", "err", err, "session", request.Name)
1298				send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "deleting stopped session failed"})
1299				return
1300			}
1301			send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1302			return
1303		}
1304	}
1305	d.mu.Unlock()
1306
1307	if target == nil {
1308		send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrNoSuchSession, Text: "no such session: " + request.Name})
1309		return
1310	}
1311	if err := d.killSession(target, protocol.ReasonSessionKilled, true); err != nil {
1312		if errors.Is(err, errSessionKillParticipantsChanged) {
1313			// The exact session was not removed; a concurrent detach or token
1314			// resume invalidated the teardown snapshot. Report the retryable
1315			// outcome instead of a permanent delete failure.
1316			send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: sessionKillRetryMessage})
1317			return
1318		}
1319		send(protocol.KillResult{Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "deleting persisted session failed"})
1320		return
1321	}
1322	send(protocol.KillResult{Outcome: protocol.KillSucceeded})
1323}
1324
1325// handleHello runs the attach handshake for direct package callers. Accepted
1326// connections use handleConn, which resolves the optional accepted deadline so
1327// one budget also covers the first frame read; handleHello adopts the same seam
1328// when the connection exposes it. A connection without the seam keeps the
1329// ordinary fresh protocol.HandshakeTimeout budget.
1330func (d *Daemon) handleHello(tr ports.ServerConnection, hello protocol.Hello) {
1331	handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(context.Background(), acceptedHandshakeDeadline(tr))
1332	stopTransport := watchHandshakeTransport(handshakeCtx, tr)
1333	defer finishHandshake()
1334	defer stopTransport()
1335	d.handleHelloWithContext(handshakeCtx, timedOut, stopTransport, finishHandshake, tr, hello)
1336}
1337
1338// handleHelloWithContext runs the attach handshake: version check, intent
1339// routing, Welcome, guaranteed first paint, then the per-connection input loop.
1340func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <-chan struct{}, stopHandshakeTransport, finishHandshake func(), tr ports.ServerConnection, h protocol.Hello) {
1341	if err := handshakeContextError(d.hardCtx, timedOut, handshakeCtx.Err()); err != nil {
1342		_ = tr.Close()
1343		return
1344	}
1345	sendHandshake := func(message protocol.ServerMessage) error {
1346		err := boundedHandshakeOperation(handshakeCtx, tr, func() error { return tr.SendServer(message) })
1347		if err != nil {
1348			return handshakeContextError(d.hardCtx, timedOut, err)
1349		}
1350		return nil
1351	}
1352	if h.Version != protocol.Version {
1353		d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", h.Version, "expected", protocol.Version, "intent", h.Intent, "session", h.Name)
1354		_ = sendHandshake(serverError(protocol.ErrVersionMismatch, "protocol version mismatch"))
1355		_ = tr.Close()
1356		return
1357	}
1358
1359	sess, ac, rerr := d.routeWithContext(handshakeCtx, h, tr)
1360	if rerr != nil {
1361		d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
1362		if pe, ok := errors.AsType[*protoErr](rerr); ok {
1363			_ = sendHandshake(serverError(pe.code, pe.text))
1364		} else {
1365			_ = sendHandshake(serverError(protocol.ErrInternal, rerr.Error()))
1366		}
1367		_ = tr.Close()
1368		return
1369	}
1370
1371	welcomeSent := false
1372	//nolint:contextcheck // handshakeCtx is intentionally not propagated: cancellation teardown must complete its geometry and ownership cleanup.
1373	failAttachment := func() {
1374		d.failHandshakeAttachment(sess, ac, tr, welcomeSent)
1375	}
1376	if err := handshakeCtx.Err(); err != nil {
1377		failAttachment()
1378		return
1379	}
1380	expected := ac.transportSnapshot()
1381	welcomeToken := sess.captureAttachmentCapability(ac, tr)
1382	welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1383	if expected.transport != tr || !admitted || welcomeToken.ac == nil {
1384		if admitted {
1385			welcomeTicket.End()
1386		}
1387		failAttachment()
1388		return
1389	}
1390	if err := handshakeCtx.Err(); err != nil {
1391		welcomeTicket.End()
1392		failAttachment()
1393		return
1394	}
1395	welcomeDone, welcomeErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1396		frame, err := serverWelcome(sess, d.resumeTokenSnapshot(ac))
1397		if err != nil {
1398			return err
1399		}
1400		return ac.sendExpectedTransportForAttachment(expected, frame, welcomeTicket)
1401	})
1402	if welcomeErr != nil {
1403		<-welcomeDone
1404		welcomeTicket.End()
1405		failAttachment()
1406		return
1407	}
1408	welcomeSent = true
1409	// Release Welcome's effect before discovering post-handshake authority so a
1410	// replacement blocked behind the send can publish its generation and lease.
1411	welcomeTicket.End()
1412	postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffectContext(handshakeCtx, sess, tr)
1413	if !admitted {
1414		failAttachment()
1415		return
1416	}
1417	postWelcomeLease := postWelcomeToken.lease
1418	if postWelcomeToken.ac == nil {
1419		postWelcomeTicket.End()
1420		failAttachment()
1421		return
1422	}
1423	postWelcomeRC := sess.renderCoordinator()
1424	if postWelcomeLease != nil && (postWelcomeRC == nil || !postWelcomeRC.markAttachmentReady(postWelcomeLease)) {
1425		postWelcomeTicket.End()
1426		// The attachment was detached while Welcome was in flight; never let
1427		// this stale handshake emit an Output frame.
1428		failAttachment()
1429		return
1430	}
1431	paintToken := sess.captureAttachmentCapability(ac, tr)
1432	painted := make(chan bool, 1)
1433	paintDone, paintErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1434		//nolint:contextcheck // firstPaintForTransition is capability-bounded; the surrounding handshake operation owns cancellation.
1435		painted <- d.firstPaintForTransition(paintToken)
1436		return nil
1437	})
1438	if paintErr != nil {
1439		<-paintDone
1440		postWelcomeTicket.End()
1441		failAttachment()
1442		return
1443	}
1444	if !<-painted || handshakeCtx.Err() != nil {
1445		postWelcomeTicket.End()
1446		failAttachment()
1447		return
1448	}
1449	if !d.commitResumeClaim(ac) {
1450		postWelcomeTicket.End()
1451		failAttachment()
1452		return
1453	}
1454	postWelcomeTicket.End()
1455	stopHandshakeTransport()
1456	finishHandshake()
1457	d.runConnLoop(ac)
1458	_ = tr.Close()
1459}
1460
1461// protoErr is a session-level rejection carrying a wire ErrorMsg code.
1462type protoErr struct {
1463	code uint16
1464	text string
1465}
1466
1467func (e *protoErr) Error() string { return e.text }
1468
1469// finishAttach completes an attachment prepared while d.mu is held. Every
1470// caller must hold d.mu on entry; finishAttach transfers ownership by
1471// unlocking d.mu before returning, including every error path. It publishes
1472// terminal and role state before releasing d.mu, then defers coordinator
1473// cleanup so obsolete workers never delay the new handshake.
1474func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domain.Size, h protocol.Hello) (*attachedClient, error) {
1475	initialTabIndex := -1
1476	if h.SessionTarget != nil {
1477		var ok bool
1478		initialTabIndex, ok = remoteTargetTabIndexLocked(sess, *h.SessionTarget)
1479		if !ok {
1480			if tr != nil {
1481				_ = tr.Close()
1482			}
1483			d.mu.Unlock()
1484			return nil, &protoErr{protocol.ErrNoSuchTarget, "remote tab no longer exists"}
1485		}
1486	}
1487	if h.PreferredTabID != "" {
1488		// Route memory is a best-effort attachment cursor, not attach authority.
1489		// It overrides point-in-time picker tab metadata when still present and
1490		// otherwise falls back to the session's normal first-tab repair.
1491		initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
1492	}
1493	terminalCapabilities := terminalcap.Resolve(h.Env, h.Color)
1494	// Kitty graphics are enabled only by the explicit direct-terminal
1495	// declaration in Hello. Environment values remain useful for color and
1496	// diagnostics, but cannot authorize terminal-global graphics side effects.
1497	terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
1498	opts := attachClientOptions{
1499		clientID:               h.ClientID,
1500		kittyKeyboard:          h.KittyKeyboard,
1501		resumeCapable:          true,
1502		maxOutputInFlight:      normalizeOutputWindow(h.MaxOutputInFlight),
1503		navigationCapabilities: h.NavigationCapabilities,
1504		terminalFocus:          h.TerminalFocus,
1505		clientPID:              h.ClientPID,
1506		clientEnv:              helloClientEnvironment(h),
1507		terminalCapabilities:   terminalCapabilities,
1508		capabilitiesSet:        true,
1509	}
1510	geometry := h.Geometry()
1511	if geometry.Size != sz {
1512		geometry = domain.Geometry{Size: sz}
1513	}
1514	ac := d.prepareAttachedClientLocked(sess, tr, geometry, opts)
1515	d.mu.Unlock()
1516	result, err := d.transitionAttachment(attachmentTransitionRequest{
1517		target:            sess,
1518		next:              ac,
1519		expectedTransport: ac.transportSnapshot(),
1520		activateTargetTab: initialTabIndex >= 0,
1521		targetTabIndex:    initialTabIndex,
1522		ready:             false,
1523	})
1524	if err != nil {
1525		if ac.output != nil && ac.output.graphicsOutput != nil {
1526			d.retireGraphicsOutput(ac, ac.output.graphicsOutput)
1527		}
1528		if tr != nil {
1529			_ = tr.Close()
1530		}
1531		return nil, err
1532	}
1533	d.finishAttachedClient(sess, ac, opts)
1534	d.deferAttachmentTransitionCleanups(result)
1535	return ac, nil
1536}
1537
1538func preferredTabIndex(sess *session, preferred domain.TabStableID) int {
1539	if sess == nil || preferred == "" {
1540		return -1
1541	}
1542	sess.mu.Lock()
1543	defer sess.mu.Unlock()
1544	for i, candidate := range sess.tabs {
1545		if candidate != nil && domain.TabStableID(candidate.stableID) == preferred {
1546			return i
1547		}
1548	}
1549	return -1
1550}
1551
1552func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1553	d.mu.Lock()
1554	var (
1555		done    chan struct{}
1556		stopped inactiveSession
1557		ok      bool
1558	)
1559	if sess := d.findByNameLocked(name); sess != nil {
1560		sess.mu.Lock()
1561		done = sess.restoreDone
1562		sess.mu.Unlock()
1563	} else {
1564		stopped, ok = d.inactive[name]
1565		if !ok || !stopped.visible() {
1566			d.mu.Unlock()
1567			return nil
1568		}
1569		if stopped.restorePending() {
1570			done = stopped.restoreDone
1571		}
1572	}
1573	d.mu.Unlock()
1574
1575	if done != nil {
1576		select {
1577		case <-done:
1578		case <-ctx.Done():
1579			return ctx.Err()
1580		}
1581	}
1582
1583	d.mu.Lock()
1584	defer d.mu.Unlock()
1585	if d.findByNameLocked(name) != nil {
1586		return nil
1587	}
1588	stopped, ok = d.inactive[name]
1589	if !ok || stopped.record.Name == "" {
1590		return nil
1591	}
1592	if stopped.broken() {
1593		return &protoErr{protocol.ErrInternal, "session durable state is broken: " + name}
1594	}
1595	if stopped.record.Committed == nil {
1596		return nil
1597	}
1598	return &protoErr{protocol.ErrInternal, "session was not restored into this daemon: " + name}
1599}
1600
1601// route resolves a Hello to a session and a freshly attached client, creating
1602// the session for ephemeral/new intents. Direct package callers retain the
1603// daemon lifetime context; inbound handshakes use routeWithContext.
1604func (d *Daemon) validateExactSessionTargetLocked(target protocol.ExactSessionTarget) error {
1605	if sess := d.findByNameLocked(target.SessionName); sess != nil {
1606		if sess.incarnation != target.LifecycleID {
1607			return &protoErr{protocol.ErrNoSuchSession, "session lifecycle has changed: " + target.SessionName}
1608		}
1609		return nil
1610	}
1611	stopped, ok := d.inactive[target.SessionName]
1612	if !ok || !stopped.visible() || stopped.incarnation != target.LifecycleID {
1613		return &protoErr{protocol.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName}
1614	}
1615	return nil
1616}
1617
1618func (d *Daemon) validateExactSessionTarget(ctx context.Context, target protocol.ExactSessionTarget) error {
1619	if err := target.Validate(); err != nil {
1620		return &protoErr{protocol.ErrNoSuchSession, "invalid exact session target"}
1621	}
1622	if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil {
1623		return err
1624	}
1625
1626	d.mu.Lock()
1627	defer d.mu.Unlock()
1628	return d.validateExactSessionTargetLocked(target)
1629}
1630
1631func (d *Daemon) route(h protocol.Hello, tr ports.ServerConnection) (*session, *attachedClient, error) {
1632	ctx := d.serveCtx
1633	if ctx == nil {
1634		ctx = context.Background()
1635	}
1636	return d.routeWithContext(ctx, h, tr)
1637}
1638
1639func (d *Daemon) routeWithContext(ctx context.Context, h protocol.Hello, tr ports.ServerConnection) (*session, *attachedClient, error) {
1640	if ctx == nil {
1641		ctx = context.Background()
1642	}
1643	if err := ctx.Err(); err != nil {
1644		return nil, nil, err
1645	}
1646	sz := h.Size
1647	if !sz.Valid() {
1648		return nil, nil, &protoErr{protocol.ErrInternal, "invalid terminal size"}
1649	}
1650	// The accepting side's provisioned admission, when present, is the authority
1651	// this Hello is validated against before any restore, creation, resume
1652	// claim, ownership change, or environment mutation. A connection without
1653	// that seam receives the standard Hello validation and gains no admission
1654	// exception, so a forged Hello can never widen what it is allowed to do.
1655	admission, admitted := sessionHelloAdmission(tr)
1656	if admitted {
1657		if err := validateSessionHelloAdmission(h, admission); err != nil {
1658			return nil, nil, err
1659		}
1660		if admission.Admission == ports.BrokerAdmissionExact && h.ResumeToken != 0 {
1661			if err := d.validateResumeAdmittedTarget(h.ResumeToken, admission.Target); err != nil {
1662				return nil, nil, err
1663			}
1664		}
1665	}
1666	if h.Intent == protocol.IntentResume && h.ResumeToken == 0 {
1667		return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is required"}
1668	}
1669	if h.ExactTarget != nil && h.ResumeToken == 0 && h.Name != h.ExactTarget.SessionName {
1670		return nil, nil, &protoErr{protocol.ErrNoSuchSession, "exact session target name mismatch"}
1671	}
1672	// A remote exact-target handoff is an admitted transition like a local
1673	// create/restore: it reserves purge admission before dispatch, so a Hello
1674	// arriving during a KillAll waits on the gate exactly as a local route does
1675	// instead of being rejected outright.
1676	if h.SessionTarget != nil && h.ResumeToken == 0 {
1677		if err := d.acquirePurgeAdmission(ctx); err != nil {
1678			return nil, nil, err
1679		}
1680		defer d.releasePurgeAdmission()
1681		return d.routeRemoteTargetWithContext(ctx, h, tr)
1682	}
1683	if h.ExactTarget != nil && h.ResumeToken == 0 {
1684		if err := d.validateExactSessionTarget(ctx, *h.ExactTarget); err != nil {
1685			return nil, nil, err
1686		}
1687	}
1688
1689	// A non-zero token is an authoritative resume credential. If it is unknown,
1690	// expired, or raced with lifecycle teardown, fail closed instead of routing
1691	// the Hello as an ordinary attach that could create or replace ownership.
1692	// The one legitimate pre-park race is a still-active same-client credential
1693	// for the requested session; hand that into the park/resume lifecycle.
1694	if h.ResumeToken != 0 {
1695		d.mu.Lock()
1696		parkedAtStart := d.resume.parkedFor(h.ResumeToken)
1697		d.mu.Unlock()
1698		if parkedAtStart == nil {
1699			if sess, ac, ok, err := d.resumeLiveAttachment(h, tr, sz); err != nil {
1700				if errors.Is(err, errResumeTokenLifecycleRace) {
1701					// Live recovery parked then lost a competing resumeParked
1702					// race; keep the fail-closed wire response instead of
1703					// leaking the internal sentinel to handleHello as ErrInternal.
1704					return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1705				}
1706				return nil, nil, err
1707			} else if ok {
1708				return sess, ac, nil
1709			}
1710			return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1711		}
1712		if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
1713			if ok {
1714				return sess, ac, nil
1715			}
1716			return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1717		} else if errors.Is(err, errResumeTokenLifecycleRace) {
1718			// The parked entry was replaced while this handshake waited for
1719			// its send lock. Fail closed on the original credential rather
1720			// than falling through to ordinary attach/create routing.
1721			return nil, nil, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
1722		} else {
1723			return nil, nil, err
1724		}
1725	}
1726	if h.Intent == protocol.IntentResume || h.Intent == protocol.IntentAttach {
1727		if err := d.waitForTargetRestore(ctx, h.Name); err != nil {
1728			return nil, nil, err
1729		}
1730	}
1731	// A daemon-owned ephemeral Hello without an exact remote target is refused by the daemon. An authenticated remote admission that names the
1732	// creation is the one exception, and it is granted only under a validated
1733	// remote admission; a connection without one never gains it.
1734	if !admitted && h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned && h.SessionTarget == nil &&
1735		h.Intent == protocol.IntentEphemeral {
1736		return nil, nil, &protoErr{protocol.ErrNoSuchTarget, "daemon-owned environment requires an exact remote target"}
1737	}
1738	// KillAll admission gate: a purge owns the exact lifecycle set it captured,
1739	// so a Hello arriving during it waits here and is then admitted as a create
1740	// or restore after the purge, never inserted into the set being removed.
1741	// This is transient and re-opened by the purge; it is not daemon closing.
1742	if err := d.acquirePurgeAdmission(ctx); err != nil {
1743		return nil, nil, err
1744	}
1745	defer d.releasePurgeAdmission()
1746	d.mu.Lock()
1747	// Shutdown/create interlock: once an explicit shutdown has begun
1748	// (shutdownAll started) no new session may be created and no attach may
1749	// proceed — the teardown snapshot has already been (or is being) taken, so
1750	// anything inserted now would leak its PTY and hang Serve. An empty registry
1751	// alone is not shutdown, and a KillAll purge is gated by admission above.
1752	if d.closing {
1753		d.mu.Unlock()
1754		return nil, nil, &protoErr{protocol.ErrServerShutdown, "daemon is shutting down"}
1755	}
1756	// The first exact-target check happens before restore I/O. Recheck while
1757	// holding d.mu so a same-name lifecycle replacement cannot slip between
1758	// validation and attachment creation.
1759	if h.ExactTarget != nil && h.ResumeToken == 0 {
1760		if err := d.validateExactSessionTargetLocked(*h.ExactTarget); err != nil {
1761			d.mu.Unlock()
1762			return nil, nil, err
1763		}
1764	}
1765	switch h.Intent {
1766	case protocol.IntentEphemeral:
1767		name := d.allocEphemeralNameLocked()
1768		// A daemon-owned creation (an authenticated remote admission) uses the
1769		// daemon's own environment and home rather than any client value; a
1770		// client-owned creation (local) uses the admitted client environment and
1771		// working directory. The Hello fields are already validated to match the
1772		// admission, so this mirrors the named-creation ownership rule below.
1773		cwd, env := h.Cwd, h.Env
1774		if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {
1775			cwd, env = d.dirOrHome(""), copyEnvironment(d.baseEnv)
1776		}
1777		sess, err := d.createSessionLockedWithMode(name, true, cwd, h.Geometry(), env)
1778		if err != nil {
1779			d.mu.Unlock()
1780			return nil, nil, err
1781		}
1782		ac, err := d.finishRouteAttach(sess, tr, sz, h, true, true)
1783		return sess, ac, err
1784
1785	case protocol.IntentNew:
1786		if h.Name == "" {
1787			d.mu.Unlock()
1788			return nil, nil, &protoErr{protocol.ErrInvalidSessionName, "empty session name"}
1789		}
1790		if err := domain.ValidateSessionName(h.Name); err != nil {
1791			d.mu.Unlock()
1792			return nil, nil, &protoErr{protocol.ErrInvalidSessionName, err.Error()}
1793		}
1794		if d.nameLiveOrStoppedLocked(h.Name) {
1795			d.mu.Unlock()
1796			return nil, nil, &protoErr{protocol.ErrNameTaken, "session name already in use: " + h.Name}
1797		}
1798		cwd, env := h.Cwd, h.Env
1799		if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {
1800			// A remote CNS destination owns shell startup just like a picker
1801			// attach. Client paths may exist here but belong to another user.
1802			cwd, env = d.dirOrHome(""), copyEnvironment(d.baseEnv)
1803		}
1804		sess, err := d.createSessionLockedWithMode(h.Name, false, cwd, h.Geometry(), env)
1805		if err != nil {
1806			d.mu.Unlock()
1807			return nil, nil, err
1808		}
1809		ac, err := d.finishRouteAttach(sess, tr, sz, h, true, true)
1810		return sess, ac, err
1811
1812	case protocol.IntentAttach:
1813		sess := d.findByNameLocked(h.Name)
1814		created := false
1815		if sess == nil {
1816			stopped, ok := d.inactive[h.Name]
1817			if !ok || !stopped.canResume() {
1818				d.mu.Unlock()
1819				return nil, nil, &protoErr{protocol.ErrNoSuchSession, "no such resumable session: " + h.Name}
1820			}
1821			cwd := d.dirOrHome(stopped.cwd)
1822			env := h.Env
1823			daemonOwned := h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned
1824			if daemonOwned {
1825				env = copyEnvironment(d.baseEnv)
1826			}
1827			var err error
1828			sess, err = d.resumeInactiveSessionLocked(h.Name, cwd, h.Geometry(), env, stopped, stopped.tabNames)
1829			if err != nil {
1830				d.mu.Unlock()
1831				return nil, nil, err
1832			}
1833			if daemonOwned {
1834				// Like a snapshot restore, the resumed session starts from the
1835				// daemon's environment until a local client attaches.
1836				markEnvironmentProvisional(sess)
1837			}
1838			created = true
1839		}
1840		ac, err := d.finishRouteAttach(sess, tr, sz, h, created, false)
1841		return sess, ac, err
1842
1843	default:
1844		d.mu.Unlock()
1845		return nil, nil, &protoErr{protocol.ErrInternal, "unknown intent"}
1846	}
1847}

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