detached e8e2f00

refactor(daemon): extract snapshot responsibilities

commit
e8e2f009d2010674b6ae82f6f084c84ed5391e09
author
brice · · 2026-07-22 01:31 UTC
parent
476768f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +430 -355
A internal/usecase/daemon/snapshot_capture.go +101 -0
@@ -0,0 +1,101 @@
1+package daemon
2+
3+import snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
4+
5+// captureSession rotates history tails and clones visible frames while holding
6+// each pane lock. The returned capture contains only immutable state; encoding
7+// and persistence are deliberately deferred to snapshotEncodeWorker.
8+func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
9+ sess.mu.Lock()
10+ capture := &snapshotCapture{
11+ session: sess,
12+ generation: generation,
13+ name: sess.name,
14+ createdAt: uint64(sess.createdAt),
15+ active: uint16(max(sess.active, 0)),
16+ }
17+ ephemeral := sess.ephemeral
18+ fallbackCwd := sess.cwd
19+ tabs := append([]*tab(nil), sess.tabs...)
20+ sess.mu.Unlock()
21+ if ephemeral || capture.name == "" {
22+ return capture, false
23+ }
24+
25+ capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
26+ for _, tb := range tabs {
27+ tb.mu.Lock()
28+ tabCapture := snapshotCaptureTab{
29+ stableID: tb.stableID,
30+ cols: uint16(max(tb.size.Cols, 0)),
31+ rows: uint16(max(tb.size.Rows, 0)),
32+ nextPaneID: uint64(max(tb.nextPaneID, 0)),
33+ tree: tb.tree.Clone(),
34+ }
35+ if tb.tree != nil {
36+ tabCapture.focus = tb.tree.Focus
37+ }
38+ panes := make([]*pane, 0, len(tb.panes))
39+ for _, p := range tb.panes {
40+ panes = append(panes, p)
41+ }
42+ tb.mu.Unlock()
43+
44+ tabCapture.panes = make([]snapshotCapturePane, 0, len(panes))
45+ for _, p := range panes {
46+ p.mu.Lock()
47+ pty := p.pty
48+ pid := 0
49+ if pty != nil {
50+ pid = pty.Pid()
51+ }
52+ paneCapture := snapshotCapturePane{
53+ id: p.id,
54+ stableID: p.stableID,
55+ visible: p.screen.PrimaryVisibleSnapshot(),
56+ }
57+ paneCapture.sealed = p.history.SnapshotView()
58+ paneCapture.tail = paneCapture.sealed.Tail()
59+ p.mu.Unlock()
60+ paneCapture.cwd = fallbackCwd
61+ if d.procCwd != nil && pid > 0 {
62+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
63+ paneCapture.cwd = cwd
64+ }
65+ }
66+ paneCapture.process = d.capturePaneProcess(pty, pid)
67+ tabCapture.panes = append(tabCapture.panes, paneCapture)
68+ }
69+ capture.tabs = append(capture.tabs, tabCapture)
70+ }
71+ return capture, true
72+}
73+
74+// captureSession remains the synchronous producer-facing trigger for callers
75+// such as teardown and benchmarks; the actual encoding and Write stay async.
76+func (d *Daemon) captureSession(sess *session) bool {
77+ markSnapshotDirty(sess)
78+ return d.scheduleSnapshot(sess)
79+}
80+
81+func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
82+ if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
83+ return nil
84+ }
85+ pgid, err := pty.ForegroundPgid()
86+ if err != nil || pgid <= 0 || pgid == shellPid {
87+ return nil
88+ }
89+ argv, err := d.procGroupArgv(pgid, shellPid)
90+ if err != nil || len(argv) == 0 || argv[0] == "" {
91+ return nil
92+ }
93+ strategy := detectProcessStrategy(argv)
94+ return &snapcodec.Process{
95+ Argv: append([]string(nil), argv...),
96+ Strategy: strategy,
97+ Opts: snapcodec.ProcessOpts{
98+ AgentSessionID: extractAgentSessionID(strategy, argv),
99+ },
100+ }
101+}
@@ -0,0 +1,101 @@
1+package daemon
2+
3+import snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
4+
5+// captureSession rotates history tails and clones visible frames while holding
6+// each pane lock. The returned capture contains only immutable state; encoding
7+// and persistence are deliberately deferred to snapshotEncodeWorker.
8+func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
9+ sess.mu.Lock()
10+ capture := &snapshotCapture{
11+ session: sess,
12+ generation: generation,
13+ name: sess.name,
14+ createdAt: uint64(sess.createdAt),
15+ active: uint16(max(sess.active, 0)),
16+ }
17+ ephemeral := sess.ephemeral
18+ fallbackCwd := sess.cwd
19+ tabs := append([]*tab(nil), sess.tabs...)
20+ sess.mu.Unlock()
21+ if ephemeral || capture.name == "" {
22+ return capture, false
23+ }
24+
25+ capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
26+ for _, tb := range tabs {
27+ tb.mu.Lock()
28+ tabCapture := snapshotCaptureTab{
29+ stableID: tb.stableID,
30+ cols: uint16(max(tb.size.Cols, 0)),
31+ rows: uint16(max(tb.size.Rows, 0)),
32+ nextPaneID: uint64(max(tb.nextPaneID, 0)),
33+ tree: tb.tree.Clone(),
34+ }
35+ if tb.tree != nil {
36+ tabCapture.focus = tb.tree.Focus
37+ }
38+ panes := make([]*pane, 0, len(tb.panes))
39+ for _, p := range tb.panes {
40+ panes = append(panes, p)
41+ }
42+ tb.mu.Unlock()
43+
44+ tabCapture.panes = make([]snapshotCapturePane, 0, len(panes))
45+ for _, p := range panes {
46+ p.mu.Lock()
47+ pty := p.pty
48+ pid := 0
49+ if pty != nil {
50+ pid = pty.Pid()
51+ }
52+ paneCapture := snapshotCapturePane{
53+ id: p.id,
54+ stableID: p.stableID,
55+ visible: p.screen.PrimaryVisibleSnapshot(),
56+ }
57+ paneCapture.sealed = p.history.SnapshotView()
58+ paneCapture.tail = paneCapture.sealed.Tail()
59+ p.mu.Unlock()
60+ paneCapture.cwd = fallbackCwd
61+ if d.procCwd != nil && pid > 0 {
62+ if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
63+ paneCapture.cwd = cwd
64+ }
65+ }
66+ paneCapture.process = d.capturePaneProcess(pty, pid)
67+ tabCapture.panes = append(tabCapture.panes, paneCapture)
68+ }
69+ capture.tabs = append(capture.tabs, tabCapture)
70+ }
71+ return capture, true
72+}
73+
74+// captureSession remains the synchronous producer-facing trigger for callers
75+// such as teardown and benchmarks; the actual encoding and Write stay async.
76+func (d *Daemon) captureSession(sess *session) bool {
77+ markSnapshotDirty(sess)
78+ return d.scheduleSnapshot(sess)
79+}
80+
81+func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
82+ if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
83+ return nil
84+ }
85+ pgid, err := pty.ForegroundPgid()
86+ if err != nil || pgid <= 0 || pgid == shellPid {
87+ return nil
88+ }
89+ argv, err := d.procGroupArgv(pgid, shellPid)
90+ if err != nil || len(argv) == 0 || argv[0] == "" {
91+ return nil
92+ }
93+ strategy := detectProcessStrategy(argv)
94+ return &snapcodec.Process{
95+ Argv: append([]string(nil), argv...),
96+ Strategy: strategy,
97+ Opts: snapcodec.ProcessOpts{
98+ AgentSessionID: extractAgentSessionID(strategy, argv),
99+ },
100+ }
101+}
M internal/usecase/daemon/snapshot_coordinator.go +0 -232
@@ -3,8 +3,6 @@ package daemon
33 import (
44 "context"
55 "time"
6-
7- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
86 )
97
108 func snapshotCoordinatorContext(sess *session) (context.Context, context.CancelFunc) {
@@ -156,83 +154,6 @@ func (sess *session) signalSnapshotChangedLocked() {
156154 sess.snapshotChanged = make(chan struct{})
157155 }
158156
159-func (d *Daemon) scheduleSnapshot(sess *session) bool {
160- return d.scheduleSnapshotWithFinalFallback(sess, false)
161-}
162-
163-// scheduleFinalSnapshot captures the terminal state even when an older capture
164-// is pending. A removed session cannot be retried by the repository scheduler, so the
165-// worker drains its retained terminal state before it stops.
166-func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
167- return d.scheduleSnapshotWithFinalFallback(sess, true)
168-}
169-
170-func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
171- if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() {
172- return true
173- }
174- kind := snapshotAttemptRoutine
175- if final {
176- kind = snapshotAttemptForced
177- }
178- sess.snapshotMu.Lock()
179- if sess.snapshotQuarantined || !sess.snapDirty.Load() {
180- available := !sess.snapshotQuarantined
181- sess.snapshotMu.Unlock()
182- return available
183- }
184- if final && sess.snapshotGeneration > sess.snapshotForcedGeneration {
185- // Do not let an older queued or in-flight routine publication satisfy a
186- // terminal checkpoint. The intent is retained until a publication at
187- // this generation (or newer) succeeds.
188- sess.snapshotForcedGeneration = sess.snapshotGeneration
189- }
190- if sess.snapshotPending ||
191- (!final && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) {
192- sess.snapshotMu.Unlock()
193- return true
194- }
195- if sess.snapshotPublicationContext == nil {
196- sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
197- }
198- generation := sess.snapshotGeneration
199- publicationContext := sess.snapshotPublicationContext
200- sess.snapshotCapturedGeneration = generation
201- sess.snapshotAttempted = true
202- sess.snapshotAttemptKind = kind
203- sess.snapshotPendingCaptures++
204- sess.snapshotPending = true
205- sess.signalSnapshotChangedLocked()
206- sess.snapshotMu.Unlock()
207-
208- capture, ok := d.captureSnapshotState(sess, generation)
209- if capture != nil {
210- capture.attemptKind = kind
211- capture.publicationContext = publicationContext
212- }
213- if !ok {
214- d.finishSnapshotCapture(capture, false)
215- return false
216- }
217- sess.snapshotMu.Lock()
218- quarantined := sess.snapshotQuarantined
219- if !quarantined {
220- sess.snapshotQueuedCapture = capture
221- }
222- sess.snapshotMu.Unlock()
223- if quarantined {
224- d.finishSnapshotCapture(capture, false)
225- return false
226- }
227- if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {
228- return true
229- }
230- // Coalesce under saturation or shutdown: the latest state stays dirty and
231- // will be captured on a later tick once an active worker has room.
232- d.finishSnapshotCapture(capture, false)
233- return false
234-}
235-
236157 // waitForSnapshotGeneration waits only for the bounded terminal checkpoint
237158 // deadline. It observes successful publication, rather than dirty state, so a
238159 // concurrent later mutation cannot turn a completed forced checkpoint into a
@@ -272,137 +193,6 @@ func (d *Daemon) waitForSnapshotGenerationWithDeadline(sess *session, generation
272193 }
273194 }
274195
275-// enqueueSnapshotCapture serializes worker shutdown with the non-blocking
276-// queue send. The queue is deliberately never closed: producers can race
277-// shutdown without risking a send-on-closed panic.
278-func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
279- d.snapshotWorkerMu.Lock()
280- defer d.snapshotWorkerMu.Unlock()
281- if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
282- return false
283- }
284- select {
285- case d.snapshotJobs <- capture:
286- return true
287- default:
288- return false
289- }
290-}
291-
292-// enqueueFinalSnapshotCapture hands terminal captures to the existing worker
293-// without making session teardown wait behind its normal bounded queue. A
294-// blocked worker retains at most one terminal capture per session, replacing
295-// stale state with the newest immutable capture.
296-func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
297- if capture == nil || capture.session == nil {
298- return false
299- }
300- d.snapshotWorkerMu.Lock()
301- if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
302- d.snapshotWorkerMu.Unlock()
303- return false
304- }
305- if d.snapshotFinalJobs == nil {
306- d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
307- }
308- replaced, exists := d.snapshotFinalJobs[capture.session]
309- if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity {
310- d.snapshotWorkerMu.Unlock()
311- d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity)
312- return false
313- }
314- // Finish the stale capture before publishing its replacement. Otherwise a
315- // fast worker could persist the replacement and then have this stale failure
316- // mark the session dirty again.
317- d.finishSnapshotCapture(replaced, false)
318- if !exists {
319- d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session)
320- }
321- d.snapshotFinalJobs[capture.session] = capture
322- select {
323- case d.snapshotWorkerFinalWake <- struct{}{}:
324- default:
325- }
326- d.snapshotWorkerMu.Unlock()
327- return true
328-}
329-
330-// captureSession rotates history tails and clones visible frames while holding
331-// each pane lock. The returned capture contains only immutable state; encoding
332-// and persistence are deliberately deferred to snapshotEncodeWorker.
333-func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
334- sess.mu.Lock()
335- capture := &snapshotCapture{
336- session: sess,
337- generation: generation,
338- name: sess.name,
339- createdAt: uint64(sess.createdAt),
340- active: uint16(max(sess.active, 0)),
341- }
342- ephemeral := sess.ephemeral
343- fallbackCwd := sess.cwd
344- tabs := append([]*tab(nil), sess.tabs...)
345- sess.mu.Unlock()
346- if ephemeral || capture.name == "" {
347- return capture, false
348- }
349-
350- capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
351- for _, tb := range tabs {
352- tb.mu.Lock()
353- tabCapture := snapshotCaptureTab{
354- stableID: tb.stableID,
355- cols: uint16(max(tb.size.Cols, 0)),
356- rows: uint16(max(tb.size.Rows, 0)),
357- nextPaneID: uint64(max(tb.nextPaneID, 0)),
358- tree: tb.tree.Clone(),
359- }
360- if tb.tree != nil {
361- tabCapture.focus = tb.tree.Focus
362- }
363- panes := make([]*pane, 0, len(tb.panes))
364- for _, p := range tb.panes {
365- panes = append(panes, p)
366- }
367- tb.mu.Unlock()
368-
369- tabCapture.panes = make([]snapshotCapturePane, 0, len(panes))
370- for _, p := range panes {
371- p.mu.Lock()
372- pty := p.pty
373- pid := 0
374- if pty != nil {
375- pid = pty.Pid()
376- }
377- paneCapture := snapshotCapturePane{
378- id: p.id,
379- stableID: p.stableID,
380- visible: p.screen.PrimaryVisibleSnapshot(),
381- }
382- paneCapture.sealed = p.history.SnapshotView()
383- paneCapture.tail = paneCapture.sealed.Tail()
384- p.mu.Unlock()
385- paneCapture.cwd = fallbackCwd
386- if d.procCwd != nil && pid > 0 {
387- if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
388- paneCapture.cwd = cwd
389- }
390- }
391- paneCapture.process = d.capturePaneProcess(pty, pid)
392- tabCapture.panes = append(tabCapture.panes, paneCapture)
393- }
394- capture.tabs = append(capture.tabs, tabCapture)
395- }
396- return capture, true
397-}
398-
399-// captureSession remains the synchronous producer-facing trigger for callers
400-// such as teardown and benchmarks; the actual encoding and Write stay async.
401-func (d *Daemon) captureSession(sess *session) bool {
402- markSnapshotDirty(sess)
403- return d.scheduleSnapshot(sess)
404-}
405-
406196 // startSnapshotPublication moves a retained capture from the producer queue to
407197 // the repository worker. Quarantine and this transition share snapshotMu, so a
408198 // repository call cannot begin after destructive teardown has quarantined the
@@ -498,28 +288,6 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
498288 // blocked disk from repeatedly repainting every attached client while retaining
499289 // the count in notification history.
500290
501-func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
502- if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
503- return nil
504- }
505- pgid, err := pty.ForegroundPgid()
506- if err != nil || pgid <= 0 || pgid == shellPid {
507- return nil
508- }
509- argv, err := d.procGroupArgv(pgid, shellPid)
510- if err != nil || len(argv) == 0 || argv[0] == "" {
511- return nil
512- }
513- strategy := detectProcessStrategy(argv)
514- return &snapcodec.Process{
515- Argv: append([]string(nil), argv...),
516- Strategy: strategy,
517- Opts: snapcodec.ProcessOpts{
518- AgentSessionID: extractAgentSessionID(strategy, argv),
519- },
520- }
521-}
522-
523291 // snapshotRepositorySaver waits until the earliest completion-derived routine
524292 // eligibility. State changes wake it immediately, so a newly dirty session is
525293 // captured without waiting for an unrelated session's interval.
@@ -3,8 +3,6 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "time"5 "time"
6-
7- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
8 )6 )
9 7
10 func snapshotCoordinatorContext(sess *session) (context.Context, context.CancelFunc) {8 func snapshotCoordinatorContext(sess *session) (context.Context, context.CancelFunc) {
@@ -156,83 +154,6 @@ func (sess *session) signalSnapshotChangedLocked() {
156 sess.snapshotChanged = make(chan struct{})154 sess.snapshotChanged = make(chan struct{})
157 }155 }
158 156
159-func (d *Daemon) scheduleSnapshot(sess *session) bool {
160- return d.scheduleSnapshotWithFinalFallback(sess, false)
161-}
162-
163-// scheduleFinalSnapshot captures the terminal state even when an older capture
164-// is pending. A removed session cannot be retried by the repository scheduler, so the
165-// worker drains its retained terminal state before it stops.
166-func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
167- return d.scheduleSnapshotWithFinalFallback(sess, true)
168-}
169-
170-func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
171- if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() {
172- return true
173- }
174- kind := snapshotAttemptRoutine
175- if final {
176- kind = snapshotAttemptForced
177- }
178- sess.snapshotMu.Lock()
179- if sess.snapshotQuarantined || !sess.snapDirty.Load() {
180- available := !sess.snapshotQuarantined
181- sess.snapshotMu.Unlock()
182- return available
183- }
184- if final && sess.snapshotGeneration > sess.snapshotForcedGeneration {
185- // Do not let an older queued or in-flight routine publication satisfy a
186- // terminal checkpoint. The intent is retained until a publication at
187- // this generation (or newer) succeeds.
188- sess.snapshotForcedGeneration = sess.snapshotGeneration
189- }
190- if sess.snapshotPending ||
191- (!final && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) {
192- sess.snapshotMu.Unlock()
193- return true
194- }
195- if sess.snapshotPublicationContext == nil {
196- sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
197- }
198- generation := sess.snapshotGeneration
199- publicationContext := sess.snapshotPublicationContext
200- sess.snapshotCapturedGeneration = generation
201- sess.snapshotAttempted = true
202- sess.snapshotAttemptKind = kind
203- sess.snapshotPendingCaptures++
204- sess.snapshotPending = true
205- sess.signalSnapshotChangedLocked()
206- sess.snapshotMu.Unlock()
207-
208- capture, ok := d.captureSnapshotState(sess, generation)
209- if capture != nil {
210- capture.attemptKind = kind
211- capture.publicationContext = publicationContext
212- }
213- if !ok {
214- d.finishSnapshotCapture(capture, false)
215- return false
216- }
217- sess.snapshotMu.Lock()
218- quarantined := sess.snapshotQuarantined
219- if !quarantined {
220- sess.snapshotQueuedCapture = capture
221- }
222- sess.snapshotMu.Unlock()
223- if quarantined {
224- d.finishSnapshotCapture(capture, false)
225- return false
226- }
227- if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {
228- return true
229- }
230- // Coalesce under saturation or shutdown: the latest state stays dirty and
231- // will be captured on a later tick once an active worker has room.
232- d.finishSnapshotCapture(capture, false)
233- return false
234-}
235-
236 // waitForSnapshotGeneration waits only for the bounded terminal checkpoint157 // waitForSnapshotGeneration waits only for the bounded terminal checkpoint
237 // deadline. It observes successful publication, rather than dirty state, so a158 // deadline. It observes successful publication, rather than dirty state, so a
238 // concurrent later mutation cannot turn a completed forced checkpoint into a159 // concurrent later mutation cannot turn a completed forced checkpoint into a
@@ -272,137 +193,6 @@ func (d *Daemon) waitForSnapshotGenerationWithDeadline(sess *session, generation
272 }193 }
273 }194 }
274 195
275-// enqueueSnapshotCapture serializes worker shutdown with the non-blocking
276-// queue send. The queue is deliberately never closed: producers can race
277-// shutdown without risking a send-on-closed panic.
278-func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
279- d.snapshotWorkerMu.Lock()
280- defer d.snapshotWorkerMu.Unlock()
281- if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
282- return false
283- }
284- select {
285- case d.snapshotJobs <- capture:
286- return true
287- default:
288- return false
289- }
290-}
291-
292-// enqueueFinalSnapshotCapture hands terminal captures to the existing worker
293-// without making session teardown wait behind its normal bounded queue. A
294-// blocked worker retains at most one terminal capture per session, replacing
295-// stale state with the newest immutable capture.
296-func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
297- if capture == nil || capture.session == nil {
298- return false
299- }
300- d.snapshotWorkerMu.Lock()
301- if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
302- d.snapshotWorkerMu.Unlock()
303- return false
304- }
305- if d.snapshotFinalJobs == nil {
306- d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
307- }
308- replaced, exists := d.snapshotFinalJobs[capture.session]
309- if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity {
310- d.snapshotWorkerMu.Unlock()
311- d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity)
312- return false
313- }
314- // Finish the stale capture before publishing its replacement. Otherwise a
315- // fast worker could persist the replacement and then have this stale failure
316- // mark the session dirty again.
317- d.finishSnapshotCapture(replaced, false)
318- if !exists {
319- d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session)
320- }
321- d.snapshotFinalJobs[capture.session] = capture
322- select {
323- case d.snapshotWorkerFinalWake <- struct{}{}:
324- default:
325- }
326- d.snapshotWorkerMu.Unlock()
327- return true
328-}
329-
330-// captureSession rotates history tails and clones visible frames while holding
331-// each pane lock. The returned capture contains only immutable state; encoding
332-// and persistence are deliberately deferred to snapshotEncodeWorker.
333-func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
334- sess.mu.Lock()
335- capture := &snapshotCapture{
336- session: sess,
337- generation: generation,
338- name: sess.name,
339- createdAt: uint64(sess.createdAt),
340- active: uint16(max(sess.active, 0)),
341- }
342- ephemeral := sess.ephemeral
343- fallbackCwd := sess.cwd
344- tabs := append([]*tab(nil), sess.tabs...)
345- sess.mu.Unlock()
346- if ephemeral || capture.name == "" {
347- return capture, false
348- }
349-
350- capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
351- for _, tb := range tabs {
352- tb.mu.Lock()
353- tabCapture := snapshotCaptureTab{
354- stableID: tb.stableID,
355- cols: uint16(max(tb.size.Cols, 0)),
356- rows: uint16(max(tb.size.Rows, 0)),
357- nextPaneID: uint64(max(tb.nextPaneID, 0)),
358- tree: tb.tree.Clone(),
359- }
360- if tb.tree != nil {
361- tabCapture.focus = tb.tree.Focus
362- }
363- panes := make([]*pane, 0, len(tb.panes))
364- for _, p := range tb.panes {
365- panes = append(panes, p)
366- }
367- tb.mu.Unlock()
368-
369- tabCapture.panes = make([]snapshotCapturePane, 0, len(panes))
370- for _, p := range panes {
371- p.mu.Lock()
372- pty := p.pty
373- pid := 0
374- if pty != nil {
375- pid = pty.Pid()
376- }
377- paneCapture := snapshotCapturePane{
378- id: p.id,
379- stableID: p.stableID,
380- visible: p.screen.PrimaryVisibleSnapshot(),
381- }
382- paneCapture.sealed = p.history.SnapshotView()
383- paneCapture.tail = paneCapture.sealed.Tail()
384- p.mu.Unlock()
385- paneCapture.cwd = fallbackCwd
386- if d.procCwd != nil && pid > 0 {
387- if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
388- paneCapture.cwd = cwd
389- }
390- }
391- paneCapture.process = d.capturePaneProcess(pty, pid)
392- tabCapture.panes = append(tabCapture.panes, paneCapture)
393- }
394- capture.tabs = append(capture.tabs, tabCapture)
395- }
396- return capture, true
397-}
398-
399-// captureSession remains the synchronous producer-facing trigger for callers
400-// such as teardown and benchmarks; the actual encoding and Write stay async.
401-func (d *Daemon) captureSession(sess *session) bool {
402- markSnapshotDirty(sess)
403- return d.scheduleSnapshot(sess)
404-}
405-
406 // startSnapshotPublication moves a retained capture from the producer queue to196 // startSnapshotPublication moves a retained capture from the producer queue to
407 // the repository worker. Quarantine and this transition share snapshotMu, so a197 // the repository worker. Quarantine and this transition share snapshotMu, so a
408 // repository call cannot begin after destructive teardown has quarantined the198 // repository call cannot begin after destructive teardown has quarantined the
@@ -498,28 +288,6 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
498 // blocked disk from repeatedly repainting every attached client while retaining288 // blocked disk from repeatedly repainting every attached client while retaining
499 // the count in notification history.289 // the count in notification history.
500 290
501-func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
502- if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
503- return nil
504- }
505- pgid, err := pty.ForegroundPgid()
506- if err != nil || pgid <= 0 || pgid == shellPid {
507- return nil
508- }
509- argv, err := d.procGroupArgv(pgid, shellPid)
510- if err != nil || len(argv) == 0 || argv[0] == "" {
511- return nil
512- }
513- strategy := detectProcessStrategy(argv)
514- return &snapcodec.Process{
515- Argv: append([]string(nil), argv...),
516- Strategy: strategy,
517- Opts: snapcodec.ProcessOpts{
518- AgentSessionID: extractAgentSessionID(strategy, argv),
519- },
520- }
521-}
522-
523 // snapshotRepositorySaver waits until the earliest completion-derived routine291 // snapshotRepositorySaver waits until the earliest completion-derived routine
524 // eligibility. State changes wake it immediately, so a newly dirty session is292 // eligibility. State changes wake it immediately, so a newly dirty session is
525 // captured without waiting for an unrelated session's interval.293 // captured without waiting for an unrelated session's interval.
A internal/usecase/daemon/snapshot_queue.go +135 -0
@@ -0,0 +1,135 @@
1+package daemon
2+
3+import "context"
4+
5+func (d *Daemon) scheduleSnapshot(sess *session) bool {
6+ return d.scheduleSnapshotWithFinalFallback(sess, false)
7+}
8+
9+// scheduleFinalSnapshot captures the terminal state even when an older capture
10+// is pending. A removed session cannot be retried by the repository scheduler, so the
11+// worker drains its retained terminal state before it stops.
12+func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
13+ return d.scheduleSnapshotWithFinalFallback(sess, true)
14+}
15+
16+func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
17+ if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() {
18+ return true
19+ }
20+ kind := snapshotAttemptRoutine
21+ if final {
22+ kind = snapshotAttemptForced
23+ }
24+ sess.snapshotMu.Lock()
25+ if sess.snapshotQuarantined || !sess.snapDirty.Load() {
26+ available := !sess.snapshotQuarantined
27+ sess.snapshotMu.Unlock()
28+ return available
29+ }
30+ if final && sess.snapshotGeneration > sess.snapshotForcedGeneration {
31+ // Do not let an older queued or in-flight routine publication satisfy a
32+ // terminal checkpoint. The intent is retained until a publication at
33+ // this generation (or newer) succeeds.
34+ sess.snapshotForcedGeneration = sess.snapshotGeneration
35+ }
36+ if sess.snapshotPending ||
37+ (!final && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) {
38+ sess.snapshotMu.Unlock()
39+ return true
40+ }
41+ if sess.snapshotPublicationContext == nil {
42+ sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
43+ }
44+ generation := sess.snapshotGeneration
45+ publicationContext := sess.snapshotPublicationContext
46+ sess.snapshotCapturedGeneration = generation
47+ sess.snapshotAttempted = true
48+ sess.snapshotAttemptKind = kind
49+ sess.snapshotPendingCaptures++
50+ sess.snapshotPending = true
51+ sess.signalSnapshotChangedLocked()
52+ sess.snapshotMu.Unlock()
53+
54+ capture, ok := d.captureSnapshotState(sess, generation)
55+ if capture != nil {
56+ capture.attemptKind = kind
57+ capture.publicationContext = publicationContext
58+ }
59+ if !ok {
60+ d.finishSnapshotCapture(capture, false)
61+ return false
62+ }
63+ sess.snapshotMu.Lock()
64+ quarantined := sess.snapshotQuarantined
65+ if !quarantined {
66+ sess.snapshotQueuedCapture = capture
67+ }
68+ sess.snapshotMu.Unlock()
69+ if quarantined {
70+ d.finishSnapshotCapture(capture, false)
71+ return false
72+ }
73+ if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {
74+ return true
75+ }
76+ // Coalesce under saturation or shutdown: the latest state stays dirty and
77+ // will be captured on a later tick once an active worker has room.
78+ d.finishSnapshotCapture(capture, false)
79+ return false
80+}
81+
82+// enqueueSnapshotCapture serializes worker shutdown with the non-blocking
83+// queue send. The queue is deliberately never closed: producers can race
84+// shutdown without risking a send-on-closed panic.
85+func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
86+ d.snapshotWorkerMu.Lock()
87+ defer d.snapshotWorkerMu.Unlock()
88+ if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
89+ return false
90+ }
91+ select {
92+ case d.snapshotJobs <- capture:
93+ return true
94+ default:
95+ return false
96+ }
97+}
98+
99+// enqueueFinalSnapshotCapture hands terminal captures to the existing worker
100+// without making session teardown wait behind its normal bounded queue. A
101+// blocked worker retains at most one terminal capture per session, replacing
102+// stale state with the newest immutable capture.
103+func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
104+ if capture == nil || capture.session == nil {
105+ return false
106+ }
107+ d.snapshotWorkerMu.Lock()
108+ if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
109+ d.snapshotWorkerMu.Unlock()
110+ return false
111+ }
112+ if d.snapshotFinalJobs == nil {
113+ d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
114+ }
115+ replaced, exists := d.snapshotFinalJobs[capture.session]
116+ if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity {
117+ d.snapshotWorkerMu.Unlock()
118+ d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity)
119+ return false
120+ }
121+ // Finish the stale capture before publishing its replacement. Otherwise a
122+ // fast worker could persist the replacement and then have this stale failure
123+ // mark the session dirty again.
124+ d.finishSnapshotCapture(replaced, false)
125+ if !exists {
126+ d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session)
127+ }
128+ d.snapshotFinalJobs[capture.session] = capture
129+ select {
130+ case d.snapshotWorkerFinalWake <- struct{}{}:
131+ default:
132+ }
133+ d.snapshotWorkerMu.Unlock()
134+ return true
135+}
@@ -0,0 +1,135 @@
1+package daemon
2+
3+import "context"
4+
5+func (d *Daemon) scheduleSnapshot(sess *session) bool {
6+ return d.scheduleSnapshotWithFinalFallback(sess, false)
7+}
8+
9+// scheduleFinalSnapshot captures the terminal state even when an older capture
10+// is pending. A removed session cannot be retried by the repository scheduler, so the
11+// worker drains its retained terminal state before it stops.
12+func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
13+ return d.scheduleSnapshotWithFinalFallback(sess, true)
14+}
15+
16+func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
17+ if d == nil || sess == nil || !d.snapsEnabled || d.snapshotRepository == nil || !sess.snapEligible.Load() {
18+ return true
19+ }
20+ kind := snapshotAttemptRoutine
21+ if final {
22+ kind = snapshotAttemptForced
23+ }
24+ sess.snapshotMu.Lock()
25+ if sess.snapshotQuarantined || !sess.snapDirty.Load() {
26+ available := !sess.snapshotQuarantined
27+ sess.snapshotMu.Unlock()
28+ return available
29+ }
30+ if final && sess.snapshotGeneration > sess.snapshotForcedGeneration {
31+ // Do not let an older queued or in-flight routine publication satisfy a
32+ // terminal checkpoint. The intent is retained until a publication at
33+ // this generation (or newer) succeeds.
34+ sess.snapshotForcedGeneration = sess.snapshotGeneration
35+ }
36+ if sess.snapshotPending ||
37+ (!final && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) {
38+ sess.snapshotMu.Unlock()
39+ return true
40+ }
41+ if sess.snapshotPublicationContext == nil {
42+ sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
43+ }
44+ generation := sess.snapshotGeneration
45+ publicationContext := sess.snapshotPublicationContext
46+ sess.snapshotCapturedGeneration = generation
47+ sess.snapshotAttempted = true
48+ sess.snapshotAttemptKind = kind
49+ sess.snapshotPendingCaptures++
50+ sess.snapshotPending = true
51+ sess.signalSnapshotChangedLocked()
52+ sess.snapshotMu.Unlock()
53+
54+ capture, ok := d.captureSnapshotState(sess, generation)
55+ if capture != nil {
56+ capture.attemptKind = kind
57+ capture.publicationContext = publicationContext
58+ }
59+ if !ok {
60+ d.finishSnapshotCapture(capture, false)
61+ return false
62+ }
63+ sess.snapshotMu.Lock()
64+ quarantined := sess.snapshotQuarantined
65+ if !quarantined {
66+ sess.snapshotQueuedCapture = capture
67+ }
68+ sess.snapshotMu.Unlock()
69+ if quarantined {
70+ d.finishSnapshotCapture(capture, false)
71+ return false
72+ }
73+ if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {
74+ return true
75+ }
76+ // Coalesce under saturation or shutdown: the latest state stays dirty and
77+ // will be captured on a later tick once an active worker has room.
78+ d.finishSnapshotCapture(capture, false)
79+ return false
80+}
81+
82+// enqueueSnapshotCapture serializes worker shutdown with the non-blocking
83+// queue send. The queue is deliberately never closed: producers can race
84+// shutdown without risking a send-on-closed panic.
85+func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
86+ d.snapshotWorkerMu.Lock()
87+ defer d.snapshotWorkerMu.Unlock()
88+ if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
89+ return false
90+ }
91+ select {
92+ case d.snapshotJobs <- capture:
93+ return true
94+ default:
95+ return false
96+ }
97+}
98+
99+// enqueueFinalSnapshotCapture hands terminal captures to the existing worker
100+// without making session teardown wait behind its normal bounded queue. A
101+// blocked worker retains at most one terminal capture per session, replacing
102+// stale state with the newest immutable capture.
103+func (d *Daemon) enqueueFinalSnapshotCapture(capture *snapshotCapture) bool {
104+ if capture == nil || capture.session == nil {
105+ return false
106+ }
107+ d.snapshotWorkerMu.Lock()
108+ if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
109+ d.snapshotWorkerMu.Unlock()
110+ return false
111+ }
112+ if d.snapshotFinalJobs == nil {
113+ d.snapshotFinalJobs = make(map[*session]*snapshotCapture)
114+ }
115+ replaced, exists := d.snapshotFinalJobs[capture.session]
116+ if !exists && len(d.snapshotFinalJobs) >= snapshotFinalQueueCapacity {
117+ d.snapshotWorkerMu.Unlock()
118+ d.log.Warn("terminal snapshot retention saturated; capture rejected", "session", capture.name, "capacity", snapshotFinalQueueCapacity)
119+ return false
120+ }
121+ // Finish the stale capture before publishing its replacement. Otherwise a
122+ // fast worker could persist the replacement and then have this stale failure
123+ // mark the session dirty again.
124+ d.finishSnapshotCapture(replaced, false)
125+ if !exists {
126+ d.snapshotFinalOrder = append(d.snapshotFinalOrder, capture.session)
127+ }
128+ d.snapshotFinalJobs[capture.session] = capture
129+ select {
130+ case d.snapshotWorkerFinalWake <- struct{}{}:
131+ default:
132+ }
133+ d.snapshotWorkerMu.Unlock()
134+ return true
135+}
M internal/usecase/daemon/snapshot_restore.go +170 -123
@@ -45,10 +45,7 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) {
4545
4646 }
4747
48-func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {
49- if err := ctx.Err(); err != nil {
50- return err
51- }
48+func validateRestoreSessionSnapshot(snap snapcodec.Session) error {
5249 if len(snap.Tabs) == 0 {
5350 if snap.Active != 0 {
5451 return fmt.Errorf("snapshot: active tab out of range")
@@ -62,127 +59,182 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
6259 if snap.CreatedAt > math.MaxInt64 {
6360 return fmt.Errorf("snapshot: created_at overflows int64")
6461 }
65- d.mu.Lock()
66- if d.closing || d.findByNameLocked(snap.Name) != nil {
67- d.mu.Unlock()
62+ return nil
63+}
64+
65+// restoreSession owns the restored tabs until registration succeeds. Every
66+// unsuccessful path closes their PTYs and cancels their shared session context.
67+func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {
68+ if err := ctx.Err(); err != nil {
69+ return err
70+ }
71+ if err := validateRestoreSessionSnapshot(snap); err != nil {
72+ return err
73+ }
74+ if d.restoredSessionAlreadyExists(snap.Name) {
6875 return nil
6976 }
70- d.mu.Unlock()
7177
7278 parent := d.serveCtx
7379 if parent == nil {
7480 parent = context.Background()
7581 }
7682 sctx, cancel := context.WithCancel(parent)
77- opened := make([]*tab, 0, len(snap.Tabs))
78- // Snapshot restore runs before any client Hello is available. Start panes
79- // from the daemon environment captured at startup; the next attach replaces
80- // this snapshot for future panes from the client's capability.
81- restoreTerm := terminalEnv{}
82- restoreEnv := copyEnvironment(d.baseEnv)
83- closeOpened := func() {
84- for _, tb := range opened {
85- tb.closeAllPanes()
86- }
83+ opened, err := d.restoreSnapshotTabs(ctx, sctx, snap)
84+ if err != nil {
8785 cancel()
86+ return err
8887 }
89- allowlist := d.restoreProcessAllowlistSnapshot()
88+ if len(opened) == 0 {
89+ cancel()
90+ return nil
91+ }
92+ ownsOpened := true
93+ defer func() {
94+ if ownsOpened {
95+ closeRestoredTabs(opened)
96+ cancel()
97+ }
98+ }()
99+
100+ sess := d.newRestoredSession(snap, sctx, cancel, opened)
101+ registered, err := d.persistAndRegisterRestoredSession(ctx, sess)
102+ if err != nil || !registered {
103+ return err
104+ }
105+ ownsOpened = false
106+ for _, tb := range opened {
107+ d.startTabGoroutines(sess, tb)
108+ }
109+ return nil
110+}
111+
112+func (d *Daemon) restoredSessionAlreadyExists(name string) bool {
90113 d.mu.Lock()
91- persisted := d.stopped[snap.Name]
92- d.mu.Unlock()
93- for tabIndex, tabSnap := range snap.Tabs {
114+ defer d.mu.Unlock()
115+ return d.closing || d.findByNameLocked(name) != nil
116+}
117+
118+func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.Session) ([]*tab, error) {
119+ // Restore runs before client Hello. Future panes therefore inherit the
120+ // daemon's startup environment until an attach supplies terminal capability.
121+ restoreTerm := terminalEnv{}
122+ restoreEnv := copyEnvironment(d.baseEnv)
123+ allowlist := d.restoreProcessAllowlistSnapshot()
124+ stoppedTabNames := d.restoredSessionTabNames(snap.Name)
125+ opened := make([]*tab, 0, len(snap.Tabs))
126+ for index, tabSnap := range snap.Tabs {
94127 if err := ctx.Err(); err != nil {
95- closeOpened()
96- return err
128+ closeRestoredTabs(opened)
129+ return nil, err
97130 }
98- tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
99- if !tbSize.Valid() {
100- closeOpened()
101- return fmt.Errorf("snapshot: invalid tab size")
131+ tabName := ""
132+ if index < len(stoppedTabNames) {
133+ tabName = stoppedTabNames[index]
102134 }
103- placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows})
104- if !ok {
105- closeOpened()
106- return fmt.Errorf("snapshot: unsolvable layout")
135+ tb, err := d.restoreSnapshotTab(ctx, sctx, snap.Name, tabName, tabSnap, restoreEnv, restoreTerm, allowlist)
136+ if err != nil {
137+ closeRestoredTabs(opened)
138+ return nil, err
107139 }
108- placementByPane := make(map[layout.PaneID]domain.Rect, len(placements))
109- for _, pl := range placements {
110- placementByPane[pl.ID] = pl.Content
140+ opened = append(opened, tb)
141+ }
142+ return opened, nil
143+}
144+
145+// closeRestoredTabs releases every PTY and pane context still owned by a
146+// failed restore. Registered sessions transfer this ownership to the daemon.
147+func closeRestoredTabs(tabs []*tab) {
148+ for _, tb := range tabs {
149+ tb.closeAllPanes()
150+ }
151+}
152+
153+func (d *Daemon) restoredSessionTabNames(name string) []string {
154+ d.mu.Lock()
155+ defer d.mu.Unlock()
156+ return append([]string(nil), d.stopped[name].tabNames...)
157+}
158+
159+func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}) (*tab, error) {
160+ tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
161+ if !tbSize.Valid() {
162+ return nil, fmt.Errorf("snapshot: invalid tab size")
163+ }
164+ placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows})
165+ if !ok {
166+ return nil, fmt.Errorf("snapshot: unsolvable layout")
167+ }
168+ placementByPane := make(map[layout.PaneID]domain.Rect, len(placements))
169+ for _, placement := range placements {
170+ placementByPane[placement.ID] = placement.Content
171+ }
172+ tabStableID := tabSnap.StableID
173+ if tabStableID == "" {
174+ var err error
175+ tabStableID, err = newStableID("t")
176+ if err != nil {
177+ return nil, fmt.Errorf("snapshot: generating tab identity: %w", err)
111178 }
112- tabStableID := tabSnap.StableID
113- if tabStableID == "" {
114- var err error
115- tabStableID, err = newStableID("t")
116- if err != nil {
117- closeOpened()
118- return fmt.Errorf("snapshot: generating tab identity: %w", err)
119- }
179+ }
180+ tb := &tab{stableID: tabStableID, name: tabName, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
181+ if tb.nextPaneID <= 0 {
182+ tb.nextPaneID = 1
183+ }
184+ tb.ctx, tb.cancel = context.WithCancel(sctx)
185+ for _, paneSnap := range tabSnap.Panes {
186+ if err := ctx.Err(); err != nil {
187+ tb.closeAllPanes()
188+ return nil, err
120189 }
121- tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
122- if tabIndex < len(persisted.tabNames) {
123- tb.name = persisted.tabNames[tabIndex]
190+ contentRect, ok := placementByPane[paneSnap.ID]
191+ if !ok {
192+ tb.closeAllPanes()
193+ return nil, fmt.Errorf("snapshot: missing pane placement")
124194 }
125- if tb.nextPaneID <= 0 {
126- tb.nextPaneID = 1
195+ p, err := d.restoreSnapshotPane(ctx, sessionName, tabStableID, paneSnap, contentRect, tbSize, restoreEnv, restoreTerm, allowlist, tb.ctx)
196+ if err != nil {
197+ tb.closeAllPanes()
198+ return nil, err
127199 }
128- tb.ctx, tb.cancel = context.WithCancel(sctx)
129- opened = append(opened, tb)
130- for _, paneSnap := range tabSnap.Panes {
131- restoreCommand := ""
132- if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {
133- restoreCommand = decision.Command
134- }
135- if err := ctx.Err(); err != nil {
136- closeOpened()
137- return err
138- }
139- contentRect, ok := placementByPane[paneSnap.ID]
140- if !ok {
141- closeOpened()
142- return fmt.Errorf("snapshot: missing pane placement")
143- }
144- contentSize := restorePTYSize(contentRect, tbSize)
145- paneStableID := paneSnap.StableID
146- if paneStableID == "" {
147- var err error
148- paneStableID, err = newStableID("p")
149- if err != nil {
150- closeOpened()
151- return fmt.Errorf("snapshot: generating pane identity: %w", err)
152- }
153- }
154- command, args := d.ptyCommand(restoreEnv)
155- pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
156- if err != nil {
157- closeOpened()
158- return err
159- }
160- p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
161- p.ctx, p.cancel = context.WithCancel(tb.ctx)
162- p.rect = contentRect
163- if err := restorePaneTerminal(p, paneSnap); err != nil {
164- // p is not in tb.panes yet, so closeOpened cannot reach it.
165- p.cancel()
166- _ = pty.Close()
167- closeOpened()
168- return err
169- }
170- if restoreCommand != "" {
171- if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {
172- d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)
173- d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume,
174- "couldn't restore the running program in session "+snap.Name, err)
175- }
176- }
177- tb.panes[paneSnap.ID] = p
200+ tb.panes[paneSnap.ID] = p
201+ }
202+ return tb, nil
203+}
204+
205+func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStableID string, paneSnap snapcodec.Pane, contentRect domain.Rect, tabSize domain.Size, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}, tabCtx context.Context) (*pane, error) {
206+ paneStableID := paneSnap.StableID
207+ if paneStableID == "" {
208+ var err error
209+ paneStableID, err = newStableID("p")
210+ if err != nil {
211+ return nil, fmt.Errorf("snapshot: generating pane identity: %w", err)
178212 }
179213 }
180- if len(opened) == 0 {
181- cancel()
182- return nil
214+ command, args := d.ptyCommand(restoreEnv)
215+ pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))
216+ if err != nil {
217+ return nil, err
218+ }
219+ p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize))
220+ p.ctx, p.cancel = context.WithCancel(tabCtx)
221+ p.rect = contentRect
222+ if err := restorePaneTerminal(p, paneSnap); err != nil {
223+ p.cancel()
224+ _ = pty.Close()
225+ return nil, err
226+ }
227+ if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {
228+ if _, err := pty.Write([]byte(decision.Command + "\n")); err != nil {
229+ d.log.Warn("writing snapshot restore command failed", "err", err, "session", sessionName, "pane", paneSnap.ID)
230+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume, "couldn't restore the running program in session "+sessionName, err)
231+ }
183232 }
184- createdAt := int64(snap.CreatedAt)
185- sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, env: restoreEnv, createdAt: createdAt, snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
233+ return p, nil
234+}
235+
236+func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
237+ sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
186238 sess.snapEligible.Store(true)
187239 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
188240 sess.cwd = snap.Tabs[0].Panes[0].Cwd
@@ -192,33 +244,28 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
192244 sess.mruAt.Store(stopped.lastUsedSeq)
193245 }
194246 d.mu.Unlock()
195- record := sess.persistRecordLocked(createdAt)
196- if err := d.persist.Save(record); err != nil {
197- closeOpened()
198- return err
247+ return sess
248+}
249+
250+// persistAndRegisterRestoredSession atomically transfers a successfully
251+// persisted session to the daemon. The caller retains cleanup on false/error.
252+func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) {
253+ if err := d.persist.Save(sess.persistRecordLocked(sess.createdAt)); err != nil {
254+ return false, err
199255 }
200256 d.mu.Lock()
201- if d.closing || d.findByNameLocked(snap.Name) != nil || ctx.Err() != nil {
202- d.mu.Unlock()
203- closeOpened()
204- return nil
257+ defer d.mu.Unlock()
258+ if d.closing || d.findByNameLocked(sess.name) != nil || ctx.Err() != nil {
259+ return false, nil
205260 }
206- id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
261+ sess.id = domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
207262 d.nextID++
208- sess.id = id
209- delete(d.stopped, snap.Name)
210- d.sessions[id] = sess
211- d.mu.Unlock()
263+ delete(d.stopped, sess.name)
264+ d.sessions[sess.id] = sess
212265 sess.snapDirty.Store(false)
213- for _, tb := range opened {
214- d.startTabGoroutines(sess, tb)
215- }
216- return nil
266+ return true, nil
217267 }
218268
219-// snapshotCoordinatorContext returns the cancellation context shared by this
220-// session's queued and in-flight captures. snapshotMu serializes creation with
221-
222269 func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
223270 if len(snap.Tail) == 0 {
224271 return fmt.Errorf("snapshot history: missing tail blob")
@@ -45,10 +45,7 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) {
45 45
46 }46 }
47 47
48-func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {48+func validateRestoreSessionSnapshot(snap snapcodec.Session) error {
49- if err := ctx.Err(); err != nil {
50- return err
51- }
52 if len(snap.Tabs) == 0 {49 if len(snap.Tabs) == 0 {
53 if snap.Active != 0 {50 if snap.Active != 0 {
54 return fmt.Errorf("snapshot: active tab out of range")51 return fmt.Errorf("snapshot: active tab out of range")
@@ -62,127 +59,182 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
62 if snap.CreatedAt > math.MaxInt64 {59 if snap.CreatedAt > math.MaxInt64 {
63 return fmt.Errorf("snapshot: created_at overflows int64")60 return fmt.Errorf("snapshot: created_at overflows int64")
64 }61 }
65- d.mu.Lock()62+ return nil
66- if d.closing || d.findByNameLocked(snap.Name) != nil {63+}
67- d.mu.Unlock()64+
65+// restoreSession owns the restored tabs until registration succeeds. Every
66+// unsuccessful path closes their PTYs and cancels their shared session context.
67+func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {
68+ if err := ctx.Err(); err != nil {
69+ return err
70+ }
71+ if err := validateRestoreSessionSnapshot(snap); err != nil {
72+ return err
73+ }
74+ if d.restoredSessionAlreadyExists(snap.Name) {
68 return nil75 return nil
69 }76 }
70- d.mu.Unlock()
71 77
72 parent := d.serveCtx78 parent := d.serveCtx
73 if parent == nil {79 if parent == nil {
74 parent = context.Background()80 parent = context.Background()
75 }81 }
76 sctx, cancel := context.WithCancel(parent)82 sctx, cancel := context.WithCancel(parent)
77- opened := make([]*tab, 0, len(snap.Tabs))83+ opened, err := d.restoreSnapshotTabs(ctx, sctx, snap)
78- // Snapshot restore runs before any client Hello is available. Start panes84+ if err != nil {
79- // from the daemon environment captured at startup; the next attach replaces
80- // this snapshot for future panes from the client's capability.
81- restoreTerm := terminalEnv{}
82- restoreEnv := copyEnvironment(d.baseEnv)
83- closeOpened := func() {
84- for _, tb := range opened {
85- tb.closeAllPanes()
86- }
87 cancel()85 cancel()
86+ return err
88 }87 }
89- allowlist := d.restoreProcessAllowlistSnapshot()88+ if len(opened) == 0 {
89+ cancel()
90+ return nil
91+ }
92+ ownsOpened := true
93+ defer func() {
94+ if ownsOpened {
95+ closeRestoredTabs(opened)
96+ cancel()
97+ }
98+ }()
99+
100+ sess := d.newRestoredSession(snap, sctx, cancel, opened)
101+ registered, err := d.persistAndRegisterRestoredSession(ctx, sess)
102+ if err != nil || !registered {
103+ return err
104+ }
105+ ownsOpened = false
106+ for _, tb := range opened {
107+ d.startTabGoroutines(sess, tb)
108+ }
109+ return nil
110+}
111+
112+func (d *Daemon) restoredSessionAlreadyExists(name string) bool {
90 d.mu.Lock()113 d.mu.Lock()
91- persisted := d.stopped[snap.Name]114+ defer d.mu.Unlock()
92- d.mu.Unlock()115+ return d.closing || d.findByNameLocked(name) != nil
93- for tabIndex, tabSnap := range snap.Tabs {116+}
117+
118+func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.Session) ([]*tab, error) {
119+ // Restore runs before client Hello. Future panes therefore inherit the
120+ // daemon's startup environment until an attach supplies terminal capability.
121+ restoreTerm := terminalEnv{}
122+ restoreEnv := copyEnvironment(d.baseEnv)
123+ allowlist := d.restoreProcessAllowlistSnapshot()
124+ stoppedTabNames := d.restoredSessionTabNames(snap.Name)
125+ opened := make([]*tab, 0, len(snap.Tabs))
126+ for index, tabSnap := range snap.Tabs {
94 if err := ctx.Err(); err != nil {127 if err := ctx.Err(); err != nil {
95- closeOpened()128+ closeRestoredTabs(opened)
96- return err129+ return nil, err
97 }130 }
98- tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}131+ tabName := ""
99- if !tbSize.Valid() {132+ if index < len(stoppedTabNames) {
100- closeOpened()133+ tabName = stoppedTabNames[index]
101- return fmt.Errorf("snapshot: invalid tab size")
102 }134 }
103- placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows})135+ tb, err := d.restoreSnapshotTab(ctx, sctx, snap.Name, tabName, tabSnap, restoreEnv, restoreTerm, allowlist)
104- if !ok {136+ if err != nil {
105- closeOpened()137+ closeRestoredTabs(opened)
106- return fmt.Errorf("snapshot: unsolvable layout")138+ return nil, err
107 }139 }
108- placementByPane := make(map[layout.PaneID]domain.Rect, len(placements))140+ opened = append(opened, tb)
109- for _, pl := range placements {141+ }
110- placementByPane[pl.ID] = pl.Content142+ return opened, nil
143+}
144+
145+// closeRestoredTabs releases every PTY and pane context still owned by a
146+// failed restore. Registered sessions transfer this ownership to the daemon.
147+func closeRestoredTabs(tabs []*tab) {
148+ for _, tb := range tabs {
149+ tb.closeAllPanes()
150+ }
151+}
152+
153+func (d *Daemon) restoredSessionTabNames(name string) []string {
154+ d.mu.Lock()
155+ defer d.mu.Unlock()
156+ return append([]string(nil), d.stopped[name].tabNames...)
157+}
158+
159+func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}) (*tab, error) {
160+ tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
161+ if !tbSize.Valid() {
162+ return nil, fmt.Errorf("snapshot: invalid tab size")
163+ }
164+ placements, ok := layout.Solve(tabSnap.Tree.Root, domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows})
165+ if !ok {
166+ return nil, fmt.Errorf("snapshot: unsolvable layout")
167+ }
168+ placementByPane := make(map[layout.PaneID]domain.Rect, len(placements))
169+ for _, placement := range placements {
170+ placementByPane[placement.ID] = placement.Content
171+ }
172+ tabStableID := tabSnap.StableID
173+ if tabStableID == "" {
174+ var err error
175+ tabStableID, err = newStableID("t")
176+ if err != nil {
177+ return nil, fmt.Errorf("snapshot: generating tab identity: %w", err)
111 }178 }
112- tabStableID := tabSnap.StableID179+ }
113- if tabStableID == "" {180+ tb := &tab{stableID: tabStableID, name: tabName, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
114- var err error181+ if tb.nextPaneID <= 0 {
115- tabStableID, err = newStableID("t")182+ tb.nextPaneID = 1
116- if err != nil {183+ }
117- closeOpened()184+ tb.ctx, tb.cancel = context.WithCancel(sctx)
118- return fmt.Errorf("snapshot: generating tab identity: %w", err)185+ for _, paneSnap := range tabSnap.Panes {
119- }186+ if err := ctx.Err(); err != nil {
187+ tb.closeAllPanes()
188+ return nil, err
120 }189 }
121- tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}190+ contentRect, ok := placementByPane[paneSnap.ID]
122- if tabIndex < len(persisted.tabNames) {191+ if !ok {
123- tb.name = persisted.tabNames[tabIndex]192+ tb.closeAllPanes()
193+ return nil, fmt.Errorf("snapshot: missing pane placement")
124 }194 }
125- if tb.nextPaneID <= 0 {195+ p, err := d.restoreSnapshotPane(ctx, sessionName, tabStableID, paneSnap, contentRect, tbSize, restoreEnv, restoreTerm, allowlist, tb.ctx)
126- tb.nextPaneID = 1196+ if err != nil {
197+ tb.closeAllPanes()
198+ return nil, err
127 }199 }
128- tb.ctx, tb.cancel = context.WithCancel(sctx)200+ tb.panes[paneSnap.ID] = p
129- opened = append(opened, tb)201+ }
130- for _, paneSnap := range tabSnap.Panes {202+ return tb, nil
131- restoreCommand := ""203+}
132- if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {204+
133- restoreCommand = decision.Command205+func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStableID string, paneSnap snapcodec.Pane, contentRect domain.Rect, tabSize domain.Size, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}, tabCtx context.Context) (*pane, error) {
134- }206+ paneStableID := paneSnap.StableID
135- if err := ctx.Err(); err != nil {207+ if paneStableID == "" {
136- closeOpened()208+ var err error
137- return err209+ paneStableID, err = newStableID("p")
138- }210+ if err != nil {
139- contentRect, ok := placementByPane[paneSnap.ID]211+ return nil, fmt.Errorf("snapshot: generating pane identity: %w", err)
140- if !ok {
141- closeOpened()
142- return fmt.Errorf("snapshot: missing pane placement")
143- }
144- contentSize := restorePTYSize(contentRect, tbSize)
145- paneStableID := paneSnap.StableID
146- if paneStableID == "" {
147- var err error
148- paneStableID, err = newStableID("p")
149- if err != nil {
150- closeOpened()
151- return fmt.Errorf("snapshot: generating pane identity: %w", err)
152- }
153- }
154- command, args := d.ptyCommand(restoreEnv)
155- pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
156- if err != nil {
157- closeOpened()
158- return err
159- }
160- p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
161- p.ctx, p.cancel = context.WithCancel(tb.ctx)
162- p.rect = contentRect
163- if err := restorePaneTerminal(p, paneSnap); err != nil {
164- // p is not in tb.panes yet, so closeOpened cannot reach it.
165- p.cancel()
166- _ = pty.Close()
167- closeOpened()
168- return err
169- }
170- if restoreCommand != "" {
171- if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {
172- d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)
173- d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume,
174- "couldn't restore the running program in session "+snap.Name, err)
175- }
176- }
177- tb.panes[paneSnap.ID] = p
178 }212 }
179 }213 }
180- if len(opened) == 0 {214+ command, args := d.ptyCommand(restoreEnv)
181- cancel()215+ pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))
182- return nil216+ if err != nil {
217+ return nil, err
218+ }
219+ p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize))
220+ p.ctx, p.cancel = context.WithCancel(tabCtx)
221+ p.rect = contentRect
222+ if err := restorePaneTerminal(p, paneSnap); err != nil {
223+ p.cancel()
224+ _ = pty.Close()
225+ return nil, err
226+ }
227+ if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {
228+ if _, err := pty.Write([]byte(decision.Command + "\n")); err != nil {
229+ d.log.Warn("writing snapshot restore command failed", "err", err, "session", sessionName, "pane", paneSnap.ID)
230+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeAutoResume, "couldn't restore the running program in session "+sessionName, err)
231+ }
183 }232 }
184- createdAt := int64(snap.CreatedAt)233+ return p, nil
185- sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, env: restoreEnv, createdAt: createdAt, snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}234+}
235+
236+func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
237+ sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
186 sess.snapEligible.Store(true)238 sess.snapEligible.Store(true)
187 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {239 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
188 sess.cwd = snap.Tabs[0].Panes[0].Cwd240 sess.cwd = snap.Tabs[0].Panes[0].Cwd
@@ -192,33 +244,28 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
192 sess.mruAt.Store(stopped.lastUsedSeq)244 sess.mruAt.Store(stopped.lastUsedSeq)
193 }245 }
194 d.mu.Unlock()246 d.mu.Unlock()
195- record := sess.persistRecordLocked(createdAt)247+ return sess
196- if err := d.persist.Save(record); err != nil {248+}
197- closeOpened()249+
198- return err250+// persistAndRegisterRestoredSession atomically transfers a successfully
251+// persisted session to the daemon. The caller retains cleanup on false/error.
252+func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) {
253+ if err := d.persist.Save(sess.persistRecordLocked(sess.createdAt)); err != nil {
254+ return false, err
199 }255 }
200 d.mu.Lock()256 d.mu.Lock()
201- if d.closing || d.findByNameLocked(snap.Name) != nil || ctx.Err() != nil {257+ defer d.mu.Unlock()
202- d.mu.Unlock()258+ if d.closing || d.findByNameLocked(sess.name) != nil || ctx.Err() != nil {
203- closeOpened()259+ return false, nil
204- return nil
205 }260 }
206- id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))261+ sess.id = domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
207 d.nextID++262 d.nextID++
208- sess.id = id263+ delete(d.stopped, sess.name)
209- delete(d.stopped, snap.Name)264+ d.sessions[sess.id] = sess
210- d.sessions[id] = sess
211- d.mu.Unlock()
212 sess.snapDirty.Store(false)265 sess.snapDirty.Store(false)
213- for _, tb := range opened {266+ return true, nil
214- d.startTabGoroutines(sess, tb)
215- }
216- return nil
217 }267 }
218 268
219-// snapshotCoordinatorContext returns the cancellation context shared by this
220-// session's queued and in-flight captures. snapshotMu serializes creation with
221-
222 func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {269 func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
223 if len(snap.Tail) == 0 {270 if len(snap.Tail) == 0 {
224 return fmt.Errorf("snapshot history: missing tail blob")271 return fmt.Errorf("snapshot history: missing tail blob")
M internal/usecase/daemon/snapshot_restore_test.go +24 -0
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "errors"
6+ "math"
67 "sync"
78 "testing"
89
@@ -155,6 +156,29 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u
155156 return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects}
156157 }
157158
159+func TestValidateRestoreSessionSnapshot(t *testing.T) {
160+ valid := restoreAcceptanceSession(t, "restored")
161+ for _, test := range []struct {
162+ name string
163+ snap snapcodec.Session
164+ }{
165+ {"valid", valid},
166+ {"empty name", func() snapcodec.Session { snap := valid; snap.Name = ""; return snap }()},
167+ {"active without tabs", func() snapcodec.Session { snap := valid; snap.Tabs = nil; snap.Active = 1; return snap }()},
168+ {"active beyond tabs", func() snapcodec.Session { snap := valid; snap.Active = 1; return snap }()},
169+ {"created at overflow", func() snapcodec.Session { snap := valid; snap.CreatedAt = math.MaxInt64 + 1; return snap }()},
170+ } {
171+ t.Run(test.name, func(t *testing.T) {
172+ err := validateRestoreSessionSnapshot(test.snap)
173+ if test.name == "valid" {
174+ require.NoError(t, err)
175+ return
176+ }
177+ require.Error(t, err)
178+ })
179+ }
180+}
181+
158182 func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
159183 snapshot := restoreAcceptanceSession(t, "restored")
160184 generation := acceptanceGeneration(t, snapshot, 9)
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "math"
6 "sync"7 "sync"
7 "testing"8 "testing"
8 9
@@ -155,6 +156,29 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u
155 return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects}156 return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects}
156 }157 }
157 158
159+func TestValidateRestoreSessionSnapshot(t *testing.T) {
160+ valid := restoreAcceptanceSession(t, "restored")
161+ for _, test := range []struct {
162+ name string
163+ snap snapcodec.Session
164+ }{
165+ {"valid", valid},
166+ {"empty name", func() snapcodec.Session { snap := valid; snap.Name = ""; return snap }()},
167+ {"active without tabs", func() snapcodec.Session { snap := valid; snap.Tabs = nil; snap.Active = 1; return snap }()},
168+ {"active beyond tabs", func() snapcodec.Session { snap := valid; snap.Active = 1; return snap }()},
169+ {"created at overflow", func() snapcodec.Session { snap := valid; snap.CreatedAt = math.MaxInt64 + 1; return snap }()},
170+ } {
171+ t.Run(test.name, func(t *testing.T) {
172+ err := validateRestoreSessionSnapshot(test.snap)
173+ if test.name == "valid" {
174+ require.NoError(t, err)
175+ return
176+ }
177+ require.Error(t, err)
178+ })
179+ }
180+}
181+
158 func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {182 func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
159 snapshot := restoreAcceptanceSession(t, "restored")183 snapshot := restoreAcceptanceSession(t, "restored")
160 generation := acceptanceGeneration(t, snapshot, 9)184 generation := acceptanceGeneration(t, snapshot, 9)

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