detached 6b789ff

fix(snapshot): enforce strict async lifecycle

commit
6b789ff6daa8e090b073e2c705842e7f8e0810bb
author
brice · · 2026-07-12 16:35 UTC
parent
8151d78
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +242 -93
M internal/usecase/daemon/daemon.go +4 -2
@@ -103,11 +103,13 @@ type Daemon struct {
103103 snaps ports.SnapshotStore
104104 snapsEnabled bool
105105 snapshotMarshal func(snapcodec.Session) ([]byte, error)
106- snapshotJobs chan snapshotCapture
106+ snapshotJobs chan *snapshotCapture
107107 snapshotWorkerMu sync.Mutex
108+ snapshotWorkerID uint64
108109 snapshotWorkerCtx context.Context
109110 snapshotWorkerCancel context.CancelFunc
110111 snapshotWorkerDone chan struct{}
112+ snapshotWorkerInFlight *snapshotCapture
111113 restoreDone chan struct{}
112114 restoreOnce sync.Once
113115 procCwd func(int) (string, error)
@@ -293,7 +295,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
293295 restoreDone: make(chan struct{}),
294296 animWake: make(chan struct{}, 1),
295297 snapshotMarshal: snapcodec.Marshal,
296- snapshotJobs: make(chan snapshotCapture, snapshotQueueCapacity),
298+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
297299 resumeParkGrace: defaultResumeParkGrace,
298300 barScripts: &barScriptState{
299301 cfg: barConfigFromDomain(domain.Defaults().Bar),
@@ -103,11 +103,13 @@ type Daemon struct {
103 snaps ports.SnapshotStore103 snaps ports.SnapshotStore
104 snapsEnabled bool104 snapsEnabled bool
105 snapshotMarshal func(snapcodec.Session) ([]byte, error)105 snapshotMarshal func(snapcodec.Session) ([]byte, error)
106- snapshotJobs chan snapshotCapture106+ snapshotJobs chan *snapshotCapture
107 snapshotWorkerMu sync.Mutex107 snapshotWorkerMu sync.Mutex
108+ snapshotWorkerID uint64
108 snapshotWorkerCtx context.Context109 snapshotWorkerCtx context.Context
109 snapshotWorkerCancel context.CancelFunc110 snapshotWorkerCancel context.CancelFunc
110 snapshotWorkerDone chan struct{}111 snapshotWorkerDone chan struct{}
112+ snapshotWorkerInFlight *snapshotCapture
111 restoreDone chan struct{}113 restoreDone chan struct{}
112 restoreOnce sync.Once114 restoreOnce sync.Once
113 procCwd func(int) (string, error)115 procCwd func(int) (string, error)
@@ -293,7 +295,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
293 restoreDone: make(chan struct{}),295 restoreDone: make(chan struct{}),
294 animWake: make(chan struct{}, 1),296 animWake: make(chan struct{}, 1),
295 snapshotMarshal: snapcodec.Marshal,297 snapshotMarshal: snapcodec.Marshal,
296- snapshotJobs: make(chan snapshotCapture, snapshotQueueCapacity),298+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
297 resumeParkGrace: defaultResumeParkGrace,299 resumeParkGrace: defaultResumeParkGrace,
298 barScripts: &barScriptState{300 barScripts: &barScriptState{
299 cfg: barConfigFromDomain(domain.Defaults().Bar),301 cfg: barConfigFromDomain(domain.Defaults().Bar),
M internal/usecase/daemon/snapshot.go +71 -39
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "math"
7+ "sync"
78 "time"
89
910 "github.com/bnema/vev/internal/domain"
@@ -24,6 +25,7 @@ type snapshotCapture struct {
2425 createdAt uint64
2526 active uint16
2627 tabs []snapshotCaptureTab
28+ finishOnce sync.Once
2729 }
2830
2931 type snapshotCaptureTab struct {
@@ -245,24 +247,19 @@ func markSnapshotDirty(sess *session) {
245247 }
246248
247249 func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
250+ if len(snap.Tail) == 0 {
251+ return fmt.Errorf("snapshot history: missing tail blob")
252+ }
253+ if len(snap.Visible) == 0 {
254+ return fmt.Errorf("snapshot visible: missing visible blob")
255+ }
248256 p.mu.Lock()
249257 defer p.mu.Unlock()
250- // Unmarshal requires both blobs. The empty fallback only supports direct
251- // in-process construction used by restore callers; it is unreachable for a
252- // durable snapshot because the v3 manifest rejects missing blobs.
253- tail := snap.Tail
254- if len(tail) == 0 {
255- tail, _ = vt.MarshalEmptyHistoryTail()
256- }
257- visible := snap.Visible
258- if len(visible) == 0 {
259- visible, _ = vt.MarshalVisible(p.screen.PrimaryVisibleFrame())
260- }
261- history, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, tail)
258+ history, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)
262259 if err != nil {
263260 return fmt.Errorf("snapshot history: %w", err)
264261 }
265- if err := p.screen.RestorePrimaryVisible(visible); err != nil {
262+ if err := p.screen.RestorePrimaryVisible(snap.Visible); err != nil {
266263 return fmt.Errorf("snapshot visible: %w", err)
267264 }
268265 p.history = history
@@ -305,7 +302,7 @@ func (d *Daemon) scheduleSnapshot(sess *session) bool {
305302
306303 capture, ok := d.captureSnapshotState(sess, generation)
307304 if !ok {
308- d.finishSnapshotCapture(sess, generation, false)
305+ d.finishSnapshotCapture(capture, false)
309306 return false
310307 }
311308 if d.enqueueSnapshotCapture(capture) {
@@ -313,14 +310,14 @@ func (d *Daemon) scheduleSnapshot(sess *session) bool {
313310 }
314311 // Coalesce under saturation or shutdown: the latest state stays dirty and
315312 // will be captured on a later tick once an active worker has room.
316- d.finishSnapshotCapture(sess, generation, false)
313+ d.finishSnapshotCapture(capture, false)
317314 return false
318315 }
319316
320317 // enqueueSnapshotCapture serializes worker shutdown with the non-blocking
321318 // queue send. The queue is deliberately never closed: producers can race
322319 // shutdown without risking a send-on-closed panic.
323-func (d *Daemon) enqueueSnapshotCapture(capture snapshotCapture) bool {
320+func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
324321 d.snapshotWorkerMu.Lock()
325322 defer d.snapshotWorkerMu.Unlock()
326323 if d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
@@ -337,9 +334,9 @@ func (d *Daemon) enqueueSnapshotCapture(capture snapshotCapture) bool {
337334 // captureSession rotates history tails and clones visible frames while holding
338335 // each pane lock. The returned capture contains only immutable state; encoding
339336 // and persistence are deliberately deferred to snapshotEncodeWorker.
340-func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (snapshotCapture, bool) {
337+func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
341338 sess.mu.Lock()
342- capture := snapshotCapture{
339+ capture := &snapshotCapture{
343340 session: sess,
344341 generation: generation,
345342 name: sess.name,
@@ -351,7 +348,7 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (snapsho
351348 tabs := append([]*tab(nil), sess.tabs...)
352349 sess.mu.Unlock()
353350 if ephemeral || capture.name == "" {
354- return snapshotCapture{}, false
351+ return capture, false
355352 }
356353
357354 capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
@@ -409,7 +406,7 @@ func (d *Daemon) captureSession(sess *session) bool {
409406 return d.scheduleSnapshot(sess)
410407 }
411408
412-func (d *Daemon) encodeSnapshotCapture(capture snapshotCapture) ([]byte, error) {
409+func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error) {
413410 snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))}
414411 for _, tabCapture := range capture.tabs {
415412 tabSnap := snapcodec.Tab{
@@ -435,15 +432,20 @@ func (d *Daemon) encodeSnapshotCapture(capture snapshotCapture) ([]byte, error)
435432 return d.snapshotMarshal(snap)
436433 }
437434
438-func (d *Daemon) finishSnapshotCapture(sess *session, generation uint64, succeeded bool) {
439- sess.snapshotMu.Lock()
440- sess.snapshotPending = false
441- if succeeded && sess.snapshotGeneration == generation {
442- sess.snapDirty.Store(false)
443- } else if !succeeded {
444- sess.snapDirty.Store(true)
435+func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) {
436+ if capture == nil {
437+ return
445438 }
446- sess.snapshotMu.Unlock()
439+ capture.finishOnce.Do(func() {
440+ capture.session.snapshotMu.Lock()
441+ capture.session.snapshotPending = false
442+ if succeeded && capture.session.snapshotGeneration == capture.generation {
443+ capture.session.snapDirty.Store(false)
444+ } else if !succeeded {
445+ capture.session.snapDirty.Store(true)
446+ }
447+ capture.session.snapshotMu.Unlock()
448+ })
447449 }
448450
449451 func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
@@ -456,9 +458,12 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
456458 return
457459 }
458460 workerCtx, cancel := context.WithCancel(ctx)
461+ d.snapshotWorkerID++
462+ workerID := d.snapshotWorkerID
459463 d.snapshotWorkerCtx = workerCtx
460464 d.snapshotWorkerCancel = cancel
461465 d.snapshotWorkerDone = make(chan struct{})
466+ d.snapshotWorkerInFlight = nil
462467 done := d.snapshotWorkerDone
463468 d.snapshotWorkerMu.Unlock()
464469 go func() {
@@ -469,45 +474,72 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
469474 return
470475 case capture := <-d.snapshotJobs:
471476 // A queued capture must not begin persistence after shutdown.
472- if workerCtx.Err() != nil {
473- d.finishSnapshotCapture(capture.session, capture.generation, false)
477+ if workerCtx.Err() != nil || !d.setSnapshotWorkerInFlight(workerID, capture) {
478+ d.finishSnapshotCapture(capture, false)
474479 return
475480 }
476481 data, err := d.encodeSnapshotCapture(capture)
477- if err == nil {
482+ if err == nil && workerCtx.Err() == nil {
478483 err = d.snaps.Write(capture.name, data)
479484 }
480- if err != nil {
485+ d.clearSnapshotWorkerInFlight(workerID, capture)
486+ if err != nil && workerCtx.Err() == nil {
481487 d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name)
482488 }
483- d.finishSnapshotCapture(capture.session, capture.generation, err == nil)
489+ d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil)
484490 }
485491 }
486492 }()
487493 }
488494
495+func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {
496+ d.snapshotWorkerMu.Lock()
497+ defer d.snapshotWorkerMu.Unlock()
498+ if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
499+ return false
500+ }
501+ d.snapshotWorkerInFlight = capture
502+ return true
503+}
504+
505+func (d *Daemon) clearSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) {
506+ d.snapshotWorkerMu.Lock()
507+ defer d.snapshotWorkerMu.Unlock()
508+ if d.snapshotWorkerID == workerID && d.snapshotWorkerInFlight == capture {
509+ d.snapshotWorkerInFlight = nil
510+ }
511+}
512+
489513 func (d *Daemon) stopSnapshotEncodeWorker() {
490514 if d == nil {
491515 return
492516 }
493517 d.snapshotWorkerMu.Lock()
494- defer d.snapshotWorkerMu.Unlock()
495- cancel, done := d.snapshotWorkerCancel, d.snapshotWorkerDone
518+ cancel := d.snapshotWorkerCancel
496519 if cancel == nil {
520+ d.snapshotWorkerMu.Unlock()
497521 return
498522 }
499- // Keep workerMu held until the old worker has exited and its queue has been
500- // drained, so a concurrent restart cannot consume a stale capture.
523+ // The queue stays open, so producers can safely race teardown. Detach an
524+ // in-flight uncancellable Write instead of waiting for it; the process owns
525+ // that goroutine until the store returns.
526+ inFlight := d.snapshotWorkerInFlight
501527 d.snapshotWorkerCtx = nil
502528 d.snapshotWorkerCancel = nil
503529 d.snapshotWorkerDone = nil
530+ d.snapshotWorkerInFlight = nil
504531 cancel()
505- <-done
532+ queued := make([]*snapshotCapture, 0, len(d.snapshotJobs))
506533 for {
507534 select {
508535 case capture := <-d.snapshotJobs:
509- d.finishSnapshotCapture(capture.session, capture.generation, false)
536+ queued = append(queued, capture)
510537 default:
538+ d.snapshotWorkerMu.Unlock()
539+ d.finishSnapshotCapture(inFlight, false)
540+ for _, capture := range queued {
541+ d.finishSnapshotCapture(capture, false)
542+ }
511543 return
512544 }
513545 }
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "math"6 "math"
7+ "sync"
7 "time"8 "time"
8 9
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
@@ -24,6 +25,7 @@ type snapshotCapture struct {
24 createdAt uint6425 createdAt uint64
25 active uint1626 active uint16
26 tabs []snapshotCaptureTab27 tabs []snapshotCaptureTab
28+ finishOnce sync.Once
27 }29 }
28 30
29 type snapshotCaptureTab struct {31 type snapshotCaptureTab struct {
@@ -245,24 +247,19 @@ func markSnapshotDirty(sess *session) {
245 }247 }
246 248
247 func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {249 func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
250+ if len(snap.Tail) == 0 {
251+ return fmt.Errorf("snapshot history: missing tail blob")
252+ }
253+ if len(snap.Visible) == 0 {
254+ return fmt.Errorf("snapshot visible: missing visible blob")
255+ }
248 p.mu.Lock()256 p.mu.Lock()
249 defer p.mu.Unlock()257 defer p.mu.Unlock()
250- // Unmarshal requires both blobs. The empty fallback only supports direct258+ history, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)
251- // in-process construction used by restore callers; it is unreachable for a
252- // durable snapshot because the v3 manifest rejects missing blobs.
253- tail := snap.Tail
254- if len(tail) == 0 {
255- tail, _ = vt.MarshalEmptyHistoryTail()
256- }
257- visible := snap.Visible
258- if len(visible) == 0 {
259- visible, _ = vt.MarshalVisible(p.screen.PrimaryVisibleFrame())
260- }
261- history, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, tail)
262 if err != nil {259 if err != nil {
263 return fmt.Errorf("snapshot history: %w", err)260 return fmt.Errorf("snapshot history: %w", err)
264 }261 }
265- if err := p.screen.RestorePrimaryVisible(visible); err != nil {262+ if err := p.screen.RestorePrimaryVisible(snap.Visible); err != nil {
266 return fmt.Errorf("snapshot visible: %w", err)263 return fmt.Errorf("snapshot visible: %w", err)
267 }264 }
268 p.history = history265 p.history = history
@@ -305,7 +302,7 @@ func (d *Daemon) scheduleSnapshot(sess *session) bool {
305 302
306 capture, ok := d.captureSnapshotState(sess, generation)303 capture, ok := d.captureSnapshotState(sess, generation)
307 if !ok {304 if !ok {
308- d.finishSnapshotCapture(sess, generation, false)305+ d.finishSnapshotCapture(capture, false)
309 return false306 return false
310 }307 }
311 if d.enqueueSnapshotCapture(capture) {308 if d.enqueueSnapshotCapture(capture) {
@@ -313,14 +310,14 @@ func (d *Daemon) scheduleSnapshot(sess *session) bool {
313 }310 }
314 // Coalesce under saturation or shutdown: the latest state stays dirty and311 // Coalesce under saturation or shutdown: the latest state stays dirty and
315 // will be captured on a later tick once an active worker has room.312 // will be captured on a later tick once an active worker has room.
316- d.finishSnapshotCapture(sess, generation, false)313+ d.finishSnapshotCapture(capture, false)
317 return false314 return false
318 }315 }
319 316
320 // enqueueSnapshotCapture serializes worker shutdown with the non-blocking317 // enqueueSnapshotCapture serializes worker shutdown with the non-blocking
321 // queue send. The queue is deliberately never closed: producers can race318 // queue send. The queue is deliberately never closed: producers can race
322 // shutdown without risking a send-on-closed panic.319 // shutdown without risking a send-on-closed panic.
323-func (d *Daemon) enqueueSnapshotCapture(capture snapshotCapture) bool {320+func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
324 d.snapshotWorkerMu.Lock()321 d.snapshotWorkerMu.Lock()
325 defer d.snapshotWorkerMu.Unlock()322 defer d.snapshotWorkerMu.Unlock()
326 if d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {323 if d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
@@ -337,9 +334,9 @@ func (d *Daemon) enqueueSnapshotCapture(capture snapshotCapture) bool {
337 // captureSession rotates history tails and clones visible frames while holding334 // captureSession rotates history tails and clones visible frames while holding
338 // each pane lock. The returned capture contains only immutable state; encoding335 // each pane lock. The returned capture contains only immutable state; encoding
339 // and persistence are deliberately deferred to snapshotEncodeWorker.336 // and persistence are deliberately deferred to snapshotEncodeWorker.
340-func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (snapshotCapture, bool) {337+func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
341 sess.mu.Lock()338 sess.mu.Lock()
342- capture := snapshotCapture{339+ capture := &snapshotCapture{
343 session: sess,340 session: sess,
344 generation: generation,341 generation: generation,
345 name: sess.name,342 name: sess.name,
@@ -351,7 +348,7 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (snapsho
351 tabs := append([]*tab(nil), sess.tabs...)348 tabs := append([]*tab(nil), sess.tabs...)
352 sess.mu.Unlock()349 sess.mu.Unlock()
353 if ephemeral || capture.name == "" {350 if ephemeral || capture.name == "" {
354- return snapshotCapture{}, false351+ return capture, false
355 }352 }
356 353
357 capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))354 capture.tabs = make([]snapshotCaptureTab, 0, len(tabs))
@@ -409,7 +406,7 @@ func (d *Daemon) captureSession(sess *session) bool {
409 return d.scheduleSnapshot(sess)406 return d.scheduleSnapshot(sess)
410 }407 }
411 408
412-func (d *Daemon) encodeSnapshotCapture(capture snapshotCapture) ([]byte, error) {409+func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error) {
413 snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))}410 snap := snapcodec.Session{Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.Tab, 0, len(capture.tabs))}
414 for _, tabCapture := range capture.tabs {411 for _, tabCapture := range capture.tabs {
415 tabSnap := snapcodec.Tab{412 tabSnap := snapcodec.Tab{
@@ -435,15 +432,20 @@ func (d *Daemon) encodeSnapshotCapture(capture snapshotCapture) ([]byte, error)
435 return d.snapshotMarshal(snap)432 return d.snapshotMarshal(snap)
436 }433 }
437 434
438-func (d *Daemon) finishSnapshotCapture(sess *session, generation uint64, succeeded bool) {435+func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) {
439- sess.snapshotMu.Lock()436+ if capture == nil {
440- sess.snapshotPending = false437+ return
441- if succeeded && sess.snapshotGeneration == generation {
442- sess.snapDirty.Store(false)
443- } else if !succeeded {
444- sess.snapDirty.Store(true)
445 }438 }
446- sess.snapshotMu.Unlock()439+ capture.finishOnce.Do(func() {
440+ capture.session.snapshotMu.Lock()
441+ capture.session.snapshotPending = false
442+ if succeeded && capture.session.snapshotGeneration == capture.generation {
443+ capture.session.snapDirty.Store(false)
444+ } else if !succeeded {
445+ capture.session.snapDirty.Store(true)
446+ }
447+ capture.session.snapshotMu.Unlock()
448+ })
447 }449 }
448 450
449 func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {451 func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
@@ -456,9 +458,12 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
456 return458 return
457 }459 }
458 workerCtx, cancel := context.WithCancel(ctx)460 workerCtx, cancel := context.WithCancel(ctx)
461+ d.snapshotWorkerID++
462+ workerID := d.snapshotWorkerID
459 d.snapshotWorkerCtx = workerCtx463 d.snapshotWorkerCtx = workerCtx
460 d.snapshotWorkerCancel = cancel464 d.snapshotWorkerCancel = cancel
461 d.snapshotWorkerDone = make(chan struct{})465 d.snapshotWorkerDone = make(chan struct{})
466+ d.snapshotWorkerInFlight = nil
462 done := d.snapshotWorkerDone467 done := d.snapshotWorkerDone
463 d.snapshotWorkerMu.Unlock()468 d.snapshotWorkerMu.Unlock()
464 go func() {469 go func() {
@@ -469,45 +474,72 @@ func (d *Daemon) startSnapshotEncodeWorker(ctx context.Context) {
469 return474 return
470 case capture := <-d.snapshotJobs:475 case capture := <-d.snapshotJobs:
471 // A queued capture must not begin persistence after shutdown.476 // A queued capture must not begin persistence after shutdown.
472- if workerCtx.Err() != nil {477+ if workerCtx.Err() != nil || !d.setSnapshotWorkerInFlight(workerID, capture) {
473- d.finishSnapshotCapture(capture.session, capture.generation, false)478+ d.finishSnapshotCapture(capture, false)
474 return479 return
475 }480 }
476 data, err := d.encodeSnapshotCapture(capture)481 data, err := d.encodeSnapshotCapture(capture)
477- if err == nil {482+ if err == nil && workerCtx.Err() == nil {
478 err = d.snaps.Write(capture.name, data)483 err = d.snaps.Write(capture.name, data)
479 }484 }
480- if err != nil {485+ d.clearSnapshotWorkerInFlight(workerID, capture)
486+ if err != nil && workerCtx.Err() == nil {
481 d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name)487 d.log.Warn("writing session snapshot failed", "err", err, "session", capture.name)
482 }488 }
483- d.finishSnapshotCapture(capture.session, capture.generation, err == nil)489+ d.finishSnapshotCapture(capture, err == nil && workerCtx.Err() == nil)
484 }490 }
485 }491 }
486 }()492 }()
487 }493 }
488 494
495+func (d *Daemon) setSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) bool {
496+ d.snapshotWorkerMu.Lock()
497+ defer d.snapshotWorkerMu.Unlock()
498+ if d.snapshotWorkerID != workerID || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
499+ return false
500+ }
501+ d.snapshotWorkerInFlight = capture
502+ return true
503+}
504+
505+func (d *Daemon) clearSnapshotWorkerInFlight(workerID uint64, capture *snapshotCapture) {
506+ d.snapshotWorkerMu.Lock()
507+ defer d.snapshotWorkerMu.Unlock()
508+ if d.snapshotWorkerID == workerID && d.snapshotWorkerInFlight == capture {
509+ d.snapshotWorkerInFlight = nil
510+ }
511+}
512+
489 func (d *Daemon) stopSnapshotEncodeWorker() {513 func (d *Daemon) stopSnapshotEncodeWorker() {
490 if d == nil {514 if d == nil {
491 return515 return
492 }516 }
493 d.snapshotWorkerMu.Lock()517 d.snapshotWorkerMu.Lock()
494- defer d.snapshotWorkerMu.Unlock()518+ cancel := d.snapshotWorkerCancel
495- cancel, done := d.snapshotWorkerCancel, d.snapshotWorkerDone
496 if cancel == nil {519 if cancel == nil {
520+ d.snapshotWorkerMu.Unlock()
497 return521 return
498 }522 }
499- // Keep workerMu held until the old worker has exited and its queue has been523+ // The queue stays open, so producers can safely race teardown. Detach an
500- // drained, so a concurrent restart cannot consume a stale capture.524+ // in-flight uncancellable Write instead of waiting for it; the process owns
525+ // that goroutine until the store returns.
526+ inFlight := d.snapshotWorkerInFlight
501 d.snapshotWorkerCtx = nil527 d.snapshotWorkerCtx = nil
502 d.snapshotWorkerCancel = nil528 d.snapshotWorkerCancel = nil
503 d.snapshotWorkerDone = nil529 d.snapshotWorkerDone = nil
530+ d.snapshotWorkerInFlight = nil
504 cancel()531 cancel()
505- <-done532+ queued := make([]*snapshotCapture, 0, len(d.snapshotJobs))
506 for {533 for {
507 select {534 select {
508 case capture := <-d.snapshotJobs:535 case capture := <-d.snapshotJobs:
509- d.finishSnapshotCapture(capture.session, capture.generation, false)536+ queued = append(queued, capture)
510 default:537 default:
538+ d.snapshotWorkerMu.Unlock()
539+ d.finishSnapshotCapture(inFlight, false)
540+ for _, capture := range queued {
541+ d.finishSnapshotCapture(capture, false)
542+ }
511 return543 return
512 }544 }
513 }545 }
M internal/usecase/daemon/snapshot_restore_test.go +72 -43
@@ -38,6 +38,27 @@ func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRo
3838 return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible}
3939 }
4040
41+func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) {
42+ valid := terminalPane(t, "pane", "p", "/work", nil, nil)
43+ tests := []struct {
44+ name string
45+ mutate func(*snapcodec.Pane)
46+ }{
47+ {name: "missing tail", mutate: func(p *snapcodec.Pane) { p.Tail = nil }},
48+ {name: "missing visible", mutate: func(p *snapcodec.Pane) { p.Visible = nil }},
49+ {name: "malformed tail", mutate: func(p *snapcodec.Pane) { p.Tail = []byte("bad") }},
50+ {name: "malformed visible", mutate: func(p *snapcodec.Pane) { p.Visible = []byte("bad") }},
51+ }
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ snap := valid
55+ tt.mutate(&snap)
56+ p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24})
57+ require.Error(t, restorePaneTerminal(p, snap))
58+ })
59+ }
60+}
61+
4162 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
4263 store := &restoreSnapshotStore{}
4364 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
@@ -126,8 +147,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
126147 func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {
127148 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
128149 Name: "work", Active: 0, Tabs: []snapcodec.Tab{
129- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/one"}}},
130- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}},
150+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}},
151+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}},
131152 },
132153 })}}}
133154 factory := &restorePTYFactory{}
@@ -156,17 +177,15 @@ func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testi
156177
157178 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
158179 t.Helper()
180+ pane := emptyTerminalPane(t, "pane-1", "/tmp")
181+ pane.Process = proc
159182 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
160183 Name: name,
161184 Tabs: []snapcodec.Tab{{
162- Cols: 80,
163- Rows: 24,
164- Tree: layout.NewTree("pane-1"),
165- Panes: []snapcodec.Pane{{
166- ID: "pane-1",
167- Cwd: "/tmp",
168- Process: proc,
169- }},
185+ Cols: 80,
186+ Rows: 24,
187+ Tree: layout.NewTree("pane-1"),
188+ Panes: []snapcodec.Pane{pane},
170189 }},
171190 })}}}
172191 }
@@ -203,20 +222,17 @@ func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) {
203222 Cols: 80,
204223 Rows: 24,
205224 Tree: layout.NewTree("pane-1"),
206- Panes: []snapcodec.Pane{{
207- ID: "pane-1",
208- Cwd: "/one",
209- Process: lessProc,
210- }},
225+ Panes: func() []snapcodec.Pane {
226+ pane := emptyTerminalPane(t, "pane-1", "/one")
227+ pane.Process = lessProc
228+ return []snapcodec.Pane{pane}
229+ }(),
211230 },
212231 {
213- Cols: 80,
214- Rows: 24,
215- Tree: layout.NewTree("pane-1"),
216- Panes: []snapcodec.Pane{{
217- ID: "pane-1",
218- Cwd: "/two",
219- }},
232+ Cols: 80,
233+ Rows: 24,
234+ Tree: layout.NewTree("pane-1"),
235+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")},
220236 },
221237 },
222238 })}}},
@@ -264,7 +280,9 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
264280 d := newTestDaemon(t, factory, stubClock{})
265281 d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
266282
267- require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}}))
283+ pane := emptyTerminalPane(t, "pane-1", "/tmp")
284+ pane.Process = proc
285+ require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}}}}))
268286 require.Len(t, factory.opens, 1)
269287 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
270288 }
@@ -276,7 +294,7 @@ func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) {
276294 defer cancel()
277295
278296 require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{
279- Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp"}},
297+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
280298 }}}))
281299 require.Len(t, factory.opens, 1)
282300 require.Same(t, ctx, factory.opens[0].ctx)
@@ -291,7 +309,11 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
291309 Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
292310 layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
293311 }}},
294- Panes: []snapcodec.Pane{{ID: "a", Cwd: "/a"}, {ID: "b", Cwd: "/b"}, {ID: "c", Cwd: "/c"}},
312+ Panes: []snapcodec.Pane{
313+ emptyTerminalPane(t, "a", "/a"),
314+ emptyTerminalPane(t, "b", "/b"),
315+ emptyTerminalPane(t, "c", "/c"),
316+ },
295317 }},
296318 })}}}
297319 factory := &restorePTYFactory{}
@@ -321,7 +343,16 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
321343 }
322344
323345 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
324- valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}})
346+ valid := mustSnapshotBytes(t, snapcodec.Session{
347+ Name: "live",
348+ CreatedAt: 1,
349+ Tabs: []snapcodec.Tab{{
350+ Cols: 80,
351+ Rows: 24,
352+ Tree: layout.NewTree("pane-1"),
353+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/ok")},
354+ }},
355+ })
325356 badVersion := append([]byte(nil), valid...)
326357 binary.BigEndian.PutUint16(badVersion[4:6], 1)
327358 tests := []struct {
@@ -358,7 +389,7 @@ func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
358389 Cols: 80,
359390 Rows: 24,
360391 Tree: layout.NewTree("pane-1"),
361- Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/bad"}},
392+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")},
362393 }},
363394 })}}}
364395 factory := &restorePTYFactory{}
@@ -384,7 +415,15 @@ func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
384415 loadFn: func() ([]ports.SnapshotBlob, error) {
385416 close(loaded)
386417 <-releaseLoad
387- return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/restored"}}}}})}}, nil
418+ return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
419+ Name: "work",
420+ Tabs: []snapcodec.Tab{{
421+ Cols: 80,
422+ Rows: 24,
423+ Tree: layout.NewTree("pane-1"),
424+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/restored")},
425+ }},
426+ })}}, nil
388427 },
389428 }
390429 factory := &restorePTYFactory{}
@@ -433,23 +472,13 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
433472 require.Equal(t, ports.ErrServerShutdown, perr.code)
434473 }
435474
475+func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane {
476+ t.Helper()
477+ return terminalPane(t, id, "", cwd, nil, nil)
478+}
479+
436480 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
437481 t.Helper()
438- for ti := range s.Tabs {
439- for pi := range s.Tabs[ti].Panes {
440- p := &s.Tabs[ti].Panes[pi]
441- if len(p.Tail) != 0 {
442- continue
443- }
444- h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
445- sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
446- require.NoError(t, err)
447- frame := renderer.NewFrame(int(s.Tabs[ti].Cols), int(s.Tabs[ti].Rows))
448- visible, err := vt.MarshalVisible(frame)
449- require.NoError(t, err)
450- p.SealedChunks, p.Tail, p.Visible = sealed, tail, visible
451- }
452- }
453482 b, err := snapcodec.Marshal(s)
454483 require.NoError(t, err)
455484 return b
@@ -38,6 +38,27 @@ func terminalPane(t *testing.T, id, stableID, cwd string, historyRows, visibleRo
38 return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible}38 return snapcodec.Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible}
39 }39 }
40 40
41+func TestRestorePaneTerminalRejectsMissingOrMalformedCanonicalBlobs(t *testing.T) {
42+ valid := terminalPane(t, "pane", "p", "/work", nil, nil)
43+ tests := []struct {
44+ name string
45+ mutate func(*snapcodec.Pane)
46+ }{
47+ {name: "missing tail", mutate: func(p *snapcodec.Pane) { p.Tail = nil }},
48+ {name: "missing visible", mutate: func(p *snapcodec.Pane) { p.Visible = nil }},
49+ {name: "malformed tail", mutate: func(p *snapcodec.Pane) { p.Tail = []byte("bad") }},
50+ {name: "malformed visible", mutate: func(p *snapcodec.Pane) { p.Visible = []byte("bad") }},
51+ }
52+ for _, tt := range tests {
53+ t.Run(tt.name, func(t *testing.T) {
54+ snap := valid
55+ tt.mutate(&snap)
56+ p := newPane("pane", newRestorePTY(), domain.Size{Cols: 40, Rows: 24})
57+ require.Error(t, restorePaneTerminal(p, snap))
58+ })
59+ }
60+}
61+
41 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {62 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
42 store := &restoreSnapshotStore{}63 store := &restoreSnapshotStore{}
43 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{64 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
@@ -126,8 +147,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
126 func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {147 func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {
127 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{148 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
128 Name: "work", Active: 0, Tabs: []snapcodec.Tab{149 Name: "work", Active: 0, Tabs: []snapcodec.Tab{
129- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/one"}}},150+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/one")}},
130- {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}},151+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")}},
131 },152 },
132 })}}}153 })}}}
133 factory := &restorePTYFactory{}154 factory := &restorePTYFactory{}
@@ -156,17 +177,15 @@ func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testi
156 177
157 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {178 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
158 t.Helper()179 t.Helper()
180+ pane := emptyTerminalPane(t, "pane-1", "/tmp")
181+ pane.Process = proc
159 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{182 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
160 Name: name,183 Name: name,
161 Tabs: []snapcodec.Tab{{184 Tabs: []snapcodec.Tab{{
162- Cols: 80,185+ Cols: 80,
163- Rows: 24,186+ Rows: 24,
164- Tree: layout.NewTree("pane-1"),187+ Tree: layout.NewTree("pane-1"),
165- Panes: []snapcodec.Pane{{188+ Panes: []snapcodec.Pane{pane},
166- ID: "pane-1",
167- Cwd: "/tmp",
168- Process: proc,
169- }},
170 }},189 }},
171 })}}}190 })}}}
172 }191 }
@@ -203,20 +222,17 @@ func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) {
203 Cols: 80,222 Cols: 80,
204 Rows: 24,223 Rows: 24,
205 Tree: layout.NewTree("pane-1"),224 Tree: layout.NewTree("pane-1"),
206- Panes: []snapcodec.Pane{{225+ Panes: func() []snapcodec.Pane {
207- ID: "pane-1",226+ pane := emptyTerminalPane(t, "pane-1", "/one")
208- Cwd: "/one",227+ pane.Process = lessProc
209- Process: lessProc,228+ return []snapcodec.Pane{pane}
210- }},229+ }(),
211 },230 },
212 {231 {
213- Cols: 80,232+ Cols: 80,
214- Rows: 24,233+ Rows: 24,
215- Tree: layout.NewTree("pane-1"),234+ Tree: layout.NewTree("pane-1"),
216- Panes: []snapcodec.Pane{{235+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/two")},
217- ID: "pane-1",
218- Cwd: "/two",
219- }},
220 },236 },
221 },237 },
222 })}}},238 })}}},
@@ -264,7 +280,9 @@ func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
264 d := newTestDaemon(t, factory, stubClock{})280 d := newTestDaemon(t, factory, stubClock{})
265 d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})281 d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
266 282
267- require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}}))283+ pane := emptyTerminalPane(t, "pane-1", "/tmp")
284+ pane.Process = proc
285+ require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{pane}}}}))
268 require.Len(t, factory.opens, 1)286 require.Len(t, factory.opens, 1)
269 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)287 require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
270 }288 }
@@ -276,7 +294,7 @@ func TestRestoreSessionPassesRestoreContextToPTYOpen(t *testing.T) {
276 defer cancel()294 defer cancel()
277 295
278 require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{296 require.NoError(t, d.restoreSession(ctx, snapcodec.Session{Name: "restore-context", Tabs: []snapcodec.Tab{{
279- Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp"}},297+ Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/tmp")},
280 }}}))298 }}}))
281 require.Len(t, factory.opens, 1)299 require.Len(t, factory.opens, 1)
282 require.Same(t, ctx, factory.opens[0].ctx)300 require.Same(t, ctx, factory.opens[0].ctx)
@@ -291,7 +309,11 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
291 Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{309 Tree: &layout.Tree{Focus: "b", Root: &layout.Node{Kind: layout.Stack, Expanded: "b", Children: []*layout.Node{
292 layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),310 layout.NewLeaf("a"), layout.NewLeaf("b"), layout.NewLeaf("c"),
293 }}},311 }}},
294- Panes: []snapcodec.Pane{{ID: "a", Cwd: "/a"}, {ID: "b", Cwd: "/b"}, {ID: "c", Cwd: "/c"}},312+ Panes: []snapcodec.Pane{
313+ emptyTerminalPane(t, "a", "/a"),
314+ emptyTerminalPane(t, "b", "/b"),
315+ emptyTerminalPane(t, "c", "/c"),
316+ },
295 }},317 }},
296 })}}}318 })}}}
297 factory := &restorePTYFactory{}319 factory := &restorePTYFactory{}
@@ -321,7 +343,16 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
321 }343 }
322 344
323 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {345 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
324- valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}})346+ valid := mustSnapshotBytes(t, snapcodec.Session{
347+ Name: "live",
348+ CreatedAt: 1,
349+ Tabs: []snapcodec.Tab{{
350+ Cols: 80,
351+ Rows: 24,
352+ Tree: layout.NewTree("pane-1"),
353+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/ok")},
354+ }},
355+ })
325 badVersion := append([]byte(nil), valid...)356 badVersion := append([]byte(nil), valid...)
326 binary.BigEndian.PutUint16(badVersion[4:6], 1)357 binary.BigEndian.PutUint16(badVersion[4:6], 1)
327 tests := []struct {358 tests := []struct {
@@ -358,7 +389,7 @@ func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
358 Cols: 80,389 Cols: 80,
359 Rows: 24,390 Rows: 24,
360 Tree: layout.NewTree("pane-1"),391 Tree: layout.NewTree("pane-1"),
361- Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/bad"}},392+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/bad")},
362 }},393 }},
363 })}}}394 })}}}
364 factory := &restorePTYFactory{}395 factory := &restorePTYFactory{}
@@ -384,7 +415,15 @@ func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
384 loadFn: func() ([]ports.SnapshotBlob, error) {415 loadFn: func() ([]ports.SnapshotBlob, error) {
385 close(loaded)416 close(loaded)
386 <-releaseLoad417 <-releaseLoad
387- return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/restored"}}}}})}}, nil418+ return []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
419+ Name: "work",
420+ Tabs: []snapcodec.Tab{{
421+ Cols: 80,
422+ Rows: 24,
423+ Tree: layout.NewTree("pane-1"),
424+ Panes: []snapcodec.Pane{emptyTerminalPane(t, "pane-1", "/restored")},
425+ }},
426+ })}}, nil
388 },427 },
389 }428 }
390 factory := &restorePTYFactory{}429 factory := &restorePTYFactory{}
@@ -433,23 +472,13 @@ func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
433 require.Equal(t, ports.ErrServerShutdown, perr.code)472 require.Equal(t, ports.ErrServerShutdown, perr.code)
434 }473 }
435 474
475+func emptyTerminalPane(t *testing.T, id, cwd string) snapcodec.Pane {
476+ t.Helper()
477+ return terminalPane(t, id, "", cwd, nil, nil)
478+}
479+
436 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {480 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
437 t.Helper()481 t.Helper()
438- for ti := range s.Tabs {
439- for pi := range s.Tabs[ti].Panes {
440- p := &s.Tabs[ti].Panes[pi]
441- if len(p.Tail) != 0 {
442- continue
443- }
444- h := vt.NewHistory(vt.HistoryConfig{MaxRows: defaultScrollbackRows})
445- sealed, tail, err := vt.MarshalSealedHistory(h.SealAndView())
446- require.NoError(t, err)
447- frame := renderer.NewFrame(int(s.Tabs[ti].Cols), int(s.Tabs[ti].Rows))
448- visible, err := vt.MarshalVisible(frame)
449- require.NoError(t, err)
450- p.SealedChunks, p.Tail, p.Visible = sealed, tail, visible
451- }
452- }
453 b, err := snapcodec.Marshal(s)482 b, err := snapcodec.Marshal(s)
454 require.NoError(t, err)483 require.NoError(t, err)
455 return b484 return b
M internal/usecase/daemon/snapshot_test.go +70 -6
@@ -44,14 +44,17 @@ func TestSnapshotEncodeWorkerDefersMarshalUntilAfterCapture(t *testing.T) {
4444 require.True(t, d.scheduleSnapshot(sess))
4545 <-entered
4646
47- paneUnlocked := make(chan struct{})
47+ paneLocked := make(chan struct{})
48+ releasePane := make(chan struct{})
4849 go func() {
4950 p := sess.tabs[0].panes["pane-1"]
5051 p.mu.Lock()
52+ close(paneLocked)
53+ <-releasePane
5154 p.mu.Unlock()
52- close(paneUnlocked)
5355 }()
54- <-paneUnlocked
56+ <-paneLocked
57+ close(releasePane)
5558 close(release)
5659 <-store.writes
5760 }
@@ -111,13 +114,16 @@ func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
111114 require.True(t, d.scheduleSnapshot(sess))
112115 <-store.entered
113116
114- paneUnlocked := make(chan struct{})
117+ paneLocked := make(chan struct{})
118+ releasePane := make(chan struct{})
115119 go func() {
116120 p.mu.Lock()
121+ close(paneLocked)
122+ <-releasePane
117123 p.mu.Unlock()
118- close(paneUnlocked)
119124 }()
120- <-paneUnlocked
125+ <-paneLocked
126+ close(releasePane)
121127
122128 reader := portsmocks.NewMockPTY(t)
123129 var reads atomic.Int32
@@ -146,6 +152,46 @@ func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
146152 require.True(t, sess.snapDirty.Load(), "PTY output published while blocked must remain retryable")
147153 }
148154
155+func TestStopSnapshotEncodeWorkerDoesNotWaitForUncancellableWrite(t *testing.T) {
156+ store := newNeverReturningSnapshotStore()
157+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
158+ WithSnapshotStore(store)(d)
159+ ctx, cancel := context.WithCancel(t.Context())
160+ defer cancel()
161+ d.startSnapshotEncodeWorker(ctx)
162+
163+ sess := newSnapshotTestSession(t, "stuck-write", false, "/work")
164+ require.True(t, d.captureSession(sess))
165+ <-store.entered
166+
167+ stopped := make(chan struct{})
168+ go func() {
169+ d.stopSnapshotEncodeWorker()
170+ close(stopped)
171+ }()
172+ select {
173+ case <-stopped:
174+ case <-time.After(time.Second):
175+ t.Fatal("snapshot worker shutdown waited for uncancellable Write")
176+ }
177+ awaitSnapshotIdle(t, sess)
178+ require.True(t, sess.snapDirty.Load(), "an abandoned write must remain retryable")
179+ require.NotPanics(t, func() { require.False(t, d.scheduleSnapshot(sess)) })
180+}
181+
182+func TestRejectedSnapshotCaptureLeavesSessionRetryable(t *testing.T) {
183+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
184+ WithSnapshotStore(&channelSnapshotStore{writes: make(chan []byte, 1)})(d)
185+ sess := newSnapshotTestSession(t, "rejected", false, "/work")
186+ sess.name = ""
187+ sess.snapEligible.Store(true)
188+
189+ markSnapshotDirty(sess)
190+ require.False(t, d.scheduleSnapshot(sess))
191+ awaitSnapshotIdle(t, sess)
192+ require.True(t, sess.snapDirty.Load())
193+}
194+
149195 func TestStoppedSnapshotWorkerLeavesCaptureRetryable(t *testing.T) {
150196 store := &channelSnapshotStore{writes: make(chan []byte, 1)}
151197 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
@@ -758,6 +804,24 @@ func (s *gatedSnapshotStore) Write(_ string, _ []byte) error {
758804 func (*gatedSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
759805 func (*gatedSnapshotStore) Delete(string) error { return nil }
760806
807+type neverReturningSnapshotStore struct {
808+ entered chan struct{}
809+ once sync.Once
810+ block chan struct{}
811+}
812+
813+func newNeverReturningSnapshotStore() *neverReturningSnapshotStore {
814+ return &neverReturningSnapshotStore{entered: make(chan struct{}), block: make(chan struct{})}
815+}
816+
817+func (s *neverReturningSnapshotStore) Write(string, []byte) error {
818+ s.once.Do(func() { close(s.entered) })
819+ <-s.block
820+ return nil
821+}
822+func (*neverReturningSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
823+func (*neverReturningSnapshotStore) Delete(string) error { return nil }
824+
761825 type channelSnapshotStore struct {
762826 writes chan []byte
763827 }
@@ -44,14 +44,17 @@ func TestSnapshotEncodeWorkerDefersMarshalUntilAfterCapture(t *testing.T) {
44 require.True(t, d.scheduleSnapshot(sess))44 require.True(t, d.scheduleSnapshot(sess))
45 <-entered45 <-entered
46 46
47- paneUnlocked := make(chan struct{})47+ paneLocked := make(chan struct{})
48+ releasePane := make(chan struct{})
48 go func() {49 go func() {
49 p := sess.tabs[0].panes["pane-1"]50 p := sess.tabs[0].panes["pane-1"]
50 p.mu.Lock()51 p.mu.Lock()
52+ close(paneLocked)
53+ <-releasePane
51 p.mu.Unlock()54 p.mu.Unlock()
52- close(paneUnlocked)
53 }()55 }()
54- <-paneUnlocked56+ <-paneLocked
57+ close(releasePane)
55 close(release)58 close(release)
56 <-store.writes59 <-store.writes
57 }60 }
@@ -111,13 +114,16 @@ func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
111 require.True(t, d.scheduleSnapshot(sess))114 require.True(t, d.scheduleSnapshot(sess))
112 <-store.entered115 <-store.entered
113 116
114- paneUnlocked := make(chan struct{})117+ paneLocked := make(chan struct{})
118+ releasePane := make(chan struct{})
115 go func() {119 go func() {
116 p.mu.Lock()120 p.mu.Lock()
121+ close(paneLocked)
122+ <-releasePane
117 p.mu.Unlock()123 p.mu.Unlock()
118- close(paneUnlocked)
119 }()124 }()
120- <-paneUnlocked125+ <-paneLocked
126+ close(releasePane)
121 127
122 reader := portsmocks.NewMockPTY(t)128 reader := portsmocks.NewMockPTY(t)
123 var reads atomic.Int32129 var reads atomic.Int32
@@ -146,6 +152,46 @@ func exerciseBlockedSnapshotWorker(t *testing.T, blockEncode bool) {
146 require.True(t, sess.snapDirty.Load(), "PTY output published while blocked must remain retryable")152 require.True(t, sess.snapDirty.Load(), "PTY output published while blocked must remain retryable")
147 }153 }
148 154
155+func TestStopSnapshotEncodeWorkerDoesNotWaitForUncancellableWrite(t *testing.T) {
156+ store := newNeverReturningSnapshotStore()
157+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
158+ WithSnapshotStore(store)(d)
159+ ctx, cancel := context.WithCancel(t.Context())
160+ defer cancel()
161+ d.startSnapshotEncodeWorker(ctx)
162+
163+ sess := newSnapshotTestSession(t, "stuck-write", false, "/work")
164+ require.True(t, d.captureSession(sess))
165+ <-store.entered
166+
167+ stopped := make(chan struct{})
168+ go func() {
169+ d.stopSnapshotEncodeWorker()
170+ close(stopped)
171+ }()
172+ select {
173+ case <-stopped:
174+ case <-time.After(time.Second):
175+ t.Fatal("snapshot worker shutdown waited for uncancellable Write")
176+ }
177+ awaitSnapshotIdle(t, sess)
178+ require.True(t, sess.snapDirty.Load(), "an abandoned write must remain retryable")
179+ require.NotPanics(t, func() { require.False(t, d.scheduleSnapshot(sess)) })
180+}
181+
182+func TestRejectedSnapshotCaptureLeavesSessionRetryable(t *testing.T) {
183+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
184+ WithSnapshotStore(&channelSnapshotStore{writes: make(chan []byte, 1)})(d)
185+ sess := newSnapshotTestSession(t, "rejected", false, "/work")
186+ sess.name = ""
187+ sess.snapEligible.Store(true)
188+
189+ markSnapshotDirty(sess)
190+ require.False(t, d.scheduleSnapshot(sess))
191+ awaitSnapshotIdle(t, sess)
192+ require.True(t, sess.snapDirty.Load())
193+}
194+
149 func TestStoppedSnapshotWorkerLeavesCaptureRetryable(t *testing.T) {195 func TestStoppedSnapshotWorkerLeavesCaptureRetryable(t *testing.T) {
150 store := &channelSnapshotStore{writes: make(chan []byte, 1)}196 store := &channelSnapshotStore{writes: make(chan []byte, 1)}
151 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})197 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
@@ -758,6 +804,24 @@ func (s *gatedSnapshotStore) Write(_ string, _ []byte) error {
758 func (*gatedSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }804 func (*gatedSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
759 func (*gatedSnapshotStore) Delete(string) error { return nil }805 func (*gatedSnapshotStore) Delete(string) error { return nil }
760 806
807+type neverReturningSnapshotStore struct {
808+ entered chan struct{}
809+ once sync.Once
810+ block chan struct{}
811+}
812+
813+func newNeverReturningSnapshotStore() *neverReturningSnapshotStore {
814+ return &neverReturningSnapshotStore{entered: make(chan struct{}), block: make(chan struct{})}
815+}
816+
817+func (s *neverReturningSnapshotStore) Write(string, []byte) error {
818+ s.once.Do(func() { close(s.entered) })
819+ <-s.block
820+ return nil
821+}
822+func (*neverReturningSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
823+func (*neverReturningSnapshotStore) Delete(string) error { return nil }
824+
761 type channelSnapshotStore struct {825 type channelSnapshotStore struct {
762 writes chan []byte826 writes chan []byte
763 }827 }
M internal/usecase/snapshot/codec.go +0 -1
@@ -17,7 +17,6 @@ var (
1717 ErrBadCRC = errors.New("snapshot: bad crc")
1818 ErrShortPayload = errors.New("snapshot: payload too short")
1919 ErrTrailingBytes = errors.New("snapshot: trailing bytes")
20- ErrStyleOOB = errors.New("snapshot: style index out of range")
2120 ErrUnknownPane = errors.New("snapshot: tree references unknown pane")
2221 ErrInvalidData = errors.New("snapshot: invalid data")
2322 )
@@ -17,7 +17,6 @@ var (
17 ErrBadCRC = errors.New("snapshot: bad crc")17 ErrBadCRC = errors.New("snapshot: bad crc")
18 ErrShortPayload = errors.New("snapshot: payload too short")18 ErrShortPayload = errors.New("snapshot: payload too short")
19 ErrTrailingBytes = errors.New("snapshot: trailing bytes")19 ErrTrailingBytes = errors.New("snapshot: trailing bytes")
20- ErrStyleOOB = errors.New("snapshot: style index out of range")
21 ErrUnknownPane = errors.New("snapshot: tree references unknown pane")20 ErrUnknownPane = errors.New("snapshot: tree references unknown pane")
22 ErrInvalidData = errors.New("snapshot: invalid data")21 ErrInvalidData = errors.New("snapshot: invalid data")
23 )22 )
M internal/usecase/snapshot/codec_test.go +25 -2
@@ -1,6 +1,7 @@
11 package snapshot
22
33 import (
4+ "bytes"
45 "encoding/binary"
56 "errors"
67 "hash/crc32"
@@ -24,7 +25,12 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
2425 rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9}
2526 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}})
2627 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}})
27- want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}
28+ sealed2, tail2 := historyBlobs(t, [][]renderer.Cell{{{Rune: 'S', Style: rgb}}, {{Rune: '2', Style: indexed}}})
29+ visible2 := visibleBlob(t, [][]renderer.Cell{{{Rune: 'P', Style: indexed}, {Rune: '2', Style: rgb}}})
30+ want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: &layout.Tree{Focus: "p", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("p"), layout.NewLeaf("p2")}}}, Panes: []Pane{
31+ {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible},
32+ {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2},
33+ }}}}
2834 encoded, err := Marshal(want)
2935 if err != nil {
3036 t.Fatal(err)
@@ -34,9 +40,26 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
3440 if err != nil {
3541 t.Fatal(err)
3642 }
37- if len(got.Tabs) != 1 || len(got.Tabs[0].Panes) != 1 || got.Tabs[0].Panes[0].ID != "p" {
43+ if len(got.Tabs) != 1 || len(got.Tabs[0].Panes) != 2 || got.Tabs[0].Panes[0].ID != "p" || got.Tabs[0].Panes[1].ID != "p2" {
3844 t.Fatalf("manifest round trip = %#v", got)
3945 }
46+ for i, wantPane := range want.Tabs[0].Panes {
47+ gotPane := got.Tabs[0].Panes[i]
48+ if len(gotPane.SealedChunks) != len(wantPane.SealedChunks) {
49+ t.Fatalf("pane %q sealed chunks = %d, want %d", wantPane.ID, len(gotPane.SealedChunks), len(wantPane.SealedChunks))
50+ }
51+ for j := range wantPane.SealedChunks {
52+ if !bytes.Equal(gotPane.SealedChunks[j], wantPane.SealedChunks[j]) {
53+ t.Fatalf("pane %q sealed chunk %d changed during round trip", wantPane.ID, j)
54+ }
55+ }
56+ if !bytes.Equal(gotPane.Tail, wantPane.Tail) {
57+ t.Fatalf("pane %q tail changed during round trip", wantPane.ID)
58+ }
59+ if !bytes.Equal(gotPane.Visible, wantPane.Visible) {
60+ t.Fatalf("pane %q visible blob changed during round trip", wantPane.ID)
61+ }
62+ }
4063 if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil {
4164 t.Fatalf("history decode: %v", err)
4265 }
@@ -1,6 +1,7 @@
1 package snapshot1 package snapshot
2 2
3 import (3 import (
4+ "bytes"
4 "encoding/binary"5 "encoding/binary"
5 "errors"6 "errors"
6 "hash/crc32"7 "hash/crc32"
@@ -24,7 +25,12 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
24 rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9}25 rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9}
25 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}})26 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}})
26 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}})27 visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}})
27- want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}}28+ sealed2, tail2 := historyBlobs(t, [][]renderer.Cell{{{Rune: 'S', Style: rgb}}, {{Rune: '2', Style: indexed}}})
29+ visible2 := visibleBlob(t, [][]renderer.Cell{{{Rune: 'P', Style: indexed}, {Rune: '2', Style: rgb}}})
30+ want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: &layout.Tree{Focus: "p", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("p"), layout.NewLeaf("p2")}}}, Panes: []Pane{
31+ {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible},
32+ {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2},
33+ }}}}
28 encoded, err := Marshal(want)34 encoded, err := Marshal(want)
29 if err != nil {35 if err != nil {
30 t.Fatal(err)36 t.Fatal(err)
@@ -34,9 +40,26 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
34 if err != nil {40 if err != nil {
35 t.Fatal(err)41 t.Fatal(err)
36 }42 }
37- if len(got.Tabs) != 1 || len(got.Tabs[0].Panes) != 1 || got.Tabs[0].Panes[0].ID != "p" {43+ if len(got.Tabs) != 1 || len(got.Tabs[0].Panes) != 2 || got.Tabs[0].Panes[0].ID != "p" || got.Tabs[0].Panes[1].ID != "p2" {
38 t.Fatalf("manifest round trip = %#v", got)44 t.Fatalf("manifest round trip = %#v", got)
39 }45 }
46+ for i, wantPane := range want.Tabs[0].Panes {
47+ gotPane := got.Tabs[0].Panes[i]
48+ if len(gotPane.SealedChunks) != len(wantPane.SealedChunks) {
49+ t.Fatalf("pane %q sealed chunks = %d, want %d", wantPane.ID, len(gotPane.SealedChunks), len(wantPane.SealedChunks))
50+ }
51+ for j := range wantPane.SealedChunks {
52+ if !bytes.Equal(gotPane.SealedChunks[j], wantPane.SealedChunks[j]) {
53+ t.Fatalf("pane %q sealed chunk %d changed during round trip", wantPane.ID, j)
54+ }
55+ }
56+ if !bytes.Equal(gotPane.Tail, wantPane.Tail) {
57+ t.Fatalf("pane %q tail changed during round trip", wantPane.ID)
58+ }
59+ if !bytes.Equal(gotPane.Visible, wantPane.Visible) {
60+ t.Fatalf("pane %q visible blob changed during round trip", wantPane.ID)
61+ }
62+ }
40 if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil {63 if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil {
41 t.Fatalf("history decode: %v", err)64 t.Fatalf("history decode: %v", err)
42 }65 }

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