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

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