detached 38a4f44

Merge pull request #137 from bnema/feat/durable-session-recovery

refactor(recovery): simplify durable session recovery
commit
38a4f44c805bdde215cdd22e56855744bcd35b80
author
bnema · · 2026-07-26 14:26 UTC
committer
GitHub · · 2026-07-26 14:26 UTC
merge of
b93fc83 + 90fb24b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
122 files · +10154 -7705
M cmd/vev-perf-harness/hangup_test.go +131 -0
@@ -4,11 +4,13 @@ package main
44
55 import (
66 "encoding/json"
7+ "errors"
78 "fmt"
89 "os"
910 "os/exec"
1011 "path/filepath"
1112 "strings"
13+ "syscall"
1214 "testing"
1315 "time"
1416 )
@@ -133,6 +135,135 @@ func TestClientTracePairsAfterPTYHangup(t *testing.T) {
133135 }
134136 }
135137
138+// TestClientGracefulShutdownDrainsTracedStdioDescendant proves the launcher's
139+// normal client stop exits the attached shell before sending any signal. The
140+// client then closes and waits for its ssh transport, giving the traced _stdio
141+// descendant time to serialize its blocked receive's end mark and exit before
142+// the client is reaped.
143+func TestClientGracefulShutdownDrainsTracedStdioDescendant(t *testing.T) {
144+ if testing.Short() {
145+ t.Skip("public CLI stdio descendant lifecycle")
146+ }
147+ root, err := filepath.Abs(filepath.Join("..", ".."))
148+ if err != nil {
149+ t.Fatal(err)
150+ }
151+ runDir, err := os.MkdirTemp("", "vev-stdio-drain-")
152+ if err != nil {
153+ t.Fatal(err)
154+ }
155+ removeTestTree(t, runDir)
156+ bin := filepath.Join(runDir, "vev")
157+ build := exec.Command("go", "build", "-o", bin, "./")
158+ build.Dir = root
159+ if output, err := build.CombinedOutput(); err != nil {
160+ t.Fatalf("build public CLI: %v\n%s", err, output)
161+ }
162+
163+ s := scenario{ID: "stdio-drain", Roles: []string{"daemon", "ssh_stdio_peer", "client"}}
164+ maps := make([]processMapping, 0, len(s.Roles))
165+ for i, role := range s.Roles {
166+ id := fmt.Sprintf("%s-r001-%s-%02d", safeName(s.ID), safeName(role), i+1)
167+ path := filepath.Join(runDir, id+".jsonl")
168+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ if err := f.Close(); err != nil {
173+ t.Fatal(err)
174+ }
175+ maps = append(maps, processMapping{ProcessID: id, ClockDomain: id, TracePath: path, Role: role, Scenario: s.ID, Run: 1, Identity: "harness-assigned:" + id})
176+ }
177+
178+ launcher := &cliLauncher{bin: bin}
179+ var daemon, peer launchedProcess
180+ var client *cliProcess
181+ var peerMapping processMapping
182+ for _, pm := range launchOrder(maps) {
183+ var command roleCommand
184+ switch pm.Role {
185+ case "daemon":
186+ command = roleCommand{Args: []string{"--daemon"}}
187+ case "ssh_stdio_peer":
188+ // An empty remote session requests an ephemeral session, avoiding a
189+ // separate bootstrap client while still executing the traced _stdio mode.
190+ command = roleCommand{Args: []string{"_stdio", ""}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}}
191+ case "client":
192+ command = roleCommand{Args: []string{"attach", "harness@127.0.0.1"}}
193+ }
194+ process, err := launcher.Launch(pm, command)
195+ if err != nil {
196+ t.Fatalf("launch %s: %v", pm.Role, err)
197+ }
198+ switch pm.Role {
199+ case "daemon":
200+ daemon = process
201+ readyProcess, ok := process.(interface{ WaitReady() error })
202+ if !ok {
203+ t.Fatalf("daemon process type %T does not support readiness", process)
204+ }
205+ if err := readyProcess.WaitReady(); err != nil {
206+ t.Fatalf("daemon readiness: %v", err)
207+ }
208+ case "ssh_stdio_peer":
209+ peer, peerMapping = process, pm
210+ case "client":
211+ var ok bool
212+ client, ok = process.(*cliProcess)
213+ if !ok {
214+ t.Fatalf("client process type = %T, want *cliProcess", process)
215+ }
216+ }
217+ }
218+ if daemon == nil || peer == nil || client == nil {
219+ t.Fatal("did not launch complete stdio process tree")
220+ }
221+ defer func() {
222+ if err := peer.Close(); err != nil {
223+ t.Errorf("close peer role: %v", err)
224+ }
225+ if err := daemon.Close(); err != nil {
226+ t.Errorf("close daemon role: %v", err)
227+ }
228+ }()
229+ defer func() {
230+ if err := client.Close(); err != nil {
231+ t.Errorf("close client role: %v", err)
232+ }
233+ }()
234+
235+ if err := waitForInflightReceive(peerMapping.TracePath, 10*time.Second); err != nil {
236+ t.Fatalf("stdio descendant never reached blocked receive: %v", err)
237+ }
238+ processGroup := client.cmd.Process.Pid
239+ if err := client.Close(); err != nil {
240+ t.Fatalf("graceful client close: %v", err)
241+ }
242+ if _, err := mergeProcessTraces([]processMapping{peerMapping}); err != nil {
243+ t.Fatalf("stdio descendant trace did not drain: %v", err)
244+ }
245+ if err := waitForProcessGroupGone(processGroup, 10*time.Second); err != nil {
246+ t.Fatal(err)
247+ }
248+}
249+
250+func waitForProcessGroupGone(pgid int, timeout time.Duration) error {
251+ deadline := time.Now().Add(timeout)
252+ for {
253+ err := syscall.Kill(-pgid, 0)
254+ if errors.Is(err, syscall.ESRCH) {
255+ return nil
256+ }
257+ if err != nil {
258+ return fmt.Errorf("probe process group %d: %w", pgid, err)
259+ }
260+ if time.Now().After(deadline) {
261+ return fmt.Errorf("process group %d remained after client shutdown", pgid)
262+ }
263+ time.Sleep(10 * time.Millisecond)
264+ }
265+}
266+
136267 // waitForInflightReceive polls a process trace until it holds an unpaired
137268 // adapter_receive_start (more starts than ends). It tolerates a partially
138269 // written trailing line while the traced process appends concurrently.
M cmd/vev-perf-harness/launcher.go +35 -37
@@ -117,21 +117,22 @@ type terminalOutput interface {
117117 }
118118
119119 type cliProcess struct {
120- cmd *exec.Cmd
121- pty io.ReadWriteCloser
122- output terminalOutput
123- chunks chan []byte
124- done chan struct{}
125- closed sync.Once
126- waitErr chan error
127- waitTimeout func() <-chan time.Time
128- forceCleanup func()
129- forceKill func()
130- shutdown func() error
131- cleanupRuntime func() error
132- terminalReady bool
133- readyPath string
134- closeResult error
120+ cmd *exec.Cmd
121+ pty io.ReadWriteCloser
122+ output terminalOutput
123+ chunks chan []byte
124+ done chan struct{}
125+ closed sync.Once
126+ waitErr chan error
127+ waitTimeout func() <-chan time.Time
128+ gracefulShutdown func()
129+ forceCleanup func()
130+ forceKill func()
131+ shutdown func() error
132+ cleanupRuntime func() error
133+ terminalReady bool
134+ readyPath string
135+ closeResult error
135136 }
136137
137138 func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProcess, error) {
@@ -203,6 +204,12 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
203204 }
204205 _ = slave.Close()
205206 p.pty, p.output, p.chunks, p.done = master, output, make(chan []byte, 32), make(chan struct{})
207+ // Stop workload generation through the attached shell before any signal.
208+ // A normal shell exit makes the daemon detach the client; the client then
209+ // closes and waits for its ssh transport, allowing a traced _stdio
210+ // descendant to flush end marks and exit. Process-group signals are reserved
211+ // for timeout escalation because they would kill the descendant concurrently.
212+ p.gracefulShutdown = func() { _, _ = master.Write([]byte("exit\n")) }
206213 go p.copyTerminal()
207214 } else {
208215 // Every launched role owns a dedicated process group, so the bounded
@@ -629,11 +636,10 @@ func (e *ptyLocalEcho) applicationOutput(chunk []byte) []byte {
629636 // Close tears down a launched role along a bounded escalation ladder. Each rung
630637 // performs one action, then waits a single closeDeadline for the process to be
631638 // reaped before the next rung escalates. The graceful rungs — the public daemon
632-// stop, the client shell exit, the controlling-terminal hangup, and SIGTERM —
633-// let a role close its blocked transport operations and serialize their end
634-// marks; only a SIGKILL severs a process before it can, so only that rung fails
635-// teardown. Every path closes the PTY exactly once and finalizes output and
636-// runtime state.
639+// stop and a client shell exit — let a role close its blocked transport
640+// operations and drain descendants' end marks. Process-group SIGTERM is reserved
641+// for timeout escalation; only a SIGKILL rung fails teardown. Every path closes
642+// the PTY exactly once and finalizes output and runtime state.
637643 func (p *cliProcess) Close() error {
638644 p.closed.Do(func() {
639645 // 1. Drain terminal output for the whole teardown. Without an active
@@ -682,27 +688,19 @@ func (p *cliProcess) Close() error {
682688 _ = p.shutdown()
683689 waitForExit()
684690 }
685- // 3. Client role: a public terminal client owns a shell session. Ask that
686- // shell to exit so the client detaches and its transport finishes its
687- // receive span cleanly.
688- if p.pty != nil && !reaped() {
689- _, _ = p.pty.Write([]byte("exit\n"))
690- waitForExit()
691- }
692- // 4. Hang up the controlling terminal. The attach path installs a signal
693- // handler, so this SIGHUP is a graceful detach — not an abrupt kill — and
694- // the client still flushes its in-flight receive end mark.
695- if p.pty != nil && !reaped() {
696- closePTY()
691+ // 3. Client role: stop the shell normally. Session teardown detaches the
692+ // client, which closes and waits for ssh/_stdio before it exits.
693+ if p.gracefulShutdown != nil && !reaped() {
694+ p.gracefulShutdown()
697695 waitForExit()
698696 }
699- // 5. SIGTERM the role's own process group. The daemon and, through its
700- // attach signal handler, the client both treat this as a graceful stop.
697+ // 4. Escalate a role that ignored graceful shutdown to process-group
698+ // SIGTERM. This remains bounded and cleans up hung descendants.
701699 if !reaped() {
702700 p.forceProcessGroupCleanup()
703701 waitForExit()
704702 }
705- // 6. SIGKILL the role's process group. This severs the process; an
703+ // 5. SIGKILL the role's process group. This severs the process; an
706704 // in-flight observed operation cannot be closed, so its trace may truncate.
707705 killed := false
708706 if !reaped() {
@@ -710,12 +708,12 @@ func (p *cliProcess) Close() error {
710708 p.forceProcessGroupKill()
711709 waitForExit()
712710 }
713- // 7. A SIGKILL escalation cannot guarantee a complete trace. Fail teardown
711+ // 6. A SIGKILL escalation cannot guarantee a complete trace. Fail teardown
714712 // with a clear cause rather than letting the truncation surface later as a
715713 // downstream merge mystery. The graceful rungs never error on their own.
716714 if killed {
717715 if exited {
718- p.closeResult = errors.New("public CLI process did not exit after hangup and SIGTERM; killed")
716+ p.closeResult = errors.New("public CLI process did not exit after graceful shutdown and SIGTERM; killed")
719717 } else {
720718 p.closeResult = errors.New("timed out reaping public CLI process")
721719 }
M cmd/vev-perf-harness/launcher_test.go +48 -34
@@ -27,29 +27,22 @@ func (discardTerminalOutput) Close() error { return nil }
2727 func TestCLIProcessCloseDrainsQueuedTerminalOutputBeforeClientExit(t *testing.T) {
2828 harnessPTY, clientPTY := net.Pipe()
2929 t.Cleanup(func() { _ = clientPTY.Close() })
30- waitErr := make(chan error)
30+ waitErr := make(chan error, 1)
3131 secondRead := make(chan struct{})
3232 p := &cliProcess{
33- pty: harnessPTY,
34- output: discardTerminalOutput{},
35- chunks: make(chan []byte, 1),
36- done: make(chan struct{}),
37- waitErr: waitErr,
38- waitTimeout: func() <-chan time.Time { return time.After(time.Second) },
33+ pty: harnessPTY,
34+ output: discardTerminalOutput{},
35+ chunks: make(chan []byte, 1),
36+ done: make(chan struct{}),
37+ waitErr: waitErr,
38+ waitTimeout: func() <-chan time.Time { return time.After(time.Second) },
39+ forceCleanup: func() { waitErr <- nil },
3940 }
4041 go p.copyTerminal()
4142 go func() {
4243 _, _ = clientPTY.Write([]byte("first"))
4344 _, _ = clientPTY.Write([]byte("second"))
4445 close(secondRead)
45- buf := make([]byte, len("exit\n"))
46- if _, err := clientPTY.Read(buf); err != nil {
47- return
48- }
49- if _, err := clientPTY.Write([]byte("third")); err != nil {
50- return
51- }
52- waitErr <- nil
5346 }()
5447 select {
5548 case <-secondRead:
@@ -134,6 +127,26 @@ func TestCLIProcessCloseIsBoundedWhenPublicRoleDoesNotExit(t *testing.T) {
134127 }
135128 }
136129
130+func TestCLIProcessCloseEscalatesHungClientAfterGracefulDrainTimeout(t *testing.T) {
131+ deadline := make(chan time.Time)
132+ close(deadline)
133+ var graceful, term, kill int
134+ p := &cliProcess{
135+ pty: &orderedPTY{close: func() {}},
136+ waitErr: make(chan error),
137+ waitTimeout: func() <-chan time.Time { return deadline },
138+ gracefulShutdown: func() { graceful++ },
139+ forceCleanup: func() { term++ },
140+ forceKill: func() { kill++ },
141+ }
142+ if err := p.Close(); err == nil {
143+ t.Fatal("hung client close succeeded")
144+ }
145+ if graceful != 1 || term != 1 || kill != 1 {
146+ t.Fatalf("graceful/TERM/KILL calls = %d/%d/%d, want 1/1/1", graceful, term, kill)
147+ }
148+}
149+
137150 func TestCLIProcessCloseEscalationOutcome(t *testing.T) {
138151 tests := []struct {
139152 name string
@@ -252,6 +265,14 @@ func TestCLILauncherIsolatesRoleXDGEnvironmentAcrossRepetitions(t *testing.T) {
252265 if err != nil {
253266 t.Fatalf("run %d %s launch: %v", run, role, err)
254267 }
268+ // The fixture exits on its own. Synchronize with that exit instead of
269+ // letting Close race its SIGTERM against the environment capture.
270+ proc, ok := p.(*cliProcess)
271+ if !ok {
272+ t.Fatalf("run %d %s process type = %T, want *cliProcess", run, role, p)
273+ }
274+ exitErr := <-proc.waitErr
275+ proc.waitErr <- exitErr
255276 runtimeDir := launcher.runtimes[runDir]
256277 if err := p.Close(); err != nil {
257278 t.Fatalf("run %d %s close: %v", run, role, err)
@@ -383,36 +404,29 @@ func TestTraceEnvironmentMatchesProcessMapping(t *testing.T) {
383404 }
384405 }
385406
386-func TestCLIProcessCloseGracefulDetachCompletesSpanBeforeForcedCleanup(t *testing.T) {
407+func TestCLIProcessCloseStopsShellBeforeProcessGroupCleanup(t *testing.T) {
387408 var order []string
388409 waitErr := make(chan error, 1)
389- timeout := make(chan time.Time)
390410 p := &cliProcess{
391- pty: &orderedPTY{
392- write: func(input []byte) {
393- if string(input) != "exit\n" {
394- t.Fatalf("graceful exit input=%q", input)
395- }
396- order = append(order, "exit_written")
397- // The graceful client exit has closed its transport and emitted
398- // its adapter receive-end mark before cmd.Wait completes.
399- order = append(order, "adapter_receive_end")
400- waitErr <- nil
401- },
402- close: func() { order = append(order, "pty_closed") },
411+ pty: &orderedPTY{close: func() { order = append(order, "pty_closed") }},
412+ output: &orderedOutput{close: func() { order = append(order, "output_closed") }},
413+ waitErr: waitErr,
414+ gracefulShutdown: func() {
415+ order = append(order, "shell_exit")
416+ // The client closes and waits for its transport descendant before it
417+ // reports its own exit to the launcher.
418+ order = append(order, "descendant_receive_end")
419+ waitErr <- nil
403420 },
404- output: &orderedOutput{close: func() { order = append(order, "output_closed") }},
405- waitErr: waitErr,
406- waitTimeout: func() <-chan time.Time { return timeout },
407421 forceCleanup: func() {
408- order = append(order, "forced_process_group_cleanup")
422+ t.Fatal("normal shutdown sent SIGTERM to the traced descendant process group")
409423 },
410424 }
411425
412426 if err := p.Close(); err != nil {
413427 t.Fatal(err)
414428 }
415- if !equalStrings(order, []string{"exit_written", "adapter_receive_end", "pty_closed", "output_closed"}) {
429+ if !equalStrings(order, []string{"shell_exit", "descendant_receive_end", "pty_closed", "output_closed"}) {
416430 t.Fatalf("cleanup order=%q", order)
417431 }
418432 }
M docs/configuration.md +8 -0
@@ -74,6 +74,14 @@ code.detach = DET
7474
7575 Invalid values log a warning and resolve that setting to its default on both initial load and reload.
7676
77+## Logs and durable state
78+
79+Set `VEV_LOG=debug`, `VEV_LOG=warn`, or `VEV_LOG=error` to change verbosity; the default is `info`. JSON-line logs such as `vev-daemon.log` live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset. The same state directory contains the strict session catalogue, migration journal, notices, and `snapshots/`. The lifecycle lock and socket live in `$XDG_RUNTIME_DIR/vev` (with platform runtime fallbacks).
80+
81+Recovery events include `lifecycle_owner_wait`, `lifecycle_owner_acquired`, `lifecycle_owner_released`, `catalogue_validated`, `catalogue_compaction_recovery_complete`, `session_restore_complete`, `fallback_checkpoint_promoted`, `snapshot_head_repair_complete`, `session_degraded`, `snapshot_maintenance_progress`, `interrupted_transaction_recovery_complete`, and `daemon_startup_complete`.
82+
83+Catalogue failure is fail-closed: vev does not publish an empty replacement daemon. Preserve the state directory, inspect `catalogue_validation_failed`, correct storage or ownership problems, and retry without editing catalogue files. See [Durable session recovery](durable-session-recovery.md) for explicit recovery commands, migration, diagnostics, and the committed checkpoint plus up to two direct fallbacks retention policy.
84+
7785 ## Theme
7886
7987 With `theme = auto`, vev follows the terminal's reported foreground, background, light/dark scheme, and ANSI palette. `theme.palette = on` is the default. `theme.accent = auto` derives one accent from the terminal's chromatic ANSI colors; repeated terminal colors are preferred, otherwise vev uses an eligible blue slot when available. `theme.accent = 0` through `theme.accent = 15` selects exactly that ANSI slot. Arbitrary RGB values and `off` are not accepted for `theme.accent`.
A docs/durable-session-recovery.md +29 -0
@@ -0,0 +1,29 @@
1+# Durable session recovery
2+
3+vev opens named-session state only while holding `$XDG_RUNTIME_DIR/vev/lifecycle.lock` (or the platform runtime fallback). Catalogue corruption prevents socket publication; vev never substitutes an empty healthy daemon.
4+
5+## Session states
6+
7+`stopped` is safe fresh metadata, `restoring` is validation in progress, and `degraded` preserves uncertain state without starting a replacement shell. A degraded session remains visible in `vev ls`, but attach is refused until an explicit recovery action succeeds.
8+
9+## Recovery commands
10+
11+- `vev cmd -s NAME session-recovery discard`
12+
13+Discard creates a new incarnation and retains the old record and snapshots under `snapshots/quarantine/` until an explicit later purge.
14+
15+## Migration and retention
16+
17+The v0.x migration is resumable, retains legacy sources, and validates each referenced HEAD. Healthy sessions retain the committed checkpoint and up to two direct fallbacks; degraded, deleting, migrating, and unresolved transactions remain pinned.
18+
19+## Fail-closed startup
20+
21+If the catalogue cannot be opened or validated, the daemon does not publish its socket. Do not delete or edit state files. Check `vev-daemon.log` for `catalogue_validation_failed`, preserve the complete state directory, correct filesystem ownership or capacity problems, and retry. For corruption, make a backup before attempting recovery or exporting data.
22+
23+A `lifecycle_owner_wait` event normally means another daemon is initializing or shutting down. Wait for ownership transfer; only terminate the prior process when its identity is known. The operating system releases `lifecycle.lock` when that process exits.
24+
25+## Diagnostics
26+
27+Durable state and recovery journals are under `$XDG_STATE_HOME/vev` (default `~/.local/state/vev`). Snapshot quarantine is under its `snapshots/quarantine/` tree. Runtime ownership is under `$XDG_RUNTIME_DIR/vev`; logs are JSON lines in the state directory.
28+
29+Use session names, incarnation IDs, generation numbers, reason codes, and cursors from recovery events when diagnosing a failure. Recovery logs never include terminal or snapshot object contents.
A internal/adapters/lifecycle/flock.go +105 -0
@@ -0,0 +1,105 @@
1+//go:build unix
2+
3+// Package lifecycle provides exclusive process ownership of vev's durable
4+// state for the complete daemon lifetime.
5+package lifecycle
6+
7+import (
8+ "context"
9+ "errors"
10+ "fmt"
11+ "os"
12+ "path/filepath"
13+ "sync"
14+ "syscall"
15+ "time"
16+
17+ "github.com/bnema/vev/pkg/safedir"
18+)
19+
20+// ErrBusy reports that another process currently owns durable session state.
21+var ErrBusy = errors.New("durable session state is busy")
22+
23+// Owner holds the lifecycle lock descriptor open until Release.
24+type Owner struct {
25+ file *os.File
26+ path string
27+ once sync.Once
28+ err error
29+}
30+
31+// Path returns the fixed lifecycle lock path in runtimeDir.
32+func Path(runtimeDir string) string {
33+ return filepath.Join(runtimeDir, "lifecycle.lock")
34+}
35+
36+// TryAcquire attempts to take lifecycle ownership without waiting.
37+func TryAcquire(runtimeDir string) (*Owner, error) {
38+ if err := safedir.EnsurePrivate(runtimeDir); err != nil {
39+ return nil, fmt.Errorf("lifecycle: securing runtime directory: %w", err)
40+ }
41+
42+ path := Path(runtimeDir)
43+ file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
44+ if err != nil {
45+ return nil, fmt.Errorf("lifecycle: opening %s: %w", path, err)
46+ }
47+ if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
48+ closeErr := file.Close()
49+ if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
50+ return nil, errors.Join(ErrBusy, closeErr)
51+ }
52+ return nil, errors.Join(fmt.Errorf("lifecycle: locking %s: %w", path, err), closeErr)
53+ }
54+ return &Owner{file: file, path: path}, nil
55+}
56+
57+// Acquire waits until lifecycle ownership is available or ctx is cancelled.
58+// Only lock contention is retried; filesystem and lock errors fail closed.
59+func Acquire(ctx context.Context, runtimeDir string, retry time.Duration) (*Owner, error) {
60+ if retry <= 0 {
61+ retry = time.Millisecond
62+ }
63+ for {
64+ if err := ctx.Err(); err != nil {
65+ return nil, err
66+ }
67+ owner, err := TryAcquire(runtimeDir)
68+ if err == nil {
69+ return owner, nil
70+ }
71+ if !errors.Is(err, ErrBusy) {
72+ return nil, err
73+ }
74+
75+ timer := time.NewTimer(retry)
76+ select {
77+ case <-ctx.Done():
78+ timer.Stop()
79+ return nil, ctx.Err()
80+ case <-timer.C:
81+ }
82+ }
83+}
84+
85+// Release unlocks and closes the owner exactly once.
86+func (o *Owner) Release() error {
87+ if o == nil {
88+ return nil
89+ }
90+ o.once.Do(func() {
91+ if o.file == nil {
92+ return
93+ }
94+ unlockErr := syscall.Flock(int(o.file.Fd()), syscall.LOCK_UN)
95+ closeErr := o.file.Close()
96+ if unlockErr != nil {
97+ unlockErr = fmt.Errorf("lifecycle: unlocking %s: %w", o.path, unlockErr)
98+ }
99+ if closeErr != nil {
100+ closeErr = fmt.Errorf("lifecycle: closing %s: %w", o.path, closeErr)
101+ }
102+ o.err = errors.Join(unlockErr, closeErr)
103+ })
104+ return o.err
105+}
A internal/adapters/lifecycle/flock_test.go +95 -0
@@ -0,0 +1,95 @@
1+//go:build unix
2+
3+package lifecycle
4+
5+import (
6+ "bufio"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func TestLifecycleLockProcessHelper(t *testing.T) {
21+ if os.Getenv("VEV_LIFECYCLE_LOCK_HELPER") != "1" {
22+ t.Skip("helper process")
23+ }
24+
25+ owner, err := TryAcquire(os.Getenv("VEV_LIFECYCLE_LOCK_DIR"))
26+ require.NoError(t, err)
27+ defer func() { require.NoError(t, owner.Release()) }()
28+
29+ _, err = fmt.Fprintln(os.Stdout, "locked")
30+ require.NoError(t, err)
31+ _, err = io.Copy(io.Discard, os.Stdin)
32+ require.NoError(t, err)
33+}
34+
35+func TestLifecycleLock(t *testing.T) {
36+ t.Run("process contention and reacquire", testLifecycleLockProcessContentionAndReacquire)
37+ t.Run("acquire cancellation", testLifecycleLockAcquireCancellation)
38+ t.Run("unavailable runtime directory", testLifecycleLockUnavailableRuntimeDirectory)
39+}
40+
41+func testLifecycleLockProcessContentionAndReacquire(t *testing.T) {
42+ runtimeDir := filepath.Join(t.TempDir(), "runtime")
43+ cmd := exec.Command(os.Args[0], "-test.run=^TestLifecycleLockProcessHelper$")
44+ cmd.Env = append(os.Environ(),
45+ "VEV_LIFECYCLE_LOCK_HELPER=1",
46+ "VEV_LIFECYCLE_LOCK_DIR="+runtimeDir,
47+ )
48+ stdin, err := cmd.StdinPipe()
49+ require.NoError(t, err)
50+ stdout, err := cmd.StdoutPipe()
51+ require.NoError(t, err)
52+ cmd.Stderr = os.Stderr
53+ require.NoError(t, cmd.Start())
54+ defer func() {
55+ _ = stdin.Close()
56+ _ = cmd.Wait()
57+ }()
58+
59+ line, err := bufio.NewReader(stdout).ReadString('\n')
60+ require.NoError(t, err)
61+ require.Equal(t, "locked\n", line)
62+
63+ _, err = TryAcquire(runtimeDir)
64+ require.ErrorIs(t, err, ErrBusy)
65+
66+ require.NoError(t, stdin.Close())
67+ require.NoError(t, cmd.Wait())
68+
69+ owner, err := TryAcquire(runtimeDir)
70+ require.NoError(t, err)
71+ require.NoError(t, owner.Release())
72+ require.NoError(t, owner.Release(), "release is idempotent")
73+}
74+
75+func testLifecycleLockAcquireCancellation(t *testing.T) {
76+ runtimeDir := filepath.Join(t.TempDir(), "runtime")
77+ owner, err := TryAcquire(runtimeDir)
78+ require.NoError(t, err)
79+ defer func() { require.NoError(t, owner.Release()) }()
80+
81+ ctx, cancel := context.WithCancel(context.Background())
82+ cancel()
83+
84+ _, err = Acquire(ctx, runtimeDir, time.Millisecond)
85+ require.ErrorIs(t, err, context.Canceled)
86+}
87+
88+func testLifecycleLockUnavailableRuntimeDirectory(t *testing.T) {
89+ runtimePath := t.TempDir() + "/not-a-directory"
90+ require.NoError(t, os.WriteFile(runtimePath, []byte("occupied"), 0o600))
91+
92+ _, err := TryAcquire(runtimePath)
93+ require.Error(t, err)
94+ require.False(t, errors.Is(err, ErrBusy))
95+}
M internal/adapters/noticefile/noticefile.go +2 -7
@@ -311,13 +311,8 @@ func appendNoticeFragment(line, fragment []byte) ([]byte, bool) {
311311 }
312312 required := len(line) + len(fragment)
313313 if required > cap(line) {
314- capacity := cap(line) * 2
315- if capacity < noticeReadBufferSize {
316- capacity = noticeReadBufferSize
317- }
318- if capacity > maxNoticeRecordSize {
319- capacity = maxNoticeRecordSize
320- }
314+ capacity := max(cap(line)*2, noticeReadBufferSize)
315+ capacity = min(capacity, maxNoticeRecordSize)
321316 grown := make([]byte, len(line), capacity)
322317 copy(grown, line)
323318 line = grown
M internal/adapters/snapshot/atomic.go +7 -49
@@ -8,19 +8,21 @@ import (
88 "path/filepath"
99 "syscall"
1010
11+ "github.com/bnema/vev/internal/domain"
1112 "github.com/bnema/vev/pkg/safedir"
1213 )
1314
14-func (r *Repository) ensureSession(key string) error {
15+func (r *Repository) ensureSession(id domain.IncarnationID) error {
16+ sessionPath := r.sessionPath(id)
1517 for _, directory := range []struct {
1618 path string
1719 phase string
1820 }{
1921 {r.dir, "repository"},
2022 {filepath.Join(r.dir, repositorySessionsDir), "sessions"},
21- {r.sessionPath(key), "session"},
22- {filepath.Join(r.sessionPath(key), repositoryObjectsDir), "objects"},
23- {filepath.Join(r.sessionPath(key), repositoryGenerations), "generations"},
23+ {sessionPath, "session"},
24+ {filepath.Join(sessionPath, repositoryObjectsDir), "objects"},
25+ {filepath.Join(sessionPath, repositoryGenerations), "generations"},
2426 } {
2527 if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {
2628 return fmt.Errorf("create snapshot repository directory: %w", err)
@@ -47,11 +49,6 @@ func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) (err error)
4749 return err
4850 }
4951 if created {
50- if hook := r.hooks.syncDirectory; hook != nil {
51- if err := hook(filepath.Dir(dir)); err != nil {
52- return fmt.Errorf("%s parent directory sync: %w", phase, err)
53- }
54- }
5552 if err := syncDirectory(filepath.Dir(dir)); err != nil {
5653 return fmt.Errorf("%s parent directory sync: %w", phase, err)
5754 }
@@ -191,36 +188,17 @@ func (r *Repository) withAtomicTemp(dir string, data []byte, publish func(string
191188 }
192189
193190 func (r *Repository) createTemp(dir string) (*os.File, error) {
194- if r.hooks.createTemp != nil {
195- if err := r.hooks.createTemp(dir); err != nil {
196- return nil, err
197- }
198- }
199191 return r.createTempAt(dir)
200192 }
201193 func (r *Repository) writeFile(f *os.File, data []byte) error {
202- if r.hooks.writeTemp != nil {
203- if err := r.hooks.writeTemp(f.Name()); err != nil {
204- return err
205- }
206- }
207194 _, err := f.Write(data)
208195 return err
209196 }
210197 func (r *Repository) syncFile(f *os.File) error {
211- if r.hooks.syncFile != nil {
212- if err := r.hooks.syncFile(f.Name()); err != nil {
213- return err
214- }
215- }
216198 return f.Sync()
217199 }
218200 func (r *Repository) closeFile(f *os.File) error {
219- var injected error
220- if r.hooks.closeFile != nil {
221- injected = r.hooks.closeFile(f.Name())
222- }
223- return errors.Join(injected, f.Close())
201+ return f.Close()
224202 }
225203
226204 // joinCloseError retains a primary operation failure and appends contextual
@@ -232,11 +210,6 @@ func joinCloseError(primary *error, operation string, closeErr error) {
232210 }
233211
234212 func (r *Repository) installImmutable(oldPath, newPath string) (err error) {
235- if r.hooks.installImmutable != nil {
236- if err := r.hooks.installImmutable(newPath); err != nil {
237- return err
238- }
239- }
240213 oldRel, ok := r.repositoryRelative(oldPath)
241214 if !ok {
242215 return fmt.Errorf("snapshot path outside repository")
@@ -253,11 +226,6 @@ func (r *Repository) installImmutable(oldPath, newPath string) (err error) {
253226 return root.Link(oldRel, newRel)
254227 }
255228 func (r *Repository) rename(oldPath, newPath string) (err error) {
256- if r.hooks.rename != nil {
257- if err := r.hooks.rename(newPath); err != nil {
258- return err
259- }
260- }
261229 oldRel, ok := r.repositoryRelative(oldPath)
262230 if !ok {
263231 return fmt.Errorf("snapshot path outside repository")
@@ -274,11 +242,6 @@ func (r *Repository) rename(oldPath, newPath string) (err error) {
274242 return root.Rename(oldRel, newRel)
275243 }
276244 func (r *Repository) remove(path string) (err error) {
277- if r.hooks.remove != nil {
278- if err := r.hooks.remove(path); err != nil {
279- return err
280- }
281- }
282245 rel, ok := r.repositoryRelative(path)
283246 if !ok {
284247 return fmt.Errorf("snapshot path outside repository")
@@ -291,11 +254,6 @@ func (r *Repository) remove(path string) (err error) {
291254 return root.Remove(rel)
292255 }
293256 func (r *Repository) syncDirectory(dir string) error {
294- if r.hooks.syncDirectory != nil {
295- if err := r.hooks.syncDirectory(dir); err != nil {
296- return err
297- }
298- }
299257 return syncDirectory(dir)
300258 }
301259 func syncDirectory(dir string) error {
M internal/adapters/snapshot/atomic_test.go +24 -32
@@ -8,51 +8,46 @@ import (
88 "testing"
99 )
1010
11-func TestWriteImmutableUsesOneFinalDirectorySync(t *testing.T) {
11+func TestWriteImmutablePublishesAndReusesIdenticalData(t *testing.T) {
1212 repo := NewRepository(privateDir(t))
1313 dir := filepath.Join(repo.dir, "immutable")
1414 if err := repo.ensurePrivateDirectory(dir); err != nil {
1515 t.Fatal(err)
1616 }
1717
18- syncs := 0
19- repo.hooks.syncDirectory = func(got string) error {
20- if got == dir {
21- syncs++
22- }
23- return nil
24- }
18+ path := filepath.Join(dir, "object")
2519 data := []byte("immutable")
26- if err := repo.writeImmutable(filepath.Join(dir, "object"), data, func(existing []byte) error {
20+ verify := func(existing []byte) error {
2721 if !bytes.Equal(existing, data) {
2822 return errors.New("unexpected immutable data")
2923 }
3024 return nil
31- }); err != nil {
25+ }
26+ if err := repo.writeImmutable(path, data, verify); err != nil {
3227 t.Fatal(err)
3328 }
34- if syncs != 1 {
35- t.Fatalf("successful immutable directory syncs = %d, want 1", syncs)
29+ if err := repo.writeImmutable(path, data, verify); err != nil {
30+ t.Fatal(err)
31+ }
32+ got, err := os.ReadFile(path)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if !bytes.Equal(got, data) {
37+ t.Fatalf("immutable data = %q, want %q", got, data)
3638 }
3739 }
3840
39-func TestWithAtomicTempPreservesLifecycleOrder(t *testing.T) {
41+func TestWithAtomicTempRemovesPublishedTemporaryFile(t *testing.T) {
4042 repo := NewRepository(privateDir(t))
4143 dir := filepath.Join(repo.dir, "temporary")
4244 if err := repo.ensurePrivateDirectory(dir); err != nil {
4345 t.Fatal(err)
4446 }
4547
46- var events []string
47- repo.hooks.createTemp = func(string) error { events = append(events, "create"); return nil }
48- repo.hooks.writeTemp = func(string) error { events = append(events, "write"); return nil }
49- repo.hooks.syncFile = func(string) error { events = append(events, "sync file"); return nil }
50- repo.hooks.closeFile = func(string) error { events = append(events, "close"); return nil }
51- repo.hooks.remove = func(string) error { events = append(events, "remove"); return nil }
52- repo.hooks.syncDirectory = func(string) error { events = append(events, "sync directory"); return nil }
53-
48+ var temporary string
5449 if err := repo.withAtomicTemp(dir, []byte("data"), func(path string) (bool, error) {
55- events = append(events, "publish")
50+ temporary = path
5651 info, err := os.Stat(path)
5752 if err != nil {
5853 return true, err
@@ -64,21 +59,14 @@ func TestWithAtomicTempPreservesLifecycleOrder(t *testing.T) {
6459 }); err != nil {
6560 t.Fatal(err)
6661 }
67- want := []string{"create", "write", "sync file", "close", "publish", "remove", "sync directory"}
68- if len(events) != len(want) {
69- t.Fatalf("lifecycle = %v, want %v", events, want)
70- }
71- for i := range want {
72- if events[i] != want[i] {
73- t.Fatalf("lifecycle = %v, want %v", events, want)
74- }
62+ if _, err := os.Stat(temporary); !errors.Is(err, os.ErrNotExist) {
63+ t.Fatalf("temporary file remains after publication: %v", err)
7564 }
7665 }
7766
78-func TestWriteMutableDoesNotRemoveConsumedTemporaryFile(t *testing.T) {
67+func TestWriteMutableConsumesTemporaryFile(t *testing.T) {
7968 repo := NewRepository(privateDir(t))
8069 path := filepath.Join(repo.dir, "HEAD")
81- repo.hooks.remove = func(string) error { return errors.New("remove must not run") }
8270
8371 if err := repo.writeMutable(path, []byte("head")); err != nil {
8472 t.Fatalf("writeMutable error = %v", err)
@@ -90,4 +78,8 @@ func TestWriteMutableDoesNotRemoveConsumedTemporaryFile(t *testing.T) {
9078 if !bytes.Equal(got, []byte("head")) {
9179 t.Fatalf("HEAD = %q, want %q", got, "head")
9280 }
81+ matches, err := filepath.Glob(filepath.Join(repo.dir, ".tmp-*"))
82+ if err != nil || len(matches) != 0 {
83+ t.Fatalf("temporary files after mutable write = %v, glob error = %v", matches, err)
84+ }
9385 }
A internal/adapters/snapshot/direct_load_test.go +204 -0
@@ -0,0 +1,204 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
14+ codec "github.com/bnema/vev/internal/usecase/snapshot"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestIncarnationNamespace(t *testing.T) {
19+ repo := NewRepository(privateDir(t))
20+ id := domain.IncarnationID{1}
21+ pub := incarnationPublication(t, id, "old", 1, nil)
22+ if err := repo.Publish(context.Background(), pub); err != nil {
23+ t.Fatal(err)
24+ }
25+ parent := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(pub.Manifest)}
26+ pub = incarnationPublication(t, id, "new", 2, &parent)
27+ if err := repo.Publish(context.Background(), pub); err != nil {
28+ t.Fatal(err)
29+ }
30+ if got := filepath.Base(repo.sessionPath(id)); got != id.String() {
31+ t.Fatalf("namespace = %q", got)
32+ }
33+
34+ wrong := pub
35+ wrong.IncarnationID = domain.IncarnationID{2}
36+ if err := repo.Publish(context.Background(), wrong); err == nil {
37+ t.Fatal("mismatched publication incarnation accepted")
38+ }
39+}
40+
41+func TestDirectCheckpointLoad(t *testing.T) {
42+ repo := NewRepository(privateDir(t))
43+ id := domain.IncarnationID{1}
44+ pub := incarnationPublication(t, id, "work", 1, nil)
45+ if err := repo.Publish(context.Background(), pub); err != nil {
46+ t.Fatal(err)
47+ }
48+ generations := filepath.Dir(repo.manifestPath(id, 1))
49+ for i := 2; i <= 5001; i++ {
50+ if err := os.WriteFile(filepath.Join(generations, generationFilename(uint64(i))), []byte("stale"), 0o600); err != nil {
51+ t.Fatal(err)
52+ }
53+ }
54+ ref := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(pub.Manifest)}
55+ got, err := repo.LoadCheckpoint(context.Background(), id, "work", ref)
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if got.Generation != 1 || got.IncarnationID != id {
60+ t.Fatalf("generation = %#v", got)
61+ }
62+}
63+
64+func TestReconcileCheckpoint(t *testing.T) {
65+ repo := NewRepository(privateDir(t))
66+ id := domain.IncarnationID{1}
67+ pub := incarnationPublication(t, id, "work", 1, nil)
68+ if err := repo.Publish(context.Background(), pub); err != nil {
69+ t.Fatal(err)
70+ }
71+ valid := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(pub.Manifest)}
72+ if err := repo.ReconcileCheckpoint(context.Background(), id, valid); err != nil {
73+ t.Fatal(err)
74+ }
75+ before, err := os.ReadFile(repo.headPath(id))
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ invalid := valid
80+ invalid.ManifestDigest[0]++
81+ if err := repo.ReconcileCheckpoint(context.Background(), id, invalid); err == nil {
82+ t.Fatal("invalid checkpoint repaired HEAD")
83+ }
84+ after, err := os.ReadFile(repo.headPath(id))
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ if string(after) != string(before) {
89+ t.Fatal("invalid repair changed HEAD")
90+ }
91+}
92+
93+func TestForwardOrphanRetryValidatesCheckpointBeforeCatalogueAdvance(t *testing.T) {
94+ for _, tt := range []struct {
95+ name string
96+ mutate func(*testing.T, *Repository, ports.SnapshotPublication)
97+ }{
98+ {
99+ name: "missing object",
100+ mutate: func(t *testing.T, repository *Repository, publication ports.SnapshotPublication) {
101+ t.Helper()
102+ require.NoError(t, os.Remove(repository.objectPath(publication.IncarnationID, publication.Objects[0].Digest)))
103+ },
104+ },
105+ {
106+ name: "corrupt object",
107+ mutate: func(t *testing.T, repository *Repository, publication ports.SnapshotPublication) {
108+ t.Helper()
109+ require.NoError(t, os.WriteFile(repository.objectPath(publication.IncarnationID, publication.Objects[0].Digest), []byte("corrupt"), 0o600))
110+ },
111+ },
112+ {
113+ name: "corrupt manifest digest",
114+ mutate: func(t *testing.T, repository *Repository, publication ports.SnapshotPublication) {
115+ t.Helper()
116+ head, err := os.ReadFile(repository.headPath(publication.IncarnationID))
117+ require.NoError(t, err)
118+ head[len(head)-1]++
119+ require.NoError(t, os.WriteFile(repository.headPath(publication.IncarnationID), head, 0o600))
120+ },
121+ },
122+ } {
123+ t.Run(tt.name, func(t *testing.T) {
124+ id := domain.IncarnationID{1}
125+ publication := incarnationPublication(t, id, "work", 1, nil)
126+ commitError := errors.New("catalogue commit failed")
127+ catalogue := &forwardOrphanCatalogue{
128+ record: domain.CatalogueRecord{Name: publication.Name, IncarnationID: id},
129+ replaceError: commitError,
130+ }
131+ repository := NewRepository(privateDir(t))
132+ coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
133+
134+ _, err := coordinator.PublishCheckpoint(t.Context(), publication.Name, publication)
135+ require.ErrorIs(t, err, commitError)
136+ tt.mutate(t, repository, publication)
137+ _, err = coordinator.PublishCheckpoint(t.Context(), publication.Name, publication)
138+ require.Error(t, err)
139+ require.Nil(t, catalogue.record.Committed, "an invalid forward orphan must not become authoritative")
140+ require.Equal(t, 1, catalogue.replaceCalls, "retry must not advance the catalogue")
141+ })
142+ }
143+}
144+
145+type forwardOrphanCatalogue struct {
146+ record domain.CatalogueRecord
147+ replaceError error
148+ replaceCalls int
149+}
150+
151+func (c *forwardOrphanCatalogue) Records() ([]domain.CatalogueRecord, error) {
152+ return []domain.CatalogueRecord{c.record}, nil
153+}
154+
155+func (c *forwardOrphanCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
156+ return c.record, c.record.Name == name, nil
157+}
158+
159+func (c *forwardOrphanCatalogue) Create(domain.CatalogueRecord) error {
160+ return errors.New("unexpected create")
161+}
162+func (c *forwardOrphanCatalogue) UpdateMetadata(domain.CatalogueMetadataUpdate) error {
163+ return errors.New("unexpected metadata update")
164+}
165+func (c *forwardOrphanCatalogue) Sync() error { return nil }
166+func (c *forwardOrphanCatalogue) Rename(string, domain.CatalogueRecord) error {
167+ return errors.New("unexpected rename")
168+}
169+func (c *forwardOrphanCatalogue) Delete(string) error { return errors.New("unexpected delete") }
170+func (c *forwardOrphanCatalogue) Close() error { return nil }
171+
172+func (c *forwardOrphanCatalogue) Replace(name string, record domain.CatalogueRecord) error {
173+ if name != c.record.Name || name != record.Name {
174+ return errors.New("catalogue key mismatch")
175+ }
176+ c.replaceCalls++
177+ if c.replaceError != nil {
178+ err := c.replaceError
179+ c.replaceError = nil
180+ return err
181+ }
182+ c.record = record
183+ return nil
184+}
185+
186+func incarnationPublication(t *testing.T, id domain.IncarnationID, name string, generation uint64, parent *domain.CheckpointRef) ports.SnapshotPublication {
187+ t.Helper()
188+ tail, err := codec.MarshalObject(codec.HistoryTail, []byte("tail"))
189+ if err != nil {
190+ t.Fatal(err)
191+ }
192+ visible, err := codec.MarshalObject(codec.Visible, []byte("visible"))
193+ if err != nil {
194+ t.Fatal(err)
195+ }
196+ manifest, err := codec.MarshalManifest(codec.Manifest{
197+ Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name,
198+ Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}},
199+ })
200+ if err != nil {
201+ t.Fatal(err)
202+ }
203+ return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}}
204+}
D internal/adapters/snapshot/dirent_cookie_darwin.go +0 -21
@@ -1,21 +0,0 @@
1-//go:build darwin
2-
3-package snapshot
4-
5-import (
6- "io"
7- "syscall"
8- "unsafe"
9-)
10-
11-// syscall.ReadDirent on Darwin implements Getdirentries by storing the number
12-// of returned entries in the descriptor seek offset. Dirent.Seekoff is not
13-// that cursor. A max-sized record buffer lets each call be drained before the
14-// descriptor-maintained count is saved.
15-const maintenanceDirentBufferSize = int(unsafe.Sizeof(syscall.Dirent{}))
16-
17-func directoryCookie(file maintenanceDirectory, _ *syscall.Dirent) (int64, error) {
18- return file.Seek(0, io.SeekCurrent)
19-}
20-
21-func drainMaintenanceDirentBatch() bool { return true }
D internal/adapters/snapshot/dirent_cookie_darwin_test.go +0 -20
@@ -1,20 +0,0 @@
1-//go:build darwin
2-
3-package snapshot
4-
5-import (
6- "syscall"
7- "testing"
8-)
9-
10-func TestDirectoryCookieUsesDescriptorEntryCount(t *testing.T) {
11- file := fakeMaintenanceDirectory{seekOffset: 42}
12- dirent := syscall.Dirent{Seekoff: 99}
13- got, err := directoryCookie(file, &dirent)
14- if err != nil {
15- t.Fatal(err)
16- }
17- if got != 42 {
18- t.Fatalf("directoryCookie() = %d, want descriptor entry count 42", got)
19- }
20-}
D internal/adapters/snapshot/dirent_cookie_linux.go +0 -14
@@ -1,14 +0,0 @@
1-//go:build linux
2-
3-package snapshot
4-
5-import "syscall"
6-
7-const maintenanceDirentBufferSize = 8192
8-
9-// directoryCookie is d_off, the resumable getdents(2) cookie for Linux.
10-func directoryCookie(_ maintenanceDirectory, dirent *syscall.Dirent) (int64, error) {
11- return dirent.Off, nil
12-}
13-
14-func drainMaintenanceDirentBatch() bool { return false }
D internal/adapters/snapshot/dirent_cookie_linux_test.go +0 -19
@@ -1,19 +0,0 @@
1-//go:build linux
2-
3-package snapshot
4-
5-import (
6- "syscall"
7- "testing"
8-)
9-
10-func TestDirectoryCookieUsesLinuxOffset(t *testing.T) {
11- dirent := syscall.Dirent{Off: 42}
12- got, err := directoryCookie(fakeMaintenanceDirectory{}, &dirent)
13- if err != nil {
14- t.Fatal(err)
15- }
16- if got != 42 {
17- t.Fatalf("directoryCookie() = %d, want 42", got)
18- }
19-}
D internal/adapters/snapshot/fd_count_darwin_test.go +0 -28
@@ -1,28 +0,0 @@
1-//go:build darwin
2-
3-package snapshot
4-
5-import (
6- "os"
7- "testing"
8-)
9-
10-// macOS exposes the calling process descriptor table through /dev/fd.
11-// Readdirnames avoids os.ReadDir's fstatat calls for descriptors that can
12-// disappear while the directory is being enumerated.
13-func openDescriptorCount(t *testing.T) int {
14- t.Helper()
15- dir, err := os.Open("/dev/fd")
16- if err != nil {
17- t.Fatal(err)
18- }
19- names, readErr := dir.Readdirnames(-1)
20- closeErr := dir.Close()
21- if readErr != nil {
22- t.Fatal(readErr)
23- }
24- if closeErr != nil {
25- t.Fatal(closeErr)
26- }
27- return len(names)
28-}
D internal/adapters/snapshot/fd_count_linux_test.go +0 -19
@@ -1,19 +0,0 @@
1-//go:build linux
2-
3-package snapshot
4-
5-import (
6- "os"
7- "testing"
8-)
9-
10-// openDescriptorCount checks the actual Linux descriptor table, not a mocked
11-// lifecycle signal, so the maintenance leak assertion remains strict.
12-func openDescriptorCount(t *testing.T) int {
13- t.Helper()
14- entries, err := os.ReadDir("/proc/self/fd")
15- if err != nil {
16- t.Fatal(err)
17- }
18- return len(entries)
19-}
D internal/adapters/snapshot/legacy.go +0 -399
@@ -1,399 +0,0 @@
1-package snapshot
2-
3-import (
4- "bytes"
5- "context"
6- "crypto/sha256"
7- "errors"
8- "fmt"
9- "io"
10- "os"
11- "path/filepath"
12- "sort"
13- "strings"
14-
15- "github.com/bnema/vev/internal/ports"
16-)
17-
18-const (
19- legacyDeleteMarkerPrefix = ".legacy-import-receipt-"
20- maxLegacyDeleteMarkers = maxLegacySnapshotFiles
21- maxLegacyDeleteNameBytes = 200
22-)
23-
24-type legacyDeleteMarker struct {
25- name string
26- digest [sha256.Size]byte
27-}
28-
29-type legacyDirectory interface {
30- ReadDir(int) ([]os.DirEntry, error)
31- Close() error
32-}
33-
34-func (r *Repository) openLegacyDirectory(path string) (legacyDirectory, error) {
35- if r.hooks.openLegacyDirectory != nil {
36- return r.hooks.openLegacyDirectory(path)
37- }
38- return r.openDirectory(path)
39-}
40-
41-// readLegacyImportCandidate reports whether an untrusted root entry can be
42-// safely imported. Legacy scanning skips entries that disappear or fail secure
43-// descriptor validation so one hostile .snap file does not block other imports.
44-func (r *Repository) readLegacyImportCandidate(path string) ([]byte, bool) {
45- data, err := r.readBounded(path)
46- return data, err == nil
47-}
48-
49-// LoadLegacy reads only pre-incremental root .snap files. It is deliberately
50-// isolated from repository reads so incremental directories are never traversed.
51-func (r *Repository) LoadLegacy(ctx context.Context) (out []ports.LegacySnapshot, err error) {
52- if err := ctx.Err(); err != nil {
53- return nil, err
54- }
55- // One budget covers marker recovery and import. Every directory entry costs
56- // work, including unrelated attacker-created names, so neither phase can be
57- // made unbounded by a huge legacy root.
58- budget := maxDirectoryTraversalEntries
59- // A marker is an already-authorized deletion, not an import candidate. Finish
60- // it before exposing any legacy blobs so process restart cannot republish one.
61- if err := r.cleanupPendingLegacyDeletes(ctx, &budget); err != nil {
62- return nil, err
63- }
64- f, err := r.openLegacyDirectory(r.dir)
65- if errors.Is(err, os.ErrNotExist) {
66- return nil, nil
67- }
68- if err != nil {
69- return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(err))
70- }
71- defer func() {
72- if closeErr := f.Close(); closeErr != nil {
73- err = errors.Join(err, fmt.Errorf("close legacy snapshot directory: %w", safeFilesystemError(closeErr)))
74- }
75- }()
76-
77- out = make([]ports.LegacySnapshot, 0, maxLegacySnapshotFiles)
78- files := 0
79- total := 0
80- for {
81- entries, readErr := f.ReadDir(maintenanceBatch)
82- for _, entry := range entries {
83- if err := ctx.Err(); err != nil {
84- return nil, err
85- }
86- if budget == 0 {
87- return nil, ErrDirectoryTraversalBudget
88- }
89- budget--
90- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".snap") {
91- continue
92- }
93- files++
94- if files > maxLegacySnapshotFiles {
95- return nil, fmt.Errorf("legacy snapshot import has too many files (maximum %d)", maxLegacySnapshotFiles)
96- }
97- path := filepath.Join(r.dir, entry.Name())
98- data, importable := r.readLegacyImportCandidate(path)
99- if !importable {
100- continue
101- }
102- if len(data) > maxLegacySnapshotBytes-total {
103- return nil, fmt.Errorf("legacy snapshot import exceeds aggregate byte limit (%d bytes)", maxLegacySnapshotBytes)
104- }
105- total += len(data)
106- name := strings.TrimSuffix(entry.Name(), ".snap")
107- if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
108- name = ""
109- }
110- killed, tombstoneErr := r.tombstoned(name)
111- if tombstoneErr != nil {
112- return nil, fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr))
113- }
114- if killed {
115- continue
116- }
117- out = append(out, ports.LegacySnapshot{Name: name, Data: data})
118- }
119- if errors.Is(readErr, io.EOF) {
120- break
121- }
122- if readErr != nil {
123- return nil, fmt.Errorf("read legacy snapshot directory: %w", safeFilesystemError(readErr))
124- }
125- }
126- sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
127- return out, nil
128-}
129-
130-// DeleteVerifiedLegacy records a hash-bound import receipt before deleting the
131-// source. LoadLegacy consumes surviving receipts after restart, but only when
132-// the source still has the exact bytes that were verified and published.
133-func (r *Repository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
134- if err := ctx.Err(); err != nil {
135- return err
136- }
137- filename := filenameForName(blob.Name)
138- lock := r.lockSession("legacy-sync:" + filename)
139- defer r.unlockSession(lock)
140- if err := ctx.Err(); err != nil {
141- return err
142- }
143- receipt := legacyDeleteMarker{name: blob.Name, digest: sha256.Sum256(blob.Data)}
144- if err := r.authorizeLegacyDelete(receipt); err != nil {
145- return err
146- }
147- return r.deleteAuthorizedLegacy(ctx, filename, receipt)
148-}
149-
150-// DeleteLegacy is for deliberate session purges, which do not represent a
151-// verified import and therefore must not create an import receipt.
152-func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
153- if err := ctx.Err(); err != nil {
154- return err
155- }
156- filename := filenameForName(name)
157- lock := r.lockSession("legacy-sync:" + filename)
158- defer r.unlockSession(lock)
159- return r.deleteLegacyFile(ctx, filename)
160-}
161-
162-func legacyDeleteMarkerFilename(key string) string { return legacyDeleteMarkerPrefix + key }
163-
164-func (r *Repository) authorizeLegacyDelete(receipt legacyDeleteMarker) error {
165- if err := r.ensurePrivateDirectory(r.dir); err != nil {
166- return fmt.Errorf("create legacy import receipt directory: %w", safeFilesystemError(err))
167- }
168- path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(receipt.name)))
169- data := append(receipt.digest[:], receipt.name...)
170- if err := r.writeImmutable(path, data, func(data []byte) error {
171- if len(data) != sha256.Size+len(receipt.name) || string(data[sha256.Size:]) != receipt.name || !bytes.Equal(data[:sha256.Size], receipt.digest[:]) {
172- return fmt.Errorf("legacy import receipt identity mismatch")
173- }
174- return nil
175- }); err != nil {
176- return fmt.Errorf("write legacy import receipt: %w", safeFilesystemError(err))
177- }
178- return nil
179-}
180-
181-// deleteAuthorizedLegacy deletes only a source whose bytes match the durable
182-// receipt. A replaced source invalidates the receipt and remains importable.
183-func (r *Repository) deleteAuthorizedLegacy(ctx context.Context, filename string, receipt legacyDeleteMarker) error {
184- if _, pending := r.pendingLegacySync.Load(filename); pending {
185- if err := r.syncPendingLegacy(ctx, filename); err != nil {
186- return err
187- }
188- return r.clearLegacyDeleteAuthorization(receipt.name)
189- }
190- path := filepath.Join(r.dir, filename)
191- data, err := r.readBounded(path)
192- if err != nil && !errors.Is(err, os.ErrNotExist) {
193- return fmt.Errorf("read legacy snapshot for deletion: %w", safeFilesystemError(err))
194- }
195- if err == nil && sha256.Sum256(data) != receipt.digest {
196- return r.clearLegacyDeleteAuthorization(receipt.name)
197- }
198- return r.deleteLegacyFile(ctx, filename, receipt.name)
199-}
200-
201-func (r *Repository) deleteLegacyFile(ctx context.Context, filename string, clearReceipt ...string) error {
202- if _, pending := r.pendingLegacySync.Load(filename); pending {
203- if err := r.syncPendingLegacy(ctx, filename); err != nil {
204- return err
205- }
206- } else {
207- path := filepath.Join(r.dir, filename)
208- if err := r.remove(path); err != nil {
209- if !errors.Is(err, os.ErrNotExist) {
210- return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err))
211- }
212- // A missing repository has no deletion to make durable. This preserves
213- // idempotence for explicit purges before the snapshot root exists.
214- if _, statErr := os.Lstat(r.dir); errors.Is(statErr, os.ErrNotExist) {
215- return nil
216- } else if statErr != nil {
217- return fmt.Errorf("stat legacy snapshot directory: %w", safeFilesystemError(statErr))
218- }
219- }
220- r.pendingLegacySync.Store(filename, struct{}{})
221- if err := r.syncPendingLegacy(ctx, filename); err != nil {
222- return err
223- }
224- }
225- if len(clearReceipt) != 0 {
226- return r.clearLegacyDeleteAuthorization(clearReceipt[0])
227- }
228- return nil
229-}
230-
231-func (r *Repository) clearLegacyDeleteAuthorization(name string) error {
232- path := filepath.Join(r.dir, legacyDeleteMarkerFilename(sessionKey(name)))
233- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
234- return fmt.Errorf("clear legacy import receipt: %w", safeFilesystemError(err))
235- }
236- if err := r.syncDirectory(r.dir); err != nil {
237- return fmt.Errorf("sync legacy import receipt directory: %w", safeFilesystemError(err))
238- }
239- return nil
240-}
241-
242-func (r *Repository) cleanupPendingLegacyDeletes(ctx context.Context, budget *int) error {
243- markers, err := r.pendingLegacyDeleteMarkers(ctx, budget)
244- if errors.Is(err, os.ErrNotExist) {
245- return nil
246- }
247- if err != nil {
248- return err
249- }
250- for _, marker := range markers {
251- filename := filenameForName(marker.name)
252- lock := r.lockSession("legacy-sync:" + filename)
253- err := r.deleteAuthorizedLegacy(ctx, filename, marker)
254- r.unlockSession(lock)
255- if err != nil {
256- return fmt.Errorf("retry authorized legacy deletion: %w", err)
257- }
258- }
259- return nil
260-}
261-
262-func (r *Repository) pendingLegacyDeleteMarkers(ctx context.Context, budget *int) (markers []legacyDeleteMarker, err error) {
263- f, err := r.openLegacyDirectory(r.dir)
264- if err != nil {
265- return nil, err
266- }
267- defer func() {
268- if closeErr := f.Close(); closeErr != nil {
269- err = errors.Join(err, fmt.Errorf("close legacy deletion marker directory: %w", safeFilesystemError(closeErr)))
270- }
271- }()
272- markers = make([]legacyDeleteMarker, 0, maxLegacyDeleteMarkers)
273- for {
274- entries, readErr := f.ReadDir(maintenanceBatch)
275- for _, entry := range entries {
276- if err := ctx.Err(); err != nil {
277- return nil, err
278- }
279- if *budget == 0 {
280- return nil, ErrDirectoryTraversalBudget
281- }
282- *budget--
283- if entry.IsDir() || !strings.HasPrefix(entry.Name(), legacyDeleteMarkerPrefix) {
284- continue
285- }
286- if len(markers) == maxLegacyDeleteMarkers {
287- return nil, fmt.Errorf("legacy deletion retry has too many markers (maximum %d)", maxLegacyDeleteMarkers)
288- }
289- key := strings.TrimPrefix(entry.Name(), legacyDeleteMarkerPrefix)
290- if !canonicalSessionKey(key) {
291- return nil, fmt.Errorf("invalid legacy deletion marker")
292- }
293- path := filepath.Join(r.dir, entry.Name())
294- data, dataErr := r.readBounded(path)
295- if dataErr != nil || len(data) < sha256.Size || len(data)-sha256.Size > maxLegacyDeleteNameBytes {
296- return nil, fmt.Errorf("invalid legacy import receipt")
297- }
298- name := string(data[sha256.Size:])
299- if sessionKey(name) != key {
300- return nil, fmt.Errorf("invalid legacy import receipt")
301- }
302- marker := legacyDeleteMarker{name: name}
303- copy(marker.digest[:], data[:sha256.Size])
304- markers = append(markers, marker)
305- }
306- if errors.Is(readErr, io.EOF) {
307- break
308- }
309- if readErr != nil {
310- return nil, fmt.Errorf("read legacy deletion markers: %w", safeFilesystemError(readErr))
311- }
312- }
313- return markers, nil
314-}
315-
316-// Tombstone durably marks name as logically killed before either snapshot
317-// source is deleted. Startup consults this marker before importing legacy or
318-// restoring incremental state, so a partial offline kill cannot resurrect it.
319-func (r *Repository) Tombstone(ctx context.Context, name string) error {
320- if err := ctx.Err(); err != nil {
321- return err
322- }
323- key := sessionKey(name)
324- lock := r.lockSession(key)
325- defer r.unlockSession(lock)
326- if err := ctx.Err(); err != nil {
327- return err
328- }
329- if err := r.ensurePrivateDirectory(r.dir); err != nil {
330- return fmt.Errorf("create killed session marker directory: %w", safeFilesystemError(err))
331- }
332- path := filepath.Join(r.dir, tombstoneFilename(key))
333- if err := r.writeImmutable(path, []byte(name), func(data []byte) error {
334- if string(data) != name {
335- return fmt.Errorf("killed session marker identity mismatch")
336- }
337- return nil
338- }); err != nil {
339- return fmt.Errorf("write killed session marker: %w", safeFilesystemError(err))
340- }
341- return nil
342-}
343-
344-// DeleteTombstone removes a marker only after both sources have been durably
345-// deleted. A missing marker is idempotent for offline-kill retries.
346-func (r *Repository) DeleteTombstone(ctx context.Context, name string) error {
347- if err := ctx.Err(); err != nil {
348- return err
349- }
350- key := sessionKey(name)
351- lock := r.lockSession(key)
352- defer r.unlockSession(lock)
353- if err := ctx.Err(); err != nil {
354- return err
355- }
356- if err := r.remove(filepath.Join(r.dir, tombstoneFilename(key))); errors.Is(err, os.ErrNotExist) {
357- return nil
358- } else if err != nil {
359- return fmt.Errorf("delete killed session marker: %w", safeFilesystemError(err))
360- }
361- if err := r.syncDirectory(r.dir); err != nil {
362- return fmt.Errorf("sync killed session marker directory: %w", safeFilesystemError(err))
363- }
364- return nil
365-}
366-
367-func tombstoneFilename(key string) string { return ".killed-" + key }
368-
369-func (r *Repository) tombstoned(name string) (bool, error) {
370- if name == "" {
371- return false, nil
372- }
373- path := filepath.Join(r.dir, tombstoneFilename(sessionKey(name)))
374- if hook := r.hooks.beforeTombstoneCheck; hook != nil {
375- hook(path)
376- }
377- data, err := r.readBounded(path)
378- if errors.Is(err, os.ErrNotExist) {
379- return false, nil
380- }
381- if err != nil {
382- return false, err
383- }
384- if string(data) != name {
385- return false, fmt.Errorf("killed session marker identity mismatch")
386- }
387- return true, nil
388-}
389-
390-func (r *Repository) syncPendingLegacy(ctx context.Context, filename string) error {
391- if err := ctx.Err(); err != nil {
392- return err
393- }
394- if err := r.syncDirectory(r.dir); err != nil {
395- return fmt.Errorf("sync legacy snapshot directory: %w", safeFilesystemError(err))
396- }
397- r.pendingLegacySync.Delete(filename)
398- return nil
399-}
D internal/adapters/snapshot/legacy_pending_delete_test.go +0 -63
@@ -1,63 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "os"
7- "path/filepath"
8- "testing"
9-
10- "github.com/stretchr/testify/require"
11-
12- "github.com/bnema/vev/internal/ports"
13-)
14-
15-func TestRepositoryLoadLegacyCompletesAuthorizedDeleteAfterRestart(t *testing.T) {
16- repo := NewRepository(privateDir(t))
17- require.NoError(t, os.Mkdir(repo.dir, 0o700))
18- path := filepath.Join(repo.dir, filenameForName("named"))
19- require.NoError(t, os.WriteFile(path, []byte("legacy"), 0o600))
20-
21- injected := errors.New("unlink failed")
22- repo.hooks.remove = func(path string) error {
23- if filepath.Base(path) == filenameForName("named") {
24- return injected
25- }
26- return os.Remove(path)
27- }
28- // The injected unlink fault is after receipt persistence, simulating a
29- // crash boundary where publication was verified but source cleanup was not.
30- require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("legacy")}), injected)
31-
32- // A new repository has no in-memory retry state. The durable receipt must
33- // fence import and complete deletion without another publication.
34- repo = NewRepository(repo.dir)
35- legacy, err := repo.LoadLegacy(context.Background())
36- require.NoError(t, err)
37- require.Empty(t, legacy)
38- _, err = os.Stat(path)
39- require.ErrorIs(t, err, os.ErrNotExist)
40-}
41-
42-func TestRepositoryReceiptDoesNotDeleteReplacedLegacySource(t *testing.T) {
43- repo := NewRepository(privateDir(t))
44- require.NoError(t, os.Mkdir(repo.dir, 0o700))
45- path := filepath.Join(repo.dir, filenameForName("named"))
46- require.NoError(t, os.WriteFile(path, []byte("verified"), 0o600))
47-
48- injected := errors.New("unlink failed")
49- repo.hooks.remove = func(path string) error {
50- if filepath.Base(path) == filenameForName("named") {
51- return injected
52- }
53- return os.Remove(path)
54- }
55- require.ErrorIs(t, repo.DeleteVerifiedLegacy(context.Background(), ports.LegacySnapshot{Name: "named", Data: []byte("verified")}), injected)
56- require.NoError(t, os.WriteFile(path, []byte("replacement"), 0o600))
57-
58- repo = NewRepository(repo.dir)
59- legacy, err := repo.LoadLegacy(context.Background())
60- require.NoError(t, err)
61- require.Equal(t, []ports.LegacySnapshot{{Name: "named", Data: []byte("replacement")}}, legacy)
62- require.FileExists(t, path)
63-}
D internal/adapters/snapshot/legacy_test.go +0 -141
@@ -1,141 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "io"
8- "os"
9- "path/filepath"
10- "strings"
11- "testing"
12-)
13-
14-func TestRepositoryLegacyReadsOnlyBoundedRootSnapshots(t *testing.T) {
15- repo := NewRepository(privateDir(t))
16- if err := os.MkdirAll(filepath.Join(repo.dir, repositorySessionsDir), 0o700); err != nil {
17- t.Fatal(err)
18- }
19- if err := os.WriteFile(filepath.Join(repo.dir, "named.snap"), []byte("legacy"), 0o600); err != nil {
20- t.Fatal(err)
21- }
22- if err := os.WriteFile(filepath.Join(repo.dir, filenameForName("unsafe/name")), []byte("hashed"), 0o600); err != nil {
23- t.Fatal(err)
24- }
25- if err := os.WriteFile(filepath.Join(repo.dir, repositorySessionsDir, "ignored.snap"), []byte("incremental"), 0o600); err != nil {
26- t.Fatal(err)
27- }
28-
29- got, err := repo.LoadLegacy(context.Background())
30- if err != nil {
31- t.Fatal(err)
32- }
33- if len(got) != 2 || got[0].Name != "" || got[1].Name != "named" {
34- t.Fatalf("LoadLegacy = %#v, want root safe and hashed snapshots", got)
35- }
36-}
37-
38-func TestRepositoryLoadLegacyReportsDirectoryCloseError(t *testing.T) {
39- for _, tt := range []struct {
40- name string
41- failOpen int
42- operation string
43- }{
44- {name: "pending deletion markers", failOpen: 1, operation: "close legacy deletion marker directory"},
45- {name: "legacy snapshots", failOpen: 2, operation: "close legacy snapshot directory"},
46- } {
47- t.Run(tt.name, func(t *testing.T) {
48- closeErr := errors.New("injected directory close failure")
49- repo := NewRepository(privateDir(t))
50- openCalls := 0
51- repo.hooks.openLegacyDirectory = func(string) (legacyDirectory, error) {
52- openCalls++
53- if openCalls == tt.failOpen {
54- return fakeLegacyDirectory{closeErr: closeErr}, nil
55- }
56- return fakeLegacyDirectory{}, nil
57- }
58-
59- _, err := repo.LoadLegacy(context.Background())
60- if !errors.Is(err, closeErr) {
61- t.Fatalf("LoadLegacy error = %v, want close error", err)
62- }
63- if !strings.Contains(err.Error(), tt.operation) {
64- t.Fatalf("LoadLegacy error = %v, want %q", err, tt.operation)
65- }
66- })
67- }
68-}
69-
70-type fakeLegacyDirectory struct {
71- closeErr error
72-}
73-
74-func (d fakeLegacyDirectory) ReadDir(int) ([]os.DirEntry, error) { return nil, io.EOF }
75-func (d fakeLegacyDirectory) Close() error { return d.closeErr }
76-
77-func TestRepositoryLegacyRejectsTooManyAndTooLargeAggregateSnapshots(t *testing.T) {
78- t.Run("count", func(t *testing.T) {
79- repo := NewRepository(privateDir(t))
80- if err := os.Mkdir(repo.dir, 0o700); err != nil {
81- t.Fatal(err)
82- }
83- for i := 0; i < maxLegacySnapshotFiles+1; i++ {
84- if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%03d.snap", i)), []byte("legacy"), 0o600); err != nil {
85- t.Fatal(err)
86- }
87- }
88- if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "too many") {
89- t.Fatalf("LoadLegacy error = %v, want actionable count limit", err)
90- }
91- })
92- t.Run("aggregate bytes", func(t *testing.T) {
93- repo := NewRepository(privateDir(t))
94- if err := os.Mkdir(repo.dir, 0o700); err != nil {
95- t.Fatal(err)
96- }
97- for i, size := range []int{maxLegacySnapshotBytes / 2, maxLegacySnapshotBytes/2 + 1} {
98- if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("%d.snap", i)), make([]byte, size), 0o600); err != nil {
99- t.Fatal(err)
100- }
101- }
102- if _, err := repo.LoadLegacy(context.Background()); err == nil || !strings.Contains(err.Error(), "aggregate") {
103- t.Fatalf("LoadLegacy error = %v, want actionable aggregate limit", err)
104- }
105- })
106-}
107-
108-func TestRepositoryDeleteLegacyMissingFileDoesNotRequireRoot(t *testing.T) {
109- repo := NewRepository(privateDir(t))
110- if err := repo.DeleteLegacy(context.Background(), "missing"); err != nil {
111- t.Fatalf("DeleteLegacy missing file: %v", err)
112- }
113-}
114-
115-func TestRepositoryDeleteLegacySurfacesRootSyncAndCanRetry(t *testing.T) {
116- repo := NewRepository(privateDir(t))
117- if err := os.Mkdir(repo.dir, 0o700); err != nil {
118- t.Fatal(err)
119- }
120- if err := os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600); err != nil {
121- t.Fatal(err)
122- }
123- injected := errors.New("root sync")
124- syncCalls := 0
125- repo.hooks.syncDirectory = func(string) error {
126- syncCalls++
127- if syncCalls == 1 {
128- return injected
129- }
130- return nil
131- }
132- if err := repo.DeleteLegacy(context.Background(), "named"); !errors.Is(err, injected) {
133- t.Fatalf("DeleteLegacy error = %v, want root sync error", err)
134- }
135- if err := repo.DeleteLegacy(context.Background(), "named"); err != nil {
136- t.Fatalf("retry DeleteLegacy: %v", err)
137- }
138- if syncCalls != 2 {
139- t.Fatalf("sync calls = %d, want retry to sync absent file", syncCalls)
140- }
141-}
M internal/adapters/snapshot/load.go +48 -219
@@ -6,252 +6,83 @@ import (
66 "encoding/binary"
77 "errors"
88 "fmt"
9- "io"
109 "os"
11- "path/filepath"
12- "sort"
1310
11+ "github.com/bnema/vev/internal/domain"
1412 "github.com/bnema/vev/internal/ports"
1513 codec "github.com/bnema/vev/internal/usecase/snapshot"
1614 )
1715
18-// Directory scans are deliberately finite. Snapshot directories are attacker
19-// controlled state, so callers get a retryable error instead of retaining an
20-// arbitrary number of names or spending an arbitrary amount of work.
21-const (
22- directoryTraversalBatch = 64
23- maxDirectoryTraversalEntries = 4096
24-)
25-
26-var (
27- ErrDirectoryTraversalBudget = errors.New("snapshot directory traversal budget exceeded")
28- ErrInvalidHEAD = errors.New("invalid snapshot HEAD")
29-)
16+var ErrInvalidHEAD = errors.New("invalid snapshot HEAD")
3017
31-func (r *Repository) List(ctx context.Context) ([]string, error) {
18+func (r *Repository) LoadCheckpoint(ctx context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) {
3219 if err := ctx.Err(); err != nil {
33- return nil, err
34- }
35- budget := maxDirectoryTraversalEntries
36- out := make([]string, 0)
37- sessions := filepath.Join(r.dir, repositorySessionsDir)
38- err := r.walkDirectory(ctx, sessions, &budget, func(entry os.DirEntry) error {
39- if !entry.IsDir() || !canonicalSessionKey(entry.Name()) {
40- return nil
41- }
42- generation, ok, err := r.loadNewestGeneration(ctx, entry.Name(), &budget)
43- if err != nil || !ok {
44- return err
45- }
46- out = append(out, generation.Name)
47- return nil
48- })
49- if errors.Is(err, os.ErrNotExist) {
50- return nil, nil
20+ return ports.SnapshotGeneration{}, err
5121 }
22+ key, err := incarnationKey(id)
5223 if err != nil {
53- return nil, fmt.Errorf("read snapshot sessions: %w", err)
54- }
55- sort.Strings(out)
56- return out, nil
57-}
58-
59-func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
60- if err := ctx.Err(); err != nil {
6124 return ports.SnapshotGeneration{}, err
6225 }
63- key := sessionKey(name)
6426 lock := r.lockSession(key)
6527 defer r.unlockSession(lock)
66- if err := ctx.Err(); err != nil {
67- return ports.SnapshotGeneration{}, err
68- }
69- killed, err := r.tombstoned(name)
70- if err != nil {
71- return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err)
72- }
73- if killed {
74- return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name)
75- }
76- return r.load(ctx, name, key)
28+ generation, _, err := r.loadCheckpointLocked(ctx, id, name, ref)
29+ return generation, err
7730 }
7831
79-func (r *Repository) load(ctx context.Context, name, key string) (ports.SnapshotGeneration, error) {
80- preferred, preferredDigest, err := r.readHead(key)
81- skipped := errors.Is(err, ErrInvalidHEAD)
82- if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
83- return ports.SnapshotGeneration{}, fmt.Errorf("read snapshot HEAD: %w", err)
32+func (r *Repository) loadCheckpointLocked(ctx context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, codec.Manifest, error) {
33+ if ref.Generation == 0 || ref.ManifestDigest == ([32]byte{}) {
34+ return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("invalid checkpoint reference")
8435 }
85- if preferred != 0 {
86- got, err := r.loadGeneration(ctx, name, key, preferred)
87- if err == nil && sha256.Sum256(got.Manifest) == preferredDigest {
88- return got, nil
89- }
90- skipped = true
91- }
92-
93- budget := maxDirectoryTraversalEntries
94- candidates, err := r.generationCandidates(ctx, key, preferred, &budget)
36+ data, err := r.readBounded(r.manifestPath(id, ref.Generation))
9537 if err != nil {
96- return ports.SnapshotGeneration{}, err
97- }
98- for _, generation := range candidates {
99- got, err := r.loadGeneration(ctx, name, key, generation)
100- if err != nil {
101- skipped = true
102- continue
103- }
104- got.Fallback = skipped || preferred != 0
105- return got, nil
106- }
107- return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
108-}
109-
110-func (r *Repository) currentGeneration(ctx context.Context, name, key string) (uint64, []byte, error) {
111- generation, digest, err := r.readHead(key)
112- if err == nil {
113- got, err := r.loadGeneration(ctx, name, key, generation)
114- if err == nil && sha256.Sum256(got.Manifest) == digest {
115- return generation, got.Manifest, nil
116- }
117- } else if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
118- return 0, nil, fmt.Errorf("read snapshot HEAD: %w", err)
38+ return ports.SnapshotGeneration{}, codec.Manifest{}, err
11939 }
120- budget := maxDirectoryTraversalEntries
121- candidates, err := r.generationCandidates(ctx, key, 0, &budget)
122- if err != nil {
123- return 0, nil, err
40+ if sha256.Sum256(data) != ref.ManifestDigest {
41+ return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("manifest digest mismatch")
12442 }
125- for _, generation := range candidates {
126- got, err := r.loadGeneration(ctx, name, key, generation)
127- if err == nil {
128- return generation, got.Manifest, nil
129- }
43+ manifest, err := codec.UnmarshalManifest(data)
44+ if err != nil || manifest.IncarnationID != id || manifest.Generation != ref.Generation {
45+ return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("invalid manifest")
13046 }
131- return 0, nil, nil
132-}
133-
134-// loadNewestGeneration validates a single bounded candidate batch in strict
135-// descending generation order. It is used by List, whose name is discovered
136-// from each manifest rather than supplied by a caller.
137-func (r *Repository) loadNewestGeneration(ctx context.Context, key string, budget *int) (ports.SnapshotGeneration, bool, error) {
138- candidates, err := r.generationCandidates(ctx, key, 0, budget)
139- if err != nil {
140- return ports.SnapshotGeneration{}, false, err
47+ refs := manifestRefs(manifest)
48+ if refs == nil || !withinGenerationBudget(len(data), refs) {
49+ return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("snapshot generation too large")
14150 }
142- for _, generation := range candidates {
143- data, err := r.readBounded(r.manifestPath(key, generation))
144- if err != nil {
145- continue
146- }
147- manifest, err := codec.UnmarshalManifest(data)
148- if err != nil || sessionKey(manifest.Name) != key {
149- continue
51+ objects := make(map[ports.SnapshotDigest][]byte, len(refs))
52+ for digest, objectRef := range refs {
53+ if err := ctx.Err(); err != nil {
54+ return ports.SnapshotGeneration{}, codec.Manifest{}, err
15055 }
151- killed, err := r.tombstoned(manifest.Name)
56+ object, err := r.readBounded(r.objectPath(id, digest))
15257 if err != nil {
153- return ports.SnapshotGeneration{}, false, fmt.Errorf("read killed session marker: %w", err)
154- }
155- if killed {
156- return ports.SnapshotGeneration{}, false, nil
58+ return ports.SnapshotGeneration{}, codec.Manifest{}, err
15759 }
158- got, err := r.loadGeneration(ctx, manifest.Name, key, generation)
159- if err == nil {
160- return got, true, nil
60+ if sha256.Sum256(object) != digest || !validObject(object, objectRef) {
61+ return ports.SnapshotGeneration{}, codec.Manifest{}, fmt.Errorf("invalid object")
16162 }
63+ objects[digest] = object
16264 }
163- return ports.SnapshotGeneration{}, false, nil
65+ return ports.SnapshotGeneration{IncarnationID: id, Name: name, Generation: ref.Generation, ParentCheckpoint: manifest.ParentCheckpoint, Manifest: data, Objects: objects}, manifest, nil
16466 }
16567
166-// generationCandidates reads the generations directory once per load attempt.
167-// The shared traversal budget bounds both retained names and work; sorting the
168-// bounded batch preserves strict newest-to-oldest fallback without rescanning.
169-func (r *Repository) generationCandidates(ctx context.Context, key string, exclude uint64, budget *int) ([]uint64, error) {
170- candidates := make([]uint64, 0)
171- err := r.walkDirectory(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, func(entry os.DirEntry) error {
172- if entry.IsDir() {
173- return nil
174- }
175- generation, ok := parseGenerationFilename(entry.Name())
176- if ok && generation != exclude {
177- candidates = append(candidates, generation)
178- }
179- return nil
180- })
181- if errors.Is(err, os.ErrNotExist) {
182- return candidates, nil
183- }
184- if err != nil {
185- return nil, err
68+func (r *Repository) ReconcileCheckpoint(ctx context.Context, id domain.IncarnationID, ref ports.CheckpointRef) error {
69+ if err := ctx.Err(); err != nil {
70+ return err
18671 }
187- sort.Slice(candidates, func(i, j int) bool { return candidates[i] > candidates[j] })
188- return candidates, nil
189-}
190-
191-// walkDirectory is the common finite directory traversal primitive for reads.
192-// File.ReadDir maintains a cursor on one descriptor and returns at most one
193-// small batch, so no full directory enumeration is retained in memory.
194-func (r *Repository) walkDirectory(ctx context.Context, dir string, budget *int, visit func(os.DirEntry) error) (err error) {
195- file, err := r.openDirectory(dir)
72+ key, err := incarnationKey(id)
19673 if err != nil {
19774 return err
19875 }
199- defer func() {
200- if closeErr := file.Close(); err == nil && closeErr != nil {
201- err = closeErr
202- }
203- }()
204- for {
205- if err := ctx.Err(); err != nil {
206- return err
207- }
208- entries, readErr := file.ReadDir(directoryTraversalBatch)
209- for _, entry := range entries {
210- if *budget == 0 {
211- return ErrDirectoryTraversalBudget
212- }
213- *budget--
214- if err := visit(entry); err != nil {
215- return err
216- }
217- }
218- if errors.Is(readErr, io.EOF) {
219- return nil
220- }
221- if readErr != nil {
222- return readErr
223- }
224- }
225-}
226-
227-func (r *Repository) loadGeneration(ctx context.Context, name, key string, generation uint64) (ports.SnapshotGeneration, error) {
228- data, err := r.readBounded(r.manifestPath(key, generation))
76+ lock := r.lockSession(key)
77+ defer r.unlockSession(lock)
78+ _, manifest, err := r.loadCheckpointLocked(ctx, id, "", ref)
22979 if err != nil {
230- return ports.SnapshotGeneration{}, err
231- }
232- manifest, err := codec.UnmarshalManifest(data)
233- if err != nil || manifest.Name != name || manifest.Generation != generation {
234- return ports.SnapshotGeneration{}, fmt.Errorf("invalid manifest")
80+ return fmt.Errorf("validate checkpoint before HEAD repair: %w", err)
23581 }
236- refs := manifestRefs(manifest)
237- if refs == nil || !withinGenerationBudget(len(data), refs) {
238- return ports.SnapshotGeneration{}, fmt.Errorf("snapshot generation too large")
82+ if manifest.Name == "" {
83+ return fmt.Errorf("validate checkpoint before HEAD repair: invalid manifest name")
23984 }
240- objects := make(map[ports.SnapshotDigest][]byte, len(refs))
241- for digest, ref := range refs {
242- if err := ctx.Err(); err != nil {
243- return ports.SnapshotGeneration{}, err
244- }
245- object, err := r.readBounded(r.objectPath(key, digest))
246- if err != nil {
247- return ports.SnapshotGeneration{}, err
248- }
249- if sha256.Sum256(object) != digest || !validObject(object, ref) {
250- return ports.SnapshotGeneration{}, fmt.Errorf("invalid object")
251- }
252- objects[digest] = object
253- }
254- return ports.SnapshotGeneration{Name: manifest.Name, Generation: generation, Manifest: data, Objects: objects}, nil
85+ return r.writeMutable(r.headPath(id), marshalHead(ref.Generation, ref.ManifestDigest))
25586 }
25687
25788 func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
@@ -261,17 +92,15 @@ func marshalHead(generation uint64, digest ports.SnapshotDigest) []byte {
26192 copy(out[12:], digest[:])
26293 return out
26394 }
264-func (r *Repository) readHead(key string) (uint64, ports.SnapshotDigest, error) {
265- return r.readHeadWithRoot(nil, key)
95+func (r *Repository) readHead(id domain.IncarnationID) (uint64, ports.SnapshotDigest, error) {
96+ return r.readHeadWithRoot(nil, id)
26697 }
26798
268-func (r *Repository) readHeadWithRoot(root *os.Root, key string) (uint64, ports.SnapshotDigest, error) {
269- path := r.headPath(key)
270- if hook := r.hooks.beforeHeadRead; hook != nil {
271- if err := hook(path); err != nil {
272- return 0, ports.SnapshotDigest{}, err
273- }
274- }
99+func (r *Repository) readHeadWithRoot(root *os.Root, id domain.IncarnationID) (uint64, ports.SnapshotDigest, error) {
100+ return r.readHeadAt(root, r.headPath(id))
101+}
102+
103+func (r *Repository) readHeadAt(root *os.Root, path string) (uint64, ports.SnapshotDigest, error) {
275104 var data []byte
276105 var err error
277106 if root == nil {
D internal/adapters/snapshot/load_bounds_test.go +0 -112
@@ -1,112 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "os"
8- "path/filepath"
9- "testing"
10-)
11-
12-func TestRepositoryListStopsAtTraversalBudget(t *testing.T) {
13- repo := NewRepository(privateDir(t))
14- sessions := filepath.Join(repo.dir, repositorySessionsDir)
15- if err := os.MkdirAll(sessions, 0o700); err != nil {
16- t.Fatal(err)
17- }
18- for i := 0; i < maxDirectoryTraversalEntries+1; i++ {
19- if err := os.Mkdir(filepath.Join(sessions, fmt.Sprintf("hostile-%05d", i)), 0o700); err != nil {
20- t.Fatal(err)
21- }
22- }
23-
24- _, err := repo.List(context.Background())
25- if !errors.Is(err, ErrDirectoryTraversalBudget) {
26- t.Fatalf("List error = %v, want directory traversal budget", err)
27- }
28-}
29-
30-func TestRepositoryLoadStopsAtGenerationTraversalBudget(t *testing.T) {
31- repo := NewRepository(privateDir(t))
32- key := sessionKey("named")
33- generations := filepath.Join(repo.sessionPath(key), repositoryGenerations)
34- if err := os.MkdirAll(generations, 0o700); err != nil {
35- t.Fatal(err)
36- }
37- for generation := uint64(1); generation <= uint64(maxDirectoryTraversalEntries+1); generation++ {
38- if err := os.WriteFile(filepath.Join(generations, generationFilename(generation)), nil, 0o600); err != nil {
39- t.Fatal(err)
40- }
41- }
42-
43- _, err := repo.Load(context.Background(), "named")
44- if !errors.Is(err, ErrDirectoryTraversalBudget) {
45- t.Fatalf("Load error = %v, want directory traversal budget", err)
46- }
47-}
48-
49-func TestRepositoryLoadFallsBackNewestToOldestWithoutGenerationEnumeration(t *testing.T) {
50- repo := NewRepository(privateDir(t))
51- for generation := uint64(1); generation <= 3; generation++ {
52- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation)))); err != nil {
53- t.Fatal(err)
54- }
55- }
56- key := sessionKey("named")
57- publication := repositoryPublication(t, "named", 3, []byte("state-3"))
58- if err := os.Remove(repo.objectPath(key, publication.Objects[0].Digest)); err != nil {
59- t.Fatal(err)
60- }
61-
62- got, err := repo.Load(context.Background(), "named")
63- if err != nil {
64- t.Fatal(err)
65- }
66- if got.Generation != 2 || !got.Fallback {
67- t.Fatalf("Load = generation %d fallback %v, want generation 2 fallback true", got.Generation, got.Fallback)
68- }
69-}
70-
71-func TestRepositoryMaintainDoesNotCollectBeyondRetainedMetadataBudget(t *testing.T) {
72- repo := NewRepository(privateDir(t))
73- for generation := uint64(1); generation <= uint64(maxMaintenanceMarkedGenerations+1); generation++ {
74- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation)))); err != nil {
75- t.Fatal(err)
76- }
77- }
78- for pass := 0; pass < maxMaintenanceMarkedGenerations*3; pass++ {
79- if err := repo.Maintain(context.Background()); err != nil {
80- t.Fatal(err)
81- }
82- if repo.maintenanceSessions[sessionKey("named")] == nil {
83- break
84- }
85- if pass == maxMaintenanceMarkedGenerations*3-1 {
86- t.Fatal("maintenance did not complete after bounded object-shard traversal")
87- }
88- }
89- if _, err := os.Lstat(repo.manifestPath(sessionKey("named"), 1)); err != nil {
90- t.Fatalf("old manifest collected after mark budget overflow: %v", err)
91- }
92- if state := repo.maintenanceSessions[sessionKey("named")]; state != nil {
93- t.Fatalf("overflow maintenance state retained = %#v, want reset", state)
94- }
95-}
96-
97-func TestMaintenanceMarkStateIsCapped(t *testing.T) {
98- references := &sessionMaintenance{marked: make(map[uint64]manifestMaintenance)}
99- if !references.canRetainManifest(maxMaintenanceReferences) {
100- t.Fatal("state rejected references at its documented ceiling")
101- }
102- if references.canRetainManifest(1) {
103- t.Fatal("state accepted references beyond its documented ceiling")
104- }
105- generations := &sessionMaintenance{marked: make(map[uint64]manifestMaintenance)}
106- for generation := uint64(1); generation <= maxMaintenanceMarkedGenerations; generation++ {
107- generations.marked[generation] = manifestMaintenance{}
108- }
109- if generations.canRetainManifest(0) {
110- t.Fatal("state accepted generations beyond its documented ceiling")
111- }
112-}
D internal/adapters/snapshot/load_major_test.go +0 -88
@@ -1,88 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "os"
6- "syscall"
7- "testing"
8-
9- "github.com/stretchr/testify/require"
10-)
11-
12-func TestRepositoryLoadPropagatesOperationalHeadErrors(t *testing.T) {
13- repo := NewRepository(privateDir(t))
14- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
15-
16- repo.hooks.beforeHeadRead = func(string) error { return os.ErrPermission }
17-
18- _, err := repo.Load(context.Background(), "named")
19- require.ErrorIs(t, err, os.ErrPermission)
20-}
21-
22-func TestRepositoryCurrentGenerationPropagatesOperationalHeadErrors(t *testing.T) {
23- repo := NewRepository(privateDir(t))
24- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
25-
26- repo.hooks.beforeHeadRead = func(string) error { return syscall.EIO }
27-
28- _, _, err := repo.currentGeneration(context.Background(), "named", sessionKey("named"))
29- require.ErrorIs(t, err, syscall.EIO)
30-}
31-
32-func TestRepositoryLoadFallsBackFromInvalidHead(t *testing.T) {
33- repo := NewRepository(privateDir(t))
34- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
35- require.NoError(t, os.WriteFile(repo.headPath(sessionKey("named")), []byte("invalid"), 0o600))
36-
37- got, err := repo.Load(context.Background(), "named")
38- require.NoError(t, err)
39- require.Equal(t, uint64(1), got.Generation)
40- require.True(t, got.Fallback)
41-}
42-
43-func TestRepositoryLoadFallsBackAcrossMultipleCorruptCandidates(t *testing.T) {
44- repo := NewRepository(privateDir(t))
45- for generation := uint64(1); generation <= 4; generation++ {
46- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte{byte(generation)})))
47- }
48- key := sessionKey("named")
49- for _, generation := range []uint64{4, 3} {
50- publication := repositoryPublication(t, "named", generation, []byte{byte(generation)})
51- require.NoError(t, os.Remove(repo.objectPath(key, publication.Objects[0].Digest)))
52- }
53-
54- got, err := repo.Load(context.Background(), "named")
55- require.NoError(t, err)
56- require.Equal(t, uint64(2), got.Generation)
57- require.True(t, got.Fallback)
58-}
59-
60-func TestRepositoryLoadHoldsSessionLockAcrossTombstoneCheck(t *testing.T) {
61- repo := NewRepository(privateDir(t))
62- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
63-
64- tombstoneCheckStarted := make(chan struct{})
65- releaseTombstoneCheck := make(chan struct{})
66- repo.hooks.beforeTombstoneCheck = func(string) {
67- close(tombstoneCheckStarted)
68- <-releaseTombstoneCheck
69- }
70- loadDone := make(chan error, 1)
71- go func() {
72- _, err := repo.Load(context.Background(), "named")
73- loadDone <- err
74- }()
75- <-tombstoneCheckStarted
76-
77- tombstoneDone := make(chan error, 1)
78- go func() { tombstoneDone <- repo.Tombstone(context.Background(), "named") }()
79- select {
80- case err := <-tombstoneDone:
81- t.Fatalf("Tombstone completed before Load released the session lock: %v", err)
82- default:
83- }
84-
85- close(releaseTombstoneCheck)
86- require.NoError(t, <-loadDone)
87- require.NoError(t, <-tombstoneDone)
88-}
D internal/adapters/snapshot/maintenance.go +0 -129
@@ -1,129 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "os"
8- "path/filepath"
9-)
10-
11-const maintenanceBatch = 64
12-
13-// These caps bound metadata retained across resumable GC passes. If a session
14-// exceeds either cap, marking remains conservative and no destructive sweep or
15-// generation collection occurs in that cycle.
16-const (
17- maxMaintenanceMarkedGenerations = 128
18- maxMaintenanceReferences = 8192
19-)
20-
21-// Maintain reaps a bounded amount of stale state. Continuation handles are
22-// repository-owned, so a stable prefix cannot starve entries later in a large
23-// directory. Cancellation and errors discard those handles.
24-func (r *Repository) Maintain(ctx context.Context) (err error) {
25- r.maintenanceMu.Lock()
26- defer r.maintenanceMu.Unlock()
27- if err := ctx.Err(); err != nil {
28- r.resetMaintenance()
29- return err
30- }
31- defer func() {
32- if err != nil {
33- r.resetMaintenance()
34- }
35- }()
36- if r.maintenanceCursors == nil {
37- r.maintenanceCursors = make(map[string]*maintenanceCursor)
38- r.maintenanceSessions = make(map[string]*sessionMaintenance)
39- }
40-
41- budget := maintenanceBatch
42- if _, err := r.removeTemps(ctx, r.dir, &budget, "root-temps"); err != nil || budget == 0 {
43- return err
44- }
45- if r.maintenanceQuarantine != nil {
46- changed, done, err := r.maintainQuarantine(ctx, &budget)
47- if err != nil {
48- return err
49- }
50- if changed {
51- if err := r.syncDirectory(filepath.Join(r.dir, repositorySessionsDir)); err != nil {
52- return fmt.Errorf("sync snapshot maintenance directory: %w", safeFilesystemError(err))
53- }
54- }
55- if done {
56- r.maintenanceQuarantine = nil
57- }
58- return nil
59- }
60- // Resume an incomplete session before discovering another one. This keeps
61- // retained marks (and their session lock/epoch references) bounded even
62- // when a hostile repository contains arbitrarily many partial sessions.
63- for key := range r.maintenanceSessions {
64- lock := r.lockSession(key)
65- err = r.maintainSession(ctx, key, &budget)
66- r.unlockSession(lock)
67- return err
68- }
69- sessions := filepath.Join(r.dir, repositorySessionsDir)
70- entries, _, err := r.readMaintenanceDir(sessions, maintenanceBatch, "sessions")
71- if errors.Is(err, os.ErrNotExist) {
72- return nil
73- }
74- if err != nil {
75- return fmt.Errorf("read snapshot maintenance sessions: %w", safeFilesystemError(err))
76- }
77- for i, entry := range entries {
78- if budget == 0 {
79- r.requeueMaintenanceEntries(sessions, "sessions", entries[i:])
80- return nil
81- }
82- if err := ctx.Err(); err != nil {
83- return err
84- }
85- path := filepath.Join(sessions, entry.name)
86- if isQuarantine(entry.name) {
87- r.maintenanceQuarantine = &quarantineMaintenance{root: path, current: path}
88- changed, done, err := r.maintainQuarantine(ctx, &budget)
89- if err != nil && !errors.Is(err, os.ErrNotExist) {
90- return fmt.Errorf("remove snapshot quarantine %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
91- }
92- if changed {
93- if err := ctx.Err(); err != nil {
94- return err
95- }
96- if err := r.syncDirectory(sessions); err != nil {
97- return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
98- }
99- }
100- if done {
101- r.maintenanceQuarantine = nil
102- }
103- if i+1 < len(entries) {
104- r.requeueMaintenanceEntries(sessions, "sessions", entries[i+1:])
105- }
106- return nil
107- }
108- if !entry.isDir || !canonicalSessionKey(entry.name) {
109- continue
110- }
111- lock := r.lockSession(entry.name)
112- err = r.maintainSession(ctx, entry.name, &budget)
113- r.unlockSession(lock)
114- if err != nil {
115- return err
116- }
117- if r.maintenanceSessions[entry.name] != nil {
118- return nil
119- }
120- }
121- return nil
122-}
123-
124-// maintenanceCursor is deliberately reopenable metadata, not an open file.
125-// On Linux, d_off is the seek cookie returned by getdents(2); seeking a newly
126-// opened descriptor to it resumes the directory stream. Go's File.ReadDir
127-// cannot be used here: its 8 KiB internal getdents buffer advances the kernel
128-// offset beyond a short ReadDir result (see os/dir_unix.go), so saving SeekCur
129-// after ReadDir would skip buffered entries.
D internal/adapters/snapshot/maintenance_cursor.go +0 -279
@@ -1,279 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "io"
8- "os"
9- "path/filepath"
10- "strings"
11- "syscall"
12- "unsafe"
13-)
14-
15-// maxMaintenanceCursors bounds all retained directory seek cookies, including
16-// cursors for attacker-controlled object shards. Maintenance processes only
17-// one shard at a time, so normal operation stays well below this ceiling.
18-const maxMaintenanceCursors = 8
19-
20-var errMaintenanceCursorLimit = errors.New("snapshot maintenance cursor limit")
21-
22-type maintenanceCursor struct {
23- offset int64
24- pending []maintenanceDirEntry
25- done bool
26-}
27-
28-type maintenanceDirEntry struct {
29- name string
30- isDir bool
31-}
32-
33-func (r *Repository) resetMaintenance() {
34- for key := range r.maintenanceSessions {
35- r.clearSessionMaintenance(key)
36- }
37- r.maintenanceCursors = nil
38- r.maintenanceSessions = nil
39- r.maintenanceQuarantine = nil
40-}
41-
42-func maintenanceCursorID(dir, purpose string) string { return purpose + "\x00" + dir }
43-
44-// readMaintenanceDir advances one named directory cursor. Its continuation is
45-// only a Linux seek cookie and at most one call's worth of pending names. The
46-// descriptor used to read a call is always closed before this method returns.
47-// Callers must return entries they could not process with
48-// requeueMaintenanceEntries before yielding for budget exhaustion.
49-func (r *Repository) readMaintenanceDir(dir string, n int, purpose string) ([]maintenanceDirEntry, bool, error) {
50- id := maintenanceCursorID(dir, purpose)
51- cursor := r.maintenanceCursors[id]
52- if cursor != nil && len(cursor.pending) != 0 {
53- count := min(n, len(cursor.pending))
54- entries := cursor.pending[:count]
55- cursor.pending = cursor.pending[count:]
56- done := cursor.done && len(cursor.pending) == 0
57- if done {
58- delete(r.maintenanceCursors, id)
59- }
60- return entries, done, nil
61- }
62- if cursor == nil {
63- if len(r.maintenanceCursors) >= maxMaintenanceCursors {
64- return nil, false, errMaintenanceCursorLimit
65- }
66- cursor = &maintenanceCursor{}
67- r.maintenanceCursors[id] = cursor
68- }
69-
70- entries, done, err := r.readMaintenanceDirent(dir, n, cursor)
71- if err != nil {
72- delete(r.maintenanceCursors, id)
73- return nil, false, err
74- }
75- if done {
76- delete(r.maintenanceCursors, id)
77- }
78- return entries, done, nil
79-}
80-
81-// maintenanceDirectory is the small syscall surface needed to resume a
82-// bounded getdents cursor. It permits deterministic fault injection without
83-// retaining directory descriptors between maintenance passes.
84-type maintenanceDirectory interface {
85- Seek(int64, int) (int64, error)
86- ReadDirent([]byte) (int, error)
87- Close() error
88-}
89-
90-type osMaintenanceDirectory struct{ file *os.File }
91-
92-func (d osMaintenanceDirectory) Seek(offset int64, whence int) (int64, error) {
93- return syscall.Seek(int(d.file.Fd()), offset, whence)
94-}
95-
96-func (d osMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
97- return syscall.ReadDirent(int(d.file.Fd()), buffer)
98-}
99-
100-func (d osMaintenanceDirectory) Close() error { return d.file.Close() }
101-
102-func (r *Repository) openMaintenanceDirectory(dir string) (maintenanceDirectory, error) {
103- if open := r.hooks.openMaintenanceDirectory; open != nil {
104- return open(dir)
105- }
106- file, err := r.openDirectory(dir)
107- if err != nil {
108- return nil, err
109- }
110- return osMaintenanceDirectory{file: file}, nil
111-}
112-
113-// maintenanceDirectoryError adds stable operation context and removes any
114-// filesystem path while retaining the underlying cause for errors.Is.
115-func maintenanceDirectoryError(operation string, err error) error {
116- return fmt.Errorf("%s: %w", operation, safeFilesystemError(err))
117-}
118-
119-// readMaintenanceDirent saves a resumable directory cursor. Linux d_off lets
120-// a large getdents batch stop at limit. Darwin's syscall.ReadDirent instead
121-// stores an entry count in the descriptor offset, so every returned buffer
122-// must be drained before that descriptor-maintained count is saved.
123-func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (entries []maintenanceDirEntry, done bool, err error) {
124- return r.readMaintenanceDirentWithDrain(dir, limit, cursor, drainMaintenanceDirentBatch(), directoryCookie)
125-}
126-
127-// readMaintenanceDirentWithDrain contains the shared traversal logic. The
128-// explicit parameters permit platform-independent verification of Darwin's
129-// buffer-draining semantics with deterministic fake directories.
130-func (r *Repository) readMaintenanceDirentWithDrain(dir string, limit int, cursor *maintenanceCursor, drainBuffer bool, cookie func(maintenanceDirectory, *syscall.Dirent) (int64, error)) (entries []maintenanceDirEntry, done bool, err error) {
131- file, err := r.openMaintenanceDirectory(dir)
132- if err != nil {
133- return nil, false, maintenanceDirectoryError("open maintenance directory", err)
134- }
135- defer func() {
136- if closeErr := file.Close(); closeErr != nil {
137- closeErr = maintenanceDirectoryError("close maintenance directory", closeErr)
138- if err != nil {
139- err = errors.Join(err, closeErr)
140- } else {
141- entries = nil
142- done = false
143- err = closeErr
144- }
145- }
146- }()
147- if cursor.offset != 0 {
148- if _, err := file.Seek(cursor.offset, io.SeekStart); err != nil {
149- return nil, false, maintenanceDirectoryError("seek maintenance directory", err)
150- }
151- }
152-
153- entries = make([]maintenanceDirEntry, 0, limit)
154- buffer := make([]byte, maintenanceDirentBufferSize)
155- for {
156- count, err := file.ReadDirent(buffer)
157- if err != nil {
158- return nil, false, maintenanceDirectoryError("read maintenance directory", err)
159- }
160- if count < 0 || count > len(buffer) {
161- return nil, false, syscall.EIO
162- }
163- if count == 0 {
164- return entries, true, nil
165- }
166- for data := buffer[:count]; len(data) != 0 && (len(entries) < limit || drainBuffer); {
167- record, nameBytes, reclen, decodeErr := decodeMaintenanceDirent(data)
168- if decodeErr != nil {
169- return nil, false, decodeErr
170- }
171- data = data[reclen:]
172- // Advance past every raw record, including dot and disappeared entries.
173- offset, cookieErr := cookie(file, record)
174- if cookieErr != nil {
175- return nil, false, maintenanceDirectoryError("tell maintenance directory", cookieErr)
176- }
177- cursor.offset = offset
178- if end := strings.IndexByte(string(nameBytes), 0); end >= 0 {
179- nameBytes = nameBytes[:end]
180- }
181- name := string(nameBytes)
182- if name == "." || name == ".." || name == "" {
183- continue
184- }
185- stat, err := r.stat(filepath.Join(dir, name))
186- if errors.Is(err, os.ErrNotExist) {
187- continue
188- }
189- if err != nil {
190- return nil, false, maintenanceDirectoryError("stat maintenance directory entry", err)
191- }
192- entries = append(entries, maintenanceDirEntry{name: name, isDir: stat.IsDir()})
193- }
194- if len(entries) >= limit {
195- if drainBuffer && len(entries) > limit {
196- // Darwin has already advanced the descriptor past this entire
197- // buffer, so retain only this final buffer's excess.
198- cursor.pending = append(cursor.pending, entries[limit:]...)
199- }
200- return entries[:limit], false, nil
201- }
202- }
203-}
204-
205-// decodeMaintenanceDirent copies a bounded raw record into aligned storage
206-// before reading its fields. syscall.ReadDirent writes to a byte buffer, whose
207-// start and record boundaries are not guaranteed to satisfy syscall.Dirent's
208-// alignment requirements on Darwin.
209-func decodeMaintenanceDirent(data []byte) (*syscall.Dirent, []byte, int, error) {
210- nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
211- if len(data) < nameOffset {
212- return nil, nil, 0, syscall.EIO
213- }
214-
215- var record syscall.Dirent
216- header := unsafe.Slice((*byte)(unsafe.Pointer(&record)), nameOffset)
217- copy(header, data[:nameOffset])
218- reclen := int(record.Reclen)
219- if reclen < nameOffset || reclen > len(data) || reclen > int(unsafe.Sizeof(record)) {
220- return nil, nil, 0, syscall.EIO
221- }
222-
223- recordBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record)), reclen)
224- copy(recordBytes, data[:reclen])
225- name := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset)
226- return &record, name, reclen, nil
227-}
228-
229-func (r *Repository) requeueMaintenanceEntries(dir, purpose string, entries []maintenanceDirEntry) {
230- if len(entries) == 0 {
231- return
232- }
233- id := maintenanceCursorID(dir, purpose)
234- cursor := r.maintenanceCursors[id]
235- if cursor == nil {
236- cursor = &maintenanceCursor{done: true}
237- r.maintenanceCursors[id] = cursor
238- }
239- cursor.pending = append(entries, cursor.pending...)
240-}
241-
242-func (r *Repository) removeTemps(ctx context.Context, dir string, budget *int, purpose string) (bool, error) {
243- if *budget == 0 {
244- return false, nil
245- }
246- entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, purpose)
247- if errors.Is(err, os.ErrNotExist) {
248- return true, nil
249- }
250- if err != nil {
251- return false, fmt.Errorf("read snapshot maintenance directory: %w", safeFilesystemError(err))
252- }
253- for i, entry := range entries {
254- if *budget == 0 {
255- r.requeueMaintenanceEntries(dir, purpose, entries[i:])
256- return false, nil
257- }
258- if err := ctx.Err(); err != nil {
259- return false, err
260- }
261- if entry.isDir || !strings.HasPrefix(entry.name, ".tmp-") {
262- continue
263- }
264- path := filepath.Join(dir, entry.name)
265- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
266- return false, fmt.Errorf("remove snapshot maintenance file %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
267- }
268- if err := ctx.Err(); err != nil {
269- return false, err
270- }
271- if err := r.syncDirectory(dir); err != nil {
272- return false, fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
273- }
274- *budget--
275- }
276- return done, nil
277-}
278-
279-// maintenancePath avoids exposing the repository root while retaining a
M internal/adapters/snapshot/maintenance_delete.go +25 -169
@@ -4,195 +4,51 @@ import (
44 "context"
55 "errors"
66 "fmt"
7- "io"
87 "os"
98 "path/filepath"
10- "strings"
9+
10+ "github.com/bnema/vev/internal/domain"
1111 )
1212
13-// Delete makes a session unavailable by durably moving it out of the canonical
14-// namespace. Maintain reaps the private quarantine later; Delete never restores
15-// a quarantined directory.
16-func (r *Repository) Delete(ctx context.Context, name string) error {
13+// DeleteIncarnation removes one incarnation-keyed snapshot namespace. It is
14+// retry-idempotent: an incarnation already absent is a successful deletion.
15+func (r *Repository) DeleteIncarnation(ctx context.Context, id domain.IncarnationID) error {
1716 if err := ctx.Err(); err != nil {
1817 return err
1918 }
20- key := sessionKey(name)
21- lock := r.lockSession(key)
22- defer r.unlockSession(lock)
23- if err := ctx.Err(); err != nil {
19+ key, err := incarnationKey(id)
20+ if err != nil {
2421 return err
2522 }
23+ lock := r.lockSession(key)
24+ defer r.unlockSession(lock)
2625
27- canonical := r.sessionPath(key)
28- sessions := filepath.Dir(canonical)
29- // A prior rename may have succeeded while its parent sync failed. Complete
30- // that durability boundary before considering a canonical directory: this
31- // also leaves a newly recreated session untouched.
32- pending, err := r.pendingQuarantine(sessions, key)
33- if err != nil {
34- return fmt.Errorf("stat deleting snapshot session %q: %w", key, safeFilesystemError(err))
26+ path := r.sessionPath(id)
27+ if err := r.removeIncarnationLocked(id); err != nil {
28+ return fmt.Errorf("remove snapshot incarnation %s: %w", id.String(), safeFilesystemError(err))
3529 }
36- if pending {
37- if err := ctx.Err(); err != nil {
38- return err
39- }
40- if err := r.syncDirectory(sessions); err != nil {
41- return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err))
42- }
43- return nil
44- }
45- if dir, err := r.openDirectory(canonical); errors.Is(err, os.ErrNotExist) {
46- return nil
47- } else if err != nil {
48- return fmt.Errorf("stat snapshot session %q: %w", key, safeFilesystemError(err))
49- } else if err := dir.Close(); err != nil {
50- return fmt.Errorf("close snapshot session %q: %w", key, safeFilesystemError(err))
51- }
52- quarantine := filepath.Join(sessions, deletingSessionName(key))
5330 if err := ctx.Err(); err != nil {
5431 return err
5532 }
56- // A renamed session can be replaced by a later publication. Invalidate any
57- // mark made for the old namespace before that replacement becomes possible.
58- r.invalidateStorageEpoch(key)
59- if err := r.rename(canonical, quarantine); err != nil {
60- return fmt.Errorf("quarantine snapshot session %q: %w", key, safeFilesystemError(err))
61- }
62- if err := ctx.Err(); err != nil {
33+ parent := filepath.Dir(path)
34+ if _, err := os.Lstat(parent); errors.Is(err, os.ErrNotExist) {
35+ return nil
36+ } else if err != nil {
6337 return err
6438 }
65- if err := r.syncDirectory(sessions); err != nil {
66- return fmt.Errorf("sync deleted snapshot session directory %q: %w", key, safeFilesystemError(err))
67- }
68- return nil
39+ return r.syncDirectory(parent)
6940 }
7041
71-// deletingSessionName is a deterministic pending-delete record. Its presence
72-// is checked directly rather than scanning an attacker-controlled sessions
73-// directory, and it prevents a retry from deleting a session republished after
74-// the initial rename reached disk but its directory sync failed.
75-func deletingSessionName(key string) string { return ".deleting-" + key }
76-
77-func (r *Repository) pendingQuarantine(dir, key string) (bool, error) {
78- path := filepath.Join(dir, deletingSessionName(key))
79- if hook := r.hooks.beforePendingQuarantineCheck; hook != nil {
80- hook(path)
81- }
82- file, err := r.openDirectory(path)
42+// removeIncarnationLocked removes an incarnation through a root-pinned path.
43+// The caller must hold the incarnation's session lock.
44+func (r *Repository) removeIncarnationLocked(id domain.IncarnationID) (err error) {
45+ root, err := r.openRoot()
8346 if errors.Is(err, os.ErrNotExist) {
84- return false, nil
47+ return nil
8548 }
8649 if err != nil {
87- return false, err
88- }
89- return true, file.Close()
90-}
91-
92-// Maintain reaps a bounded amount of stale state. Continuation handles are
93-// repository-owned, so a stable prefix cannot starve entries later in a large
94-// directory. Cancellation and errors discard those handles: a restarted
95-// repository (and the next fresh cycle) always begins at a safe boundary.
96-func isQuarantine(name string) bool { return strings.HasPrefix(name, ".deleting-") }
97-
98-// quarantineMaintenance retains one root-to-current path, never an unbounded
99-// stack of directory handles or names. Parent paths are recovered after a
100-// removal, while descent resumes from current on the next budgeted step.
101-type quarantineMaintenance struct {
102- root string
103- current string
104-}
105-
106-// consumeQuarantineWork charges every filesystem operation that can be driven
107-// by quarantine contents. This makes deep and wide trees subject to exactly
108-// the same maintenanceBatch ceiling as removal work.
109-func (r *Repository) consumeQuarantineWork(budget *int, operation string) bool {
110- if *budget == 0 {
111- return false
112- }
113- *budget--
114- if hook := r.hooks.beforeMaintenanceWork; hook != nil {
115- hook(operation)
116- }
117- return true
118-}
119-
120-// maintainQuarantine makes one bounded depth-first traversal step at a time.
121-// It deliberately reopens a directory to select its first child: every
122-// successful child removal changes that prefix, so no directory cursor is
123-// retained at each hostile nesting level.
124-func (r *Repository) maintainQuarantine(ctx context.Context, budget *int) (changed, done bool, err error) {
125- state := r.maintenanceQuarantine
126- if state == nil {
127- return false, true, nil
128- }
129- for *budget > 0 {
130- if err := ctx.Err(); err != nil {
131- return changed, false, err
132- }
133- if !r.consumeQuarantineWork(budget, "stat") {
134- return changed, false, nil
135- }
136- stat, err := r.stat(state.current)
137- if errors.Is(err, os.ErrNotExist) {
138- if state.current == state.root {
139- return changed, true, nil
140- }
141- state.current = filepath.Dir(state.current)
142- continue
143- }
144- if err != nil {
145- return changed, false, err
146- }
147- if !stat.IsDir() {
148- if !r.consumeQuarantineWork(budget, "remove") {
149- return changed, false, nil
150- }
151- if err := r.remove(state.current); err != nil && !errors.Is(err, os.ErrNotExist) {
152- return changed, false, err
153- }
154- changed = true
155- state.current = filepath.Dir(state.current)
156- continue
157- }
158-
159- if !r.consumeQuarantineWork(budget, "open") {
160- return changed, false, nil
161- }
162- dir, err := r.openDirectory(state.current)
163- if err != nil {
164- return changed, false, err
165- }
166- if !r.consumeQuarantineWork(budget, "read") {
167- _ = dir.Close()
168- return changed, false, nil
169- }
170- entries, readErr := dir.ReadDir(1)
171- closeErr := dir.Close()
172- if readErr != nil && !errors.Is(readErr, io.EOF) {
173- return changed, false, readErr
174- }
175- if closeErr != nil {
176- return changed, false, closeErr
177- }
178- if len(entries) == 0 {
179- if !r.consumeQuarantineWork(budget, "remove") {
180- return changed, false, nil
181- }
182- if err := r.remove(state.current); err != nil && !errors.Is(err, os.ErrNotExist) {
183- return changed, false, err
184- }
185- changed = true
186- if state.current == state.root {
187- return changed, true, nil
188- }
189- state.current = filepath.Dir(state.current)
190- continue
191- }
192- if !r.consumeQuarantineWork(budget, "descend") {
193- return changed, false, nil
194- }
195- state.current = filepath.Join(state.current, entries[0].Name())
50+ return err
19651 }
197- return changed, false, nil
52+ defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }()
53+ return root.RemoveAll(filepath.Join(repositorySessionsDir, id.String()))
19854 }
D internal/adapters/snapshot/maintenance_error.go +0 -38
@@ -1,38 +0,0 @@
1-package snapshot
2-
3-import (
4- "errors"
5- "os"
6- "path/filepath"
7- "strings"
8-)
9-
10-func maintenancePath(root, path string) string {
11- rel, err := filepath.Rel(root, path)
12- if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
13- return filepath.Base(path)
14- }
15- return rel
16-}
17-
18-type safePathError struct {
19- op string
20- cause error
21-}
22-
23-func (e safePathError) Error() string { return e.op + ": " + e.cause.Error() }
24-func (e safePathError) Unwrap() error { return e.cause }
25-
26-// safeFilesystemError preserves error matching without including a filesystem
27-// path from terminal-facing error text.
28-func safeFilesystemError(err error) error {
29- var linkError *os.LinkError
30- if errors.As(err, &linkError) {
31- return safePathError{op: linkError.Op, cause: linkError.Err}
32- }
33- var pathError *os.PathError
34- if errors.As(err, &pathError) {
35- return safePathError{op: pathError.Op, cause: pathError.Err}
36- }
37- return err
38-}
M internal/adapters/snapshot/maintenance_gc.go +201 -328
@@ -7,408 +7,281 @@ import (
77 "fmt"
88 "os"
99 "path/filepath"
10- "sort"
11- "strings"
1210
11+ "github.com/bnema/vev/internal/domain"
1312 "github.com/bnema/vev/internal/ports"
1413 codec "github.com/bnema/vev/internal/usecase/snapshot"
1514 )
1615
17-type manifestMaintenance struct {
18- refs map[ports.SnapshotDigest]codec.ObjectRef
19- complete bool
20-}
21-
22-type sessionMaintenance struct {
23- lock *sessionMutex
24- token string
25- epoch uint64
26- marked map[uint64]manifestMaintenance
27- referenceCount int
28- uncertain bool
29- conservative bool
30- markDone bool
31- manifestQueue []uint64
32- objectTempShard string
33- objectTempsDone bool
34- sweepShard string
35- sweepRootDone bool
36-}
37-
38-// canRetainManifest reserves bounded state before retaining a manifest's
39-// reference map. The caller must leave the map unretained when it returns
40-// false, and make the cycle conservative instead.
41-func (s *sessionMaintenance) canRetainManifest(referenceCount int) bool {
42- if referenceCount < 0 || len(s.marked) >= maxMaintenanceMarkedGenerations {
43- return false
44- }
45- if referenceCount > maxMaintenanceReferences-s.referenceCount {
46- return false
47- }
48- s.referenceCount += referenceCount
49- return true
50-}
51-
52-func (r *Repository) maintainSession(ctx context.Context, key string, budget *int) error {
16+// CollectGarbage applies the complete snapshot retention policy. The keep map
17+// must come from a catalogue that loaded and validated successfully: only then
18+// is an incarnation absent from keep known to be an orphan.
19+func (r *Repository) CollectGarbage(ctx context.Context, keep map[domain.IncarnationID]domain.CheckpointRef) error {
5320 if err := ctx.Err(); err != nil {
5421 return err
5522 }
56- state, err := r.sessionMaintenanceState(key)
57- if err != nil {
58- return err
23+ sessions := filepath.Join(r.dir, repositorySessionsDir)
24+ entries, err := r.readGarbageCollectionDirectory(sessions)
25+ if errors.Is(err, os.ErrNotExist) {
26+ return nil
5927 }
60- if _, err := r.removeTemps(ctx, filepath.Join(r.sessionPath(key), repositoryGenerations), budget, "generation-temps:"+key); err != nil || *budget == 0 {
61- return err
28+ if err != nil {
29+ return fmt.Errorf("read snapshot incarnations: %w", safeFilesystemError(err))
6230 }
63- if !state.objectTempsDone {
64- done, err := r.removeObjectTemps(ctx, key, state, budget)
65- if err != nil || !done || *budget == 0 {
66- return err
31+
32+ var collected error
33+ for _, entry := range entries {
34+ if err := ctx.Err(); err != nil {
35+ return errors.Join(collected, err)
6736 }
68- }
69- if !state.markDone {
70- if err := r.markSession(ctx, key, state); err != nil {
71- return err
37+ if !entry.IsDir() {
38+ continue
7239 }
73- if !state.markDone {
74- return nil
40+ var id domain.IncarnationID
41+ if err := id.UnmarshalText([]byte(entry.Name())); err != nil {
42+ continue
43+ }
44+ committed, known := keep[id]
45+ if !known {
46+ key, keyErr := incarnationKey(id)
47+ if keyErr != nil {
48+ collected = errors.Join(collected, keyErr)
49+ continue
50+ }
51+ lock := r.lockSession(key)
52+ _, known = keep[id]
53+ if !known {
54+ err = r.removeIncarnationLocked(id)
55+ }
56+ r.unlockSession(lock)
57+ if err != nil {
58+ collected = errors.Join(collected, fmt.Errorf("remove orphan snapshot incarnation %s: %w", id.String(), safeFilesystemError(err)))
59+ continue
60+ }
61+ if !known {
62+ r.log.Info("snapshot_garbage_collected", "incarnation", id.String(), "action", "remove-incarnation")
63+ }
64+ continue
65+ }
66+ if err := r.pruneGenerations(ctx, id, committed); err != nil {
67+ collected = errors.Join(collected, err)
7568 }
7669 }
77- if state.epoch != r.storageEpoch(key) {
78- // A publication may have left an unpublished manifest after this mark.
79- // Discard all mark and sweep cursors; the next pass starts from it.
80- r.clearSessionMaintenance(key)
81- return nil
82- }
83- if state.uncertain {
84- // An unreadable, invalid, or unretained manifest may reference any
85- // existing blob. Do not collect manifests or sweep in this cycle; retry
86- // marking from a fresh directory pass.
87- r.clearSessionMaintenance(key)
88- return nil
89- }
90- if err := r.removeObsoleteManifests(ctx, key, state, budget); err != nil || *budget == 0 {
91- return err
92- }
93- if err := r.sweepSession(ctx, key, state, budget); err != nil {
94- return err
70+ if err := r.syncDirectory(sessions); err != nil {
71+ collected = errors.Join(collected, fmt.Errorf("sync snapshot sessions directory: %w", safeFilesystemError(err)))
9572 }
96- return nil
73+ return collected
9774 }
9875
99-func (r *Repository) sessionMaintenanceState(key string) (*sessionMaintenance, error) {
100- token, conservative, err := r.maintenanceToken(key)
76+// pruneGenerations keeps the committed generation and its immediate
77+// predecessor. Everything else, including a forward orphan newer than the
78+// catalogue commit, is removed before unreferenced objects are swept.
79+func (r *Repository) pruneGenerations(ctx context.Context, id domain.IncarnationID, committed domain.CheckpointRef) error {
80+ key, err := incarnationKey(id)
10181 if err != nil {
102- return nil, err
103- }
104- state := r.maintenanceSessions[key]
105- if state == nil || state.token != token || state.epoch != r.storageEpoch(key) {
106- r.clearSessionMaintenance(key)
107- state = &sessionMaintenance{
108- lock: r.retainSessionState(key),
109- token: token,
110- epoch: r.storageEpoch(key),
111- conservative: conservative,
112- marked: make(map[uint64]manifestMaintenance),
113- }
114- r.maintenanceSessions[key] = state
82+ return err
11583 }
116- return state, nil
117-}
84+ lock := r.lockSession(key)
85+ defer r.unlockSession(lock)
11886
119-// maintenanceToken is the publication boundary. A missing or corrupt HEAD is
120-// itself stable maintenance state: we retain every classified reference until a
121-// valid publication changes that state, rather than restarting the mark pass.
122-func (r *Repository) maintenanceToken(key string) (string, bool, error) {
123- data, exists, err := r.readOptionalBounded(r.headPath(key))
124- if err != nil {
125- return "", false, err
126- }
127- if !exists {
128- return "missing", true, nil
87+ generationsDir := filepath.Join(r.sessionPath(id), repositoryGenerations)
88+ entries, err := r.readGarbageCollectionDirectory(generationsDir)
89+ if errors.Is(err, os.ErrNotExist) {
90+ return nil
12991 }
130- sum := sha256.Sum256(data)
131- if _, _, err := r.readHead(key); err != nil {
132- return fmt.Sprintf("invalid:%x", sum), true, nil
92+ if err != nil {
93+ return fmt.Errorf("read snapshot generations for %s: %w", id.String(), safeFilesystemError(err))
13394 }
134- return fmt.Sprintf("valid:%x", sum), false, nil
135-}
13695
137-func (r *Repository) clearSessionMaintenance(key string) {
138- if state := r.maintenanceSessions[key]; state != nil && state.lock != nil {
139- r.releaseSessionReference(state.lock)
96+ kept := make(map[uint64]struct{}, 2)
97+ if committed.Generation > 0 {
98+ kept[committed.Generation] = struct{}{}
14099 }
141- delete(r.maintenanceSessions, key)
142- prefix := "\x00" + filepath.Clean(r.sessionPath(key))
143- for id := range r.maintenanceCursors {
144- if strings.HasSuffix(id, prefix) || strings.Contains(id, ":"+key+":") || strings.Contains(id, ":"+key+"\x00") {
145- delete(r.maintenanceCursors, id)
146- }
147- }
148-}
149-
150-func (r *Repository) markSession(ctx context.Context, key string, state *sessionMaintenance) error {
151- dir := filepath.Join(r.sessionPath(key), repositoryGenerations)
152- entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "mark-generations:"+key)
153- if errors.Is(err, os.ErrNotExist) {
154- state.markDone = true
155- return nil
100+ if committed.Generation > 1 {
101+ kept[committed.Generation-1] = struct{}{}
156102 }
103+ references, err := r.referencesForGenerations(id, entries, kept, committed)
157104 if err != nil {
158- return fmt.Errorf("read snapshot generations: %w", safeFilesystemError(err))
105+ return fmt.Errorf("mark snapshot objects for %s: %w", id.String(), err)
159106 }
107+
108+ removed := 0
160109 for _, entry := range entries {
161110 if err := ctx.Err(); err != nil {
162111 return err
163112 }
164- if entry.isDir {
165- continue
166- }
167- generation, ok := parseGenerationFilename(entry.name)
168- if !ok {
169- continue
170- }
171- data, err := r.readBounded(r.manifestPath(key, generation))
172- if err != nil {
173- state.uncertain = true
174- continue
175- }
176- manifest, err := codec.UnmarshalManifest(data)
177- if err != nil || manifest.Generation != generation || sessionKey(manifest.Name) != key {
178- state.uncertain = true
113+ generation, canonical := parseGenerationFilename(entry.Name())
114+ if !canonical || entry.IsDir() {
179115 continue
180116 }
181- refs, validRefs := maintenanceManifestRefs(manifest)
182- if !validRefs || !withinGenerationBudget(len(data), refs) || !state.canRetainManifest(len(refs)) {
183- state.uncertain = true
117+ if _, retain := kept[generation]; retain {
184118 continue
185119 }
186- // Valid but incomplete generations are marked too. This protects blobs
187- // written before a failed or in-flight publication reaches HEAD.
188- _, loadErr := r.loadGeneration(ctx, manifest.Name, key, generation)
189- state.marked[generation] = manifestMaintenance{refs: refs, complete: loadErr == nil}
190- }
191- if done {
192- state.markDone = true
193- if !state.conservative {
194- state.manifestQueue = retainedManifestQueue(state.marked)
120+ if err := r.remove(filepath.Join(generationsDir, entry.Name())); err != nil && !errors.Is(err, os.ErrNotExist) {
121+ return fmt.Errorf("remove snapshot generation %d for %s: %w", generation, id.String(), safeFilesystemError(err))
195122 }
123+ removed++
196124 }
197- return nil
198-}
199-
200-// maintenanceManifestRefs applies the GC reference ceiling while building its
201-// map, rather than first materializing a hostile manifest's entire reference
202-// set. Conflicting references are invalid just as they are for publication.
203-func maintenanceManifestRefs(manifest codec.Manifest) (map[ports.SnapshotDigest]codec.ObjectRef, bool) {
204- refs := make(map[ports.SnapshotDigest]codec.ObjectRef)
205- add := func(ref codec.ObjectRef) bool {
206- if old, ok := refs[ref.Digest]; ok {
207- return old == ref
125+ if removed > 0 {
126+ if err := r.syncDirectory(generationsDir); err != nil {
127+ return fmt.Errorf("sync snapshot generations for %s: %w", id.String(), safeFilesystemError(err))
208128 }
209- if len(refs) == maxMaintenanceReferences {
210- return false
211- }
212- refs[ref.Digest] = ref
213- return true
129+ r.log.Info("snapshot_garbage_collected", "incarnation", id.String(), "action", "remove-generations", "removed", removed)
214130 }
215- for _, tab := range manifest.Tabs {
216- for _, pane := range tab.Panes {
217- for _, ref := range pane.Sealed {
218- if !add(ref) {
219- return nil, false
220- }
221- }
222- if !add(pane.Tail) || !add(pane.Visible) {
223- return nil, false
224- }
225- }
131+ if err := r.removeHeadForDiscardedGenerations(id, kept); err != nil {
132+ return err
226133 }
227- return refs, true
134+ return r.sweepUnreferencedObjects(ctx, id, references)
228135 }
229136
230-// completeGenerationsDescending returns only complete generations in canonical
231-// newest-first order for both manifest and object retention.
232-func completeGenerationsDescending(marked map[uint64]manifestMaintenance) []uint64 {
233- complete := make([]uint64, 0, len(marked))
234- for generation, item := range marked {
235- if item.complete {
236- complete = append(complete, generation)
137+// removeHeadForDiscardedGenerations removes a repository HEAD only when its
138+// generation was removed by the retention policy. In particular, an
139+// incarnation with no committed checkpoint retains no generations, so its
140+// crash-published HEAD cannot make a retry conflict with an orphan.
141+func (r *Repository) removeHeadForDiscardedGenerations(id domain.IncarnationID, kept map[uint64]struct{}) error {
142+ removeHead := len(kept) == 0
143+ if !removeHead {
144+ generation, _, err := r.readHead(id)
145+ if errors.Is(err, os.ErrNotExist) {
146+ return nil
147+ }
148+ if err != nil {
149+ return fmt.Errorf("read snapshot HEAD for %s: %w", id.String(), safeFilesystemError(err))
237150 }
151+ _, retained := kept[generation]
152+ removeHead = !retained
238153 }
239- sort.Slice(complete, func(i, j int) bool { return complete[i] > complete[j] })
240- return complete
241-}
242-
243-func retainedManifestQueue(marked map[uint64]manifestMaintenance) []uint64 {
244- complete := completeGenerationsDescending(marked)
245- if len(complete) <= 2 {
154+ if !removeHead {
246155 return nil
247156 }
248- return complete[2:]
249-}
250-
251-func (r *Repository) removeObsoleteManifests(ctx context.Context, key string, state *sessionMaintenance, budget *int) error {
252- for len(state.manifestQueue) != 0 && *budget > 0 {
253- if err := ctx.Err(); err != nil {
254- return err
255- }
256- generation := state.manifestQueue[0]
257- path := r.manifestPath(key, generation)
258- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
259- return fmt.Errorf("remove snapshot manifest %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
260- }
261- if err := ctx.Err(); err != nil {
262- return err
263- }
264- if err := r.syncDirectory(filepath.Dir(path)); err != nil {
265- return fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
266- }
267- state.manifestQueue = state.manifestQueue[1:]
268- *budget--
157+ if err := r.remove(r.headPath(id)); err != nil && !errors.Is(err, os.ErrNotExist) {
158+ return fmt.Errorf("remove snapshot HEAD for %s: %w", id.String(), safeFilesystemError(err))
159+ }
160+ if err := r.syncDirectory(r.sessionPath(id)); err != nil {
161+ return fmt.Errorf("sync snapshot incarnation for %s: %w", id.String(), safeFilesystemError(err))
269162 }
270163 return nil
271164 }
272165
273-// removeObjectTemps performs at most one shard traversal or one root-entry
274-// discovery per Maintain call. Empty attacker-controlled shards consume no
275-// removal budget, so this fixed step ceiling prevents an unbounded scan of
276-// them while retaining only the root cursor and one active shard cursor.
277-func (r *Repository) removeObjectTemps(ctx context.Context, key string, state *sessionMaintenance, budget *int) (bool, error) {
278- root := filepath.Join(r.sessionPath(key), repositoryObjectsDir)
279- if *budget == 0 {
280- return false, nil
281- }
282- if state.objectTempShard != "" {
283- done, err := r.removeTemps(ctx, filepath.Join(root, state.objectTempShard), budget, "object-temps:"+key+":"+state.objectTempShard)
284- if err != nil || !done {
285- return false, err
166+func (r *Repository) referencesForGenerations(id domain.IncarnationID, entries []os.DirEntry, keep map[uint64]struct{}, committed domain.CheckpointRef) (map[ports.SnapshotDigest]struct{}, error) {
167+ references := make(map[ports.SnapshotDigest]struct{})
168+ committedFound := committed.Generation == 0
169+ for _, entry := range entries {
170+ generation, canonical := parseGenerationFilename(entry.Name())
171+ if !canonical || entry.IsDir() {
172+ continue
286173 }
287- state.objectTempShard = ""
288- return state.objectTempsDone, nil
289- }
290- if state.objectTempsDone {
291- return true, nil
292- }
293- entries, done, err := r.readMaintenanceDir(root, 1, "object-temps-shards:"+key)
294- if errors.Is(err, os.ErrNotExist) {
295- state.objectTempsDone = true
296- return true, nil
297- }
298- if err != nil {
299- return false, fmt.Errorf("read snapshot object shards: %w", safeFilesystemError(err))
300- }
301- state.objectTempsDone = done
302- if len(entries) != 0 && entries[0].isDir {
303- state.objectTempShard = entries[0].name
304- }
305- return state.objectTempsDone && state.objectTempShard == "", nil
306-}
307-
308-func (r *Repository) sweepSession(ctx context.Context, key string, state *sessionMaintenance, budget *int) error {
309- // Every bounded sweep batch is tied to the mark's storage epoch. Do not use
310- // stale references after any publication or replacement.
311- if state.epoch != r.storageEpoch(key) {
312- r.clearSessionMaintenance(key)
313- return nil
314- }
315- referenced := retainedReferences(state.marked, state.conservative)
316- root := filepath.Join(r.sessionPath(key), repositoryObjectsDir)
317- if state.sweepShard != "" && *budget > 0 {
318- shard := state.sweepShard
319- done, err := r.sweepShard(ctx, filepath.Join(root, shard), referenced, budget, key, shard)
174+ if _, retained := keep[generation]; !retained {
175+ continue
176+ }
177+ data, err := r.readBounded(r.manifestPath(id, generation))
320178 if err != nil {
321- return err
179+ return nil, err
322180 }
323- if !done {
324- return nil
181+ manifest, err := codec.UnmarshalManifest(data)
182+ if err != nil || manifest.IncarnationID != id || manifest.Generation != generation {
183+ return nil, fmt.Errorf("invalid retained manifest generation %d", generation)
325184 }
326- state.sweepShard = ""
327- // Release this shard's cursor before discovering another shard.
328- return nil
329- }
330- if !state.sweepRootDone {
331- entries, done, err := r.readMaintenanceDir(root, 1, "sweep-shards:"+key)
332- if errors.Is(err, os.ErrNotExist) {
333- state.sweepRootDone = true
334- } else if err != nil {
335- return fmt.Errorf("read snapshot objects: %w", safeFilesystemError(err))
336- } else {
337- state.sweepRootDone = done
338- if len(entries) != 0 && entries[0].isDir {
339- state.sweepShard = entries[0].name
185+ if generation == committed.Generation {
186+ committedFound = true
187+ if sha256.Sum256(data) != committed.ManifestDigest {
188+ return nil, fmt.Errorf("committed manifest digest mismatch")
340189 }
341190 }
191+ refs := manifestRefs(manifest)
192+ if refs == nil || !withinGenerationBudget(len(data), refs) {
193+ return nil, fmt.Errorf("invalid retained manifest references generation %d", generation)
194+ }
195+ for digest := range refs {
196+ references[digest] = struct{}{}
197+ }
342198 }
343- if state.sweepRootDone && state.sweepShard == "" {
344- r.clearSessionMaintenance(key)
199+ if !committedFound {
200+ return nil, fmt.Errorf("committed manifest generation %d is missing", committed.Generation)
345201 }
346- return nil
202+ return references, nil
347203 }
348204
349-func retainedReferences(marked map[uint64]manifestMaintenance, conservative bool) map[ports.SnapshotDigest]struct{} {
350- if conservative {
351- references := make(map[ports.SnapshotDigest]struct{})
352- for _, item := range marked {
353- for digest := range item.refs {
354- references[digest] = struct{}{}
355- }
356- }
357- return references
358- }
359- complete := completeGenerationsDescending(marked)
360- keep := make(map[uint64]bool, 2)
361- for _, generation := range complete {
362- if len(keep) == 2 {
363- break
364- }
365- keep[generation] = true
205+type safePathError struct {
206+ op string
207+ cause error
208+}
209+
210+func (e safePathError) Error() string { return e.op + ": " + e.cause.Error() }
211+func (e safePathError) Unwrap() error { return e.cause }
212+
213+// safeFilesystemError preserves error matching without exposing repository
214+// paths in terminal-facing errors.
215+func safeFilesystemError(err error) error {
216+ var linkError *os.LinkError
217+ if errors.As(err, &linkError) {
218+ return safePathError{op: linkError.Op, cause: linkError.Err}
219+ }
220+ var pathError *os.PathError
221+ if errors.As(err, &pathError) {
222+ return safePathError{op: pathError.Op, cause: pathError.Err}
223+ }
224+ return err
225+}
226+
227+func (r *Repository) readGarbageCollectionDirectory(path string) (entries []os.DirEntry, err error) {
228+ directory, err := r.openDirectory(path)
229+ if err != nil {
230+ return nil, err
366231 }
367- references := make(map[ports.SnapshotDigest]struct{})
368- for generation, item := range marked {
369- if !item.complete || keep[generation] {
370- for digest := range item.refs {
371- references[digest] = struct{}{}
372- }
232+ defer func() {
233+ if closeErr := directory.Close(); closeErr != nil {
234+ err = errors.Join(err, closeErr)
373235 }
374- }
375- return references
236+ }()
237+ return directory.ReadDir(-1)
376238 }
377239
378-func (r *Repository) sweepShard(ctx context.Context, dir string, referenced map[ports.SnapshotDigest]struct{}, budget *int, key, shard string) (bool, error) {
379- entries, done, err := r.readMaintenanceDir(dir, maintenanceBatch, "sweep-objects:"+key+":"+shard)
240+func (r *Repository) sweepUnreferencedObjects(ctx context.Context, id domain.IncarnationID, references map[ports.SnapshotDigest]struct{}) error {
241+ objectsRoot := filepath.Join(r.sessionPath(id), repositoryObjectsDir)
242+ shards, err := r.readGarbageCollectionDirectory(objectsRoot)
380243 if errors.Is(err, os.ErrNotExist) {
381- return true, nil
244+ return nil
382245 }
383246 if err != nil {
384- return false, fmt.Errorf("read snapshot object shard: %w", safeFilesystemError(err))
247+ return fmt.Errorf("read snapshot objects for %s: %w", id.String(), safeFilesystemError(err))
385248 }
386- for i, object := range entries {
387- if *budget == 0 {
388- r.requeueMaintenanceEntries(dir, "sweep-objects:"+key+":"+shard, entries[i:])
389- return false, nil
390- }
249+ removed := 0
250+ for _, shard := range shards {
391251 if err := ctx.Err(); err != nil {
392- return false, err
393- }
394- digest, ok := parseObjectDigest(object.name)
395- if !ok || object.isDir {
396- continue
252+ return err
397253 }
398- if _, used := referenced[digest]; used {
254+ if !shard.IsDir() || len(shard.Name()) != 2 {
399255 continue
400256 }
401- path := filepath.Join(dir, object.name)
402- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
403- return false, fmt.Errorf("remove snapshot object %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
257+ dir := filepath.Join(objectsRoot, shard.Name())
258+ objects, err := r.readGarbageCollectionDirectory(dir)
259+ if err != nil {
260+ return fmt.Errorf("read snapshot object shard for %s: %w", id.String(), safeFilesystemError(err))
404261 }
405- if err := ctx.Err(); err != nil {
406- return false, err
262+ shardRemoved := false
263+ for _, object := range objects {
264+ digest, canonical := parseObjectDigest(object.Name())
265+ if !canonical || object.IsDir() {
266+ continue
267+ }
268+ if _, retained := references[digest]; retained {
269+ continue
270+ }
271+ if err := r.remove(filepath.Join(dir, object.Name())); err != nil && !errors.Is(err, os.ErrNotExist) {
272+ return fmt.Errorf("remove snapshot object for %s: %w", id.String(), safeFilesystemError(err))
273+ }
274+ removed++
275+ shardRemoved = true
407276 }
408- if err := r.syncDirectory(dir); err != nil {
409- return false, fmt.Errorf("sync snapshot maintenance directory for %q: %w", maintenancePath(r.dir, path), safeFilesystemError(err))
277+ if shardRemoved {
278+ if err := r.syncDirectory(dir); err != nil {
279+ return fmt.Errorf("sync snapshot object shard for %s: %w", id.String(), safeFilesystemError(err))
280+ }
410281 }
411- *budget--
412282 }
413- return done, nil
283+ if removed > 0 {
284+ r.log.Info("snapshot_garbage_collected", "incarnation", id.String(), "action", "remove-objects", "removed", removed)
285+ }
286+ return nil
414287 }
A internal/adapters/snapshot/maintenance_gc_test.go +157 -0
@@ -0,0 +1,157 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "os"
7+ "path/filepath"
8+ "slices"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestCollectGarbageClearsUncommittedCheckpointHead(t *testing.T) {
17+ t.Parallel()
18+ repository := NewRepository(privateDir(t))
19+ publication := repositoryPublication(t, "work", 1, []byte("first checkpoint"))
20+ require.NoError(t, repository.Publish(context.Background(), publication))
21+
22+ // Repository publication completed, but the catalogue commit did not.
23+ // Startup GC knows the incarnation but has no committed checkpoint for it.
24+ keep := map[domain.IncarnationID]domain.CheckpointRef{publication.IncarnationID: {}}
25+ require.NoError(t, repository.CollectGarbage(context.Background(), keep))
26+ _, err := os.Stat(repository.headPath(publication.IncarnationID))
27+ require.ErrorIs(t, err, os.ErrNotExist)
28+ require.Empty(t, garbageCollectionGenerations(t, repository, publication.IncarnationID))
29+
30+ // Retrying starts from an empty repository, then the resulting checkpoint
31+ // can be committed to the catalogue and restored.
32+ require.NoError(t, repository.Publish(context.Background(), publication))
33+ committed := domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)}
34+ keep[publication.IncarnationID] = committed
35+ require.NoError(t, repository.CollectGarbage(context.Background(), keep))
36+ _, err = repository.LoadCheckpoint(context.Background(), publication.IncarnationID, publication.Name, committed)
37+ require.NoError(t, err)
38+}
39+
40+func TestCollectGarbageRejectsMalformedAuthorityWithoutMutation(t *testing.T) {
41+ for _, tt := range []struct {
42+ name string
43+ mutate func(*testing.T, *Repository, domain.IncarnationID, domain.CheckpointRef) domain.CheckpointRef
44+ }{
45+ {
46+ name: "committed digest mismatch",
47+ mutate: func(_ *testing.T, _ *Repository, _ domain.IncarnationID, committed domain.CheckpointRef) domain.CheckpointRef {
48+ committed.ManifestDigest[0]++
49+ return committed
50+ },
51+ },
52+ {
53+ name: "committed manifest missing",
54+ mutate: func(t *testing.T, repository *Repository, id domain.IncarnationID, committed domain.CheckpointRef) domain.CheckpointRef {
55+ t.Helper()
56+ require.NoError(t, os.Remove(repository.manifestPath(id, committed.Generation)))
57+ return committed
58+ },
59+ },
60+ {
61+ name: "committed manifest corrupt",
62+ mutate: func(t *testing.T, repository *Repository, id domain.IncarnationID, committed domain.CheckpointRef) domain.CheckpointRef {
63+ t.Helper()
64+ require.NoError(t, os.WriteFile(repository.manifestPath(id, committed.Generation), []byte("corrupt"), 0o600))
65+ return committed
66+ },
67+ },
68+ } {
69+ t.Run(tt.name, func(t *testing.T) {
70+ repository := NewRepository(privateDir(t))
71+ publications := publishGarbageCollectionGenerations(t, repository, "work", 3)
72+ id := publications[0].IncarnationID
73+ committed := domain.CheckpointRef{Generation: 3, ManifestDigest: sha256.Sum256(publications[2].Manifest)}
74+ committed = tt.mutate(t, repository, id, committed)
75+ before := snapshotRepositoryFiles(t, repository.dir)
76+
77+ require.Error(t, repository.CollectGarbage(t.Context(), map[domain.IncarnationID]domain.CheckpointRef{id: committed}))
78+ require.Equal(t, before, snapshotRepositoryFiles(t, repository.dir), "malformed catalogue authority must fail closed")
79+ })
80+ }
81+}
82+
83+func TestCollectGarbage(t *testing.T) {
84+ t.Parallel()
85+ tests := []struct {
86+ name string
87+ committed uint64
88+ known bool
89+ wantGenerations []uint64
90+ }{
91+ {name: "incarnation absent from catalogue is removed whole"},
92+ {name: "committed checkpoint prunes forward orphan and keeps predecessor", committed: 3, known: true, wantGenerations: []uint64{2, 3}},
93+ }
94+ for _, tt := range tests {
95+ t.Run(tt.name, func(t *testing.T) {
96+ t.Parallel()
97+ repository := NewRepository(privateDir(t))
98+ publications := publishGarbageCollectionGenerations(t, repository, "work", 4)
99+ id := publications[0].IncarnationID
100+ keep := make(map[domain.IncarnationID]domain.CheckpointRef)
101+ if tt.known {
102+ publication := publications[tt.committed-1]
103+ keep[id] = domain.CheckpointRef{Generation: tt.committed, ManifestDigest: sha256.Sum256(publication.Manifest)}
104+ }
105+
106+ require.NoError(t, repository.CollectGarbage(context.Background(), keep))
107+ if !tt.known {
108+ _, err := os.Stat(repository.sessionPath(id))
109+ require.ErrorIs(t, err, os.ErrNotExist)
110+ return
111+ }
112+ require.Equal(t, tt.wantGenerations, garbageCollectionGenerations(t, repository, id))
113+ retainedObjects := make(map[ports.SnapshotDigest]struct{})
114+ for _, publication := range publications[tt.committed-2 : tt.committed] {
115+ for _, object := range publication.Objects {
116+ retainedObjects[object.Digest] = struct{}{}
117+ }
118+ }
119+ for _, publication := range publications {
120+ for _, object := range publication.Objects {
121+ _, retained := retainedObjects[object.Digest]
122+ _, err := os.Stat(repository.objectPath(id, object.Digest))
123+ if retained {
124+ require.NoError(t, err)
125+ } else {
126+ require.ErrorIs(t, err, os.ErrNotExist)
127+ }
128+ }
129+ }
130+ })
131+ }
132+}
133+
134+func publishGarbageCollectionGenerations(t *testing.T, repository *Repository, name string, count uint64) []ports.SnapshotPublication {
135+ t.Helper()
136+ publications := make([]ports.SnapshotPublication, 0, count)
137+ for generation := uint64(1); generation <= count; generation++ {
138+ publication := repositoryPublicationAfter(t, repository, name, generation, []byte{byte(generation)})
139+ require.NoError(t, repository.Publish(context.Background(), publication))
140+ publications = append(publications, publication)
141+ }
142+ return publications
143+}
144+
145+func garbageCollectionGenerations(t *testing.T, repository *Repository, id domain.IncarnationID) []uint64 {
146+ t.Helper()
147+ entries, err := os.ReadDir(filepath.Join(repository.sessionPath(id), repositoryGenerations))
148+ require.NoError(t, err)
149+ generations := make([]uint64, 0, len(entries))
150+ for _, entry := range entries {
151+ if generation, ok := parseGenerationFilename(entry.Name()); ok {
152+ generations = append(generations, generation)
153+ }
154+ }
155+ slices.Sort(generations)
156+ return generations
157+}
D internal/adapters/snapshot/maintenance_hardening_test.go +0 -141
@@ -1,141 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "os"
8- "path/filepath"
9- "testing"
10-)
11-
12-func TestRepositoryMaintainBoundsCursorsAcrossHostileObjectShards(t *testing.T) {
13- repo := NewRepository(privateDir(t))
14- key := sessionKey("named")
15- objects := filepath.Join(repo.sessionPath(key), repositoryObjectsDir)
16- const shards = maintenanceBatch * 2
17- for i := 0; i < shards; i++ {
18- shard := filepath.Join(objects, fmt.Sprintf("%03d", i))
19- if err := os.MkdirAll(shard, 0o700); err != nil {
20- t.Fatal(err)
21- }
22- if err := os.WriteFile(filepath.Join(shard, ".tmp-stale"), []byte("stale"), 0o600); err != nil {
23- t.Fatal(err)
24- }
25- // Force a resumable cursor in every attacker-controlled shard.
26- for j := 0; j < maintenanceBatch+1; j++ {
27- if err := os.WriteFile(filepath.Join(shard, fmt.Sprintf("live-%03d", j)), []byte("live"), 0o600); err != nil {
28- t.Fatal(err)
29- }
30- }
31- }
32-
33- for pass := 0; pass < shards*4; pass++ {
34- if err := repo.Maintain(context.Background()); err != nil {
35- t.Fatal(err)
36- }
37- if got := len(repo.maintenanceCursors); got > maxMaintenanceCursors {
38- t.Fatalf("retained maintenance cursors = %d, want at most %d", got, maxMaintenanceCursors)
39- }
40- }
41- for i := 0; i < shards; i++ {
42- _, err := os.Lstat(filepath.Join(objects, fmt.Sprintf("%03d", i), ".tmp-stale"))
43- if !errors.Is(err, os.ErrNotExist) {
44- t.Fatalf("stale temporary object in shard %d = %v, want removed", i, err)
45- }
46- }
47-}
48-
49-func TestRepositoryMaintainBoundsEmptyObjectTempShards(t *testing.T) {
50- repo := NewRepository(privateDir(t))
51- key := sessionKey("named")
52- objects := filepath.Join(repo.sessionPath(key), repositoryObjectsDir)
53- const shards = maintenanceBatch * 2
54- for i := 0; i < shards; i++ {
55- if err := os.MkdirAll(filepath.Join(objects, fmt.Sprintf("%03d", i)), 0o700); err != nil {
56- t.Fatal(err)
57- }
58- }
59-
60- openedShards := make(map[string]struct{}, shards)
61- opensThisPass := 0
62- tempPass := false
63- repo.hooks.openMaintenanceDirectory = func(dir string) (maintenanceDirectory, error) {
64- if tempPass && filepath.Dir(dir) == objects {
65- opensThisPass++
66- openedShards[dir] = struct{}{}
67- }
68- file, err := os.Open(dir)
69- if err != nil {
70- return nil, err
71- }
72- return osMaintenanceDirectory{file: file}, nil
73- }
74-
75- for pass := 0; pass < shards*3; pass++ {
76- opensThisPass = 0
77- tempPass = true
78- if err := repo.Maintain(context.Background()); err != nil {
79- t.Fatal(err)
80- }
81- tempPass = false
82- if opensThisPass > 1 {
83- t.Fatalf("empty object shards opened in maintenance pass %d = %d, want at most 1", pass, opensThisPass)
84- }
85- state := repo.maintenanceSessions[key]
86- if state != nil && state.objectTempsDone {
87- if got := len(openedShards); got != shards {
88- t.Fatalf("empty object shards opened = %d, want %d", got, shards)
89- }
90- return
91- }
92- }
93- t.Fatal("empty object temp shards were not eventually traversed")
94-}
95-
96-func TestRepositoryMaintainBoundsAndResumesDeepWideQuarantine(t *testing.T) {
97- repo := NewRepository(privateDir(t))
98- quarantine := filepath.Join(repo.dir, repositorySessionsDir, ".deleting-hostile")
99- deep := quarantine
100- for i := 0; i < maintenanceBatch*2; i++ {
101- deep = filepath.Join(deep, "d")
102- }
103- leaf := filepath.Join(deep, "leaf")
104- if err := os.MkdirAll(filepath.Dir(leaf), 0o700); err != nil {
105- t.Fatal(err)
106- }
107- if err := os.WriteFile(leaf, []byte("stale"), 0o600); err != nil {
108- t.Fatal(err)
109- }
110- for i := 0; i < maintenanceBatch*2; i++ {
111- if err := os.WriteFile(filepath.Join(quarantine, fmt.Sprintf("wide-%03d", i)), []byte("stale"), 0o600); err != nil {
112- t.Fatal(err)
113- }
114- }
115-
116- work := 0
117- repo.hooks.beforeMaintenanceWork = func(string) { work++ }
118- if err := repo.Maintain(context.Background()); err != nil {
119- t.Fatal(err)
120- }
121- if work == 0 || work > maintenanceBatch {
122- t.Fatalf("quarantine work in one maintenance call = %d, want 1..%d", work, maintenanceBatch)
123- }
124- if _, err := os.Lstat(leaf); err != nil {
125- t.Fatalf("deep quarantine leaf removed despite bounded traversal: %v", err)
126- }
127-
128- for pass := 0; pass < 1000; pass++ {
129- work = 0
130- if err := repo.Maintain(context.Background()); err != nil {
131- t.Fatal(err)
132- }
133- if work > maintenanceBatch {
134- t.Fatalf("quarantine work in pass %d = %d, want at most %d", pass, work, maintenanceBatch)
135- }
136- if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) {
137- return
138- }
139- }
140- t.Fatal("deep and wide quarantine was not eventually removed")
141-}
D internal/adapters/snapshot/maintenance_test.go +0 -1010
@@ -1,1010 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "crypto/sha256"
6- "encoding/binary"
7- "errors"
8- "fmt"
9- "io"
10- "os"
11- "path/filepath"
12- "slices"
13- "strings"
14- "syscall"
15- "testing"
16- "unsafe"
17-
18- "github.com/bnema/vev/internal/ports"
19-)
20-
21-func TestReadMaintenanceDirentPreservesCursorAndCloseFailuresWithoutPaths(t *testing.T) {
22- privatePath := filepath.Join(t.TempDir(), "private-maintenance-directory")
23- closeCause := errors.New("injected close failure")
24- seekCause := errors.New("injected seek failure")
25- readCause := errors.New("injected read failure")
26-
27- for _, tc := range []struct {
28- name string
29- offset int64
30- seekErr error
31- readErr error
32- wantCause []error
33- wantOps []string
34- }{
35- {
36- name: "close only",
37- wantCause: []error{closeCause},
38- wantOps: []string{"close maintenance directory"},
39- },
40- {
41- name: "seek and close",
42- offset: 1,
43- seekErr: &os.PathError{Op: "seek", Path: privatePath, Err: seekCause},
44- wantCause: []error{seekCause, closeCause},
45- wantOps: []string{"seek maintenance directory", "close maintenance directory"},
46- },
47- {
48- name: "read and close",
49- readErr: &os.PathError{Op: "read", Path: privatePath, Err: readCause},
50- wantCause: []error{readCause, closeCause},
51- wantOps: []string{"read maintenance directory", "close maintenance directory"},
52- },
53- } {
54- t.Run(tc.name, func(t *testing.T) {
55- repo := NewRepository(privateDir(t))
56- repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) {
57- return fakeMaintenanceDirectory{
58- seekErr: tc.seekErr,
59- readErr: tc.readErr,
60- closeErr: &os.PathError{Op: "close", Path: privatePath, Err: closeCause},
61- }, nil
62- }
63-
64- _, _, err := repo.readMaintenanceDirent(privatePath, 1, &maintenanceCursor{offset: tc.offset})
65- if err == nil {
66- t.Fatal("readMaintenanceDirent error = nil, want failure")
67- }
68- for _, cause := range tc.wantCause {
69- if !errors.Is(err, cause) {
70- t.Errorf("errors.Is(%v, %v) = false, want true", err, cause)
71- }
72- }
73- for _, operation := range tc.wantOps {
74- if !strings.Contains(err.Error(), operation) {
75- t.Errorf("error = %q, want stable operation context %q", err, operation)
76- }
77- }
78- if strings.Contains(err.Error(), privatePath) {
79- t.Errorf("error leaks private path: %q", err)
80- }
81- })
82- }
83-}
84-
85-func TestReadMaintenanceDirentRejectsRecordShorterThanNameOffset(t *testing.T) {
86- repo := NewRepository(privateDir(t))
87- nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
88- recordData := make([]byte, nameOffset)
89- reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
90- binary.NativeEndian.PutUint16(recordData[reclenOffset:], 1)
91-
92- repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) {
93- return fakeMaintenanceDirectory{data: recordData}, nil
94- }
95- _, _, err := repo.readMaintenanceDirent("unused", 1, &maintenanceCursor{})
96- if !errors.Is(err, syscall.EIO) {
97- t.Fatalf("readMaintenanceDirent error = %v, want EIO", err)
98- }
99-}
100-
101-func TestDecodeMaintenanceDirentBoundsRecords(t *testing.T) {
102- nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
103- reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
104- valid := make([]byte, nameOffset+len("entry")+1)
105- binary.NativeEndian.PutUint16(valid[reclenOffset:], uint16(len(valid)))
106- copy(valid[nameOffset:], "entry")
107-
108- tests := []struct {
109- name string
110- data []byte
111- wantName string
112- wantErr bool
113- }{
114- {name: "truncated header", data: make([]byte, nameOffset-1), wantErr: true},
115- {name: "short record", data: append([]byte(nil), valid...), wantErr: true},
116- {name: "truncated record", data: valid[:nameOffset], wantErr: true},
117- {name: "record exceeds aligned storage", data: make([]byte, int(unsafe.Sizeof(syscall.Dirent{}))+1), wantErr: true},
118- {name: "valid", data: valid, wantName: "entry"},
119- }
120- binary.NativeEndian.PutUint16(tests[1].data[reclenOffset:], uint16(nameOffset-1))
121- binary.NativeEndian.PutUint16(tests[2].data[reclenOffset:], uint16(len(valid)))
122- binary.NativeEndian.PutUint16(tests[3].data[reclenOffset:], uint16(len(tests[3].data)))
123-
124- for _, tc := range tests {
125- t.Run(tc.name, func(t *testing.T) {
126- record, name, reclen, err := decodeMaintenanceDirent(tc.data)
127- if tc.wantErr {
128- if !errors.Is(err, syscall.EIO) {
129- t.Fatalf("decodeMaintenanceDirent() error = %v, want EIO", err)
130- }
131- return
132- }
133- if err != nil {
134- t.Fatal(err)
135- }
136- if record == nil {
137- t.Fatal("decodeMaintenanceDirent() record = nil")
138- }
139- if reclen != len(valid) {
140- t.Fatalf("decodeMaintenanceDirent() reclen = %d, want %d", reclen, len(valid))
141- }
142- if got := string(name[:len(tc.wantName)]); got != tc.wantName {
143- t.Fatalf("decodeMaintenanceDirent() name = %q, want %q", got, tc.wantName)
144- }
145- })
146- }
147-}
148-
149-type fakeMaintenanceDirectory struct {
150- data []byte
151- seekOffset int64
152- seekErr error
153- readErr error
154- closeErr error
155-}
156-
157-func (d fakeMaintenanceDirectory) Seek(int64, int) (int64, error) { return d.seekOffset, d.seekErr }
158-func (d fakeMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
159- if d.readErr != nil {
160- return 0, d.readErr
161- }
162- return copy(buffer, d.data), nil
163-}
164-func (d fakeMaintenanceDirectory) Close() error { return d.closeErr }
165-
166-func TestReadMaintenanceDirentDrainsDarwinBatchesWithoutStarvation(t *testing.T) {
167- dir := privateDir(t)
168- if err := os.Mkdir(dir, 0o700); err != nil {
169- t.Fatal(err)
170- }
171- batches := [][]string{
172- {"one", "two"},
173- {"three", "four", "five", "six"},
174- }
175- for _, batch := range batches {
176- for _, name := range batch {
177- if err := os.WriteFile(filepath.Join(dir, name), nil, 0o600); err != nil {
178- t.Fatal(err)
179- }
180- }
181- }
182-
183- file := &fakeMultiBatchMaintenanceDirectory{batches: [][]byte{
184- maintenanceDirentBatch(batches[0]...),
185- maintenanceDirentBatch(batches[1]...),
186- }}
187- repo := NewRepository(dir)
188- repo.hooks.openMaintenanceDirectory = func(string) (maintenanceDirectory, error) { return file, nil }
189- cursor := &maintenanceCursor{}
190-
191- entries, done, err := repo.readMaintenanceDirentWithDrain(dir, 4, cursor, true, maintenanceTestDirectoryCookie)
192- if err != nil {
193- t.Fatal(err)
194- }
195- if done {
196- t.Fatal("readMaintenanceDirentWithDrain() done = true, want false before EOF")
197- }
198- if got, want := maintenanceEntryNames(entries), []string{"one", "two", "three", "four"}; !slices.Equal(got, want) {
199- t.Fatalf("first entries = %v, want %v", got, want)
200- }
201- if got, want := maintenanceEntryNames(cursor.pending), []string{"five", "six"}; !slices.Equal(got, want) {
202- t.Fatalf("pending entries = %v, want exactly final-buffer excess %v", got, want)
203- }
204- if got := file.reads; got != 2 {
205- t.Fatalf("ReadDirent calls = %d, want 2 to fill the requested batch", got)
206- }
207-
208- id := maintenanceCursorID(dir, "test")
209- repo.maintenanceCursors = map[string]*maintenanceCursor{id: cursor}
210- entries, done, err = repo.readMaintenanceDir(dir, 4, "test")
211- if err != nil {
212- t.Fatal(err)
213- }
214- if done {
215- t.Fatal("pending batch done = true, want false before EOF")
216- }
217- if got, want := maintenanceEntryNames(entries), []string{"five", "six"}; !slices.Equal(got, want) {
218- t.Fatalf("pending entries = %v, want %v", got, want)
219- }
220- if got := file.reads; got != 2 {
221- t.Fatalf("ReadDirent calls while delivering pending entries = %d, want 2", got)
222- }
223-
224- entries, done, err = repo.readMaintenanceDir(dir, 4, "test")
225- if err != nil {
226- t.Fatal(err)
227- }
228- if !done {
229- t.Fatal("EOF batch done = false, want true")
230- }
231- if len(entries) != 0 {
232- t.Fatalf("EOF entries = %v, want none", entries)
233- }
234- if _, ok := repo.maintenanceCursors[id]; ok {
235- t.Fatal("EOF cursor retained after pending entries were delivered")
236- }
237-}
238-
239-func maintenanceEntryNames(entries []maintenanceDirEntry) []string {
240- names := make([]string, len(entries))
241- for i, entry := range entries {
242- names[i] = entry.name
243- }
244- return names
245-}
246-
247-func maintenanceDirentBatch(names ...string) []byte {
248- nameOffset := int(unsafe.Offsetof(syscall.Dirent{}.Name))
249- reclenOffset := unsafe.Offsetof(syscall.Dirent{}.Reclen)
250- var data []byte
251- for _, name := range names {
252- record := make([]byte, nameOffset+len(name)+1)
253- binary.NativeEndian.PutUint16(record[reclenOffset:], uint16(len(record)))
254- copy(record[nameOffset:], name)
255- data = append(data, record...)
256- }
257- return data
258-}
259-
260-type fakeMultiBatchMaintenanceDirectory struct {
261- batches [][]byte
262- index int
263- reads int
264-}
265-
266-func (d *fakeMultiBatchMaintenanceDirectory) Seek(offset int64, whence int) (int64, error) {
267- if whence == io.SeekCurrent {
268- return int64(d.index), nil
269- }
270- d.index = int(offset)
271- return offset, nil
272-}
273-
274-func (d *fakeMultiBatchMaintenanceDirectory) ReadDirent(buffer []byte) (int, error) {
275- d.reads++
276- if d.index == len(d.batches) {
277- return 0, nil
278- }
279- batch := d.batches[d.index]
280- d.index++
281- return copy(buffer, batch), nil
282-}
283-
284-func (d *fakeMultiBatchMaintenanceDirectory) Close() error { return nil }
285-
286-func maintenanceTestDirectoryCookie(file maintenanceDirectory, _ *syscall.Dirent) (int64, error) {
287- return file.Seek(0, io.SeekCurrent)
288-}
289-
290-func TestRepositoryDeleteQuarantinesCanonicalSessionBeforeCleanup(t *testing.T) {
291- repo := NewRepository(privateDir(t))
292- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
293- t.Fatal(err)
294- }
295- if err := repo.Delete(context.Background(), "named"); err != nil {
296- t.Fatal(err)
297- }
298-
299- canonical := repo.sessionPath(sessionKey("named"))
300- if _, err := os.Lstat(canonical); !errors.Is(err, os.ErrNotExist) {
301- t.Fatalf("canonical session after Delete = %v, want not exist", err)
302- }
303- entries, err := os.ReadDir(filepath.Join(repo.dir, repositorySessionsDir))
304- if err != nil {
305- t.Fatal(err)
306- }
307- if len(entries) != 1 || !isQuarantine(entries[0].Name()) || !entries[0].IsDir() {
308- t.Fatalf("session entries after Delete = %#v, want one quarantine", entries)
309- }
310- if err := repo.Maintain(context.Background()); err != nil {
311- t.Fatal(err)
312- }
313- entries, err = os.ReadDir(filepath.Join(repo.dir, repositorySessionsDir))
314- if err != nil {
315- t.Fatal(err)
316- }
317- if len(entries) != 0 {
318- t.Fatalf("session entries after Maintain = %#v, want none", entries)
319- }
320-}
321-
322-func TestRepositoryDeleteReturnsRootSyncFailureAfterQuarantine(t *testing.T) {
323- repo := NewRepository(privateDir(t))
324- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
325- t.Fatal(err)
326- }
327- injected := errors.New("sessions sync")
328- repo.hooks.syncDirectory = func(string) error { return injected }
329- if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) {
330- t.Fatalf("Delete error = %v, want root sync failure", err)
331- }
332- if _, err := os.Lstat(repo.sessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) {
333- t.Fatalf("canonical session after failed sync = %v, want not exist", err)
334- }
335-}
336-
337-func TestRepositoryDeleteWaitsForPublication(t *testing.T) {
338- repo := NewRepository(privateDir(t))
339- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("first"))); err != nil {
340- t.Fatal(err)
341- }
342- entered := make(chan struct{})
343- release := make(chan struct{})
344- repo.hooks.beforeHeadWrite = func(string) error {
345- close(entered)
346- <-release
347- return nil
348- }
349- publishDone := make(chan error, 1)
350- go func() {
351- publishDone <- repo.Publish(context.Background(), repositoryPublication(t, "named", 2, []byte("second")))
352- }()
353- <-entered
354- deleteReached := make(chan struct{})
355- deleteRelease := make(chan struct{})
356- repo.hooks.beforeSessionLock = waitAtSessionLock(deleteReached, deleteRelease)
357- deleteDone := make(chan error, 1)
358- go func() { deleteDone <- repo.Delete(context.Background(), "named") }()
359- <-deleteReached
360- // Delete is parked at the session-lock boundary while Publish owns it.
361- close(deleteRelease)
362- close(release)
363- if err := <-publishDone; err != nil {
364- t.Fatal(err)
365- }
366- if err := <-deleteDone; err != nil {
367- t.Fatal(err)
368- }
369- if _, err := os.Lstat(repo.sessionPath(sessionKey("named"))); !errors.Is(err, os.ErrNotExist) {
370- t.Fatalf("canonical session after concurrent Delete = %v, want not exist", err)
371- }
372-}
373-
374-func TestRepositoryMaintainRetainsNewestTwoCompleteGenerations(t *testing.T) {
375- repo := NewRepository(privateDir(t))
376- for generation := uint64(1); generation <= 3; generation++ {
377- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte{byte(generation)})); err != nil {
378- t.Fatal(err)
379- }
380- }
381- key := sessionKey("named")
382- for pass := 0; pass < maintenanceBatch; pass++ {
383- if err := repo.Maintain(context.Background()); err != nil {
384- t.Fatal(err)
385- }
386- _, err := os.Lstat(repo.manifestPath(key, 1))
387- if errors.Is(err, os.ErrNotExist) {
388- break
389- }
390- if err != nil {
391- t.Fatalf("old manifest during Maintain = %v", err)
392- }
393- if pass == maintenanceBatch-1 {
394- t.Fatal("old manifest was not eventually collected")
395- }
396- }
397- for _, generation := range []uint64{2, 3} {
398- if _, err := os.Lstat(repo.manifestPath(key, generation)); err != nil {
399- t.Fatalf("retained manifest %d: %v", generation, err)
400- }
401- }
402- if _, err := os.Lstat(repo.manifestPath(key, 1)); !errors.Is(err, os.ErrNotExist) {
403- t.Fatalf("old manifest after Maintain = %v, want not exist", err)
404- }
405- for _, generation := range []uint64{2, 3} {
406- if _, err := repo.Load(context.Background(), "named"); err != nil {
407- t.Fatalf("Load after collecting generation %d: %v", generation, err)
408- }
409- }
410-}
411-
412-func TestRepositoryMaintainPreservesIncompleteManifestReferences(t *testing.T) {
413- repo := NewRepository(privateDir(t))
414- for generation := uint64(1); generation <= 3; generation++ {
415- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", generation, []byte{byte(generation)})); err != nil {
416- t.Fatal(err)
417- }
418- }
419- key := sessionKey("named")
420- manifest := repositoryPublication(t, "named", 3, []byte{3})
421- if err := os.Remove(repo.objectPath(key, manifest.Objects[1].Digest)); err != nil {
422- t.Fatal(err)
423- }
424- if err := repo.Maintain(context.Background()); err != nil {
425- t.Fatal(err)
426- }
427- if _, err := os.Lstat(repo.manifestPath(key, 3)); err != nil {
428- t.Fatalf("incomplete manifest removed: %v", err)
429- }
430- if _, err := os.Lstat(repo.objectPath(key, manifest.Objects[0].Digest)); err != nil {
431- t.Fatalf("object referenced by incomplete manifest removed: %v", err)
432- }
433-}
434-
435-func TestRepositoryCancellationAfterSessionLockWaitPreventsMutations(t *testing.T) {
436- t.Run("publish", func(t *testing.T) {
437- repo := NewRepository(privateDir(t))
438- key := sessionKey("named")
439- lock := repo.lockSession(key)
440- ctx, cancel := context.WithCancel(context.Background())
441- reached := make(chan struct{})
442- release := make(chan struct{})
443- repo.hooks.beforeSessionLock = waitAtSessionLock(reached, release)
444- done := make(chan error, 1)
445- go func() { done <- repo.Publish(ctx, repositoryPublication(t, "named", 1, []byte("state"))) }()
446- <-reached
447- cancel()
448- close(release)
449- repo.unlockSession(lock)
450- if err := <-done; !errors.Is(err, context.Canceled) {
451- t.Fatalf("Publish error = %v, want canceled", err)
452- }
453- if _, err := os.Lstat(repo.dir); !errors.Is(err, os.ErrNotExist) {
454- t.Fatalf("Publish mutated canceled repository: %v", err)
455- }
456- })
457- t.Run("load and delete", func(t *testing.T) {
458- repo := NewRepository(privateDir(t))
459- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
460- t.Fatal(err)
461- }
462- key := sessionKey("named")
463- for _, operation := range []struct {
464- name string
465- run func(context.Context) error
466- }{
467- {"load", func(ctx context.Context) error { _, err := repo.Load(ctx, "named"); return err }},
468- {"delete", func(ctx context.Context) error { return repo.Delete(ctx, "named") }},
469- } {
470- t.Run(operation.name, func(t *testing.T) {
471- lock := repo.lockSession(key)
472- ctx, cancel := context.WithCancel(context.Background())
473- reached := make(chan struct{})
474- release := make(chan struct{})
475- repo.hooks.beforeSessionLock = waitAtSessionLock(reached, release)
476- done := make(chan error, 1)
477- go func() { done <- operation.run(ctx) }()
478- <-reached
479- cancel()
480- close(release)
481- repo.unlockSession(lock)
482- if err := <-done; !errors.Is(err, context.Canceled) {
483- t.Fatalf("%s error = %v, want canceled", operation.name, err)
484- }
485- repo.hooks.beforeSessionLock = nil
486- if _, err := repo.Load(context.Background(), "named"); err != nil {
487- t.Fatalf("%s mutated session: %v", operation.name, err)
488- }
489- })
490- }
491- })
492- t.Run("maintain", func(t *testing.T) {
493- repo := NewRepository(privateDir(t))
494- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
495- t.Fatal(err)
496- }
497- lock := repo.lockSession(sessionKey("named"))
498- ctx, cancel := context.WithCancel(context.Background())
499- reached := make(chan struct{})
500- release := make(chan struct{})
501- repo.hooks.beforeSessionLock = waitAtSessionLock(reached, release)
502- done := make(chan error, 1)
503- go func() { done <- repo.Maintain(ctx) }()
504- <-reached
505- cancel()
506- close(release)
507- repo.unlockSession(lock)
508- if err := <-done; !errors.Is(err, context.Canceled) {
509- t.Fatalf("Maintain error = %v, want canceled", err)
510- }
511- repo.hooks.beforeSessionLock = nil
512- if _, err := repo.Load(context.Background(), "named"); err != nil {
513- t.Fatalf("Maintain mutated session: %v", err)
514- }
515- })
516-}
517-
518-func waitAtSessionLock(reached chan<- struct{}, release <-chan struct{}) func(string) {
519- return func(string) {
520- close(reached)
521- <-release
522- }
523-}
524-
525-func TestRepositoryDeleteRetriesPendingQuarantineSyncWithoutDeletingRecreatedSession(t *testing.T) {
526- repo := NewRepository(privateDir(t))
527- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("old"))); err != nil {
528- t.Fatal(err)
529- }
530- injected := errors.New("sessions sync")
531- repo.hooks.syncDirectory = func(string) error { return injected }
532- if err := repo.Delete(context.Background(), "named"); !errors.Is(err, injected) {
533- t.Fatalf("Delete error = %v, want sync failure", err)
534- }
535- repo.hooks.syncDirectory = nil
536- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("new"))); err != nil {
537- t.Fatal(err)
538- }
539- if err := repo.Delete(context.Background(), "named"); err != nil {
540- t.Fatalf("retry Delete: %v", err)
541- }
542- if _, err := repo.Load(context.Background(), "named"); err != nil {
543- t.Fatalf("recreated session was deleted: %v", err)
544- }
545-}
546-
547-func TestRepositoryMaintainResumesNestedQuarantineWithinBatch(t *testing.T) {
548- repo := NewRepository(privateDir(t))
549- quarantine := filepath.Join(repo.dir, repositorySessionsDir, ".deleting-named-test")
550- for i := 0; i < maintenanceBatch+1; i++ {
551- path := filepath.Join(quarantine, "nested", string(rune('a'+i)))
552- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
553- t.Fatal(err)
554- }
555- if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
556- t.Fatal(err)
557- }
558- }
559- if err := repo.Maintain(context.Background()); err != nil {
560- t.Fatal(err)
561- }
562- if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) {
563- t.Fatalf("quarantine after one bounded call = %v, want remaining tree", err)
564- }
565- for i := 0; i < 12; i++ {
566- if err := repo.Maintain(context.Background()); err != nil {
567- t.Fatal(err)
568- }
569- if _, err := os.Lstat(quarantine); errors.Is(err, os.ErrNotExist) {
570- return
571- }
572- }
573- if _, err := os.Lstat(quarantine); !errors.Is(err, os.ErrNotExist) {
574- t.Fatalf("quarantine after resumed maintenance = %v, want removed", err)
575- }
576-}
577-
578-func TestRepositoryMaintainClosesContinuationDirectories(t *testing.T) {
579- repo := NewRepository(privateDir(t))
580- sessions := filepath.Join(repo.dir, repositorySessionsDir)
581- for i := 0; i < maintenanceBatch+1; i++ {
582- generations := filepath.Join(sessions, fmt.Sprintf("named-%03d", i), repositoryGenerations)
583- if err := os.MkdirAll(generations, 0o700); err != nil {
584- t.Fatal(err)
585- }
586- for j := 0; j < maintenanceBatch+1; j++ {
587- if err := os.WriteFile(filepath.Join(generations, fmt.Sprintf("unclassified-%03d", j)), []byte("state"), 0o600); err != nil {
588- t.Fatal(err)
589- }
590- }
591- }
592- before := openDescriptorCount(t)
593- if err := repo.Maintain(context.Background()); err != nil {
594- t.Fatal(err)
595- }
596- if got := openDescriptorCount(t); got > before+4 {
597- t.Fatalf("open descriptors after bounded maintenance = %d, want at most %d", got, before+4)
598- }
599-}
600-
601-func TestRepositoryMaintainBoundsQueuedShardNames(t *testing.T) {
602- repo := NewRepository(privateDir(t))
603- key := sessionKey("named")
604- root := filepath.Join(repo.sessionPath(key), repositoryObjectsDir)
605- for i := 0; i < maintenanceBatch*2; i++ {
606- if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("%02x", i)), 0o700); err != nil {
607- t.Fatal(err)
608- }
609- }
610- repo.maintenanceCursors = make(map[string]*maintenanceCursor)
611- state := &sessionMaintenance{marked: make(map[uint64]manifestMaintenance), markDone: true}
612- for i := 0; i < maintenanceBatch+2; i++ {
613- budget := maintenanceBatch
614- if err := repo.sweepSession(context.Background(), key, state, &budget); err != nil {
615- t.Fatal(err)
616- }
617- if state.sweepShard != "" && len(repo.maintenanceCursors) > maxMaintenanceCursors {
618- t.Fatalf("retained maintenance cursors = %d, want at most %d", len(repo.maintenanceCursors), maxMaintenanceCursors)
619- }
620- }
621-}
622-
623-func TestRepositoryMaintainResumesCursorAfterDirectoryMutation(t *testing.T) {
624- repo := NewRepository(privateDir(t))
625- if err := os.Mkdir(repo.dir, 0o700); err != nil {
626- t.Fatal(err)
627- }
628- for i := 0; i < maintenanceBatch+1; i++ {
629- if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("live-%03d", i)), []byte("live"), 0o600); err != nil {
630- t.Fatal(err)
631- }
632- }
633- if err := repo.Maintain(context.Background()); err != nil {
634- t.Fatal(err)
635- }
636- // Mutating entries ahead of the saved cookie must not reset traversal to
637- // the prefix or lose a stale entry appended after that cookie.
638- if err := os.Remove(filepath.Join(repo.dir, "live-000")); err != nil {
639- t.Fatal(err)
640- }
641- stale := filepath.Join(repo.dir, ".tmp-appended")
642- if err := os.WriteFile(stale, []byte("stale"), 0o600); err != nil {
643- t.Fatal(err)
644- }
645- for i := 0; i < 4; i++ {
646- if err := repo.Maintain(context.Background()); err != nil {
647- t.Fatal(err)
648- }
649- if _, err := os.Lstat(stale); errors.Is(err, os.ErrNotExist) {
650- return
651- }
652- }
653- t.Fatal("stale entry appended after cursor mutation was not eventually removed")
654-}
655-
656-func TestRepositoryMaintainDoesNotStarveLaterTemporaryEntries(t *testing.T) {
657- repo := NewRepository(privateDir(t))
658- if err := os.Mkdir(repo.dir, 0o700); err != nil {
659- t.Fatal(err)
660- }
661- // The live entries precede the stale entries in directory insertion order.
662- // A fresh ReadDir from the start on every call would never reach the latter.
663- for i := 0; i < maintenanceBatch+1; i++ {
664- if err := os.WriteFile(filepath.Join(repo.dir, "live-"+string(rune('a'+i))), []byte("live"), 0o600); err != nil {
665- t.Fatal(err)
666- }
667- }
668- for i := 0; i < maintenanceBatch+1; i++ {
669- if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-later-"+string(rune('a'+i))), []byte("stale"), 0o600); err != nil {
670- t.Fatal(err)
671- }
672- }
673- for i := 0; i < 4; i++ {
674- if err := repo.Maintain(context.Background()); err != nil {
675- t.Fatal(err)
676- }
677- }
678- entries, err := os.ReadDir(repo.dir)
679- if err != nil {
680- t.Fatal(err)
681- }
682- for _, entry := range entries {
683- if strings.HasPrefix(entry.Name(), ".tmp-") {
684- t.Fatalf("stale entry %q was starved", entry.Name())
685- }
686- }
687-}
688-
689-func TestRepositoryMaintainMarksLargeGenerationSetBeforeSweeping(t *testing.T) {
690- repo := NewRepository(privateDir(t))
691- var newest ports.SnapshotPublication
692- for generation := uint64(1); generation <= maintenanceBatch+2; generation++ {
693- publication := repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation)))
694- if err := repo.Publish(context.Background(), publication); err != nil {
695- t.Fatal(err)
696- }
697- newest = publication
698- }
699- key := sessionKey("named")
700- stale := sha256.Sum256([]byte("stale object"))
701- stalePath := repo.objectPath(key, stale)
702- if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil {
703- t.Fatal(err)
704- }
705- if err := os.WriteFile(stalePath, []byte("stale object"), 0o600); err != nil {
706- t.Fatal(err)
707- }
708-
709- // The first call has classified only one manifest batch. It must not sweep
710- // even an otherwise stale object, nor a blob owned by a later manifest.
711- if err := repo.Maintain(context.Background()); err != nil {
712- t.Fatal(err)
713- }
714- if _, err := os.Lstat(stalePath); err != nil {
715- t.Fatalf("stale object swept before complete mark pass: %v", err)
716- }
717- for _, object := range newest.Objects {
718- if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil {
719- t.Fatalf("object referenced by not-yet-classified manifest removed: %v", err)
720- }
721- }
722-
723- for i := 0; i < 600; i++ {
724- if err := repo.Maintain(context.Background()); err != nil {
725- t.Fatal(err)
726- }
727- if _, err := os.Lstat(stalePath); errors.Is(err, os.ErrNotExist) {
728- break
729- }
730- if i == 599 {
731- t.Fatal("stale object was not eventually swept")
732- }
733- }
734- for _, generation := range []uint64{maintenanceBatch + 1, maintenanceBatch + 2} {
735- if _, err := os.Lstat(repo.manifestPath(key, generation)); err != nil {
736- t.Fatalf("newest complete manifest %d removed: %v", generation, err)
737- }
738- }
739-}
740-
741-func TestRepositoryMaintainUsesFixedRemovalBatch(t *testing.T) {
742- repo := NewRepository(privateDir(t))
743- if err := os.Mkdir(repo.dir, 0o700); err != nil {
744- t.Fatal(err)
745- }
746- for i := 0; i < maintenanceBatch+1; i++ {
747- if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-"+string(rune('a'+i))), []byte("stale"), 0o600); err != nil {
748- t.Fatal(err)
749- }
750- }
751- if err := repo.Maintain(context.Background()); err != nil {
752- t.Fatal(err)
753- }
754- entries, err := os.ReadDir(repo.dir)
755- if err != nil {
756- t.Fatal(err)
757- }
758- if len(entries) != 1 {
759- t.Fatalf("remaining stale entries = %d, want 1 after one batch", len(entries))
760- }
761-}
762-
763-func TestRepositoryMaintainQueuesFetchedSessionsPastWorkBudget(t *testing.T) {
764- repo := NewRepository(privateDir(t))
765- if err := os.MkdirAll(filepath.Join(repo.dir, repositorySessionsDir), 0o700); err != nil {
766- t.Fatal(err)
767- }
768- if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-consume-budget"), []byte("stale"), 0o600); err != nil {
769- t.Fatal(err)
770- }
771-
772- removed := make(map[string]int)
773- for i := 0; i < maintenanceBatch+1; i++ {
774- path := filepath.Join(repo.dir, repositorySessionsDir, fmt.Sprintf(".deleting-%03d", i))
775- if err := os.Mkdir(path, 0o700); err != nil {
776- t.Fatal(err)
777- }
778- }
779- repo.hooks.remove = func(path string) error {
780- if strings.HasPrefix(filepath.Base(path), ".deleting-") {
781- removed[path]++
782- }
783- return nil
784- }
785-
786- sessions := filepath.Join(repo.dir, repositorySessionsDir)
787- f, err := os.Open(sessions)
788- if err != nil {
789- t.Fatal(err)
790- }
791- fetched, err := f.ReadDir(maintenanceBatch)
792- if err != nil {
793- t.Fatal(err)
794- }
795- unread, err := f.ReadDir(1)
796- if err != nil {
797- t.Fatal(err)
798- }
799- if err := f.Close(); err != nil {
800- t.Fatal(err)
801- }
802- queued := fetched[len(fetched)-1].Name()
803- for i := 0; i < maintenanceBatch+4; i++ {
804- if err := repo.Maintain(context.Background()); err != nil {
805- t.Fatal(err)
806- }
807- if _, err := os.Lstat(filepath.Join(sessions, queued)); errors.Is(err, os.ErrNotExist) {
808- break
809- } else if i == maintenanceBatch+3 {
810- t.Fatalf("fetched but unprocessed session %q = %v, want eventually removed", queued, err)
811- }
812- }
813- if _, err := os.Lstat(filepath.Join(sessions, unread[0].Name())); err != nil {
814- t.Fatalf("unread session %q = %v, want queued entry to run first", unread[0].Name(), err)
815- }
816-
817- for i := 0; i < maintenanceBatch+4; i++ {
818- if err := repo.Maintain(context.Background()); err != nil {
819- t.Fatal(err)
820- }
821- entries, err := os.ReadDir(sessions)
822- if err != nil {
823- t.Fatal(err)
824- }
825- if len(entries) == 0 {
826- break
827- }
828- }
829- entries, err := os.ReadDir(sessions)
830- if err != nil {
831- t.Fatal(err)
832- }
833- if len(entries) != 0 {
834- t.Fatalf("queued sessions after resumed maintenance = %d, want none", len(entries))
835- }
836- if len(removed) != maintenanceBatch+1 {
837- t.Fatalf("removed sessions = %d, want %d", len(removed), maintenanceBatch+1)
838- }
839- for path, calls := range removed {
840- if calls != 1 {
841- t.Fatalf("remove calls for %q = %d, want one", path, calls)
842- }
843- }
844-}
845-
846-func TestRepositoryMaintainClassifiesUnpublishedGenerationsBeforeSweep(t *testing.T) {
847- for _, head := range []struct {
848- name string
849- set func(t *testing.T, repo *Repository, key string)
850- }{
851- {
852- name: "missing",
853- set: func(t *testing.T, repo *Repository, key string) {
854- t.Helper()
855- if err := os.Remove(repo.headPath(key)); err != nil {
856- t.Fatal(err)
857- }
858- },
859- },
860- {
861- name: "corrupt",
862- set: func(t *testing.T, repo *Repository, key string) {
863- t.Helper()
864- if err := os.WriteFile(repo.headPath(key), []byte("corrupt"), 0o600); err != nil {
865- t.Fatal(err)
866- }
867- },
868- },
869- } {
870- t.Run(head.name, func(t *testing.T) {
871- repo := NewRepository(privateDir(t))
872- key := sessionKey("named")
873- publications := make([]ports.SnapshotPublication, 0, maintenanceBatch+2)
874- for generation := uint64(1); generation <= maintenanceBatch+2; generation++ {
875- publication := repositoryPublication(t, "named", generation, []byte(fmt.Sprintf("state-%d", generation)))
876- if err := repo.Publish(context.Background(), publication); err != nil {
877- t.Fatal(err)
878- }
879- publications = append(publications, publication)
880- }
881- head.set(t, repo, key)
882- stale := sha256.Sum256([]byte("unpublished stale object"))
883- stalePath := repo.objectPath(key, stale)
884- if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil {
885- t.Fatal(err)
886- }
887- if err := os.WriteFile(stalePath, []byte("unpublished stale object"), 0o600); err != nil {
888- t.Fatal(err)
889- }
890-
891- if err := repo.Maintain(context.Background()); err != nil {
892- t.Fatal(err)
893- }
894- if _, err := os.Lstat(stalePath); err != nil {
895- t.Fatalf("stale object swept before full manifest classification: %v", err)
896- }
897- for _, publication := range publications {
898- if _, err := os.Lstat(repo.objectPath(key, publication.Objects[0].Digest)); err != nil {
899- t.Fatalf("object for unpublished generation %d removed early: %v", publication.Generation, err)
900- }
901- }
902-
903- for i := 0; i < 600; i++ {
904- if err := repo.Maintain(context.Background()); err != nil {
905- t.Fatal(err)
906- }
907- if _, err := os.Lstat(stalePath); errors.Is(err, os.ErrNotExist) {
908- break
909- } else if i == 599 {
910- t.Fatal("stale object was not swept after complete classification")
911- }
912- }
913- for _, publication := range publications {
914- if _, err := os.Lstat(repo.objectPath(key, publication.Objects[0].Digest)); err != nil {
915- t.Fatalf("object for unpublished generation %d removed: %v", publication.Generation, err)
916- }
917- }
918- })
919- }
920-}
921-
922-func TestRepositoryMaintainRestartsMarkAfterFailedPublication(t *testing.T) {
923- repo := NewRepository(privateDir(t))
924- key := sessionKey("named")
925- first := publicationWithTailShard(t, "named", 1, "ff")
926- stalePaths := make([]string, 0, maintenanceBatch+1)
927- for i := 0; i < maintenanceBatch+1; i++ {
928- digest := digestInShard(t, "00", i)
929- path := repo.objectPath(key, digest)
930- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
931- t.Fatal(err)
932- }
933- if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
934- t.Fatal(err)
935- }
936- stalePaths = append(stalePaths, path)
937- }
938- if err := repo.Publish(context.Background(), first); err != nil {
939- t.Fatal(err)
940- }
941-
942- // This completes marking but exhausts the sweep budget in the stale shard,
943- // leaving the next sweep batch pending.
944- if err := repo.Maintain(context.Background()); err != nil {
945- t.Fatal(err)
946- }
947- staleRemaining := false
948- for _, path := range stalePaths {
949- if _, err := os.Lstat(path); err == nil {
950- staleRemaining = true
951- break
952- }
953- }
954- if !staleRemaining {
955- t.Fatal("sweep did not leave a pending stale object")
956- }
957-
958- second := publicationWithTailShard(t, "named", 2, "ff")
959- injected := errors.New("before HEAD")
960- repo.hooks.beforeHeadWrite = func(string) error { return injected }
961- if err := repo.Publish(context.Background(), second); !errors.Is(err, injected) {
962- t.Fatalf("Publish error = %v, want injected failure", err)
963- }
964- repo.hooks.beforeHeadWrite = nil
965-
966- for i := 0; i < 20; i++ {
967- if err := repo.Maintain(context.Background()); err != nil {
968- t.Fatal(err)
969- }
970- for _, object := range second.Objects {
971- if _, err := os.Lstat(repo.objectPath(key, object.Digest)); err != nil {
972- t.Fatalf("object referenced by failed publication removed on pass %d: %v", i, err)
973- }
974- }
975- staleRemaining = false
976- for _, path := range stalePaths {
977- if _, err := os.Lstat(path); err == nil {
978- staleRemaining = true
979- break
980- }
981- }
982- }
983- if staleRemaining {
984- t.Fatal("stale object was not eventually swept")
985- }
986-}
987-
988-func publicationWithTailShard(t *testing.T, name string, generation uint64, shard string) ports.SnapshotPublication {
989- t.Helper()
990- for i := 0; i < 4096; i++ {
991- publication := repositoryPublication(t, name, generation, []byte(fmt.Sprintf("state-%d-%d", generation, i)))
992- if fmt.Sprintf("%02x", publication.Objects[0].Digest[0]) == shard {
993- return publication
994- }
995- }
996- t.Fatalf("did not find publication tail in shard %q", shard)
997- return ports.SnapshotPublication{}
998-}
999-
1000-func digestInShard(t *testing.T, shard string, n int) ports.SnapshotDigest {
1001- t.Helper()
1002- for i := 0; i < 4096; i++ {
1003- digest := sha256.Sum256([]byte(fmt.Sprintf("stale-%d-%d", n, i)))
1004- if fmt.Sprintf("%02x", digest[0]) == shard {
1005- return digest
1006- }
1007- }
1008- t.Fatalf("did not find stale digest in shard %q", shard)
1009- return ports.SnapshotDigest{}
1010-}
D internal/adapters/snapshot/offline_kill_tombstone_test.go +0 -53
@@ -1,53 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "os"
6- "path/filepath"
7- "testing"
8-
9- "github.com/stretchr/testify/require"
10-)
11-
12-func TestRepositoryTombstoneBlocksIncrementalRestoreAndLegacyImport(t *testing.T) {
13- repo := NewRepository(privateDir(t))
14- require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
15- require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600))
16-
17- require.NoError(t, repo.Tombstone(context.Background(), "named"))
18-
19- names, err := repo.List(context.Background())
20- require.NoError(t, err)
21- require.NotContains(t, names, "named")
22- legacy, err := repo.LoadLegacy(context.Background())
23- require.NoError(t, err)
24- for _, snapshot := range legacy {
25- require.NotEqual(t, "named", snapshot.Name)
26- }
27-}
28-
29-func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) {
30- repo := NewRepository(privateDir(t))
31- require.NoError(t, os.Mkdir(repo.dir, 0o700))
32- require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600))
33- require.NoError(t, repo.Tombstone(context.Background(), "named"))
34-
35- calls := 0
36- repo.hooks.syncDirectory = func(string) error {
37- calls++
38- if calls == 1 {
39- return os.ErrPermission
40- }
41- return nil
42- }
43- require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), os.ErrPermission)
44-
45- // A retry after process restart has no in-memory pending-sync state.
46- repo = NewRepository(repo.dir)
47- repo.hooks.syncDirectory = func(string) error {
48- calls++
49- return nil
50- }
51- require.NoError(t, repo.DeleteLegacy(context.Background(), "named"))
52- require.Equal(t, 2, calls, "explicit purge retries only the pending source-directory sync")
53-}
M internal/adapters/snapshot/path.go +14 -36
@@ -1,61 +1,39 @@
11 package snapshot
22
33 import (
4- "crypto/sha256"
54 "encoding/hex"
65 "fmt"
76 "path/filepath"
8- "regexp"
97 "strconv"
108 "strings"
119
10+ "github.com/bnema/vev/internal/domain"
1211 "github.com/bnema/vev/internal/ports"
1312 )
1413
15-var safeNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,200}$`)
16-
1714 const maxSnapshotFileSize = 16 + (256 << 20)
1815
19-// filenameForName maps a session name to its deterministic legacy filename.
20-// It remains only for the one-way v3 import path.
21-func filenameForName(name string) string {
22- if safeNameRE.MatchString(name) {
23- return name + ".snap"
16+func incarnationKey(id domain.IncarnationID) (string, error) {
17+ if id == (domain.IncarnationID{}) {
18+ return "", fmt.Errorf("snapshot: zero incarnation ID")
2419 }
25- sum := sha256.Sum256([]byte(name))
26- return "@" + hex.EncodeToString(sum[:])[:40] + ".snap"
20+ return id.String(), nil
2721 }
2822
29-func (r *Repository) sessionPath(key string) string {
30- return filepath.Join(r.dir, repositorySessionsDir, key)
23+func (r *Repository) sessionPath(id domain.IncarnationID) string {
24+ return filepath.Join(r.dir, repositorySessionsDir, id.String())
3125 }
32-func (r *Repository) objectPath(key string, digest ports.SnapshotDigest) string {
26+func (r *Repository) objectPath(id domain.IncarnationID, digest ports.SnapshotDigest) string {
3327 hexDigest := hex.EncodeToString(digest[:])
34- return filepath.Join(r.sessionPath(key), repositoryObjectsDir, hexDigest[:2], hexDigest)
35-}
36-func (r *Repository) manifestPath(key string, generation uint64) string {
37- return filepath.Join(r.sessionPath(key), repositoryGenerations, generationFilename(generation))
28+ return filepath.Join(r.sessionPath(id), repositoryObjectsDir, hexDigest[:2], hexDigest)
3829 }
39-func (r *Repository) headPath(key string) string {
40- return filepath.Join(r.sessionPath(key), repositoryHead)
30+func (r *Repository) manifestPath(id domain.IncarnationID, generation uint64) string {
31+ return filepath.Join(r.sessionPath(id), repositoryGenerations, generationFilename(generation))
4132 }
42-func sessionKey(name string) string {
43- if safeNameRE.MatchString(name) && name != "." && name != ".." {
44- return name
45- }
46- sum := sha256.Sum256([]byte(name))
47- return "@" + hex.EncodeToString(sum[:])
48-}
49-func canonicalSessionKey(key string) bool {
50- if safeNameRE.MatchString(key) && key != "." && key != ".." {
51- return true
52- }
53- if len(key) != 65 || key[0] != '@' {
54- return false
55- }
56- _, err := hex.DecodeString(key[1:])
57- return err == nil && strings.ToLower(key[1:]) == key[1:]
33+func (r *Repository) headPath(id domain.IncarnationID) string {
34+ return filepath.Join(r.sessionPath(id), repositoryHead)
5835 }
36+
5937 func generationFilename(generation uint64) string {
6038 return fmt.Sprintf("%0*d.manifest", generationWidth, generation)
6139 }
M internal/adapters/snapshot/path_security_test.go +15 -105
@@ -3,12 +3,14 @@ package snapshot
33 import (
44 "context"
55 "errors"
6- "fmt"
76 "os"
87 "path/filepath"
98 "strings"
109 "syscall"
1110 "testing"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ codec "github.com/bnema/vev/internal/usecase/snapshot"
1214 )
1315
1416 func TestRepositoryRejectsSymlinkedRoot(t *testing.T) {
@@ -39,13 +41,14 @@ func TestRepositoryRejectsSymlinkedRoot(t *testing.T) {
3941 t.Fatal(err)
4042 }
4143
42- if _, err := repo.Load(context.Background(), publication.Name); err == nil {
43- t.Fatal("Load succeeded through replaced configured-root symlink")
44+ if _, err := loadPublication(context.Background(), repo, publication); err == nil {
45+ t.Fatal("LoadCheckpoint succeeded through replaced configured-root symlink")
4446 }
45- if err := repo.Maintain(context.Background()); err == nil {
46- t.Fatal("Maintain succeeded through replaced configured-root symlink")
47+ keep := map[domain.IncarnationID]domain.CheckpointRef{publication.IncarnationID: {Generation: publication.Generation, ManifestDigest: codec.ManifestDigest(publication.Manifest)}}
48+ if err := repo.CollectGarbage(context.Background(), keep); err == nil {
49+ t.Fatal("CollectGarbage succeeded through replaced configured-root symlink")
4750 }
48- loaded, err := external.Load(context.Background(), publication.Name)
51+ loaded, err := loadPublication(context.Background(), external, externalPublication)
4952 if err != nil {
5053 t.Fatal(err)
5154 }
@@ -67,84 +70,6 @@ func TestOpenRootRejectsUnsafePinnedRoot(t *testing.T) {
6770 }
6871 }
6972
70-func TestOpenRootDetectsReplacementAfterOpen(t *testing.T) {
71- dir := privateDir(t)
72- if err := os.Mkdir(dir, 0o700); err != nil {
73- t.Fatal(err)
74- }
75- old := dir + "-old"
76- replacementMarker := filepath.Join(dir, "replacement-marker")
77- repo := NewRepository(dir)
78- repo.hooks.afterOpenRoot = func() {
79- repo.hooks.afterOpenRoot = nil
80- if err := os.Rename(dir, old); err != nil {
81- t.Fatal(err)
82- }
83- if err := os.Mkdir(dir, 0o700); err != nil {
84- t.Fatal(err)
85- }
86- if err := os.WriteFile(replacementMarker, []byte("unchanged"), 0o600); err != nil {
87- t.Fatal(err)
88- }
89- }
90- if _, err := repo.openRoot(); !errors.Is(err, syscall.ESTALE) {
91- t.Fatalf("openRoot error = %v, want ESTALE", err)
92- }
93- if got, err := os.ReadFile(replacementMarker); err != nil || string(got) != "unchanged" {
94- t.Fatalf("replacement marker = %q, %v", got, err)
95- }
96- if _, err := os.Stat(filepath.Join(old, "replacement-marker")); !errors.Is(err, os.ErrNotExist) {
97- t.Fatalf("pinned tree was operated on: %v", err)
98- }
99-}
100-
101-func TestOpenDirectoryClosesChildWhenRootCloseFails(t *testing.T) {
102- repo := NewRepository(privateDir(t))
103- if err := os.Mkdir(repo.dir, 0o700); err != nil {
104- t.Fatal(err)
105- }
106- child := filepath.Join(repo.dir, "child")
107- if err := os.Mkdir(child, 0o700); err != nil {
108- t.Fatal(err)
109- }
110- closeErr := errors.New("injected root close")
111- repo.hooks.closeRoot = func() error { return closeErr }
112- before := openDescriptorCount(t)
113- for range 20 {
114- file, err := repo.openDirectory(child)
115- if file != nil || !errors.Is(err, closeErr) {
116- t.Fatalf("openDirectory = (%v, %v), want nil and close error", file, err)
117- }
118- }
119- if got := openDescriptorCount(t); got > before+4 {
120- t.Fatalf("open descriptors after failed openDirectory = %d, before = %d", got, before)
121- }
122-}
123-
124-func TestRootCloseErrorsJoinAndCloseRealRoot(t *testing.T) {
125- repo := NewRepository(privateDir(t))
126- if err := os.Mkdir(repo.dir, 0o700); err != nil {
127- t.Fatal(err)
128- }
129- closeErr := errors.New("injected root close")
130- repo.hooks.closeRoot = func() error { return closeErr }
131- root, err := repo.openRoot()
132- if err != nil {
133- t.Fatal(err)
134- }
135- if err := repo.closeRoot(root); !errors.Is(err, closeErr) {
136- t.Fatalf("closeRoot error = %v, want injected error", err)
137- }
138- if _, err := root.Stat("."); err == nil {
139- t.Fatal("real root remained open after injected close error")
140- }
141-
142- _, err = repo.stat(filepath.Join(repo.dir, "missing"))
143- if !errors.Is(err, closeErr) || !errors.Is(err, os.ErrNotExist) {
144- t.Fatalf("stat error = %v, want primary and close errors", err)
145- }
146-}
147-
14873 func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
14974 for _, target := range []string{repositoryGenerations, repositoryObjectsDir} {
15075 t.Run(target, func(t *testing.T) {
@@ -153,8 +78,7 @@ func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
15378 if err := repo.Publish(context.Background(), publication); err != nil {
15479 t.Fatal(err)
15580 }
156- key := sessionKey(publication.Name)
157- inside := filepath.Join(repo.sessionPath(key), target)
81+ inside := filepath.Join(repo.sessionPath(publication.IncarnationID), target)
15882 outside := t.TempDir()
15983 guard := filepath.Join(outside, "must-not-change")
16084 if err := os.WriteFile(guard, []byte("guard"), 0o600); err != nil {
@@ -166,11 +90,12 @@ func TestRepositoryRejectsSymlinkedGenerationAndObjectShards(t *testing.T) {
16690 if err := os.Symlink(outside, inside); err != nil {
16791 t.Fatal(err)
16892 }
169- if _, err := repo.Load(context.Background(), publication.Name); err == nil {
170- t.Fatal("Load succeeded through symlinked repository component")
93+ if _, err := loadPublication(context.Background(), repo, publication); err == nil {
94+ t.Fatal("LoadCheckpoint succeeded through symlinked repository component")
17195 }
172- if err := repo.Maintain(context.Background()); err == nil {
173- t.Fatal("Maintain succeeded through symlinked repository component")
96+ keep := map[domain.IncarnationID]domain.CheckpointRef{publication.IncarnationID: {Generation: publication.Generation, ManifestDigest: codec.ManifestDigest(publication.Manifest)}}
97+ if err := repo.CollectGarbage(context.Background(), keep); err == nil {
98+ t.Fatal("CollectGarbage succeeded through symlinked repository component")
17499 }
175100 got, err := os.ReadFile(guard)
176101 if err != nil || string(got) != "guard" {
@@ -200,18 +125,3 @@ func TestFinalSymlinksRejectedByRootOperations(t *testing.T) {
200125 t.Fatalf("readBounded error = %v, want sanitized ELOOP", err)
201126 }
202127 }
203-
204-func TestRepositoryLoadLegacyChargesUnrelatedRootEntries(t *testing.T) {
205- repo := NewRepository(privateDir(t))
206- if err := os.MkdirAll(repo.dir, 0o700); err != nil {
207- t.Fatal(err)
208- }
209- for i := 0; i <= maxDirectoryTraversalEntries; i++ {
210- if err := os.WriteFile(filepath.Join(repo.dir, fmt.Sprintf("unrelated-%05d", i)), nil, 0o600); err != nil {
211- t.Fatal(err)
212- }
213- }
214- if _, err := repo.LoadLegacy(context.Background()); !errors.Is(err, ErrDirectoryTraversalBudget) {
215- t.Fatalf("LoadLegacy error = %v, want traversal budget error", err)
216- }
217-}
M internal/adapters/snapshot/publish.go +83 -81
@@ -4,8 +4,11 @@ import (
44 "bytes"
55 "context"
66 "crypto/sha256"
7+ "errors"
78 "fmt"
9+ "os"
810
11+ "github.com/bnema/vev/internal/domain"
912 "github.com/bnema/vev/internal/ports"
1013 codec "github.com/bnema/vev/internal/usecase/snapshot"
1114 )
@@ -14,7 +17,10 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
1417 if err := ctx.Err(); err != nil {
1518 return err
1619 }
17- key := sessionKey(publication.Name)
20+ key, err := incarnationKey(publication.IncarnationID)
21+ if err != nil {
22+ return err
23+ }
1824 lock := r.lockSession(key)
1925 defer r.unlockSession(lock)
2026 if err := ctx.Err(); err != nil {
@@ -28,20 +34,16 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
2834 if err := ctx.Err(); err != nil {
2935 return err
3036 }
31- // A failed publication can still leave immutable blobs or a manifest behind.
32- // Invalidate an in-progress GC mark before creating any publication storage.
33- r.invalidateStorageEpoch(key)
34- if err := r.ensureSession(key); err != nil {
37+ // A replay may find a forward orphan left after publication but before its
38+ // catalogue commit. Accept it only after the canonical checkpoint loader has
39+ // revalidated the pointer digest, manifest, and every referenced object.
40+ if unchanged, err := r.unchangedPublication(ctx, publication); err != nil {
3541 return err
36- }
37- // The common replay path needs neither a manifest decode nor a reference
38- // map. Checking the immutable bytes directly also keeps its descriptor
39- // reads beneath the pinned repository root.
40- if unchanged, err := r.unchangedPublication(key, publication); err == nil && unchanged {
42+ } else if unchanged {
4143 return nil
4244 }
4345
44- current, currentManifest, currentRefs, err := r.currentPublication(ctx, publication.Name, key)
46+ current, currentManifest, currentRefs, err := r.currentIncarnationPublication(ctx, publication)
4547 if err != nil {
4648 return err
4749 }
@@ -55,6 +57,13 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
5557 return nil
5658 }
5759
60+ // Parent validation and all other publication checks above happen before
61+ // creating repository state. A rejected child must leave the authoritative
62+ // checkpoint and its storage byte-for-byte unchanged.
63+ if err := r.ensureSession(publication.IncarnationID); err != nil {
64+ return err
65+ }
66+
5867 // Retained refs from the authoritative generation were verified when their
5968 // immutable files were installed. They are intentionally not re-read when a
6069 // producer supplies them again. Every other referenced object is checked
@@ -70,7 +79,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
7079 if err := ctx.Err(); err != nil {
7180 return err
7281 }
73- path := r.objectPath(key, digest)
82+ path := r.objectPath(publication.IncarnationID, digest)
7483 exists, err := r.verifyObjectFile(path, digest, ref)
7584 if err != nil {
7685 return fmt.Errorf("verify object %x: %w", digest, err)
@@ -86,11 +95,6 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
8695 if err != nil {
8796 return err
8897 }
89- if r.hooks.beforeBlobWrite != nil {
90- if err := r.hooks.beforeBlobWrite(path); err != nil {
91- return err
92- }
93- }
9498 if err := ctx.Err(); err != nil {
9599 return err
96100 }
@@ -104,7 +108,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
104108 }
105109 }
106110
107- manifestPath := r.manifestPath(key, publication.Generation)
111+ manifestPath := r.manifestPath(publication.IncarnationID, publication.Generation)
108112 existing, exists, err := r.readOptionalBounded(manifestPath)
109113 if err != nil {
110114 return err
@@ -114,11 +118,6 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
114118 return fmt.Errorf("manifest generation %d: immutable conflict", publication.Generation)
115119 }
116120 } else {
117- if r.hooks.beforeManifestWrite != nil {
118- if err := r.hooks.beforeManifestWrite(manifestPath); err != nil {
119- return err
120- }
121- }
122121 if err := ctx.Err(); err != nil {
123122 return err
124123 }
@@ -131,63 +130,78 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl
131130 return fmt.Errorf("write manifest: %w", err)
132131 }
133132 }
134- if r.hooks.beforeHeadWrite != nil {
135- if err := r.hooks.beforeHeadWrite(r.headPath(key)); err != nil {
136- return err
137- }
138- }
139133 if err := ctx.Err(); err != nil {
140134 return err
141135 }
142- if err := r.writeMutable(r.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil {
136+ if err := r.writeMutable(r.headPath(publication.IncarnationID), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest))); err != nil {
143137 return fmt.Errorf("write HEAD: %w", err)
144138 }
145139 return nil
146140 }
147141
148-// unchangedPublication uses one root for the two reads in the replay fast
149-// path. The root is confined to this operation and is closed before return;
150-// all other repository operations retain their open-per-operation roots.
151-func (r *Repository) unchangedPublication(key string, publication ports.SnapshotPublication) (unchanged bool, err error) {
152- root, err := r.openRoot()
153- if err != nil {
154- return false, err
155- }
156- defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }()
157-
158- generation, _, err := r.readHeadWithRoot(root, key)
142+// unchangedPublication validates an existing forward orphan through the same
143+// loader used for recovery. A corrupt replay must not be accepted merely
144+// because its manifest bytes happen to match the requested publication.
145+func (r *Repository) unchangedPublication(ctx context.Context, publication ports.SnapshotPublication) (bool, error) {
146+ generation, digest, err := r.readHead(publication.IncarnationID)
147+ _ = err // A missing or unreadable HEAD is not treated as a replay.
159148 if err != nil || generation != publication.Generation {
160149 return false, nil
161150 }
162- current, err := r.readBoundedRoot(root, r.manifestPath(key, generation))
163- return err == nil && bytes.Equal(current, publication.Manifest), nil
151+ current, _, err := r.loadCheckpointLocked(ctx, publication.IncarnationID, publication.Name, ports.CheckpointRef{
152+ Generation: generation,
153+ ManifestDigest: digest,
154+ })
155+ if err != nil {
156+ return false, fmt.Errorf("validate replayed checkpoint: %w", err)
157+ }
158+ return bytes.Equal(current.Manifest, publication.Manifest), nil
164159 }
165160
166-// currentPublication takes the normal HEAD path without loading every object
167-// in the current generation. A damaged HEAD or manifest still uses the full
168-// fallback scan, because only Load's object validation can establish a safe
169-// replacement authoritative generation in that recovery case.
170-func (r *Repository) currentPublication(ctx context.Context, name, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) {
171- generation, digest, err := r.readHead(key)
172- if err == nil {
173- data, readErr := r.readBounded(r.manifestPath(key, generation))
174- if readErr == nil && sha256.Sum256(data) == digest {
175- refs, validateErr := validateManifest(data, name, generation)
176- if validateErr == nil {
177- return generation, data, refs, nil
178- }
161+func (r *Repository) currentIncarnationPublication(ctx context.Context, publication ports.SnapshotPublication) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) {
162+ generation, digest, err := r.readHead(publication.IncarnationID)
163+ if errors.Is(err, os.ErrNotExist) {
164+ if publication.Generation != 1 || publication.ParentCheckpoint != nil {
165+ return 0, nil, nil, fmt.Errorf("first snapshot generation must be 1 with no parent")
179166 }
167+ return 0, nil, nil, nil
180168 }
181-
182- generation, data, err := r.currentGeneration(ctx, name, key)
183- if err != nil || generation == 0 {
184- return generation, data, nil, err
169+ if err != nil {
170+ return 0, nil, nil, fmt.Errorf("read snapshot HEAD: %w", err)
171+ }
172+ data, err := r.readBounded(r.manifestPath(publication.IncarnationID, generation))
173+ if err != nil {
174+ return 0, nil, nil, fmt.Errorf("validate current checkpoint: %w", err)
185175 }
186- refs, err := validateManifest(data, name, generation)
176+ if sha256.Sum256(data) != digest {
177+ return 0, nil, nil, fmt.Errorf("validate current checkpoint: manifest digest mismatch")
178+ }
179+ manifest, err := codec.UnmarshalManifest(data)
187180 if err != nil {
188- return 0, nil, nil, err
181+ return 0, nil, nil, fmt.Errorf("validate current manifest: %w", err)
182+ }
183+ if manifest.IncarnationID != publication.IncarnationID || manifest.Generation != generation {
184+ return 0, nil, nil, fmt.Errorf("current manifest identity mismatch")
185+ }
186+ // Every child checkpoint is bound to the exact authoritative HEAD.
187+ if publication.Generation == generation+1 {
188+ wantParent := &domain.CheckpointRef{Generation: generation, ManifestDigest: digest}
189+ if !checkpointRefEqual(publication.ParentCheckpoint, wantParent) {
190+ return 0, nil, nil, fmt.Errorf("publication parent does not match current checkpoint")
191+ }
189192 }
190- return generation, data, refs, nil
193+ refs := manifestRefs(manifest)
194+ if refs == nil || !withinGenerationBudget(len(data), refs) {
195+ return 0, nil, nil, fmt.Errorf("invalid current generation")
196+ }
197+ return generation, data, refs, ctx.Err()
198+}
199+
200+func checkpointRefEqual(left, right *domain.CheckpointRef) bool {
201+ if left == nil || right == nil {
202+ return left == nil && right == nil
203+ }
204+ return *left == *right
191205 }
192206
193207 func validatePublication(p ports.SnapshotPublication) (map[ports.SnapshotDigest]codec.ObjectRef, map[ports.SnapshotDigest]ports.SnapshotObject, error) {
@@ -201,7 +215,7 @@ func validatePublication(p ports.SnapshotPublication) (map[ports.SnapshotDigest]
201215 }
202216 objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(supplied))
203217 for digest, entries := range supplied {
204- object, err := validateSuppliedObject(entries, digest, refs[digest], nil)
218+ object, err := validateSuppliedObject(entries, digest, refs[digest])
205219 if err != nil {
206220 return nil, nil, err
207221 }
@@ -211,16 +225,16 @@ func validatePublication(p ports.SnapshotPublication) (map[ports.SnapshotDigest]
211225 }
212226
213227 func validatePublicationManifest(p ports.SnapshotPublication) (map[ports.SnapshotDigest]codec.ObjectRef, error) {
214- return validateManifest(p.Manifest, p.Name, p.Generation)
228+ return validateManifest(p.Manifest, p.IncarnationID, p.Name, p.Generation, p.ParentCheckpoint)
215229 }
216230
217-func validateManifest(data []byte, name string, generation uint64) (map[ports.SnapshotDigest]codec.ObjectRef, error) {
231+func validateManifest(data []byte, incarnationID domain.IncarnationID, name string, generation uint64, parent *domain.CheckpointRef) (map[ports.SnapshotDigest]codec.ObjectRef, error) {
218232 manifest, err := codec.UnmarshalManifest(data)
219233 if err != nil {
220234 return nil, fmt.Errorf("invalid manifest: %w", err)
221235 }
222- if manifest.Name != name || manifest.Generation != generation || generation == 0 {
223- return nil, fmt.Errorf("manifest name or generation does not match publication")
236+ if manifest.IncarnationID != incarnationID || manifest.Name != name || manifest.Generation != generation || generation == 0 || !checkpointRefEqual(manifest.ParentCheckpoint, parent) {
237+ return nil, fmt.Errorf("manifest identity, name, generation, or parent does not match publication")
224238 }
225239 refs := manifestRefs(manifest)
226240 if refs == nil {
@@ -255,16 +269,13 @@ func retainedObject(currentRefs map[ports.SnapshotDigest]codec.ObjectRef, digest
255269 }
256270
257271 func (r *Repository) validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef) (ports.SnapshotObject, error) {
258- return validateSuppliedObject(objects, digest, ref, r)
272+ return validateSuppliedObject(objects, digest, ref)
259273 }
260274
261-func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef, repository *Repository) (ports.SnapshotObject, error) {
275+func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef) (ports.SnapshotObject, error) {
262276 object := objects[0]
263277 for _, candidate := range objects {
264278 actual := sha256.Sum256(candidate.Data)
265- if repository != nil {
266- actual = repository.objectDigest(candidate.Data)
267- }
268279 if actual != candidate.Digest || candidate.Digest != digest {
269280 return ports.SnapshotObject{}, fmt.Errorf("object digest mismatch")
270281 }
@@ -276,9 +287,6 @@ func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.Snapsho
276287 if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 {
277288 return ports.SnapshotObject{}, fmt.Errorf("invalid object envelope")
278289 }
279- if repository != nil && repository.hooks.beforeObjectCopy != nil {
280- repository.hooks.beforeObjectCopy(object.Data)
281- }
282290 return ports.SnapshotObject{Digest: digest, Data: append([]byte(nil), object.Data...)}, nil
283291 }
284292
@@ -326,16 +334,10 @@ func validObject(data []byte, ref codec.ObjectRef) bool {
326334 }
327335
328336 func (r *Repository) objectDigest(data []byte) ports.SnapshotDigest {
329- if r.hooks.beforeObjectHash != nil {
330- r.hooks.beforeObjectHash(data)
331- }
332337 return sha256.Sum256(data)
333338 }
334339
335340 func (r *Repository) verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) {
336- if r.hooks.beforeObjectRead != nil {
337- r.hooks.beforeObjectRead(path)
338- }
339341 data, exists, err := r.readOptionalBounded(path)
340342 if err != nil || !exists {
341343 return exists, err
M internal/adapters/snapshot/publish_efficiency_test.go +22 -106
@@ -8,47 +8,31 @@ import (
88 "path/filepath"
99 "testing"
1010
11+ "github.com/bnema/vev/internal/domain"
1112 "github.com/bnema/vev/internal/ports"
1213 codec "github.com/bnema/vev/internal/usecase/snapshot"
1314 )
1415
15-func TestRepositoryPublishSkipsUnchangedTenThousandObjectHistory(t *testing.T) {
16+func TestRepositoryPublishRevalidatesUnchangedTenThousandObjectHistory(t *testing.T) {
1617 const historyObjects = 10_000
1718
1819 repo := NewRepository(privateDir(t))
1920 first, second := largeIncrementalPublications(t, "named", historyObjects)
2021 seedCompletePublication(t, repo, first)
2122
22- accounting := installFilesystemPublishAccounting(repo)
2323 if err := repo.Publish(context.Background(), second); err != nil {
2424 t.Fatal(err)
2525 }
26- // Only the new mutable tail and visible objects need work. The 10k sealed
27- // objects are retained history supplied again by the producer, not input
28- // that Publish needs to read, hash, copy, or write.
29- requireFilesystemPublishAccounting(t, accounting, filesystemPublishAccounting{
30- objectReads: 2, objectHashes: 2, objectCopies: 2,
31- tempCreates: 4, tempWrites: 4, fileSyncs: 4,
32- objectWrites: 2, manifestWrites: 1, headWrites: 1,
33- immutableInstalls: 3, mutableRenames: 1, tempRemoves: 3, directorySyncs: 4,
34- })
3526
36- // Replaying the now-current generation still parses the 10k references, but
37- // must not allocate one object-sized buffer per retained history entry.
38- // The limit is 450, not lower: the os.Root-based fast path pays a constant
39- // allocation cost for guarded reads and root identity validation, and -race
40- // adds further overhead. A per-retained-object regression would exceed this
41- // by orders of magnitude.
42- allocations := testing.AllocsPerRun(3, func() {
43- if err := repo.Publish(context.Background(), second); err != nil {
44- panic(err)
45- }
46- })
47- if allocations > 450 {
48- t.Fatalf("unchanged 10k history Publish allocations = %.0f, want <= 450; retained objects were likely copied", allocations)
27+ // Replaying the current generation revalidates every referenced immutable
28+ // object before accepting it. That is required for a forward orphan retry:
29+ // the catalogue must not advance to a checkpoint whose files were damaged
30+ // after its original publication.
31+ if err := repo.Publish(context.Background(), second); err != nil {
32+ t.Fatal(err)
4933 }
5034
51- got, err := repo.Load(context.Background(), second.Name)
35+ got, err := loadPublication(context.Background(), repo, second)
5236 if err != nil {
5337 t.Fatal(err)
5438 }
@@ -57,44 +41,10 @@ func TestRepositoryPublishSkipsUnchangedTenThousandObjectHistory(t *testing.T) {
5741 }
5842 }
5943
60-type filesystemPublishAccounting struct {
61- objectReads, objectHashes, objectCopies int
62- tempCreates, tempWrites, fileSyncs int
63- objectWrites, manifestWrites, headWrites int
64- immutableInstalls, mutableRenames, tempRemoves, directorySyncs int
65-}
66-
67-func installFilesystemPublishAccounting(repo *Repository) *filesystemPublishAccounting {
68- accounting := &filesystemPublishAccounting{}
69- repo.hooks.beforeObjectRead = func(string) { accounting.objectReads++ }
70- repo.hooks.beforeObjectHash = func([]byte) { accounting.objectHashes++ }
71- repo.hooks.beforeObjectCopy = func([]byte) { accounting.objectCopies++ }
72- repo.hooks.createTemp = func(string) error { accounting.tempCreates++; return nil }
73- repo.hooks.writeTemp = func(string) error { accounting.tempWrites++; return nil }
74- repo.hooks.syncFile = func(string) error { accounting.fileSyncs++; return nil }
75- repo.hooks.beforeBlobWrite = func(string) error { accounting.objectWrites++; return nil }
76- repo.hooks.beforeManifestWrite = func(string) error { accounting.manifestWrites++; return nil }
77- repo.hooks.beforeHeadWrite = func(string) error { accounting.headWrites++; return nil }
78- repo.hooks.installImmutable = func(string) error { accounting.immutableInstalls++; return nil }
79- repo.hooks.rename = func(string) error { accounting.mutableRenames++; return nil }
80- repo.hooks.remove = func(string) error { accounting.tempRemoves++; return nil }
81- repo.hooks.syncDirectory = func(string) error { accounting.directorySyncs++; return nil }
82- return accounting
83-}
84-
85-func requireFilesystemPublishAccounting(t *testing.T, got *filesystemPublishAccounting, want filesystemPublishAccounting) {
86- t.Helper()
87- if *got != want {
88- t.Fatalf("filesystem Publish accounting = %+v, want %+v", *got, want)
89- }
90-}
91-
9244 func BenchmarkRepositoryPublishUnchangedTenThousandObjectHistory(b *testing.B) {
9345 first, second := largeIncrementalPublications(b, "named", 10_000)
9446 repo := NewRepository(filepath.Join(b.TempDir(), "vev"))
9547 seedCompletePublication(b, repo, first)
96- accounting := installFilesystemPublishAccounting(repo)
97-
9848 b.ReportAllocs()
9949 b.ResetTimer()
10050 for b.Loop() {
@@ -103,57 +53,22 @@ func BenchmarkRepositoryPublishUnchangedTenThousandObjectHistory(b *testing.B) {
10353 }
10454 }
10555 b.StopTimer()
106- // The benchmark deliberately replays the current generation so it can run
107- // repeatedly without fixture writes. Its bounds catch a regression that
108- // reads, hashes, or copies all 10k retained objects on the fast path.
109- if accounting.objectReads > 2 || accounting.objectHashes > 2 || accounting.objectCopies > 2 {
110- b.Fatalf("unchanged 10k history per-run object work = %d/%d/%d, want <= 2/2/2", accounting.objectReads, accounting.objectHashes, accounting.objectCopies)
111- }
112-}
113-
114-func TestRepositoryPublishVerifiesNecessaryExistingObjectOnce(t *testing.T) {
115- repo := NewRepository(privateDir(t))
116- first := repositoryPublication(t, "named", 1, []byte("first"))
117- if err := repo.Publish(context.Background(), first); err != nil {
118- t.Fatal(err)
119- }
120- second := repositoryPublication(t, "named", 2, []byte("second"))
121- newTail := second.Objects[0]
122- key := sessionKey(second.Name)
123- if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, newTail.Digest)), 0o700); err != nil {
124- t.Fatal(err)
125- }
126- if err := os.WriteFile(repo.objectPath(key, newTail.Digest), newTail.Data, 0o600); err != nil {
127- t.Fatal(err)
128- }
129-
130- var reads, hashes, copies int
131- repo.hooks.beforeObjectRead = func(string) { reads++ }
132- repo.hooks.beforeObjectHash = func([]byte) { hashes++ }
133- repo.hooks.beforeObjectCopy = func([]byte) { copies++ }
134- if err := repo.Publish(context.Background(), second); err != nil {
135- t.Fatal(err)
136- }
137- // The existing new tail is checked from disk once. The unchanged visible
138- // object is retained from generation one and needs no further work.
139- if reads != 1 || hashes != 1 || copies != 0 {
140- t.Fatalf("necessary object reads/hashes/copies = %d/%d/%d, want 1/1/0", reads, hashes, copies)
141- }
14256 }
14357
14458 func largeIncrementalPublications(t testing.TB, name string, count int) (ports.SnapshotPublication, ports.SnapshotPublication) {
14559 t.Helper()
60+ id := testIncarnationID(name)
14661 sealed := make([]codec.ObjectRef, 0, count)
14762 objects := make([]ports.SnapshotObject, 0, count+2)
14863 for i := range count {
149- object, err := codec.MarshalObject(codec.HistoryChunk, []byte(fmt.Sprintf("history-%d", i)))
64+ object, err := codec.MarshalObject(codec.HistoryChunk, fmt.Appendf(nil, "history-%d", i))
15065 if err != nil {
15166 t.Fatal(err)
15267 }
15368 sealed = append(sealed, codec.ObjectRef{Kind: codec.HistoryChunk, Digest: object.Digest, Size: uint32(len(object.Data))})
15469 objects = append(objects, object)
15570 }
156- makePublication := func(generation uint64, tailPayload, visiblePayload string) ports.SnapshotPublication {
71+ makePublication := func(generation uint64, parent *domain.CheckpointRef, tailPayload, visiblePayload string) ports.SnapshotPublication {
15772 t.Helper()
15873 tail, err := codec.MarshalObject(codec.HistoryTail, []byte(tailPayload))
15974 if err != nil {
@@ -163,19 +78,20 @@ func largeIncrementalPublications(t testing.TB, name string, count int) (ports.S
16378 if err != nil {
16479 t.Fatal(err)
16580 }
166- manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}})
81+ manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}})
16782 if err != nil {
16883 t.Fatal(err)
16984 }
170- return ports.SnapshotPublication{Name: name, Generation: generation, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, visible)}
85+ return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, visible)}
17186 }
172- return makePublication(1, "tail-1", "visible-1"), makePublication(2, "tail-2", "visible-2")
87+ first := makePublication(1, nil, "tail-1", "visible-1")
88+ parent := &domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(first.Manifest)}
89+ return first, makePublication(2, parent, "tail-2", "visible-2")
17390 }
17491
17592 func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) {
17693 t.Helper()
177- key := sessionKey(publication.Name)
178- if err := repo.ensureSession(key); err != nil {
94+ if err := repo.ensureSession(publication.IncarnationID); err != nil {
17995 t.Fatal(err)
18096 }
18197 manifest, err := codec.UnmarshalManifest(publication.Manifest)
@@ -183,17 +99,17 @@ func seedCompletePublication(t testing.TB, repo *Repository, publication ports.S
18399 t.Fatal(err)
184100 }
185101 for _, object := range publication.Objects {
186- if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, object.Digest)), 0o700); err != nil {
102+ if err := os.MkdirAll(filepath.Dir(repo.objectPath(publication.IncarnationID, object.Digest)), 0o700); err != nil {
187103 t.Fatal(err)
188104 }
189- if err := os.WriteFile(repo.objectPath(key, object.Digest), object.Data, 0o600); err != nil {
105+ if err := os.WriteFile(repo.objectPath(publication.IncarnationID, object.Digest), object.Data, 0o600); err != nil {
190106 t.Fatal(err)
191107 }
192108 }
193- if err := os.WriteFile(repo.manifestPath(key, publication.Generation), publication.Manifest, 0o600); err != nil {
109+ if err := os.WriteFile(repo.manifestPath(publication.IncarnationID, publication.Generation), publication.Manifest, 0o600); err != nil {
194110 t.Fatal(err)
195111 }
196- if err := os.WriteFile(repo.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil {
112+ if err := os.WriteFile(repo.headPath(publication.IncarnationID), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil {
197113 t.Fatal(err)
198114 }
199115 if refs := manifestRefs(manifest); refs == nil {
A internal/adapters/snapshot/publish_parent_test.go +109 -0
@@ -0,0 +1,109 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+)
12+
13+func TestRepositoryPublishAcceptsGenerationOneWithoutParent(t *testing.T) {
14+ repo := NewRepository(privateDir(t))
15+ publication := repositoryPublication(t, "named", 1, []byte("one"))
16+
17+ if err := repo.Publish(context.Background(), publication); err != nil {
18+ t.Fatalf("Publish generation 1 without parent: %v", err)
19+ }
20+}
21+
22+func TestRepositoryPublishRejectsInvalidLaterParentWithoutMutation(t *testing.T) {
23+ tests := []struct {
24+ name string
25+ parent func(domain.CheckpointRef) *domain.CheckpointRef
26+ }{
27+ {name: "nil", parent: func(domain.CheckpointRef) *domain.CheckpointRef { return nil }},
28+ {name: "stale generation", parent: func(current domain.CheckpointRef) *domain.CheckpointRef {
29+ current.Generation--
30+ return &current
31+ }},
32+ {name: "mismatched digest", parent: func(current domain.CheckpointRef) *domain.CheckpointRef {
33+ current.ManifestDigest[0]++
34+ return &current
35+ }},
36+ }
37+
38+ for _, test := range tests {
39+ t.Run(test.name, func(t *testing.T) {
40+ repo := NewRepository(privateDir(t))
41+ first := repositoryPublication(t, "named", 1, []byte("one"))
42+ if err := repo.Publish(context.Background(), first); err != nil {
43+ t.Fatalf("Publish generation 1: %v", err)
44+ }
45+ firstRef := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(first.Manifest)}
46+ second := incarnationPublication(t, first.IncarnationID, first.Name, 2, &firstRef)
47+ if err := repo.Publish(context.Background(), second); err != nil {
48+ t.Fatalf("Publish generation 2: %v", err)
49+ }
50+ current := domain.CheckpointRef{Generation: 2, ManifestDigest: sha256.Sum256(second.Manifest)}
51+ third := incarnationPublication(t, first.IncarnationID, first.Name, 3, test.parent(current))
52+ before := snapshotRepositoryFiles(t, repo.dir)
53+
54+ if err := repo.Publish(context.Background(), third); err == nil {
55+ t.Fatal("Publish accepted invalid parent")
56+ }
57+ after := snapshotRepositoryFiles(t, repo.dir)
58+ if len(after) != len(before) {
59+ t.Fatalf("repository file count changed: before=%d after=%d", len(before), len(after))
60+ }
61+ for path, data := range before {
62+ if string(after[path]) != string(data) {
63+ t.Fatalf("repository file %q mutated", path)
64+ }
65+ }
66+ })
67+ }
68+}
69+
70+func TestRepositoryPublishAcceptsExactCurrentParent(t *testing.T) {
71+ repo := NewRepository(privateDir(t))
72+ first := repositoryPublication(t, "named", 1, []byte("one"))
73+ if err := repo.Publish(context.Background(), first); err != nil {
74+ t.Fatalf("Publish generation 1: %v", err)
75+ }
76+ parent := &domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(first.Manifest)}
77+ second := incarnationPublication(t, first.IncarnationID, first.Name, 2, parent)
78+
79+ if err := repo.Publish(context.Background(), second); err != nil {
80+ t.Fatalf("Publish generation 2 with exact parent: %v", err)
81+ }
82+}
83+
84+func snapshotRepositoryFiles(t *testing.T, root string) map[string][]byte {
85+ t.Helper()
86+ files := make(map[string][]byte)
87+ err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
88+ if err != nil {
89+ return err
90+ }
91+ if entry.IsDir() {
92+ return nil
93+ }
94+ data, err := os.ReadFile(path)
95+ if err != nil {
96+ return err
97+ }
98+ relative, err := filepath.Rel(root, path)
99+ if err != nil {
100+ return err
101+ }
102+ files[relative] = data
103+ return nil
104+ })
105+ if err != nil {
106+ t.Fatalf("snapshot repository files: %v", err)
107+ }
108+ return files
109+}
M internal/adapters/snapshot/repository.go +22 -110
@@ -1,6 +1,7 @@
11 package snapshot
22
33 import (
4+ "log/slog"
45 "sync"
56
67 "github.com/bnema/vev/internal/ports"
@@ -13,109 +14,43 @@ const (
1314 repositoryHead = "HEAD"
1415 generationWidth = 20
1516 maxRepositoryRead = maxSnapshotFileSize
16- // Legacy import is deliberately smaller than the per-file parser ceiling:
17- // importing old snapshots must not accumulate an unbounded startup payload.
18- maxLegacySnapshotFiles = 64
19- maxLegacySnapshotBytes = 8 << 20
2017 )
2118
2219 // Repository is the crash-safe, content-addressed session snapshot store.
23-// Store remains available separately for the one-way legacy bridge.
2420 type Repository struct {
25- dir string
26- hooks repositoryHooks
21+ dir string
22+ log *slog.Logger
2723
28- // sessionStateMu owns both maps. A caller retains a reference before it
29- // waits on a session mutex, so an idle entry can never be removed while a
30- // waiter still holds its old mutex. Epoch entries share that lifetime.
24+ // sessionStateMu owns locks. A caller retains a reference before it waits
25+ // on a session mutex, so an idle entry can never be removed while a waiter
26+ // still holds its old mutex.
3127 sessionStateMu sync.Mutex
3228 locks map[string]*sessionMutex
33- storageEpochs map[string]uint64
34- nextEpoch uint64
35-
36- // maintenanceMu owns bounded continuation metadata: seek cookies and
37- // pending directory entries. Directory descriptors are opened and closed
38- // for each maintenance call.
39- maintenanceMu sync.Mutex
40- maintenanceCursors map[string]*maintenanceCursor
41- maintenanceSessions map[string]*sessionMaintenance
42- maintenanceQuarantine *quarantineMaintenance
43-
44- // pendingLegacySync records an unlink whose root-directory sync failed.
45- // It is keyed by the deterministic legacy filename and shares a per-file
46- // lock with DeleteLegacy so a retry cannot acknowledge a recreated file.
47- pendingLegacySync sync.Map // map[string]struct{}
48-}
49-
50-// repositoryHooks makes each persistence boundary fault-injectable. Hooks run
51-// immediately before their respective syscall and are intentionally package
52-// private so production callers cannot weaken repository guarantees.
53-type repositoryHooks struct {
54- beforeBlobWrite func(string) error
55- beforeManifestWrite func(string) error
56- // Object hooks instrument the publication path in package tests. They keep
57- // the steady-state cost of retained history observable without exposing a
58- // production metrics surface.
59- beforeObjectRead func(string)
60- beforeObjectHash func([]byte)
61- beforeObjectCopy func([]byte)
62- beforeHeadRead func(string) error
63- beforeHeadWrite func(string) error
64- beforeTombstoneCheck func(string)
65- beforePendingQuarantineCheck func(string)
66- createTemp func(string) error
67- writeTemp func(string) error
68- syncFile func(string) error
69- closeFile func(string) error
70- installImmutable func(string) error
71- rename func(string) error
72- syncDirectory func(string) error
73- remove func(string) error
74- // afterOpenRoot and closeRoot make descriptor race and close-error paths
75- // deterministic in package tests. closeRoot never replaces the real close.
76- afterOpenRoot func()
77- closeRoot func() error
78- openMaintenanceDirectory func(string) (maintenanceDirectory, error)
79- // beforeMaintenanceWork observes each budgeted quarantine filesystem step.
80- // It is test-only instrumentation for hostile traversal bounds.
81- beforeMaintenanceWork func(string)
82- openLegacyDirectory func(string) (legacyDirectory, error)
83- beforeSessionLock func(string)
8429 }
8530
8631 var _ ports.SnapshotRepository = (*Repository)(nil)
87-var _ ports.LegacySnapshotSource = (*Repository)(nil)
8832
89-// NewRepository creates a repository rooted at dir. It does not create files
90-// until the first publication, so merely constructing it is side-effect free.
33+// NewRepository creates a repository rooted at dir with the default logger. It
34+// does not create files until the first publication, so construction is side-effect free.
9135 func NewRepository(dir string) *Repository {
92- return &Repository{
93- dir: dir,
94- locks: make(map[string]*sessionMutex),
95- storageEpochs: make(map[string]uint64),
96- }
97-}
98-
99-// invalidateStorageEpoch must be called with lockSession(key) held. The
100-// registry mutex makes accesses for different session keys race-free too.
101-func (r *Repository) invalidateStorageEpoch(key string) {
102- r.sessionStateMu.Lock()
103- r.nextEpoch++
104- r.storageEpochs[key] = r.nextEpoch
105- r.sessionStateMu.Unlock()
36+ return NewRepositoryWithLogger(dir, slog.Default())
10637 }
10738
108-// storageEpoch must be called with lockSession(key) held.
109-func (r *Repository) storageEpoch(key string) uint64 {
110- r.sessionStateMu.Lock()
111- epoch := r.storageEpochs[key]
112- r.sessionStateMu.Unlock()
113- return epoch
39+// NewRepositoryWithLogger creates a repository rooted at dir with log.
40+func NewRepositoryWithLogger(dir string, log *slog.Logger) *Repository {
41+ if log == nil {
42+ log = slog.Default()
43+ }
44+ return &Repository{
45+ dir: dir,
46+ log: log,
47+ locks: make(map[string]*sessionMutex),
48+ }
11449 }
11550
116-// sessionMutex is retained by each waiter and each resumable maintenance
117-// state. That reference is what makes eviction safe: a new caller cannot lock
118-// a replacement mutex until all users of the prior mutex have left it.
51+// sessionMutex is retained by each waiter. That reference makes eviction
52+// safe: a new caller cannot lock a replacement mutex until all users of the
53+// prior mutex have left it.
11954 type sessionMutex struct {
12055 key string
12156 mu sync.Mutex
@@ -131,9 +66,6 @@ func (r *Repository) lockSession(key string) *sessionMutex {
13166 }
13267 lock.references++
13368 r.sessionStateMu.Unlock()
134- if hook := r.hooks.beforeSessionLock; hook != nil {
135- hook(key)
136- }
13769 lock.mu.Lock()
13870 return lock
13971 }
@@ -143,18 +75,6 @@ func (r *Repository) unlockSession(lock *sessionMutex) {
14375 r.releaseSessionReference(lock)
14476 }
14577
146-func (r *Repository) retainSessionState(key string) *sessionMutex {
147- r.sessionStateMu.Lock()
148- lock := r.locks[key]
149- // sessionMaintenanceState is called while the active session lock is held.
150- if lock == nil {
151- panic("snapshot: retain missing session lock")
152- }
153- lock.references++
154- r.sessionStateMu.Unlock()
155- return lock
156-}
157-
15878 func (r *Repository) releaseSessionReference(lock *sessionMutex) {
15979 r.sessionStateMu.Lock()
16080 lock.references--
@@ -168,14 +88,6 @@ func (r *Repository) releaseSessionReference(lock *sessionMutex) {
16888 panic("snapshot: session lock registry mismatch")
16989 }
17090 delete(r.locks, lock.key)
171- delete(r.storageEpochs, lock.key)
17291 }
17392 r.sessionStateMu.Unlock()
17493 }
175-
176-// sessionStateCounts is test evidence that idle per-session state is evicted.
177-func (r *Repository) sessionStateCounts() (locks, epochs int) {
178- r.sessionStateMu.Lock()
179- defer r.sessionStateMu.Unlock()
180- return len(r.locks), len(r.storageEpochs)
181-}
D internal/adapters/snapshot/repository_durability_test.go +0 -183
@@ -1,183 +0,0 @@
1-package snapshot
2-
3-import (
4- "bytes"
5- "context"
6- "errors"
7- "os"
8- "path/filepath"
9- "testing"
10-
11- "github.com/bnema/vev/internal/ports"
12- codec "github.com/bnema/vev/internal/usecase/snapshot"
13-)
14-
15-func TestRepositoryImmutableInstallDoesNotOverwriteRacedTarget(t *testing.T) {
16- repo := NewRepository(privateDir(t))
17- pub := repositoryPublication(t, "named", 1, []byte("state"))
18- object := pub.Objects[0]
19- target := repo.objectPath(sessionKey(pub.Name), object.Digest)
20- repo.hooks.installImmutable = func(path string) error {
21- if path == target {
22- return os.WriteFile(path, []byte("attacker bytes"), 0o600)
23- }
24- return nil
25- }
26- if err := repo.Publish(context.Background(), pub); err == nil {
27- t.Fatal("Publish succeeded after raced immutable target")
28- }
29- got, err := os.ReadFile(target)
30- if err != nil {
31- t.Fatal(err)
32- }
33- if string(got) != "attacker bytes" {
34- t.Fatalf("raced target = %q, want attacker bytes", got)
35- }
36-}
37-
38-func prepareHeadStage(t *testing.T, repo *Repository, pub ports.SnapshotPublication) {
39- t.Helper()
40- key := sessionKey(pub.Name)
41- prepareObjects(t, repo, pub)
42- if err := repo.writeImmutable(repo.manifestPath(key, pub.Generation), pub.Manifest, func(data []byte) error {
43- if !bytes.Equal(data, pub.Manifest) {
44- return errors.New("invalid manifest")
45- }
46- return nil
47- }); err != nil {
48- t.Fatal(err)
49- }
50-}
51-
52-func manifestReference(t *testing.T, data []byte, digest ports.SnapshotDigest) codec.ObjectRef {
53- t.Helper()
54- manifest, err := codec.UnmarshalManifest(data)
55- if err != nil {
56- t.Fatal(err)
57- }
58- ref, ok := manifestRefs(manifest)[digest]
59- if !ok {
60- t.Fatal("missing object reference")
61- }
62- return ref
63-}
64-
65-func TestRepositoryFaultsAtEveryPublicationBoundary(t *testing.T) {
66- cases := []struct {
67- location string
68- operation string
69- }{
70- {"blob", "create"}, {"blob", "write"}, {"blob", "sync-file"}, {"blob", "close"}, {"blob", "install"}, {"blob", "sync-directory"},
71- {"manifest", "create"}, {"manifest", "write"}, {"manifest", "sync-file"}, {"manifest", "close"}, {"manifest", "install"}, {"manifest", "sync-directory"},
72- {"HEAD", "create"}, {"HEAD", "write"}, {"HEAD", "sync-file"}, {"HEAD", "close"}, {"HEAD", "rename"}, {"HEAD", "sync-directory"},
73- }
74- for _, tc := range cases {
75- t.Run(tc.location+"/"+tc.operation, func(t *testing.T) {
76- repo := NewRepository(privateDir(t))
77- first := repositoryPublication(t, "named", 1, []byte("one"))
78- if err := repo.Publish(context.Background(), first); err != nil {
79- t.Fatal(err)
80- }
81- second := repositoryPublication(t, "named", 2, []byte("two"))
82- key := sessionKey(second.Name)
83- if err := repo.ensureSession(key); err != nil {
84- t.Fatal(err)
85- }
86- preparePublicationLocation(t, repo, second, tc.location)
87- injectBoundary(repo, tc.location, tc.operation, second)
88- if err := repo.Publish(context.Background(), second); err == nil {
89- t.Fatal("Publish succeeded with injected persistence failure")
90- }
91- repo.hooks = repositoryHooks{}
92- got, err := repo.Load(context.Background(), "named")
93- if err != nil {
94- t.Fatal(err)
95- }
96- if got.Generation != 1 && got.Generation != 2 {
97- t.Fatalf("authoritative generation = %d, want old or new", got.Generation)
98- }
99- })
100- }
101-}
102-
103-// preparePublicationLocation advances setup only far enough to target the
104-// requested persistence location in the publication fault matrix.
105-func preparePublicationLocation(t *testing.T, repo *Repository, pub ports.SnapshotPublication, location string) {
106- t.Helper()
107- switch location {
108- case "manifest":
109- prepareObjects(t, repo, pub)
110- case "HEAD":
111- prepareHeadStage(t, repo, pub)
112- }
113-}
114-
115-func injectBoundary(repo *Repository, location, operation string, pub ports.SnapshotPublication) {
116- key := sessionKey(pub.Name)
117- dir := filepath.Dir(repo.objectPath(key, pub.Objects[0].Digest))
118- if location == "manifest" {
119- dir = filepath.Dir(repo.manifestPath(key, pub.Generation))
120- }
121- if location == "HEAD" {
122- dir = filepath.Dir(repo.headPath(key))
123- }
124- fail := errors.New("injected persistence failure")
125- repo.hooks.createTemp = func(got string) error {
126- if operation == "create" && got == dir {
127- return fail
128- }
129- return nil
130- }
131- repo.hooks.writeTemp = func(path string) error {
132- if operation == "write" && filepath.Dir(path) == dir {
133- return fail
134- }
135- return nil
136- }
137- repo.hooks.syncFile = func(path string) error {
138- if operation == "sync-file" && filepath.Dir(path) == dir {
139- return fail
140- }
141- return nil
142- }
143- repo.hooks.closeFile = func(path string) error {
144- if operation == "close" && filepath.Dir(path) == dir {
145- return fail
146- }
147- return nil
148- }
149- repo.hooks.installImmutable = func(path string) error {
150- if operation == "install" && filepath.Dir(path) == dir {
151- return fail
152- }
153- return nil
154- }
155- repo.hooks.rename = func(path string) error {
156- if operation == "rename" && location == "HEAD" && path == repo.headPath(key) {
157- return fail
158- }
159- return nil
160- }
161- repo.hooks.syncDirectory = func(got string) error {
162- if operation == "sync-directory" && got == dir {
163- return fail
164- }
165- return nil
166- }
167-}
168-
169-func prepareObjects(t *testing.T, repo *Repository, pub ports.SnapshotPublication) {
170- t.Helper()
171- key := sessionKey(pub.Name)
172- for _, object := range pub.Objects {
173- ref := manifestReference(t, pub.Manifest, object.Digest)
174- if err := repo.writeImmutable(repo.objectPath(key, object.Digest), object.Data, func(data []byte) error {
175- if !bytes.Equal(data, object.Data) || !validObject(data, ref) {
176- return errors.New("invalid object")
177- }
178- return nil
179- }); err != nil {
180- t.Fatal(err)
181- }
182- }
183-}
D internal/adapters/snapshot/repository_fault_test.go +0 -204
@@ -1,204 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "os"
7- "path/filepath"
8- "strings"
9- "testing"
10-
11- codec "github.com/bnema/vev/internal/usecase/snapshot"
12-)
13-
14-func TestRepositoryHeadFailureKeepsOldCompleteGeneration(t *testing.T) {
15- dir := privateDir(t)
16- repo := NewRepository(dir)
17- first := repositoryPublication(t, "named", 1, []byte("one"))
18- if err := repo.Publish(context.Background(), first); err != nil {
19- t.Fatal(err)
20- }
21- second := repositoryPublication(t, "named", 2, []byte("two"))
22- repo.hooks.beforeHeadWrite = func(string) error { return errors.New("injected HEAD failure") }
23- if err := repo.Publish(context.Background(), second); err == nil {
24- t.Fatal("Publish succeeded with injected HEAD failure")
25- }
26- repo.hooks.beforeHeadWrite = nil
27- got, err := repo.Load(context.Background(), "named")
28- if err != nil {
29- t.Fatal(err)
30- }
31- if got.Generation != 1 {
32- t.Fatalf("generation after failed publish = %d, want 1", got.Generation)
33- }
34-}
35-
36-func TestRepositoryCleanupFaultsSurfaceForImmutableAndMutablePublication(t *testing.T) {
37- primary := errors.New("primary write failure")
38- cleanup := errors.New("cleanup failure")
39- cases := []struct {
40- name string
41- location string
42- cleanup string
43- }{
44- {name: "immutable/close temporary", location: "manifest", cleanup: "close"},
45- {name: "immutable/remove temporary", location: "manifest", cleanup: "remove"},
46- {name: "immutable/sync after temporary removal", location: "manifest", cleanup: "sync-after-remove"},
47- {name: "mutable/close temporary", location: "HEAD", cleanup: "close"},
48- {name: "mutable/remove temporary", location: "HEAD", cleanup: "remove"},
49- {name: "mutable/sync after temporary removal", location: "HEAD", cleanup: "sync-after-remove"},
50- }
51- for _, tc := range cases {
52- t.Run(tc.name, func(t *testing.T) {
53- repo := NewRepository(privateDir(t))
54- first := repositoryPublication(t, "named", 1, []byte("one"))
55- if err := repo.Publish(context.Background(), first); err != nil {
56- t.Fatal(err)
57- }
58- second := repositoryPublication(t, "named", 2, []byte("two"))
59- key := sessionKey(second.Name)
60- if tc.location == "manifest" {
61- prepareObjects(t, repo, second)
62- } else {
63- prepareHeadStage(t, repo, second)
64- }
65- dir := filepath.Dir(repo.manifestPath(key, second.Generation))
66- phase := "write manifest"
67- if tc.location == "HEAD" {
68- dir = filepath.Dir(repo.headPath(key))
69- phase = "write HEAD"
70- }
71- repo.hooks.writeTemp = func(path string) error {
72- if filepath.Dir(path) == dir {
73- return primary
74- }
75- return nil
76- }
77- repo.hooks.closeFile = func(path string) error {
78- if tc.cleanup == "close" && filepath.Dir(path) == dir {
79- return cleanup
80- }
81- return nil
82- }
83- repo.hooks.remove = func(string) error {
84- if tc.cleanup == "remove" {
85- return cleanup
86- }
87- return nil
88- }
89- repo.hooks.syncDirectory = func(path string) error {
90- if tc.cleanup == "sync-after-remove" && path == dir {
91- return cleanup
92- }
93- return nil
94- }
95-
96- err := repo.Publish(context.Background(), second)
97- if !errors.Is(err, primary) || !errors.Is(err, cleanup) || !strings.Contains(err.Error(), phase) {
98- t.Fatalf("Publish error = %v, want %q and both primary and cleanup failures", err, phase)
99- }
100- if tc.cleanup != "remove" {
101- matches, globErr := filepath.Glob(filepath.Join(dir, ".tmp-*"))
102- if globErr != nil || len(matches) != 0 {
103- t.Fatalf("temporary files after successful cleanup = %v, glob error = %v", matches, globErr)
104- }
105- }
106- repo.hooks = repositoryHooks{}
107- assertRepositoryCompleteGeneration(t, repo, "named", 1, 2)
108- })
109- }
110-}
111-
112-func TestRepositoryNewDirectorySyncFaultsAreIndependent(t *testing.T) {
113- cases := []struct {
114- name string
115- phase string
116- occurrence int
117- directory func(*Repository, string) string
118- }{
119- {name: "repository", phase: "repository", occurrence: 1, directory: func(_ *Repository, root string) string { return filepath.Dir(root) }},
120- {name: "sessions", phase: "sessions", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.dir }},
121- {name: "session", phase: "session", occurrence: 1, directory: func(repo *Repository, _ string) string { return filepath.Join(repo.dir, repositorySessionsDir) }},
122- {name: "objects", phase: "objects", occurrence: 1, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }},
123- {name: "generations", phase: "generations", occurrence: 2, directory: func(repo *Repository, _ string) string { return repo.sessionPath(sessionKey("named")) }},
124- {name: "object shard", phase: "object shard", occurrence: 1, directory: func(repo *Repository, _ string) string {
125- return filepath.Join(repo.sessionPath(sessionKey("named")), repositoryObjectsDir)
126- }},
127- }
128- for _, tc := range cases {
129- t.Run(tc.name, func(t *testing.T) {
130- parent := privateDir(t)
131- root := filepath.Join(parent, "repository")
132- repo := NewRepository(root)
133- wantDirectory := tc.directory(repo, root)
134- calls := 0
135- injected := errors.New("new directory sync failure")
136- repo.hooks.syncDirectory = func(directory string) error {
137- if directory == wantDirectory {
138- calls++
139- if calls == tc.occurrence {
140- return injected
141- }
142- }
143- return nil
144- }
145-
146- publication := repositoryPublication(t, "named", 1, []byte("one"))
147- err := repo.Publish(context.Background(), publication)
148- if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.phase) {
149- t.Fatalf("Publish error = %v, want %q-labelled directory sync failure", err, tc.phase)
150- }
151- repo.hooks = repositoryHooks{}
152- // A first-generation failure cannot have an older complete generation;
153- // it must not expose an incomplete one as if it were loadable.
154- if _, loadErr := repo.Load(context.Background(), "named"); loadErr == nil || !strings.Contains(loadErr.Error(), "no complete snapshot generation") {
155- t.Fatalf("Load error after incomplete first generation = %v, want no complete generation", loadErr)
156- }
157- if err := repo.Publish(context.Background(), publication); err != nil {
158- t.Fatal(err)
159- }
160- assertRepositoryCompleteGeneration(t, repo, "named", 1)
161- })
162- }
163-}
164-
165-func assertRepositoryCompleteGeneration(t *testing.T, repo *Repository, name string, generations ...uint64) {
166- t.Helper()
167- got, err := repo.Load(context.Background(), name)
168- if err != nil {
169- t.Fatal(err)
170- }
171- for _, generation := range generations {
172- if got.Generation == generation && len(got.Manifest) != 0 && len(got.Objects) != 0 {
173- return
174- }
175- }
176- t.Fatalf("Load = incomplete or mixed generation %+v, want one of %v", got, generations)
177-}
178-
179-func TestRepositoryLoadFallsBackFromIncompleteNewestGeneration(t *testing.T) {
180- dir := privateDir(t)
181- repo := NewRepository(dir)
182- first := repositoryPublication(t, "named", 1, []byte("one"))
183- if err := repo.Publish(context.Background(), first); err != nil {
184- t.Fatal(err)
185- }
186- second := repositoryPublication(t, "named", 2, []byte("two"))
187- if err := repo.Publish(context.Background(), second); err != nil {
188- t.Fatal(err)
189- }
190- manifest, err := codec.UnmarshalManifest(second.Manifest)
191- if err != nil {
192- t.Fatal(err)
193- }
194- if err := os.Remove(repo.objectPath(sessionKey("named"), manifest.Tabs[0].Panes[0].Tail.Digest)); err != nil {
195- t.Fatal(err)
196- }
197- got, err := repo.Load(context.Background(), "named")
198- if err != nil {
199- t.Fatal(err)
200- }
201- if got.Generation != 1 || !got.Fallback {
202- t.Fatalf("Load = generation %d fallback %v, want generation 1 fallback true", got.Generation, got.Fallback)
203- }
204-}
D internal/adapters/snapshot/repository_maintenance_test.go +0 -69
@@ -1,69 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "os"
7- "path/filepath"
8- "strings"
9- "testing"
10-)
11-
12-func TestSafeFilesystemErrorDoesNotExposePath(t *testing.T) {
13- cause := errors.New("permission denied")
14- err := safeFilesystemError(&os.PathError{Op: "remove", Path: "\x1b[31munsafe", Err: cause})
15- if !errors.Is(err, cause) || strings.Contains(err.Error(), "\x1b[31munsafe") {
16- t.Fatalf("safe filesystem error = %q", err)
17- }
18-}
19-
20-func TestSafeFilesystemErrorDoesNotExposeLinkErrorPath(t *testing.T) {
21- err := safeFilesystemError(&os.LinkError{Op: "rename", Old: "/unsafe/old", New: "/unsafe/new", Err: os.ErrExist})
22- if !errors.Is(err, os.ErrExist) || strings.Contains(err.Error(), "/unsafe/") {
23- t.Fatalf("safe link error = %q", err)
24- }
25-}
26-
27-func TestRepositoryMaintenanceFilesystemErrorsHaveSafeContext(t *testing.T) {
28- injected := errors.New("injected filesystem failure")
29- cases := []struct {
30- name string
31- run func(*Repository) error
32- want string
33- }{
34- {
35- name: "delete sync",
36- run: func(repo *Repository) error {
37- publication := repositoryPublication(t, "named", 1, []byte("state"))
38- if err := repo.Publish(context.Background(), publication); err != nil {
39- t.Fatal(err)
40- }
41- repo.hooks.syncDirectory = func(string) error { return injected }
42- return repo.Delete(context.Background(), "named")
43- },
44- want: `sync deleted snapshot session directory "named"`,
45- },
46- {
47- name: "maintain remove",
48- run: func(repo *Repository) error {
49- if err := os.Mkdir(repo.dir, 0o700); err != nil {
50- t.Fatal(err)
51- }
52- if err := os.WriteFile(filepath.Join(repo.dir, ".tmp-stale"), []byte("stale"), 0o600); err != nil {
53- t.Fatal(err)
54- }
55- repo.hooks.remove = func(string) error { return injected }
56- return repo.Maintain(context.Background())
57- },
58- want: `remove snapshot maintenance file ".tmp-stale"`,
59- },
60- }
61- for _, tc := range cases {
62- t.Run(tc.name, func(t *testing.T) {
63- err := tc.run(NewRepository(privateDir(t)))
64- if !errors.Is(err, injected) || !strings.Contains(err.Error(), tc.want) {
65- t.Fatalf("error = %v, want context %q and injected error", err, tc.want)
66- }
67- })
68- }
69-}
D internal/adapters/snapshot/repository_phase6_test.go +0 -107
@@ -1,107 +0,0 @@
1-package snapshot
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "os"
8- "path/filepath"
9- "sync"
10- "sync/atomic"
11- "testing"
12-
13- "github.com/bnema/vev/internal/ports"
14-)
15-
16-func TestRepositoryPublishDeleteManySessionsRaceFree(t *testing.T) {
17- repo := NewRepository(privateDir(t))
18- const sessions = 128
19-
20- publications := make([]ports.SnapshotPublication, sessions)
21- for i := range publications {
22- name := fmt.Sprintf("named-%03d", i)
23- publications[i] = repositoryPublication(t, name, 1, []byte(name))
24- if err := repo.Publish(context.Background(), publications[i]); err != nil {
25- t.Fatalf("initial Publish(%q): %v", name, err)
26- }
27- }
28-
29- var wg sync.WaitGroup
30- for _, publication := range publications {
31- wg.Add(2)
32- go func() {
33- defer wg.Done()
34- if err := repo.Publish(context.Background(), publication); err != nil {
35- t.Errorf("Publish(%q): %v", publication.Name, err)
36- }
37- }()
38- go func() {
39- defer wg.Done()
40- if err := repo.Delete(context.Background(), publication.Name); err != nil {
41- t.Errorf("Delete(%q): %v", publication.Name, err)
42- }
43- }()
44- }
45- wg.Wait()
46-
47- locks, epochs := repo.sessionStateCounts()
48- if locks != 0 || epochs != 0 {
49- t.Fatalf("retained session state = %d locks, %d epochs, want none", locks, epochs)
50- }
51-}
52-
53-func TestRepositoryBoundsAndEvictsPartialMaintenanceSessionState(t *testing.T) {
54- repo := NewRepository(privateDir(t))
55- for _, key := range []string{"named-000", "named-001"} {
56- generations := filepath.Join(repo.sessionPath(key), repositoryGenerations)
57- for i := 0; i < maintenanceBatch+1; i++ {
58- if err := os.MkdirAll(generations, 0o700); err != nil {
59- t.Fatal(err)
60- }
61- if err := os.WriteFile(filepath.Join(generations, fmt.Sprintf("unclassified-%03d", i)), []byte("state"), 0o600); err != nil {
62- t.Fatal(err)
63- }
64- }
65- }
66- if err := repo.Maintain(context.Background()); err != nil {
67- t.Fatal(err)
68- }
69- locks, epochs := repo.sessionStateCounts()
70- if locks != 1 || epochs != 0 {
71- t.Fatalf("partial maintenance state = %d locks, %d epochs, want one lock and no epochs", locks, epochs)
72- }
73-
74- ctx, cancel := context.WithCancel(context.Background())
75- cancel()
76- if err := repo.Maintain(ctx); !errors.Is(err, context.Canceled) {
77- t.Fatalf("canceled Maintain error = %v, want context canceled", err)
78- }
79- locks, epochs = repo.sessionStateCounts()
80- if locks != 0 || epochs != 0 {
81- t.Fatalf("session state after maintenance reset = %d locks, %d epochs, want none", locks, epochs)
82- }
83-}
84-
85-func TestRepositoryDeleteUsesDeterministicPendingQuarantine(t *testing.T) {
86- repo := NewRepository(privateDir(t))
87- if err := repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))); err != nil {
88- t.Fatal(err)
89- }
90- sessions := filepath.Join(repo.dir, repositorySessionsDir)
91- for i := 0; i < maxDirectoryTraversalEntries*2; i++ {
92- if err := os.Mkdir(filepath.Join(sessions, fmt.Sprintf("unrelated-%05d", i)), 0o700); err != nil {
93- t.Fatal(err)
94- }
95- }
96- var checks atomic.Int32
97- repo.hooks.beforePendingQuarantineCheck = func(string) { checks.Add(1) }
98- if err := repo.Delete(context.Background(), "named"); err != nil {
99- t.Fatal(err)
100- }
101- if got := checks.Load(); got != 1 {
102- t.Fatalf("pending quarantine checks = %d, want one deterministic lookup", got)
103- }
104- if _, err := os.Lstat(filepath.Join(sessions, deletingSessionName(sessionKey("named")))); err != nil {
105- t.Fatalf("deterministic quarantine missing: %v", err)
106- }
107-}
M internal/adapters/snapshot/repository_test.go +42 -21
@@ -2,8 +2,11 @@ package snapshot
22
33 import (
44 "context"
5+ "crypto/sha256"
56 "testing"
67
8+ "github.com/bnema/vev/internal/domain"
9+
710 "github.com/bnema/vev/internal/ports"
811 codec "github.com/bnema/vev/internal/usecase/snapshot"
912 )
@@ -16,7 +19,7 @@ func TestRepositoryPublishesAndLoadsCompleteGeneration(t *testing.T) {
1619 if err := repo.Publish(context.Background(), pub); err != nil {
1720 t.Fatalf("Publish: %v", err)
1821 }
19- got, err := repo.Load(context.Background(), "named")
22+ got, err := loadPublication(context.Background(), repo, pub)
2023 if err != nil {
2124 t.Fatalf("Load: %v", err)
2225 }
@@ -25,36 +28,47 @@ func TestRepositoryPublishesAndLoadsCompleteGeneration(t *testing.T) {
2528 }
2629 }
2730
28-func TestRepositoryDoesNotRewriteVerifiedImmutableBlob(t *testing.T) {
29- t.Parallel()
30- dir := privateDir(t)
31- repo := NewRepository(dir)
32- pub := repositoryPublication(t, "named", 1, []byte("state"))
33- if err := repo.Publish(context.Background(), pub); err != nil {
34- t.Fatal(err)
31+func loadPublication(ctx context.Context, repo *Repository, publication ports.SnapshotPublication) (ports.SnapshotGeneration, error) {
32+ return repo.LoadCheckpoint(ctx, publication.IncarnationID, publication.Name, ports.CheckpointRef{
33+ Generation: publication.Generation,
34+ ManifestDigest: codec.ManifestDigest(publication.Manifest),
35+ })
36+}
37+
38+func repositoryPublicationAfter(t *testing.T, repo *Repository, name string, generation uint64, payload []byte) ports.SnapshotPublication {
39+ t.Helper()
40+ return publicationWithCurrentParent(t, repo, repositoryPublication(t, name, generation, payload))
41+}
42+
43+// publicationWithCurrentParent requires repositories to contain HEAD for every
44+// publication after generation one. Callers such as prepareHeadStage and
45+// seedCompletePublication must seed or publish the parent generation first.
46+func publicationWithCurrentParent(t *testing.T, repo *Repository, publication ports.SnapshotPublication) ports.SnapshotPublication {
47+ t.Helper()
48+ if publication.Generation == 1 {
49+ return publication
3550 }
36- writes := 0
37- repo.hooks.beforeBlobWrite = func(string) error { writes++; return nil }
38- pub.Generation = 2
39- manifest, err := codec.UnmarshalManifest(pub.Manifest)
51+ currentGeneration, digest, err := repo.readHead(publication.IncarnationID)
4052 if err != nil {
41- t.Fatal(err)
53+ t.Fatalf("read parent HEAD: %v", err)
4254 }
43- manifest.Generation = 2
44- pub.Manifest, err = codec.MarshalManifest(manifest)
55+ parent := &domain.CheckpointRef{Generation: currentGeneration, ManifestDigest: digest}
56+ manifest, err := codec.UnmarshalManifest(publication.Manifest)
4557 if err != nil {
4658 t.Fatal(err)
4759 }
48- if err := repo.Publish(context.Background(), pub); err != nil {
60+ manifest.ParentCheckpoint = parent
61+ publication.Manifest, err = codec.MarshalManifest(manifest)
62+ if err != nil {
4963 t.Fatal(err)
5064 }
51- if writes != 0 {
52- t.Fatalf("blob writes = %d, want 0", writes)
53- }
65+ publication.ParentCheckpoint = parent
66+ return publication
5467 }
5568
5669 func repositoryPublication(t *testing.T, name string, generation uint64, payload []byte) ports.SnapshotPublication {
5770 t.Helper()
71+ id := testIncarnationID(name)
5872 tail, err := codec.MarshalObject(codec.HistoryTail, payload)
5973 if err != nil {
6074 t.Fatal(err)
@@ -63,9 +77,16 @@ func repositoryPublication(t *testing.T, name string, generation uint64, payload
6377 if err != nil {
6478 t.Fatal(err)
6579 }
66- manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}})
80+ manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}})
6781 if err != nil {
6882 t.Fatal(err)
6983 }
70- return ports.SnapshotPublication{Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}}
84+ return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}}
85+}
86+
87+func testIncarnationID(name string) domain.IncarnationID {
88+ digest := sha256.Sum256([]byte("test incarnation: " + name))
89+ var id domain.IncarnationID
90+ copy(id[:], digest[:])
91+ return id
7192 }
M internal/adapters/snapshot/safe_path.go +1 -21
@@ -18,9 +18,6 @@ func (r *Repository) openRoot() (*os.Root, error) {
1818 if err != nil {
1919 return nil, err
2020 }
21- if hook := r.hooks.afterOpenRoot; hook != nil {
22- hook()
23- }
2421 current, err := os.Lstat(r.dir)
2522 if err != nil {
2623 return nil, r.closeRootOnError(root, err)
@@ -47,11 +44,7 @@ func privateDirectory(fi os.FileInfo) bool {
4744 }
4845
4946 func (r *Repository) closeRoot(root *os.Root) error {
50- var injected error
51- if hook := r.hooks.closeRoot; hook != nil {
52- injected = hook()
53- }
54- return errors.Join(injected, root.Close())
47+ return root.Close()
5548 }
5649
5750 func (r *Repository) closeRootOnError(root *os.Root, err error) error {
@@ -110,19 +103,6 @@ func (r *Repository) repositoryRelative(path string) (string, bool) {
110103 return rel, true
111104 }
112105
113-func (r *Repository) stat(path string) (fi os.FileInfo, err error) {
114- rel, ok := r.repositoryRelative(path)
115- if !ok {
116- return nil, fmt.Errorf("snapshot path outside repository")
117- }
118- root, err := r.openRoot()
119- if err != nil {
120- return nil, err
121- }
122- defer func() { joinCloseError(&err, "close snapshot root", r.closeRoot(root)) }()
123- return root.Lstat(rel)
124-}
125-
126106 // createTempAt creates an exclusively owned temporary file in an already
127107 // validated directory. os.CreateTemp(path) would resolve that path again.
128108 func (r *Repository) createTempAt(dir string) (temp *os.File, err error) {
M internal/app/daemon_integration_test.go +782 -0
@@ -4,12 +4,16 @@ package app
44
55 import (
66 "context"
7+ "crypto/rand"
78 "errors"
89 "io"
910 "log/slog"
11+ "net"
1012 "os"
1113 "path/filepath"
1214 "strings"
15+ "sync"
16+ "sync/atomic"
1317 "testing"
1418 "time"
1519
@@ -17,10 +21,14 @@ import (
1721
1822 "github.com/bnema/vev/internal/adapters/clock"
1923 "github.com/bnema/vev/internal/adapters/ipc"
24+ "github.com/bnema/vev/internal/adapters/lifecycle"
2025 "github.com/bnema/vev/internal/adapters/pty"
26+ "github.com/bnema/vev/internal/adapters/snapshot"
2127 "github.com/bnema/vev/internal/domain"
28+ "github.com/bnema/vev/internal/persist"
2229 "github.com/bnema/vev/internal/ports"
2330 "github.com/bnema/vev/internal/usecase/daemon"
31+ "github.com/bnema/vev/internal/usecase/recovery"
2432 "github.com/bnema/vev/pkg/vt"
2533 )
2634
@@ -422,6 +430,780 @@ func TestIntegration_NamedSurvivesReattach(t *testing.T) {
422430 awaitText(t, p2, sz, "MARKER")
423431 }
424432
433+func TestMultipleClientsOneLifecycleOwner(t *testing.T) {
434+ dir := filepath.Join(t.TempDir(), "runtime")
435+ owner, err := lifecycle.TryAcquire(dir)
436+ require.NoError(t, err)
437+
438+ ready := make(chan struct{})
439+ transport := &integrationTransport{}
440+ dial := func(context.Context, string) (ports.Transport, error) {
441+ select {
442+ case <-ready:
443+ return transport, nil
444+ default:
445+ return nil, os.ErrNotExist
446+ }
447+ }
448+ var spawns atomic.Int32
449+ cfg := backoffConfig{initial: time.Millisecond, max: 2 * time.Millisecond, total: time.Second}
450+
451+ const clients = 4
452+ errs := make(chan error, clients)
453+ var wg sync.WaitGroup
454+ for range clients {
455+ wg.Go(func() {
456+ _, err := ensureDaemonWithLifecycle(context.Background(), dir, dial, func() error {
457+ spawns.Add(1)
458+ return nil
459+ }, cfg)
460+ errs <- err
461+ })
462+ }
463+ require.Never(t, func() bool { return spawns.Load() != 0 }, 50*time.Millisecond, time.Millisecond)
464+ close(ready)
465+ require.NoError(t, owner.Release())
466+ wg.Wait()
467+ close(errs)
468+ for err := range errs {
469+ require.NoError(t, err)
470+ }
471+ require.Zero(t, spawns.Load())
472+}
473+
474+func TestListWaitsForLifecycleOwner(t *testing.T) {
475+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
476+ t.Setenv("XDG_STATE_HOME", stateRoot)
477+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
478+ p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
479+ require.NoError(t, p.Close())
480+ owner, err := lifecycle.TryAcquire(ipc.SocketDir())
481+ require.NoError(t, err)
482+
483+ done := make(chan error, 1)
484+ go func() { done <- runList(context.Background()) }()
485+ require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond)
486+ require.NoError(t, owner.Release())
487+ require.NoError(t, <-done)
488+}
489+
490+func TestOfflineKillWaitsForLifecycleOwner(t *testing.T) {
491+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
492+ t.Setenv("XDG_STATE_HOME", stateRoot)
493+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
494+ p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
495+ now := time.Now().UnixNano()
496+ require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, CreatedAt: now, UpdatedAt: now}))
497+ require.NoError(t, p.Close())
498+ owner, err := lifecycle.TryAcquire(ipc.SocketDir())
499+ require.NoError(t, err)
500+
501+ done := make(chan error, 1)
502+ go func() { done <- runKill(context.Background(), "named", false, false) }()
503+ require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond)
504+ require.NoError(t, owner.Release())
505+ require.NoError(t, <-done)
506+}
507+
508+func TestKillDaemonWaitsForOwnershipTransfer(t *testing.T) {
509+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
510+ t.Setenv("XDG_STATE_HOME", stateRoot)
511+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
512+ owner, err := lifecycle.TryAcquire(ipc.SocketDir())
513+ require.NoError(t, err)
514+ _, served := startDaemonInDir(t, ipc.SocketDir())
515+
516+ done := make(chan error, 1)
517+ go func() { done <- requestDaemonStop(context.Background()) }()
518+ select {
519+ case err := <-served:
520+ require.NoError(t, err)
521+ case <-time.After(time.Second):
522+ t.Fatal("daemon did not stop")
523+ }
524+ require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond)
525+ require.NoError(t, owner.Release())
526+ require.NoError(t, <-done)
527+}
528+
529+func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
530+ runtimeDir := filepath.Join(t.TempDir(), "runtime")
531+ stateDir := filepath.Join(t.TempDir(), "state")
532+ catalogue := &lifecycleObservedCatalogue{
533+ Catalogue: newTestPersister(t, stateDir),
534+ closed: make(chan struct{}),
535+ }
536+ require.NoError(t, catalogue.Create(domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}}))
537+ repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
538+ shutdownClock := newLifecycleShutdownClock(t)
539+ maintenanceRepository := &lifecycleBlockingMaintenanceRepository{
540+ SnapshotRepository: repository,
541+ entered: make(chan struct{}),
542+ release: make(chan struct{}),
543+ returned: make(chan struct{}),
544+ }
545+ ctx, cancel := context.WithCancel(context.Background())
546+ t.Cleanup(cancel)
547+ var releaseOnce sync.Once
548+ releaseMaintenance := func() { releaseOnce.Do(func() { close(maintenanceRepository.release) }) }
549+ t.Cleanup(releaseMaintenance)
550+
551+ callbackReturned := make(chan struct{})
552+ wrapperReturned := make(chan error, 1)
553+ go func() {
554+ wrapperReturned <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(ctx context.Context) error {
555+ defer close(callbackReturned)
556+ d := daemon.New(
557+ pty.NewFactory(),
558+ shutdownClock,
559+ discardLog(),
560+ daemon.WithSnapshotRepository(repository),
561+ daemon.WithDurableMaintenance(catalogue, maintenanceRepository),
562+ daemon.WithCatalogue(catalogue, nil),
563+ )
564+ if err := d.CollectStartupGarbage(ctx); err != nil {
565+ return err
566+ }
567+ listener, err := ipc.Listen(runtimeDir)
568+ if err != nil {
569+ return err
570+ }
571+ return d.Serve(ctx, listener)
572+ })
573+ }()
574+
575+ awaitLifecycleStage(t, maintenanceRepository.entered, "pre-publication maintenance repository call")
576+ cancel()
577+ select {
578+ case <-callbackReturned:
579+ t.Fatal("Serve callback returned after shutdown deadline while maintenance was blocked")
580+ case <-time.After(100 * time.Millisecond):
581+ }
582+ assertLifecycleStagePending(t, catalogue.closed, "catalogue close while maintenance is blocked")
583+ assertLifecycleResultPending(t, wrapperReturned, "lifecycle wrapper return while maintenance is blocked")
584+ _, err := lifecycle.TryAcquire(runtimeDir)
585+ require.ErrorIs(t, err, lifecycle.ErrBusy)
586+
587+ releaseMaintenance()
588+ awaitLifecycleStage(t, maintenanceRepository.returned, "maintenance repository return")
589+ awaitLifecycleStage(t, catalogue.closed, "catalogue close after maintenance")
590+ awaitLifecycleStage(t, callbackReturned, "Serve callback return after catalogue close")
591+ require.NoError(t, awaitLifecycleResult(t, wrapperReturned, "lifecycle wrapper return"))
592+ owner, err := lifecycle.TryAcquire(runtimeDir)
593+ require.NoError(t, err)
594+ require.NoError(t, owner.Release())
595+}
596+
597+type lifecycleBlockingMaintenanceRepository struct {
598+ ports.SnapshotRepository
599+ entered chan struct{}
600+ release chan struct{}
601+ returned chan struct{}
602+ enterOnce sync.Once
603+ returnOnce sync.Once
604+}
605+
606+func (r *lifecycleBlockingMaintenanceRepository) CollectGarbage(context.Context, map[domain.IncarnationID]domain.CheckpointRef) error {
607+ r.enterOnce.Do(func() { close(r.entered) })
608+ <-r.release // Intentionally ignore cancellation: lifecycle ownership must outlive this call.
609+ r.returnOnce.Do(func() { close(r.returned) })
610+ return nil
611+}
612+
613+type lifecycleObservedCatalogue struct {
614+ ports.Catalogue
615+ closed chan struct{}
616+ closeOnce sync.Once
617+}
618+
619+func (c *lifecycleObservedCatalogue) Close() error {
620+ err := c.Catalogue.Close()
621+ c.closeOnce.Do(func() { close(c.closed) })
622+ return err
623+}
624+
625+func TestLifecycleOwnershipOutlivesSnapshotWriter(t *testing.T) {
626+ result := runBlockedSnapshotWriterShutdown(t)
627+ assertLifecycleStagePending(t, result.callbackReturned, "Serve callback return while snapshot writer is blocked")
628+ assertLifecycleResultPending(t, result.wrapperReturned, "lifecycle wrapper return while snapshot writer is blocked")
629+ _, err := lifecycle.TryAcquire(result.runtimeDir)
630+ require.ErrorIs(t, err, lifecycle.ErrBusy)
631+
632+ result.releaseWriter()
633+ awaitLifecycleStage(t, result.publishReturned, "snapshot writer return")
634+ require.NoError(t, awaitLifecycleResult(t, result.wrapperReturned, "lifecycle wrapper return"))
635+ owner, err := lifecycle.TryAcquire(result.runtimeDir)
636+ require.NoError(t, err)
637+ require.NoError(t, owner.Release())
638+}
639+
640+func TestLifecycleCallbackWaitsForEveryWriter(t *testing.T) {
641+ result := runBlockedSnapshotWriterShutdown(t)
642+ assertLifecycleStagePending(t, result.callbackReturned, "Serve callback return while snapshot writer is blocked")
643+
644+ result.releaseWriter()
645+ awaitLifecycleStage(t, result.publishReturned, "snapshot writer return")
646+ awaitLifecycleStage(t, result.callbackReturned, "Serve callback return after snapshot writer")
647+ require.NoError(t, awaitLifecycleResult(t, result.wrapperReturned, "lifecycle wrapper return"))
648+}
649+
650+func TestFinalCheckpointTimeoutKeepsOwner(t *testing.T) {
651+ result := runBlockedSnapshotWriterShutdown(t)
652+ assertLifecycleResultPending(t, result.wrapperReturned, "ownership release after checkpoint timeout with writer alive")
653+ _, err := lifecycle.TryAcquire(result.runtimeDir)
654+ require.ErrorIs(t, err, lifecycle.ErrBusy)
655+
656+ result.releaseWriter()
657+ require.NoError(t, awaitLifecycleResult(t, result.wrapperReturned, "lifecycle wrapper return"))
658+}
659+
660+// Startup restoration repairs HEADs, promotes fallbacks, and replaces catalogue
661+// records. Lifecycle ownership must therefore outlive it: releasing the flock
662+// while a restoration repository call is still running would let a second
663+// daemon mutate the same durable state.
664+func TestLifecycleOwnershipOutlivesRestorationWriter(t *testing.T) {
665+ stateDir := filepath.Join(t.TempDir(), "state")
666+ repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
667+ checkpointed := publishRestorableCheckpoint(t, stateDir, repository)
668+
669+ runtimeDir := filepath.Join(t.TempDir(), "runtime")
670+ blocking := &lifecycleBlockingRestore{
671+ SnapshotRepository: repository,
672+ entered: make(chan struct{}),
673+ release: make(chan struct{}),
674+ returned: make(chan struct{}),
675+ }
676+ var releaseOnce sync.Once
677+ releaseRestore := func() { releaseOnce.Do(func() { close(blocking.release) }) }
678+ t.Cleanup(releaseRestore)
679+
680+ shutdownClock := newLifecycleShutdownClock(t)
681+ ctx, cancel := context.WithCancel(context.Background())
682+ t.Cleanup(cancel)
683+ listenerReady := make(chan struct{})
684+ callbackReturned := make(chan struct{})
685+ wrapperReturned := make(chan error, 1)
686+ go func() {
687+ wrapperReturned <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(ctx context.Context) error {
688+ defer close(callbackReturned)
689+ opened, err := persist.OpenOrCreate(stateDir)
690+ if err != nil {
691+ return err
692+ }
693+ listener, err := ipc.Listen(runtimeDir)
694+ if err != nil {
695+ return errors.Join(err, opened.Catalogue.Close())
696+ }
697+ close(listenerReady)
698+ d := daemon.New(
699+ pty.NewFactory(),
700+ shutdownClock,
701+ discardLog(),
702+ daemon.WithShell("/bin/cat", nil),
703+ daemon.WithCatalogue(opened.Catalogue, opened.Records),
704+ daemon.WithSnapshotRepository(blocking),
705+ )
706+ return d.Serve(ctx, listener)
707+ })
708+ }()
709+
710+ awaitLifecycleStage(t, listenerReady, "daemon listener")
711+ awaitLifecycleStage(t, blocking.entered, "restoration repository call")
712+ require.Equal(t, checkpointed, blocking.repairedName(), "restoration must target the checkpointed session")
713+
714+ cancel()
715+ shutdownClock.nextTimer(t).fire()
716+ select {
717+ case <-callbackReturned:
718+ t.Fatal("Serve callback returned after shutdown deadline while restoration was blocked")
719+ case <-time.After(100 * time.Millisecond):
720+ }
721+ assertLifecycleStagePending(t, callbackReturned, "Serve callback return while restoration is blocked")
722+ assertLifecycleResultPending(t, wrapperReturned, "lifecycle wrapper return while restoration is blocked")
723+ _, err := lifecycle.TryAcquire(runtimeDir)
724+ require.ErrorIs(t, err, lifecycle.ErrBusy)
725+
726+ releaseRestore()
727+ awaitLifecycleStage(t, blocking.returned, "restoration repository return")
728+ awaitLifecycleStage(t, callbackReturned, "Serve callback return after restoration")
729+ require.NoError(t, awaitLifecycleResult(t, wrapperReturned, "lifecycle wrapper return"))
730+ owner, err := lifecycle.TryAcquire(runtimeDir)
731+ require.NoError(t, err)
732+ require.NoError(t, owner.Release())
733+}
734+
735+// publishRestorableCheckpoint runs a complete daemon over the real catalogue and
736+// repository until one named session owns a committed checkpoint, then shuts it
737+// down. The returned name is restorable by any later daemon on the same state.
738+func publishRestorableCheckpoint(t *testing.T, stateDir string, repository *snapshot.Repository) string {
739+ t.Helper()
740+ const name = "restorable"
741+ runtimeDir := filepath.Join(t.TempDir(), "seed-runtime")
742+ ctx, cancel := context.WithCancel(context.Background())
743+ defer cancel()
744+
745+ opened, err := persist.OpenOrCreate(stateDir)
746+ require.NoError(t, err)
747+ coordinator := recovery.NewCoordinator(opened.Catalogue, repository, rand.Reader)
748+ listener, err := ipc.Listen(runtimeDir)
749+ require.NoError(t, err)
750+ d := daemon.New(
751+ pty.NewFactory(),
752+ clock.New(),
753+ discardLog(),
754+ daemon.WithShell("/bin/cat", nil),
755+ daemon.WithCatalogue(opened.Catalogue, opened.Records),
756+ daemon.WithSnapshotRepository(repository),
757+ daemon.WithRecoveryCoordinator(coordinator),
758+ )
759+ served := make(chan error, 1)
760+ go func() { served <- d.Serve(ctx, listener) }()
761+
762+ tr, _ := attach(t, runtimeDir, ports.IntentNew, name, domain.Size{Cols: 80, Rows: 24})
763+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("checkpoint me\n")})}))
764+ require.Eventually(t, func() bool {
765+ record, ok, _ := opened.Catalogue.Record(name)
766+ return ok && record.Committed != nil && record.DegradedReason == ""
767+ }, 5*time.Second, 10*time.Millisecond, "session never committed a checkpoint")
768+ require.NoError(t, tr.Close())
769+
770+ cancel()
771+ require.NoError(t, awaitLifecycleResult(t, served, "seed daemon shutdown"))
772+ return name
773+}
774+
775+type lifecycleBlockingRestore struct {
776+ ports.SnapshotRepository
777+ entered chan struct{}
778+ release chan struct{}
779+ returned chan struct{}
780+ enterOnce sync.Once
781+ returnOnce sync.Once
782+ mu sync.Mutex
783+ repaired string
784+}
785+
786+func (r *lifecycleBlockingRestore) LoadCheckpoint(ctx context.Context, id domain.IncarnationID, name string, ref domain.CheckpointRef) (ports.SnapshotGeneration, error) {
787+ r.mu.Lock()
788+ r.repaired = name
789+ r.mu.Unlock()
790+ return r.SnapshotRepository.LoadCheckpoint(ctx, id, name, ref)
791+}
792+
793+func (r *lifecycleBlockingRestore) ReconcileCheckpoint(ctx context.Context, id domain.IncarnationID, ref domain.CheckpointRef) error {
794+ r.enterOnce.Do(func() { close(r.entered) })
795+ <-r.release // Intentionally ignore cancellation: lifecycle ownership must outlive this call.
796+ r.returnOnce.Do(func() { close(r.returned) })
797+ return r.SnapshotRepository.ReconcileCheckpoint(ctx, id, ref)
798+}
799+
800+func (r *lifecycleBlockingRestore) repairedName() string {
801+ r.mu.Lock()
802+ defer r.mu.Unlock()
803+ return r.repaired
804+}
805+
806+type blockedSnapshotWriterShutdown struct {
807+ runtimeDir string
808+ callbackReturned <-chan struct{}
809+ wrapperReturned <-chan error
810+ publishReturned <-chan struct{}
811+ releaseWriter func()
812+}
813+
814+func runBlockedSnapshotWriterShutdown(t *testing.T) blockedSnapshotWriterShutdown {
815+ t.Helper()
816+ runtimeDir := filepath.Join(t.TempDir(), "runtime")
817+ stateDir := filepath.Join(t.TempDir(), "state")
818+ shutdownClock := newLifecycleShutdownClock(t)
819+ repository := newLifecycleBlockingRepository()
820+ ctx, cancel := context.WithCancel(context.Background())
821+ t.Cleanup(cancel)
822+
823+ listenerReady := make(chan struct{})
824+ listenerClosed := make(chan struct{})
825+ callbackReturned := make(chan struct{})
826+ wrapperReturned := make(chan error, 1)
827+ go func() {
828+ wrapperReturned <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(ctx context.Context) error {
829+ defer close(callbackReturned)
830+ listener, err := ipc.Listen(runtimeDir)
831+ if err != nil {
832+ return err
833+ }
834+ observed := &lifecycleObservedListener{Listener: listener, closed: listenerClosed}
835+ close(listenerReady)
836+ d := daemon.New(
837+ pty.NewFactory(),
838+ shutdownClock,
839+ discardLog(),
840+ daemon.WithShell("/bin/cat", nil),
841+ daemon.WithSnapshotRepository(repository),
842+ )
843+ return d.Serve(ctx, observed)
844+ })
845+ }()
846+
847+ awaitLifecycleStage(t, listenerReady, "daemon listener")
848+ tr, _ := attach(t, runtimeDir, ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24})
849+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("dirty state\n")})}))
850+ awaitLifecycleStage(t, repository.entered, "snapshot publication")
851+ require.NoError(t, tr.Close())
852+ require.Eventually(t, func() bool {
853+ sessions := listRemoteSessions(t, runtimeDir)
854+ return len(sessions.Sessions) == 1 && !sessions.Sessions[0].Attached
855+ }, time.Second, 10*time.Millisecond)
856+ cancel()
857+ awaitLifecycleStage(t, listenerClosed, "listener close")
858+
859+ shutdownClock.nextTimer(t).fire()
860+ select {
861+ case <-callbackReturned:
862+ t.Fatal("Serve callback returned after checkpoint timeout while snapshot writer was blocked")
863+ case <-time.After(100 * time.Millisecond):
864+ }
865+ assertLifecycleStagePending(t, callbackReturned, "Serve callback return after checkpoint timeout")
866+ assertLifecycleResultPending(t, wrapperReturned, "lifecycle wrapper return after checkpoint timeout")
867+
868+ var releaseOnce sync.Once
869+ releaseWriter := func() { releaseOnce.Do(func() { close(repository.release) }) }
870+ t.Cleanup(releaseWriter)
871+ return blockedSnapshotWriterShutdown{
872+ runtimeDir: runtimeDir,
873+ callbackReturned: callbackReturned,
874+ wrapperReturned: wrapperReturned,
875+ publishReturned: repository.returned,
876+ releaseWriter: releaseWriter,
877+ }
878+}
879+
880+type lifecycleBlockingRepository struct {
881+ ports.SnapshotRepository
882+ entered chan struct{}
883+ release chan struct{}
884+ returned chan struct{}
885+ enterOnce sync.Once
886+ returnOnce sync.Once
887+}
888+
889+func newLifecycleBlockingRepository() *lifecycleBlockingRepository {
890+ return &lifecycleBlockingRepository{
891+ entered: make(chan struct{}),
892+ release: make(chan struct{}),
893+ returned: make(chan struct{}),
894+ }
895+}
896+
897+func (r *lifecycleBlockingRepository) Publish(context.Context, ports.SnapshotPublication) error {
898+ r.enterOnce.Do(func() { close(r.entered) })
899+ <-r.release // Intentionally ignore cancellation: lifecycle ownership must outlive this call.
900+ r.returnOnce.Do(func() { close(r.returned) })
901+ return nil
902+}
903+
904+type lifecycleShutdownClock struct {
905+ t *testing.T
906+ base ports.Clock
907+ timers chan *lifecycleManualTimer
908+}
909+
910+func newLifecycleShutdownClock(t *testing.T) *lifecycleShutdownClock {
911+ t.Helper()
912+ return &lifecycleShutdownClock{t: t, base: clock.New(), timers: make(chan *lifecycleManualTimer, 4)}
913+}
914+
915+func (c *lifecycleShutdownClock) Now() time.Time { return c.base.Now() }
916+func (c *lifecycleShutdownClock) NewTimer(delay time.Duration) ports.Timer {
917+ if delay != daemon.SnapshotShutdownTimeout() {
918+ return c.base.NewTimer(delay)
919+ }
920+ timer := &lifecycleManualTimer{t: c.t, ch: make(chan time.Time, 1)}
921+ select {
922+ case c.timers <- timer:
923+ default:
924+ c.t.Errorf("unexpected additional snapshot shutdown timer with delay %s", delay)
925+ timer.fire()
926+ }
927+ return timer
928+}
929+
930+func (c *lifecycleShutdownClock) nextTimer(t *testing.T) *lifecycleManualTimer {
931+ t.Helper()
932+ select {
933+ case timer := <-c.timers:
934+ return timer
935+ case <-time.After(3 * time.Second):
936+ t.Fatal("timed out waiting for shutdown deadline timer")
937+ return nil
938+ }
939+}
940+
941+type lifecycleManualTimer struct {
942+ t *testing.T
943+ ch chan time.Time
944+ stopped atomic.Bool
945+}
946+
947+func (t *lifecycleManualTimer) C() <-chan time.Time { return t.ch }
948+func (t *lifecycleManualTimer) Reset(delay time.Duration) bool {
949+ if delay != daemon.SnapshotShutdownTimeout() {
950+ t.t.Errorf("snapshot shutdown timer reset with delay %s, want %s", delay, daemon.SnapshotShutdownTimeout())
951+ }
952+ t.stopped.Store(false)
953+ return false
954+}
955+func (t *lifecycleManualTimer) Stop() bool { return !t.stopped.Swap(true) }
956+func (t *lifecycleManualTimer) fire() {
957+ if t.stopped.Load() {
958+ return
959+ }
960+ select {
961+ case t.ch <- time.Now():
962+ default:
963+ }
964+}
965+
966+func TestLifecycleManualTimerRepeatedFireIsNonBlocking(t *testing.T) {
967+ timer := &lifecycleManualTimer{t: t, ch: make(chan time.Time, 1)}
968+ timer.fire()
969+
970+ returned := make(chan struct{})
971+ go func() {
972+ timer.fire()
973+ close(returned)
974+ }()
975+
976+ select {
977+ case <-returned:
978+ case <-time.After(time.Second):
979+ t.Fatal("repeated manual timer fire blocked on a pending tick")
980+ }
981+}
982+
983+type lifecycleObservedListener struct {
984+ ports.Listener
985+ closed chan struct{}
986+ once sync.Once
987+}
988+
989+func (l *lifecycleObservedListener) Close() error {
990+ err := l.Listener.Close()
991+ l.once.Do(func() { close(l.closed) })
992+ return err
993+}
994+
995+func TestLifecycleSocketCloseCatalogueRace(t *testing.T) {
996+ runtimeDir := filepath.Join(t.TempDir(), "runtime")
997+ stateDir := filepath.Join(t.TempDir(), "state")
998+ oldOwner, err := lifecycle.TryAcquire(runtimeDir)
999+ require.NoError(t, err)
1000+ oldOpened, err := persist.OpenOrCreate(stateDir)
1001+ require.NoError(t, err)
1002+ oldListener, err := ipc.Listen(runtimeDir)
1003+ require.NoError(t, err)
1004+
1005+ socketCloseEntered := make(chan struct{})
1006+ allowSocketClose := make(chan struct{})
1007+ socketClosed := make(chan struct{})
1008+ catalogueCloseEntered := make(chan struct{})
1009+ allowCatalogueClose := make(chan struct{})
1010+ catalogueClosed := make(chan struct{})
1011+ ownerReleaseEntered := make(chan struct{})
1012+ allowOwnerRelease := make(chan struct{})
1013+ ownerReleased := make(chan struct{})
1014+ teardownEvents := make(chan string, 3)
1015+
1016+ controlledListener := &lifecycleControlledListener{
1017+ Listener: oldListener,
1018+ entered: socketCloseEntered,
1019+ proceed: allowSocketClose,
1020+ closed: socketClosed,
1021+ events: teardownEvents,
1022+ }
1023+ controlledCatalogue := &lifecycleControlledCatalogue{
1024+ Catalogue: oldOpened.Catalogue,
1025+ entered: catalogueCloseEntered,
1026+ proceed: allowCatalogueClose,
1027+ closed: catalogueClosed,
1028+ events: teardownEvents,
1029+ }
1030+ oldDaemon := daemon.New(pty.NewFactory(), clock.New(), discardLog(), daemon.WithCatalogue(controlledCatalogue, oldOpened.Records))
1031+ oldCtx, stopOld := context.WithCancel(context.Background())
1032+ oldDone := make(chan error, 1)
1033+ go func() {
1034+ serveErr := oldDaemon.Serve(oldCtx, controlledListener)
1035+ teardownEvents <- "owner-release"
1036+ close(ownerReleaseEntered)
1037+ <-allowOwnerRelease
1038+ releaseErr := oldOwner.Release()
1039+ close(ownerReleased)
1040+ oldDone <- errors.Join(serveErr, releaseErr)
1041+ }()
1042+
1043+ newAcquireAttempted := make(chan struct{})
1044+ newDurableOpen := make(chan struct{})
1045+ newListen := make(chan struct{})
1046+ startupEvents := make(chan string, 3)
1047+ newDone := make(chan error, 1)
1048+ go func() {
1049+ newDone <- runWithLifecycleOwnerDeps(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {
1050+ close(newDurableOpen)
1051+ opened, err := persist.OpenOrCreate(stateDir)
1052+ if err != nil {
1053+ return err
1054+ }
1055+ startupEvents <- "durable-open"
1056+
1057+ close(newListen)
1058+ listener, err := ipc.Listen(runtimeDir)
1059+ if err != nil {
1060+ return errors.Join(err, opened.Catalogue.Close())
1061+ }
1062+ startupEvents <- "listen"
1063+ return errors.Join(listener.Close(), opened.Catalogue.Close())
1064+ }, lifecycleStartupDeps{
1065+ ensurePrivate: func(string) error { return nil },
1066+ acquire: func(ctx context.Context, dir string, retry time.Duration) (lifecycleOwnership, error) {
1067+ close(newAcquireAttempted)
1068+ owner, err := lifecycle.Acquire(ctx, dir, retry)
1069+ if err != nil {
1070+ return nil, err
1071+ }
1072+ <-ownerReleased
1073+ startupEvents <- "owner-acquired"
1074+ return owner, nil
1075+ },
1076+ })
1077+ }()
1078+
1079+ awaitLifecycleStage(t, newAcquireAttempted, "new lifecycle acquisition attempt")
1080+ assertLifecycleStagePending(t, newDurableOpen, "new durable open before old teardown")
1081+ assertLifecycleStagePending(t, newListen, "new listen before old teardown")
1082+
1083+ stopOld()
1084+ awaitLifecycleStage(t, socketCloseEntered, "old socket close")
1085+ assertNewDaemonStartupPending(t, newDurableOpen, newListen, "socket close")
1086+ close(allowSocketClose)
1087+ awaitLifecycleStage(t, socketClosed, "old socket closed")
1088+
1089+ awaitLifecycleStage(t, catalogueCloseEntered, "old catalogue close")
1090+ assertNewDaemonStartupPending(t, newDurableOpen, newListen, "catalogue close")
1091+ close(allowCatalogueClose)
1092+ awaitLifecycleStage(t, catalogueClosed, "old catalogue closed")
1093+
1094+ awaitLifecycleStage(t, ownerReleaseEntered, "old lifecycle owner release")
1095+ assertNewDaemonStartupPending(t, newDurableOpen, newListen, "owner release")
1096+ close(allowOwnerRelease)
1097+ awaitLifecycleStage(t, ownerReleased, "old lifecycle owner released")
1098+
1099+ require.NoError(t, awaitLifecycleResult(t, oldDone, "old daemon teardown"))
1100+ require.NoError(t, awaitLifecycleResult(t, newDone, "new daemon startup"))
1101+ require.Equal(t, []string{"socket-close", "catalogue-close", "owner-release"}, []string{
1102+ <-teardownEvents,
1103+ <-teardownEvents,
1104+ <-teardownEvents,
1105+ })
1106+ require.Equal(t, []string{"owner-acquired", "durable-open", "listen"}, []string{
1107+ <-startupEvents,
1108+ <-startupEvents,
1109+ <-startupEvents,
1110+ })
1111+}
1112+
1113+func awaitLifecycleStage(t *testing.T, stage <-chan struct{}, name string) {
1114+ t.Helper()
1115+ select {
1116+ case <-stage:
1117+ case <-time.After(3 * time.Second):
1118+ t.Fatalf("timed out waiting for %s", name)
1119+ }
1120+}
1121+
1122+func awaitLifecycleResult(t *testing.T, result <-chan error, name string) error {
1123+ t.Helper()
1124+ select {
1125+ case err := <-result:
1126+ return err
1127+ case <-time.After(3 * time.Second):
1128+ t.Fatalf("timed out waiting for %s", name)
1129+ return nil
1130+ }
1131+}
1132+
1133+func assertLifecycleStagePending(t *testing.T, stage <-chan struct{}, name string) {
1134+ t.Helper()
1135+ select {
1136+ case <-stage:
1137+ t.Fatalf("unexpected %s", name)
1138+ default:
1139+ }
1140+}
1141+
1142+func assertLifecycleResultPending(t *testing.T, result <-chan error, name string) {
1143+ t.Helper()
1144+ select {
1145+ case err := <-result:
1146+ t.Fatalf("unexpected %s: %v", name, err)
1147+ default:
1148+ }
1149+}
1150+
1151+func assertNewDaemonStartupPending(t *testing.T, durableOpen, listen <-chan struct{}, heldAt string) {
1152+ t.Helper()
1153+ assertLifecycleStagePending(t, durableOpen, "new durable open while old daemon held at "+heldAt)
1154+ assertLifecycleStagePending(t, listen, "new listen while old daemon held at "+heldAt)
1155+}
1156+
1157+type lifecycleControlledListener struct {
1158+ ports.Listener
1159+ entered chan struct{}
1160+ proceed chan struct{}
1161+ closed chan struct{}
1162+ events chan<- string
1163+ once sync.Once
1164+ err error
1165+}
1166+
1167+func (l *lifecycleControlledListener) Close() error {
1168+ l.once.Do(func() {
1169+ l.events <- "socket-close"
1170+ close(l.entered)
1171+ <-l.proceed
1172+ l.err = l.Listener.Close()
1173+ close(l.closed)
1174+ })
1175+ return l.err
1176+}
1177+
1178+type lifecycleControlledCatalogue struct {
1179+ ports.Catalogue
1180+ entered chan struct{}
1181+ proceed chan struct{}
1182+ closed chan struct{}
1183+ events chan<- string
1184+ once sync.Once
1185+ err error
1186+}
1187+
1188+func (c *lifecycleControlledCatalogue) Close() error {
1189+ c.once.Do(func() {
1190+ c.events <- "catalogue-close"
1191+ close(c.entered)
1192+ <-c.proceed
1193+ c.err = c.Catalogue.Close()
1194+ close(c.closed)
1195+ })
1196+ return c.err
1197+}
1198+
1199+type integrationTransport struct{}
1200+
1201+func (*integrationTransport) Send(ports.Frame) error { return nil }
1202+func (*integrationTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1203+func (*integrationTransport) Close() error { return nil }
1204+func (*integrationTransport) LocalAddr() net.Addr { return nil }
1205+func (*integrationTransport) RemoteAddr() net.Addr { return nil }
1206+
4251207 func TestIntegration_KillAllShutsDownDaemon(t *testing.T) {
4261208 sz := domain.Size{Cols: 80, Rows: 24}
4271209 dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}))
M internal/app/offline_kill_retry_test.go +50 -57
@@ -3,86 +3,79 @@ package app
33 import (
44 "context"
55 "errors"
6+ "os"
67 "path/filepath"
78 "testing"
89 "time"
910
10- "github.com/bnema/vev/internal/persist"
1111 "github.com/stretchr/testify/require"
12-)
1312
14-type offlineSnapshotFake struct {
15- calls []string
16- deleteErr error
17- deleteLegacyErr error
18- tombstoneErr error
19- deleteTombstoneErr error
20-}
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/persist"
15+)
2116
22-func (f *offlineSnapshotFake) Tombstone(context.Context, string) error {
23- f.calls = append(f.calls, "tombstone")
24- return f.tombstoneErr
25-}
17+func TestRunOfflineNamedKillUsesOpenCatalogueSeam(t *testing.T) {
18+ stateRoot := t.TempDir()
19+ t.Setenv("XDG_STATE_HOME", stateRoot)
20+ stateDir := filepath.Join(stateRoot, "vev")
2621
27-func (f *offlineSnapshotFake) Delete(context.Context, string) error {
28- f.calls = append(f.calls, "incremental")
29- return f.deleteErr
30-}
22+ p := newTestPersister(t, stateDir)
23+ now := time.Now().UnixNano()
24+ require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
25+ require.NoError(t, p.Close())
3126
32-func (f *offlineSnapshotFake) DeleteLegacy(context.Context, string) error {
33- f.calls = append(f.calls, "legacy")
34- return f.deleteLegacyErr
35-}
27+ original := openCatalogue
28+ t.Cleanup(func() { openCatalogue = original })
29+ wantErr := errors.New("open seam called")
30+ called := false
31+ openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
32+ called = true
33+ require.Equal(t, stateDir, gotStateDir)
34+ return persist.OpenResult{}, wantErr
35+ }
3636
37-func (f *offlineSnapshotFake) DeleteTombstone(context.Context, string) error {
38- f.calls = append(f.calls, "delete tombstone")
39- return f.deleteTombstoneErr
37+ err := runOfflineNamedKill(context.Background(), "named")
38+ require.ErrorIs(t, err, wantErr)
39+ require.True(t, called)
4040 }
4141
42-func TestRunKillOfflineRetriesSnapshotDeletion(t *testing.T) {
43- for _, tt := range []struct {
44- name string
45- incrementalErr error
46- legacyErr error
42+func TestRunKillOfflineUsesIncarnationDeletionProtocol(t *testing.T) {
43+ tests := []struct {
44+ name string
45+ crashHook func(*testing.T, string)
4746 }{
48- {name: "legacy failure", legacyErr: errors.New("legacy unlink failed")},
49- {name: "incremental failure", incrementalErr: errors.New("incremental quarantine failed")},
50- {name: "both failures", incrementalErr: errors.New("incremental quarantine failed"), legacyErr: errors.New("legacy unlink failed")},
51- } {
47+ {name: "normal"},
48+ {
49+ name: "stray tmp is ignored",
50+ crashHook: func(t *testing.T, stateDir string) {
51+ t.Helper()
52+ storePath := persist.StorePath(stateDir)
53+ require.NoError(t, os.WriteFile(storePath+".tmp", []byte("partial rewrite"), 0o600))
54+ },
55+ },
56+ }
57+
58+ for _, tt := range tests {
5259 t.Run(tt.name, func(t *testing.T) {
5360 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
5461 t.Setenv("XDG_STATE_HOME", stateRoot)
5562 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
63+ stateDir := filepath.Join(stateRoot, "vev")
5664
57- p, err := persist.Open(filepath.Join(stateRoot, "vev"))
58- require.NoError(t, err)
65+ id := domain.IncarnationID{1}
66+ p := newTestPersister(t, stateDir)
5967 now := time.Now().UnixNano()
60- require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
68+ require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
6169 require.NoError(t, p.Close())
62-
63- fake := &offlineSnapshotFake{deleteErr: tt.incrementalErr, deleteLegacyErr: tt.legacyErr}
64- originalFactory := newOfflineSnapshotRepository
65- newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake }
66- t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory })
67-
68- err = runKill(context.Background(), "named", false, false)
69- if tt.incrementalErr != nil {
70- require.ErrorIs(t, err, tt.incrementalErr)
71- }
72- if tt.legacyErr != nil {
73- require.ErrorIs(t, err, tt.legacyErr)
70+ source := filepath.Join(stateDir, "snapshots", "sessions", id.String())
71+ require.NoError(t, os.MkdirAll(source, 0o700))
72+ require.NoError(t, os.WriteFile(filepath.Join(source, "sentinel"), []byte("durable"), 0o600))
73+ if tt.crashHook != nil {
74+ tt.crashHook(t, stateDir)
7475 }
75- require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls)
76- records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
77- require.NoError(t, err)
78- require.Len(t, records, 1, "a failed source delete must retain retry identity")
7976
80- fake.deleteErr, fake.deleteLegacyErr = nil, nil
8177 require.NoError(t, runKill(context.Background(), "named", false, false))
82- require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "delete tombstone"}, fake.calls)
83- records, err = persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
84- require.NoError(t, err)
85- require.Empty(t, records)
78+ require.NoDirExists(t, source)
8679 })
8780 }
8881 }
M internal/app/run.go +254 -143
@@ -32,6 +32,7 @@ import (
3232 "github.com/bnema/vev/internal/adapters/config"
3333 "github.com/bnema/vev/internal/adapters/dgram"
3434 "github.com/bnema/vev/internal/adapters/ipc"
35+ "github.com/bnema/vev/internal/adapters/lifecycle"
3536 "github.com/bnema/vev/internal/adapters/noticefile"
3637 "github.com/bnema/vev/internal/adapters/observability"
3738 "github.com/bnema/vev/internal/adapters/pty"
@@ -48,8 +49,9 @@ import (
4849 "github.com/bnema/vev/internal/usecase/client"
4950 "github.com/bnema/vev/internal/usecase/confirm"
5051 "github.com/bnema/vev/internal/usecase/daemon"
52+ "github.com/bnema/vev/internal/usecase/recovery"
5153 pdgram "github.com/bnema/vev/pkg/dgram"
52- "github.com/bnema/vev/pkg/kv"
54+ "github.com/bnema/vev/pkg/safedir"
5355 )
5456
5557 // cmdKind identifies which sub-command the CLI parsed.
@@ -114,6 +116,11 @@ var (
114116 version = "0.1.0-dev"
115117 commit = "none"
116118 date = "unknown"
119+
120+ openCatalogue = persist.OpenOrCreate
121+ listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (ports.Listener, error) {
122+ return ipc.Listen(dir, ipc.WithRuntimeObserver(observer))
123+ }
117124 )
118125
119126 // versionLine renders the --version output.
@@ -410,17 +417,130 @@ func pprofAddrIsLoopback(addr string) bool {
410417 return ip != nil && ip.IsLoopback()
411418 }
412419
420+type ownedDaemonStart func(context.Context) error
421+
422+type lifecycleOwnership interface {
423+ Release() error
424+}
425+
426+func joinLifecycleReleaseError(retErr *error, owner lifecycleOwnership) {
427+ *retErr = errors.Join(*retErr, owner.Release())
428+}
429+
430+type lifecycleStartupDeps struct {
431+ ensurePrivate func(string) error
432+ acquire func(context.Context, string, time.Duration) (lifecycleOwnership, error)
433+ log *slog.Logger
434+}
435+
436+const lifecycleAcquireRetry = 25 * time.Millisecond
437+
438+func lifecycleStartupDependencies(log *slog.Logger) lifecycleStartupDeps {
439+ return lifecycleStartupDeps{
440+ ensurePrivate: safedir.EnsurePrivate,
441+ acquire: func(ctx context.Context, runtimeDir string, retry time.Duration) (lifecycleOwnership, error) {
442+ return lifecycle.Acquire(ctx, runtimeDir, retry)
443+ },
444+ log: log,
445+ }
446+}
447+
448+func runWithLifecycleOwner(ctx context.Context, runtimeDir, stateDir string, start ownedDaemonStart) error {
449+ return runWithLifecycleOwnerDeps(ctx, runtimeDir, stateDir, start, lifecycleStartupDependencies(nil))
450+}
451+
452+func runWithLifecycleOwnerDeps(ctx context.Context, runtimeDir, stateDir string, start ownedDaemonStart, deps lifecycleStartupDeps) (retErr error) {
453+ log := deps.log
454+ if log == nil {
455+ log = slog.Default()
456+ }
457+ if err := deps.ensurePrivate(runtimeDir); err != nil {
458+ return fmt.Errorf("vev: secure runtime directory: %w", err)
459+ }
460+ if err := deps.ensurePrivate(stateDir); err != nil {
461+ return fmt.Errorf("vev: secure state directory: %w", err)
462+ }
463+ lockPath := lifecycle.Path(runtimeDir)
464+ log.Info("lifecycle_owner_wait", "path", lockPath)
465+ owner, err := deps.acquire(ctx, runtimeDir, lifecycleAcquireRetry)
466+ if err != nil {
467+ log.Warn("lifecycle_owner_wait_failed", "path", lockPath, "reason_code", "acquire-failed")
468+ return fmt.Errorf("vev: acquire lifecycle ownership: %w", err)
469+ }
470+ log.Info("lifecycle_owner_acquired", "path", lockPath)
471+ defer func() {
472+ releaseErr := owner.Release()
473+ if releaseErr != nil {
474+ log.Error("lifecycle_owner_release_failed", "path", lockPath, "reason_code", "release-failed")
475+ } else {
476+ log.Info("lifecycle_owner_released", "path", lockPath)
477+ }
478+ retErr = errors.Join(retErr, releaseErr)
479+ }()
480+ if start == nil {
481+ return errors.New("vev: nil owned daemon startup")
482+ }
483+ return start(ctx)
484+}
485+
413486 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
414-// entered by an auto-spawned child): it sets up logging, binds the socket,
415-// constructs the daemon, and serves until the last session exits or a
416-// termination signal arrives (graceful shutdown notifies attached clients).
487+// entered by an auto-spawned child) while holding exclusive lifecycle
488+// ownership through complete teardown.
417489 func runDaemon() (retErr error) {
490+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
491+ defer stop()
418492 log, logCloser, err := configureLogging(logging.Daemon, true)
419493 if err != nil {
420494 return err
421495 }
422- defer func() { _ = logCloser.Close() }()
496+ defer func() { retErr = errors.Join(retErr, logCloser.Close()) }()
497+ return runWithLifecycleOwnerDeps(ctx, ipc.SocketDir(), platform.StateDir(), func(ctx context.Context) error {
498+ return runDaemonOwnedWithLogger(ctx, log)
499+ }, lifecycleStartupDependencies(log))
500+}
501+
502+func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, recoveryMode string) {
503+ log.Info("catalogue_validated", "records", len(records), "recovery", recoveryMode)
504+}
505+
506+func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) {
507+ healthy, fresh, broken := 0, 0, 0
508+ for _, record := range records {
509+ switch {
510+ case record.DegradedReason != "":
511+ broken++
512+ case record.Committed == nil:
513+ fresh++
514+ default:
515+ healthy++
516+ }
517+ }
518+ log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "broken", broken)
519+}
520+
521+func constructDaemonBeforeSocketPublication(
522+ construct func() *daemon.Daemon,
523+ prepare func(*daemon.Daemon) error,
524+ listen func() (ports.Listener, error),
525+) (*daemon.Daemon, ports.Listener, error) {
526+ d := construct()
527+ if err := prepare(d); err != nil {
528+ return d, nil, err
529+ }
530+ ln, err := listen()
531+ return d, ln, err
532+}
423533
534+func runDaemonOwned(ctx context.Context) (retErr error) {
535+ log, logCloser, err := configureLogging(logging.Daemon, true)
536+ if err != nil {
537+ return err
538+ }
539+ defer func() { retErr = errors.Join(retErr, logCloser.Close()) }()
540+ return runDaemonOwnedWithLogger(ctx, log)
541+}
542+
543+func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr error) {
424544 clk := clock.New()
425545 observer, observerCloser, err := newPerformanceTrace(clk)
426546 if err != nil {
@@ -429,17 +549,6 @@ func runDaemon() (retErr error) {
429549 if observerCloser != nil {
430550 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
431551 }
432- // Every accepted local connection shares this process's serialized sink.
433- ln, err := ipc.Listen(ipc.SocketDir(), ipc.WithRuntimeObserver(observer))
434- if err != nil {
435- log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)
436- return fmt.Errorf("vev: daemon listen: %w", err)
437- }
438- defer func() { _ = ln.Close() }()
439-
440- ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
441- defer stop()
442-
443552 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
444553 if !pprofAddrIsLoopback(addr) {
445554 log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr)
@@ -452,7 +561,6 @@ func runDaemon() (retErr error) {
452561 log.Info("pprof enabled", "addr", addr)
453562 }
454563
455- log.Info("daemon starting", "socket", ln.Addr())
456564 daemonOpts := []daemon.Option(nil)
457565 if observer != nil {
458566 daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))
@@ -467,23 +575,54 @@ func runDaemon() (retErr error) {
467575 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
468576 daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
469577 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
470- snapshotRepository := snapshotadapter.NewRepository(snapshotDir())
471- daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository, snapshotRepository))
472- daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticefile.New(platform.StateDir())))
473- storePath := persist.StorePath(platform.StateDir())
474- var storeErr error
475- if store, err := kv.Open(storePath); err != nil {
476- log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)
477- storeErr = err
478- } else {
479- log.Info("session persistence enabled", "path", storePath)
480- daemonOpts = append(daemonOpts, daemon.WithStore(store))
481- }
482- d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
483- if storeErr != nil {
484- d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled,
485- "session persistence is disabled; sessions will not survive daemon restarts", storeErr)
578+ snapshotRepository := snapshotadapter.NewRepositoryWithLogger(snapshotDir(), log)
579+ daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository))
580+ noticeStore := noticefile.New(platform.StateDir())
581+ daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticeStore))
582+ stateDir := platform.StateDir()
583+ storePath := persist.StorePath(stateDir)
584+ opened, err := openCatalogue(stateDir)
585+ if err != nil {
586+ log.Error("catalogue_validation_failed", "path", storePath, "reason_code", "open-failed")
587+ if errors.Is(err, persist.ErrCatalogueUnreadable) {
588+ return unreadableCatalogueError(stateDir)
589+ }
590+ return fmt.Errorf("vev: open durable session state %s: %w", storePath, err)
591+ }
592+ recoveryMode := "current"
593+ if opened.NewInstall {
594+ recoveryMode = "new-install"
595+ }
596+ logCatalogueRecovery(log, opened.Records, recoveryMode)
597+ coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, rand.Reader)
598+ daemonOpts = append(daemonOpts, daemon.WithRecoveryCoordinator(coordinator))
599+ daemonOpts = append(daemonOpts, daemon.WithDurableMaintenance(opened.Catalogue, snapshotRepository))
600+ log.Info("session persistence enabled", "path", storePath)
601+ daemonOpts = append(daemonOpts, daemon.WithCatalogue(opened.Catalogue, opened.Records))
602+
603+ // Construct the catalogue-backed expected-session registry before socket
604+ // publication. Phase 3 snapshot restoration remains asynchronous in Serve.
605+ d, ln, err := constructDaemonBeforeSocketPublication(
606+ func() *daemon.Daemon { return daemon.New(pty.NewFactory(), clk, log, daemonOpts...) },
607+ func(d *daemon.Daemon) error {
608+ if err := d.CollectStartupGarbage(ctx); err != nil {
609+ // GC is best-effort, but it is fully finished before socket
610+ // publication. A failed pass leaves durable state untouched and
611+ // restoration retains its per-session failure isolation.
612+ log.Warn("snapshot_garbage_collection_failed", "err", err)
613+ }
614+ return nil
615+ },
616+ func() (ports.Listener, error) { return listenDaemon(ipc.SocketDir(), observer) },
617+ )
618+ if err != nil {
619+ closeErr := opened.Catalogue.Close()
620+ log.Error("daemon startup preparation failed", "socket_dir", ipc.SocketDir(), "err", err)
621+ return errors.Join(fmt.Errorf("vev: prepare daemon startup: %w", err), closeErr)
486622 }
623+ defer func() { _ = ln.Close() }()
624+ log.Info("daemon starting", "socket", ln.Addr())
625+
487626 watchCtx, stopWatch := context.WithCancel(ctx)
488627 defer stopWatch()
489628 go func() {
@@ -695,7 +834,7 @@ func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
695834 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(d.observer))
696835 }
697836 }
698- return ensureDaemon(ctx, d.dir, dial, realSpawn, defaultBackoff)
837+ return ensureDaemonWithLifecycle(ctx, d.dir, dial, realSpawn, defaultBackoff)
699838 }
700839
701840 func detachedLocalHello(name, cwd string) ports.Hello {
@@ -713,7 +852,7 @@ func detachedLocalHello(name, cwd string) ports.Hello {
713852 }
714853
715854 func createDetachedLocalSession(ctx context.Context, name string) error {
716- transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
855+ transport, err := ensureDaemonWithLifecycle(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
717856 if err != nil {
718857 return err
719858 }
@@ -776,40 +915,26 @@ func requestDaemonStop(ctx context.Context) error {
776915 ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout)
777916 defer cancel()
778917
779- done := make(chan error, 1)
780- go func() {
781- transport, err := realDial(ctx, ipc.SocketDir())
782- if err != nil {
783- done <- fmt.Errorf("vev: no daemon running")
784- return
785- }
786- defer func() { _ = transport.Close() }()
787- closed := make(chan struct{})
788- go func() {
789- select {
790- case <-ctx.Done():
791- _ = transport.Close()
792- case <-closed:
793- }
794- }()
795- defer close(closed)
796- if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil {
797- done <- fmt.Errorf("vev: requesting daemon stop: %w", err)
798- return
799- }
800- if _, err := transport.Recv(); err != nil && !errors.Is(err, io.EOF) {
801- done <- fmt.Errorf("vev: reading daemon stop reply: %w", err)
802- return
803- }
804- done <- nil
805- }()
918+ transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff)
919+ if err != nil {
920+ return fmt.Errorf("vev: stopping daemon: %w", err)
921+ }
922+ if owner != nil {
923+ return errors.Join(errors.New("vev: no daemon running"), owner.Release())
924+ }
925+ defer func() { _ = transport.Close() }()
926+ if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil {
927+ return fmt.Errorf("vev: requesting daemon stop: %w", err)
928+ }
929+ if _, err := transport.Recv(); err != nil && !errors.Is(err, io.EOF) {
930+ return fmt.Errorf("vev: reading daemon stop reply: %w", err)
931+ }
806932
807- select {
808- case err := <-done:
809- return err
810- case <-ctx.Done():
811- return fmt.Errorf("vev: stopping daemon: %w", ctx.Err())
933+ owner, err = waitForLifecycleAvailability(ctx, ipc.SocketDir(), defaultBackoff)
934+ if err != nil {
935+ return fmt.Errorf("vev: waiting for daemon ownership transfer: %w", err)
812936 }
937+ return owner.Release()
813938 }
814939
815940 // runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it
@@ -831,7 +956,7 @@ func runStdio(ctx context.Context) (retErr error) {
831956 if observerCloser != nil {
832957 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
833958 }
834- transport, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
959+ transport, err := ensureDaemonWithLifecycle(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
835960 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
836961 }, realSpawn, defaultBackoff)
837962 if err != nil {
@@ -946,7 +1071,7 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
9461071 return err
9471072 }
9481073 defer func() { _ = conn.Close() }()
949- daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
1074+ daemonTr, err := ensureDaemonWithLifecycle(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) {
9501075 return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer))
9511076 }, realSpawn, defaultBackoff)
9521077 if err != nil {
@@ -1163,16 +1288,27 @@ func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger
11631288
11641289 // runList prints the daemon's session listing. With no daemon running, it
11651290 // falls back to the persisted stopped-session records.
1166-func runList(ctx context.Context) error {
1167- transport, err := realDial(ctx, ipc.SocketDir())
1291+func runList(ctx context.Context) (retErr error) {
1292+ transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff)
11681293 if err != nil {
1294+ return fmt.Errorf("vev: waiting for durable session state: %w", err)
1295+ }
1296+ if owner != nil {
1297+ defer joinLifecycleReleaseError(&retErr, owner)
11691298 records, loadErr := persist.LoadReadOnly(platform.StateDir())
11701299 if loadErr != nil {
1300+ if errors.Is(loadErr, persist.ErrCatalogueUnreadable) {
1301+ return unreadableCatalogueError(platform.StateDir())
1302+ }
11711303 return fmt.Errorf("vev: reading stored sessions: %w", loadErr)
11721304 }
11731305 infos := make([]ports.SessionInfo, 0, len(records))
11741306 for _, r := range records {
1175- infos = append(infos, ports.SessionInfo{Name: r.Name, Stopped: true})
1307+ state := ports.SessionStopped
1308+ if r.DegradedReason != "" {
1309+ state = ports.SessionBroken
1310+ }
1311+ infos = append(infos, ports.SessionInfo{Name: r.Name, State: state})
11761312 }
11771313 printSessions(os.Stdout, infos)
11781314 return nil
@@ -1213,11 +1349,17 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
12131349 state := "running"
12141350 tabs := fmt.Sprintf("%d", s.Tabs)
12151351 attached := "no"
1216- if s.Stopped {
1352+ switch s.State {
1353+ case ports.SessionStopped:
12171354 state = "stopped"
12181355 tabs = "-"
1219- } else if s.Ephemeral {
1220- state = "temporary"
1356+ case ports.SessionBroken:
1357+ state = "broken"
1358+ tabs = "-"
1359+ default:
1360+ if s.Ephemeral {
1361+ state = "temporary"
1362+ }
12211363 }
12221364 if s.Attached {
12231365 attached = "yes"
@@ -1227,83 +1369,52 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
12271369 _ = tw.Flush()
12281370 }
12291371
1230-// offlineSnapshotSource owns the durable source and tombstone transitions for
1231-// a daemon-less kill. It is intentionally local to the composition root: the
1232-// daemon uses the repository through its narrower ports.
1233-type offlineSnapshotSource interface {
1234- Tombstone(context.Context, string) error
1235- Delete(context.Context, string) error
1236- DeleteLegacy(context.Context, string) error
1237- DeleteTombstone(context.Context, string) error
1372+func unreadableCatalogueError(stateDir string) error {
1373+ return fmt.Errorf("%w: vev: durable session state at %s cannot be read and was left untouched.\n"+
1374+ "vev does not erase it automatically. To start fresh, remove it:\n"+
1375+ " rm -rf %s", persist.ErrCatalogueUnreadable, stateDir, stateDir)
12381376 }
12391377
1240-var newOfflineSnapshotRepository = func(dir string) offlineSnapshotSource {
1241- return snapshotadapter.NewRepository(dir)
1242-}
1378+func runOfflineNamedKill(ctx context.Context, name string) (retErr error) {
1379+ stateDir := platform.StateDir()
1380+ if _, err := os.Stat(persist.StorePath(stateDir)); errors.Is(err, os.ErrNotExist) {
1381+ return fmt.Errorf("vev: no such session: %s", name)
1382+ } else if err != nil {
1383+ return fmt.Errorf("vev: reading stored sessions: %w", err)
1384+ }
1385+ repository := snapshotadapter.NewRepository(snapshotDir())
1386+ opened, err := openCatalogue(stateDir)
1387+ if err != nil {
1388+ if errors.Is(err, persist.ErrCatalogueUnreadable) {
1389+ return unreadableCatalogueError(stateDir)
1390+ }
1391+ return fmt.Errorf("vev: opening stored sessions: %w", err)
1392+ }
1393+ defer func() { retErr = errors.Join(retErr, opened.Catalogue.Close()) }()
12431394
1244-func phaseError(phase string, err error) error {
1245- if err == nil {
1246- return nil
1395+ if _, ok, err := opened.Catalogue.Record(name); err != nil {
1396+ return fmt.Errorf("vev: reading stored session: %w", err)
1397+ } else if !ok {
1398+ return fmt.Errorf("vev: no such session: %s", name)
1399+ }
1400+ coordinator := recovery.NewCoordinator(opened.Catalogue, repository, rand.Reader)
1401+ if err := coordinator.Delete(ctx, name); err != nil {
1402+ return fmt.Errorf("vev: deleting stored session: %w", err)
12471403 }
1248- return fmt.Errorf("vev: %s: %w", phase, err)
1404+ return nil
12491405 }
12501406
12511407 // runKill asks the daemon to terminate a named session, every session, or the daemon.
1252-func runKill(ctx context.Context, name string, all, daemon bool) error {
1253- transport, err := realDial(ctx, ipc.SocketDir())
1408+func runKill(ctx context.Context, name string, all, daemon bool) (retErr error) {
1409+ transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff)
12541410 if err != nil {
1411+ return fmt.Errorf("vev: waiting for durable session state: %w", err)
1412+ }
1413+ if owner != nil {
1414+ defer joinLifecycleReleaseError(&retErr, owner)
12551415 if name != "" && !all && !daemon {
1256- storePath := persist.StorePath(platform.StateDir())
1257- if _, statErr := os.Stat(storePath); errors.Is(statErr, os.ErrNotExist) {
1258- return fmt.Errorf("vev: no such session: %s", name)
1259- } else if statErr != nil {
1260- return fmt.Errorf("vev: reading stored sessions: %w", statErr)
1261- }
1262- records, loadErr := persist.LoadReadOnly(platform.StateDir())
1263- if loadErr != nil {
1264- return fmt.Errorf("vev: reading stored sessions: %w", loadErr)
1265- }
1266- found := false
1267- for _, record := range records {
1268- if record.Name == name {
1269- found = true
1270- break
1271- }
1272- }
1273- if !found {
1274- return fmt.Errorf("vev: no such session: %s", name)
1275- }
1276- p, openErr := persist.Open(platform.StateDir())
1277- if openErr != nil {
1278- return fmt.Errorf("vev: opening stored sessions: %w", openErr)
1279- }
1280- snapshots := newOfflineSnapshotRepository(snapshotDir())
1281- // The durable marker is the commit point for a logical kill. Keep it and
1282- // the persisted record until both independently recoverable sources have
1283- // crossed their deletion durability boundaries.
1284- if tombstoneErr := snapshots.Tombstone(ctx, name); tombstoneErr != nil {
1285- _ = p.Close()
1286- return fmt.Errorf("vev: marking session killed: %w", tombstoneErr)
1287- }
1288- incrementalErr := snapshots.Delete(ctx, name)
1289- legacyErr := snapshots.DeleteLegacy(ctx, name)
1290- if incrementalErr != nil || legacyErr != nil {
1291- _ = p.Close()
1292- return errors.Join(
1293- phaseError("deleting incremental session snapshot", incrementalErr),
1294- phaseError("deleting legacy session snapshot", legacyErr),
1295- )
1296- }
1297- if deleteErr := p.Delete(name); deleteErr != nil {
1298- _ = p.Close()
1299- return fmt.Errorf("vev: deleting stored session: %w", deleteErr)
1300- }
1301- if tombstoneErr := snapshots.DeleteTombstone(ctx, name); tombstoneErr != nil {
1302- _ = p.Close()
1303- return fmt.Errorf("vev: clearing killed session marker: %w", tombstoneErr)
1304- }
1305- if closeErr := p.Close(); closeErr != nil {
1306- return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
1416+ if err := runOfflineNamedKill(ctx, name); err != nil {
1417+ return err
13071418 }
13081419 printKillSuccess(name, all, daemon)
13091420 return nil
M internal/app/run_test.go +437 -25
@@ -1,10 +1,13 @@
11 package app
22
33 import (
4+ "bufio"
45 "bytes"
56 "context"
7+ "encoding/json"
68 "errors"
79 "io"
10+ "log/slog"
811 "net"
912 "os"
1013 "os/exec"
@@ -13,17 +16,395 @@ import (
1316 "testing"
1417 "time"
1518
16- "github.com/bnema/vev/internal/adapters/snapshot"
19+ "github.com/bnema/vev/internal/adapters/lifecycle"
1720 "github.com/bnema/vev/internal/domain"
1821 "github.com/bnema/vev/internal/persist"
1922 "github.com/bnema/vev/internal/ports"
2023 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2124 "github.com/bnema/vev/internal/usecase/client"
2225 "github.com/bnema/vev/internal/usecase/confirm"
26+ "github.com/bnema/vev/internal/usecase/daemon"
27+ "github.com/bnema/vev/pkg/kv"
28+ "github.com/bnema/vev/pkg/safedir"
2329 "github.com/stretchr/testify/mock"
2430 "github.com/stretchr/testify/require"
2531 )
2632
33+func decodeJSONLogs(t *testing.T, data []byte) []map[string]any {
34+ t.Helper()
35+ var entries []map[string]any
36+ scanner := bufio.NewScanner(bytes.NewReader(data))
37+ for scanner.Scan() {
38+ var entry map[string]any
39+ require.NoError(t, json.Unmarshal(scanner.Bytes(), &entry))
40+ entries = append(entries, entry)
41+ }
42+ require.NoError(t, scanner.Err())
43+ return entries
44+}
45+
46+func eventNames(entries []map[string]any) []string {
47+ names := make([]string, 0, len(entries))
48+ for _, entry := range entries {
49+ if name, ok := entry["msg"].(string); ok {
50+ names = append(names, name)
51+ }
52+ }
53+ return names
54+}
55+
56+func requireEvent(t *testing.T, entries []map[string]any, name string) map[string]any {
57+ t.Helper()
58+ for _, entry := range entries {
59+ if entry["msg"] == name {
60+ return entry
61+ }
62+ }
63+ t.Fatalf("event %q not found in %v", name, eventNames(entries))
64+ return nil
65+}
66+
67+func TestRecoveryObservability(t *testing.T) {
68+ var logBuffer bytes.Buffer
69+ log := slog.New(slog.NewJSONHandler(&logBuffer, nil))
70+ owner := fakeLifecycleOwnership{release: func() error { return nil }}
71+ deps := lifecycleStartupDeps{
72+ ensurePrivate: func(string) error { return nil },
73+ acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
74+ return owner, nil
75+ },
76+ log: log,
77+ }
78+ ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
79+ records := []domain.CatalogueRecord{
80+ {Committed: ref},
81+ {Committed: ref},
82+ {},
83+ {Committed: ref, DegradedReason: "checkpoint validation failed"},
84+ }
85+ require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error {
86+ logCatalogueRecovery(log, records, "current")
87+ logStartupRecoveryCounts(log, records, 0)
88+ return nil
89+ }, deps))
90+
91+ entries := decodeJSONLogs(t, logBuffer.Bytes())
92+ for _, name := range []string{"lifecycle_owner_wait", "lifecycle_owner_acquired", "catalogue_validated", "daemon_startup_complete", "lifecycle_owner_released"} {
93+ require.Contains(t, eventNames(entries), name)
94+ }
95+ startup := requireEvent(t, entries, "daemon_startup_complete")
96+ require.EqualValues(t, 2, startup["healthy"])
97+ require.EqualValues(t, 1, startup["fresh"])
98+ require.EqualValues(t, 0, startup["restoring"])
99+ require.EqualValues(t, 1, startup["broken"])
100+ require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete")
101+}
102+
103+// A session-scoped conflict no longer aborts startup, so the daemon comes up
104+// looking healthy. Startup must therefore name the fenced session in the log,
105+// persist a notice for it, and never report the recovery as clean.
106+type fakeLifecycleOwnership struct {
107+ release func() error
108+}
109+
110+func (o fakeLifecycleOwnership) Release() error { return o.release() }
111+
112+type fakeLifecycleProbe struct {
113+ owner lifecycleOwnership
114+ err error
115+}
116+
117+func (p fakeLifecycleProbe) TryAcquire(string) (lifecycleOwnership, error) {
118+ return p.owner, p.err
119+}
120+
121+func TestJoinLifecycleReleaseError(t *testing.T) {
122+ operationErr := errors.New("operation failed")
123+ releaseErr := errors.New("release failed")
124+
125+ tests := []struct {
126+ name string
127+ operationErr error
128+ releaseErr error
129+ wantErrors []error
130+ }{
131+ {
132+ name: "successful operation preserves release failure",
133+ releaseErr: releaseErr,
134+ wantErrors: []error{releaseErr},
135+ },
136+ {
137+ name: "operation and release failures are joined",
138+ operationErr: operationErr,
139+ releaseErr: releaseErr,
140+ wantErrors: []error{operationErr, releaseErr},
141+ },
142+ }
143+
144+ for _, tt := range tests {
145+ t.Run(tt.name, func(t *testing.T) {
146+ got := tt.operationErr
147+ joinLifecycleReleaseError(&got, fakeLifecycleOwnership{release: func() error {
148+ return tt.releaseErr
149+ }})
150+
151+ for _, wantErr := range tt.wantErrors {
152+ require.ErrorIs(t, got, wantErr)
153+ }
154+ })
155+ }
156+}
157+
158+func TestOfflineCommandsPropagateLifecycleReleaseErrors(t *testing.T) {
159+ releaseErr := errors.New("release failed")
160+
161+ tests := []struct {
162+ name string
163+ run func(context.Context) error
164+ wantOperationErr string
165+ }{
166+ {
167+ name: "list joins release failure to success",
168+ run: runList,
169+ },
170+ {
171+ name: "kill joins release failure to operation failure",
172+ run: func(ctx context.Context) error {
173+ return runKill(ctx, "", false, false)
174+ },
175+ wantOperationErr: "vev: no daemon running",
176+ },
177+ }
178+
179+ originalProbe := daemonLifecycleProbe
180+ t.Cleanup(func() { daemonLifecycleProbe = originalProbe })
181+ for _, tt := range tests {
182+ t.Run(tt.name, func(t *testing.T) {
183+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
184+ t.Setenv("XDG_STATE_HOME", t.TempDir())
185+ daemonLifecycleProbe = fakeLifecycleProbe{
186+ owner: fakeLifecycleOwnership{release: func() error { return releaseErr }},
187+ }
188+
189+ err := tt.run(context.Background())
190+ require.ErrorIs(t, err, releaseErr)
191+ if tt.wantOperationErr != "" {
192+ require.ErrorContains(t, err, tt.wantOperationErr)
193+ }
194+ })
195+ }
196+}
197+
198+func TestCatalogueFailureDoesNotListen(t *testing.T) {
199+ originalOpenCatalogue, originalListenDaemon := openCatalogue, listenDaemon
200+ t.Cleanup(func() {
201+ openCatalogue = originalOpenCatalogue
202+ listenDaemon = originalListenDaemon
203+ })
204+
205+ for _, catalogueErr := range []error{errors.New("catalogue corrupt"), errors.New("catalogue unavailable")} {
206+ t.Run(catalogueErr.Error(), func(t *testing.T) {
207+ runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
208+ runtimeDir := filepath.Join(runtimeRoot, "vev")
209+ stateDir := filepath.Join(stateRoot, "vev")
210+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
211+ t.Setenv("XDG_STATE_HOME", stateRoot)
212+
213+ listenCalls := 0
214+ openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
215+ require.Equal(t, stateDir, gotStateDir)
216+ return persist.OpenResult{}, catalogueErr
217+ }
218+ listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) {
219+ listenCalls++
220+ return nil, errors.New("IPC listen must not run after catalogue failure")
221+ }
222+
223+ err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {
224+ return runDaemonOwnedWithLogger(ctx, slog.New(slog.NewTextHandler(io.Discard, nil)))
225+ })
226+ require.ErrorIs(t, err, catalogueErr)
227+ require.Zero(t, listenCalls)
228+ require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
229+
230+ owner, acquireErr := lifecycle.TryAcquire(runtimeDir)
231+ require.NoError(t, acquireErr, "catalogue failure must release lifecycle ownership")
232+ require.NoError(t, owner.Release())
233+ })
234+ }
235+}
236+
237+func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {
238+ var events []string
239+ _, _, err := constructDaemonBeforeSocketPublication(
240+ func() *daemon.Daemon {
241+ events = append(events, "catalogue-registry")
242+ return nil
243+ },
244+ func(*daemon.Daemon) error {
245+ require.Equal(t, []string{"catalogue-registry"}, events)
246+ events = append(events, "startup-garbage-collection")
247+ return nil
248+ },
249+ func() (ports.Listener, error) {
250+ require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection"}, events)
251+ events = append(events, "socket-publication")
252+ return nil, nil
253+ },
254+ )
255+ require.NoError(t, err)
256+ require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection", "socket-publication"}, events)
257+}
258+
259+func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
260+ t.Run("ownership prefix is exact", func(t *testing.T) {
261+ var events []string
262+ owner := fakeLifecycleOwnership{release: func() error {
263+ events = append(events, "unlock")
264+ return nil
265+ }}
266+ deps := lifecycleStartupDeps{
267+ ensurePrivate: func(path string) error {
268+ if path == "runtime" {
269+ events = append(events, "ensure-runtime")
270+ } else {
271+ events = append(events, "ensure-state")
272+ }
273+ return nil
274+ },
275+ acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
276+ events = append(events, "lock")
277+ return owner, nil
278+ },
279+ }
280+
281+ err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
282+ events = append(events, "durable-open", "listen", "serve")
283+ return nil
284+ }, deps)
285+ require.NoError(t, err)
286+ require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock"}, events[:3])
287+ require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock", "durable-open", "listen", "serve", "unlock"}, events)
288+ })
289+
290+ t.Run("callback observes held lock and failure releases it", func(t *testing.T) {
291+ runtimeDir := filepath.Join(t.TempDir(), "runtime")
292+ stateDir := filepath.Join(t.TempDir(), "state")
293+ startErr := errors.New("catalogue corrupt")
294+
295+ err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(context.Context) error {
296+ _, lockErr := lifecycle.TryAcquire(runtimeDir)
297+ require.ErrorIs(t, lockErr, lifecycle.ErrBusy)
298+ return startErr
299+ })
300+ require.ErrorIs(t, err, startErr)
301+
302+ owner, err := lifecycle.TryAcquire(runtimeDir)
303+ require.NoError(t, err, "callback failure must release lifecycle ownership")
304+ require.NoError(t, owner.Release())
305+ })
306+
307+ t.Run("busy and unavailable ownership fail before startup", func(t *testing.T) {
308+ runtimeDir := filepath.Join(t.TempDir(), "runtime")
309+ stateDir := filepath.Join(t.TempDir(), "state")
310+ owner, err := lifecycle.TryAcquire(runtimeDir)
311+ require.NoError(t, err)
312+ defer func() { require.NoError(t, owner.Release()) }()
313+
314+ ctx, cancel := context.WithCancel(context.Background())
315+ cancel()
316+ started := false
317+ err = runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(context.Context) error {
318+ started = true
319+ return nil
320+ })
321+ require.ErrorIs(t, err, context.Canceled)
322+ require.False(t, started)
323+
324+ badState := filepath.Join(t.TempDir(), "state-file")
325+ require.NoError(t, os.WriteFile(badState, []byte("unavailable"), 0o600))
326+ err = runWithLifecycleOwner(context.Background(), filepath.Join(t.TempDir(), "other-runtime"), badState, func(context.Context) error {
327+ started = true
328+ return nil
329+ })
330+ require.Error(t, err)
331+ require.False(t, started)
332+ })
333+
334+ t.Run("corrupt catalogue fails before socket publication", func(t *testing.T) {
335+ runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
336+ runtimeDir := filepath.Join(runtimeRoot, "vev")
337+ stateDir := filepath.Join(stateRoot, "vev")
338+ require.NoError(t, safedir.EnsurePrivate(stateDir))
339+ store, err := kv.Open(persist.StorePath(stateDir))
340+ require.NoError(t, err)
341+ require.NoError(t, store.Set([]byte("work"), []byte("malformed catalogue value")))
342+ require.NoError(t, store.Close())
343+
344+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
345+ t.Setenv("XDG_STATE_HOME", stateRoot)
346+ before, err := os.ReadFile(persist.StorePath(stateDir))
347+ require.NoError(t, err)
348+ err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)
349+ require.Error(t, err)
350+ require.Contains(t, err.Error(), stateDir)
351+ require.Contains(t, err.Error(), "rm -rf "+stateDir)
352+ after, readErr := os.ReadFile(persist.StorePath(stateDir))
353+ require.NoError(t, readErr)
354+ require.Equal(t, before, after, "failed startup must leave the catalogue untouched")
355+ _, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
356+ require.ErrorIs(t, statErr, os.ErrNotExist)
357+ })
358+
359+ t.Run("proven absence creates the catalogue before socket publication", func(t *testing.T) {
360+ runtimeRoot, err := os.MkdirTemp("/tmp", "vev-")
361+ require.NoError(t, err)
362+ t.Cleanup(func() { require.NoError(t, os.RemoveAll(runtimeRoot)) })
363+ stateRoot := t.TempDir()
364+ runtimeDir := filepath.Join(runtimeRoot, "vev")
365+ stateDir := filepath.Join(stateRoot, "vev")
366+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
367+ t.Setenv("XDG_STATE_HOME", stateRoot)
368+ ctx, cancel := context.WithCancel(t.Context())
369+ result := make(chan error, 1)
370+ go func() {
371+ result <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, runDaemonOwned)
372+ }()
373+ t.Cleanup(func() {
374+ cancel()
375+ select {
376+ case err := <-result:
377+ require.NoError(t, err)
378+ case <-time.After(5 * time.Second):
379+ t.Error("daemon lifecycle did not stop after cancellation")
380+ }
381+ })
382+
383+ require.Eventually(t, func() bool {
384+ _, err := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
385+ return err == nil
386+ }, 5*time.Second, time.Millisecond, "daemon socket was not published")
387+ require.FileExists(t, persist.StorePath(stateDir))
388+ })
389+
390+ t.Run("callback and release errors are joined", func(t *testing.T) {
391+ startErr := errors.New("catalogue unavailable")
392+ releaseErr := errors.New("unlock failed")
393+ deps := lifecycleStartupDeps{
394+ ensurePrivate: func(string) error { return nil },
395+ acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
396+ return fakeLifecycleOwnership{release: func() error { return releaseErr }}, nil
397+ },
398+ }
399+
400+ err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
401+ return startErr
402+ }, deps)
403+ require.ErrorIs(t, err, startErr)
404+ require.ErrorIs(t, err, releaseErr)
405+ })
406+}
407+
27408 // scriptRecv makes a transport yield frames in order, then wait for done
28409 // before returning EOF. The shared done channel coordinates proxy readers
29410 // without relying on scheduling or sleeps.
@@ -266,11 +647,26 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
266647 }
267648 }
268649
650+func TestListShowsBroken(t *testing.T) {
651+ var out bytes.Buffer
652+ printSessions(&out, []ports.SessionInfo{
653+ {Name: "fresh", State: ports.SessionStopped},
654+ {Name: "loading", State: ports.SessionStopped},
655+ {Name: "broken", State: ports.SessionBroken},
656+ })
657+ require.Contains(t, out.String(), "fresh")
658+ require.Contains(t, out.String(), "stopped")
659+ require.Contains(t, out.String(), "loading")
660+ require.Contains(t, out.String(), "broken")
661+ require.NotContains(t, out.String(), "restoring")
662+ require.NotContains(t, out.String(), "degraded")
663+}
664+
269665 func TestPrintSessionsShowsStoppedState(t *testing.T) {
270666 var out bytes.Buffer
271667 printSessions(&out, []ports.SessionInfo{
272- {Name: "main", Tabs: 2, Attached: true},
273- {Name: "old", Stopped: true},
668+ {Name: "main", State: ports.SessionRunning, Tabs: 2, Attached: true},
669+ {Name: "old", State: ports.SessionStopped},
274670 })
275671 got := out.String()
276672 for _, want := range []string{"NAME", "STATE", "main", "running", "2", "yes", "old", "stopped", "-"} {
@@ -283,9 +679,9 @@ func TestPrintSessionsShowsStoppedState(t *testing.T) {
283679 func TestPrintSessionsMarksEphemeral(t *testing.T) {
284680 var buf bytes.Buffer
285681 printSessions(&buf, []ports.SessionInfo{
286- {Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
287- {Name: "work", Tabs: 2, Attached: true},
288- {Name: "old", Stopped: true},
682+ {Name: "0", State: ports.SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},
683+ {Name: "work", State: ports.SessionRunning, Tabs: 2, Attached: true},
684+ {Name: "old", State: ports.SessionStopped},
289685 })
290686 out := buf.String()
291687 for _, want := range []string{"0", "temporary", "work", "running", "old", "stopped"} {
@@ -293,17 +689,21 @@ func TestPrintSessionsMarksEphemeral(t *testing.T) {
293689 }
294690 }
295691
692+func newTestPersister(t *testing.T, stateDir string) *persist.Persister {
693+ t.Helper()
694+ store, err := persist.OpenStore(persist.StorePath(stateDir))
695+ require.NoError(t, err)
696+ return persist.New(store)
697+}
698+
296699 func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
297700 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
298701 t.Setenv("XDG_STATE_HOME", stateRoot)
299702 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
300703
301- p, err := persist.Open(filepath.Join(stateRoot, "vev"))
302- if err != nil {
303- t.Fatalf("persist.Open error = %v", err)
304- }
704+ p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
305705 now := time.Now().UnixNano()
306- if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
706+ if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
307707 t.Fatalf("Save error = %v", err)
308708 }
309709 if err := p.Close(); err != nil {
@@ -322,6 +722,30 @@ func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
322722 }
323723 }
324724
725+func TestRunListUnreadableCatalogueProvidesResetGuidance(t *testing.T) {
726+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
727+ stateDir := filepath.Join(stateRoot, "vev")
728+ runtimeDir := filepath.Join(runtimeRoot, "vev")
729+ t.Setenv("XDG_STATE_HOME", stateRoot)
730+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
731+ require.NoError(t, safedir.EnsurePrivate(stateDir))
732+
733+ catalogue := persist.StorePath(stateDir)
734+ before := []byte("corrupt catalogue")
735+ require.NoError(t, os.WriteFile(catalogue, before, 0o600))
736+
737+ var runErr error
738+ stdout := captureStdout(t, func() { runErr = Run([]string{"ls"}) })
739+ require.Empty(t, stdout)
740+ require.Error(t, runErr)
741+ require.ErrorContains(t, runErr, stateDir)
742+ require.ErrorContains(t, runErr, "rm -rf "+stateDir)
743+ require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
744+ after, err := os.ReadFile(catalogue)
745+ require.NoError(t, err)
746+ require.Equal(t, before, after)
747+}
748+
325749 func TestRunKillMissingStoppedSessionDoesNotCreateStore(t *testing.T) {
326750 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
327751 t.Setenv("XDG_STATE_HOME", stateRoot)
@@ -341,19 +765,14 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
341765 t.Setenv("XDG_STATE_HOME", stateRoot)
342766 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
343767
344- p, err := persist.Open(filepath.Join(stateRoot, "vev"))
345- if err != nil {
346- t.Fatalf("persist.Open error = %v", err)
347- }
768+ p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
348769 now := time.Now().UnixNano()
349- if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
770+ if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
350771 t.Fatalf("Save error = %v", err)
351772 }
352773 if err := p.Close(); err != nil {
353774 t.Fatalf("Close error = %v", err)
354775 }
355- snapshots := snapshot.NewRepository(filepath.Join(stateRoot, "vev", "snapshots"))
356-
357776 got := captureStdout(t, func() {
358777 if err := runKill(context.Background(), "stored", false, false); err != nil {
359778 t.Fatalf("runKill error = %v", err)
@@ -369,13 +788,6 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
369788 if len(records) != 0 {
370789 t.Fatalf("records after kill = %#v, want none", records)
371790 }
372- names, err := snapshots.List(context.Background())
373- if err != nil {
374- t.Fatalf("snapshot List error = %v", err)
375- }
376- if len(names) != 0 {
377- t.Fatalf("snapshots after kill = %#v, want none", names)
378- }
379791 }
380792
381793 func captureStdout(t *testing.T, fn func()) string {
M internal/app/spawn.go +123 -24
@@ -13,6 +13,7 @@ import (
1313 "time"
1414
1515 "github.com/bnema/vev/internal/adapters/ipc"
16+ "github.com/bnema/vev/internal/adapters/lifecycle"
1617 "github.com/bnema/vev/internal/platform"
1718 "github.com/bnema/vev/internal/ports"
1819 "github.com/bnema/vev/pkg/safedir"
@@ -39,6 +40,18 @@ type dialFunc func(ctx context.Context, dir string) (ports.Transport, error)
3940 // re-exec a real binary.
4041 type spawnFunc func() error
4142
43+type lifecycleProbe interface {
44+ TryAcquire(string) (lifecycleOwnership, error)
45+}
46+
47+type osLifecycleProbe struct{}
48+
49+func (osLifecycleProbe) TryAcquire(runtimeDir string) (lifecycleOwnership, error) {
50+ return lifecycle.TryAcquire(runtimeDir)
51+}
52+
53+var daemonLifecycleProbe lifecycleProbe = osLifecycleProbe{}
54+
4255 // backoffConfig parameterises retry-dial timing so tests can shrink it.
4356 type backoffConfig struct {
4457 initial time.Duration
@@ -54,10 +67,110 @@ var defaultBackoff = backoffConfig{
5467 total: 5 * time.Second,
5568 }
5669
70+func retryAttempts[T any](ctx context.Context, cfg backoffConfig, attempt func() (T, bool, error)) (T, error) {
71+ deadline := time.Now().Add(cfg.total)
72+ backoff := cfg.initial
73+ if backoff <= 0 {
74+ backoff = time.Millisecond
75+ }
76+ for {
77+ if err := ctx.Err(); err != nil {
78+ var zero T
79+ return zero, err
80+ }
81+ result, done, err := attempt()
82+ if err != nil {
83+ var zero T
84+ return zero, err
85+ }
86+ if done {
87+ return result, nil
88+ }
89+ if !time.Now().Before(deadline) {
90+ var zero T
91+ return zero, ErrDaemonUnreachable
92+ }
93+ if err := waitBackoff(ctx, backoff); err != nil {
94+ var zero T
95+ return zero, err
96+ }
97+ if backoff *= 2; backoff > cfg.max {
98+ backoff = cfg.max
99+ }
100+ }
101+}
102+
103+func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg backoffConfig) (lifecycleOwnership, error) {
104+ return retryAttempts(ctx, cfg, func() (lifecycleOwnership, bool, error) {
105+ owner, err := daemonLifecycleProbe.TryAcquire(runtimeDir)
106+ if err == nil {
107+ return owner, true, nil
108+ }
109+ if !errors.Is(err, lifecycle.ErrBusy) {
110+ return nil, false, err
111+ }
112+ return nil, false, nil
113+ })
114+}
115+
116+type daemonOrLifecycle struct {
117+ transport ports.Transport
118+ owner lifecycleOwnership
119+}
120+
121+func waitForDaemonOrLifecycle(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, lifecycleOwnership, error) {
122+ result, err := retryAttempts(ctx, cfg, func() (daemonOrLifecycle, bool, error) {
123+ if transport, err := dial(ctx, dir); err == nil {
124+ return daemonOrLifecycle{transport: transport}, true, nil
125+ }
126+ owner, err := daemonLifecycleProbe.TryAcquire(dir)
127+ if err == nil {
128+ return daemonOrLifecycle{owner: owner}, true, nil
129+ }
130+ if !errors.Is(err, lifecycle.ErrBusy) {
131+ return daemonOrLifecycle{}, false, err
132+ }
133+ return daemonOrLifecycle{}, false, nil
134+ })
135+ return result.transport, result.owner, err
136+}
137+
138+func waitBackoff(ctx context.Context, duration time.Duration) error {
139+ timer := time.NewTimer(duration)
140+ defer timer.Stop()
141+ select {
142+ case <-ctx.Done():
143+ return ctx.Err()
144+ case <-timer.C:
145+ return nil
146+ }
147+}
148+
149+// ensureDaemonWithLifecycle never elects a spawner while another lifecycle
150+// owner may still be initializing or tearing down durable state.
151+func ensureDaemonWithLifecycle(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
152+ if err := ctx.Err(); err != nil {
153+ return nil, err
154+ }
155+ if transport, err := dial(ctx, dir); err == nil {
156+ return transport, nil
157+ }
158+ transport, owner, err := waitForDaemonOrLifecycle(ctx, dir, dial, cfg)
159+ if err != nil {
160+ return nil, err
161+ }
162+ if transport != nil {
163+ return transport, nil
164+ }
165+ if err := owner.Release(); err != nil {
166+ return nil, fmt.Errorf("vev: release lifecycle spawn probe: %w", err)
167+ }
168+ return ensureDaemon(ctx, dir, dial, spawn, cfg)
169+}
170+
57171 // ensureDaemon returns a transport to a running daemon, spawning one if
58-// necessary. It first tries a plain dial; on failure it elects a single
59-// spawner via an mkdir lock (losers simply wait), then retry-dials with
60-// backoff until the socket is live or the budget expires.
172+// necessary after ensureDaemonWithLifecycle has established lifecycle
173+// availability. Tests also exercise this lower-level spawn election directly.
61174 func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) {
62175 if err := ctx.Err(); err != nil {
63176 return nil, err
@@ -124,28 +237,14 @@ func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
124237 // retryDial dials repeatedly with exponential backoff until the daemon
125238 // answers, the context is cancelled, or the total budget is exhausted.
126239 func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, error) {
127- deadline := time.Now().Add(cfg.total)
128- backoff := cfg.initial
129- for {
130- if err := ctx.Err(); err != nil {
131- return nil, err
132- }
133- if t, err := dial(ctx, dir); err == nil {
134- return t, nil
135- }
136- if !time.Now().Before(deadline) {
137- slog.Error("daemon did not become reachable before retry budget expired", "socket_dir", dir, "budget", cfg.total)
138- return nil, ErrDaemonUnreachable
139- }
140- select {
141- case <-ctx.Done():
142- return nil, ctx.Err()
143- case <-time.After(backoff):
144- }
145- if backoff *= 2; backoff > cfg.max {
146- backoff = cfg.max
147- }
240+ transport, err := retryAttempts(ctx, cfg, func() (ports.Transport, bool, error) {
241+ transport, err := dial(ctx, dir)
242+ return transport, err == nil, nil
243+ })
244+ if errors.Is(err, ErrDaemonUnreachable) {
245+ slog.Error("daemon did not become reachable before retry budget expired", "socket_dir", dir, "budget", cfg.total)
148246 }
247+ return transport, err
149248 }
150249
151250 // realDial is the production dialer.
M internal/app/spawn_test.go +30 -0
@@ -258,6 +258,36 @@ func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {
258258 release()
259259 }
260260
261+func TestRetryAttemptsChecksOperationErrorBeforeDeadline(t *testing.T) {
262+ attemptErr := errors.New("attempt failed")
263+ calls := 0
264+ _, err := retryAttempts(context.Background(), backoffConfig{}, func() (struct{}, bool, error) {
265+ calls++
266+ return struct{}{}, false, attemptErr
267+ })
268+ if !errors.Is(err, attemptErr) {
269+ t.Fatalf("retryAttempts error = %v, want %v", err, attemptErr)
270+ }
271+ if calls != 1 {
272+ t.Fatalf("attempt called %d times, want 1", calls)
273+ }
274+}
275+
276+func TestRetryAttemptsClampsNonPositiveInitialDelay(t *testing.T) {
277+ started := time.Now()
278+ calls := 0
279+ _, err := retryAttempts(context.Background(), backoffConfig{total: time.Second, max: time.Second}, func() (struct{}, bool, error) {
280+ calls++
281+ return struct{}{}, calls == 2, nil
282+ })
283+ if err != nil {
284+ t.Fatalf("retryAttempts: %v", err)
285+ }
286+ if elapsed := time.Since(started); elapsed < 500*time.Microsecond {
287+ t.Fatalf("retryAttempts retried after %s, want a positive initial delay", elapsed)
288+ }
289+}
290+
261291 func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
262292 want := portsmocks.NewMockTransport(t)
263293 var calls atomic.Int32
A internal/domain/recovery.go +134 -0
@@ -0,0 +1,134 @@
1+package domain
2+
3+import (
4+ "encoding/hex"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "slices"
9+ "strings"
10+)
11+
12+// IncarnationID permanently distinguishes uses of the same session name.
13+type IncarnationID [16]byte
14+
15+// CheckpointRef identifies one immutable snapshot manifest.
16+type CheckpointRef struct {
17+ Generation uint64
18+ ManifestDigest [32]byte
19+}
20+
21+// CatalogueRecord is the durable source of truth for one named session.
22+type CatalogueRecord struct {
23+ Name string
24+ IncarnationID IncarnationID
25+ Cwd string
26+ CreatedAt int64
27+ UpdatedAt int64
28+ LastUsedSeq uint64
29+ TabNames []string
30+ Committed *CheckpointRef
31+ DegradedReason string
32+}
33+
34+// Equal reports canonical value equality between checkpoint references.
35+func (r *CheckpointRef) Equal(other *CheckpointRef) bool {
36+ if r == nil || other == nil {
37+ return r == nil && other == nil
38+ }
39+ return *r == *other
40+}
41+
42+// Equal reports canonical value equality, including ordered tab names and
43+// pointed-to checkpoint values rather than pointer identity.
44+func (r CatalogueRecord) Equal(other CatalogueRecord) bool {
45+ return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd &&
46+ r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq &&
47+ slices.Equal(r.TabNames, other.TabNames) && r.Committed.Equal(other.Committed) &&
48+ r.DegradedReason == other.DegradedReason
49+}
50+
51+// CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue
52+// incarnation without carrying authority-owned recovery or checkpoint fields.
53+type CatalogueMetadataUpdate struct {
54+ Name string
55+ IncarnationID IncarnationID
56+ Cwd *string
57+ UpdatedAt *int64
58+ LastUsedSeq *uint64
59+ TabNames *[]string
60+}
61+
62+// MetadataUpdate returns a complete mutable-metadata update for r. Catalogue
63+// implementations retain all recovery and checkpoint fields they own.
64+func (r CatalogueRecord) MetadataUpdate() CatalogueMetadataUpdate {
65+ cwd, updatedAt, lastUsedSeq := r.Cwd, r.UpdatedAt, r.LastUsedSeq
66+ tabNames := append([]string(nil), r.TabNames...)
67+ return CatalogueMetadataUpdate{
68+ Name: r.Name, IncarnationID: r.IncarnationID,
69+ Cwd: &cwd, UpdatedAt: &updatedAt, LastUsedSeq: &lastUsedSeq, TabNames: &tabNames,
70+ }
71+}
72+
73+// NewIncarnationID reads a new non-zero identity from r.
74+func NewIncarnationID(r io.Reader) (IncarnationID, error) {
75+ var id IncarnationID
76+ if _, err := io.ReadFull(r, id[:]); err != nil {
77+ return id, err
78+ }
79+ if id == (IncarnationID{}) {
80+ return id, errors.New("zero incarnation ID")
81+ }
82+ return id, nil
83+}
84+
85+func (id IncarnationID) String() string {
86+ return hex.EncodeToString(id[:])
87+}
88+
89+// MarshalText emits the canonical lowercase hexadecimal identity.
90+func (id IncarnationID) MarshalText() ([]byte, error) {
91+ if id == (IncarnationID{}) {
92+ return nil, errors.New("zero incarnation ID")
93+ }
94+ return []byte(id.String()), nil
95+}
96+
97+// UnmarshalText accepts only a canonical lowercase non-zero identity.
98+func (id *IncarnationID) UnmarshalText(text []byte) error {
99+ if len(text) != 32 || strings.ToLower(string(text)) != string(text) {
100+ return errors.New("invalid incarnation ID")
101+ }
102+ var decoded IncarnationID
103+ if _, err := hex.Decode(decoded[:], text); err != nil {
104+ return fmt.Errorf("invalid incarnation ID: %w", err)
105+ }
106+ if decoded == (IncarnationID{}) {
107+ return errors.New("zero incarnation ID")
108+ }
109+ *id = decoded
110+ return nil
111+}
112+
113+// Validate rejects catalogue states that cannot be restored deterministically.
114+func (r CatalogueRecord) Validate() error {
115+ if err := ValidateSessionName(r.Name); err != nil {
116+ return err
117+ }
118+ if r.IncarnationID == (IncarnationID{}) {
119+ return errors.New("zero catalogue incarnation ID")
120+ }
121+
122+ if r.DegradedReason != "" && r.Committed == nil {
123+ return errors.New("broken session has no committed checkpoint")
124+ }
125+ if r.Committed != nil {
126+ if r.Committed.Generation == 0 {
127+ return errors.New("zero checkpoint generation")
128+ }
129+ if r.Committed.ManifestDigest == ([32]byte{}) {
130+ return errors.New("zero checkpoint manifest digest")
131+ }
132+ }
133+ return nil
134+}
A internal/domain/recovery_test.go +74 -0
@@ -0,0 +1,74 @@
1+package domain
2+
3+import (
4+ "crypto/rand"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestNewIncarnationID(t *testing.T) {
11+ id, err := NewIncarnationID(rand.Reader)
12+ require.NoError(t, err)
13+ require.NotEqual(t, IncarnationID{}, id)
14+ require.Len(t, id.String(), 32)
15+}
16+
17+func TestIncarnationIDText(t *testing.T) {
18+ id := IncarnationID{0: 0xab, 15: 0xcd}
19+ text, err := id.MarshalText()
20+ require.NoError(t, err)
21+ require.Equal(t, "ab0000000000000000000000000000cd", string(text))
22+ var decoded IncarnationID
23+ require.NoError(t, decoded.UnmarshalText(text))
24+ require.Equal(t, id, decoded)
25+ require.Error(t, decoded.UnmarshalText([]byte("AB0000000000000000000000000000CD")))
26+
27+ _, err = (IncarnationID{}).MarshalText()
28+ require.Error(t, err)
29+ require.Error(t, decoded.UnmarshalText([]byte("00000000000000000000000000000000")))
30+ require.Equal(t, id, decoded, "rejected zero text must not mutate the receiver")
31+}
32+
33+func TestCatalogueRecordValidate(t *testing.T) {
34+ validRef := &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}
35+ tests := []struct {
36+ name string
37+ record CatalogueRecord
38+ wantValid bool
39+ }{
40+ {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}}, wantValid: true},
41+ {name: "healthy with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}}, wantValid: true},
42+ {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}}},
43+ {name: "zero incarnation", record: CatalogueRecord{Name: "work"}},
44+ {name: "broken with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: validRef, DegradedReason: "missing manifest"}, wantValid: true},
45+ {name: "broken without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, DegradedReason: "missing manifest"}},
46+ {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}},
47+ {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{Generation: 7}}},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ err := tt.record.Validate()
52+ if tt.wantValid {
53+ require.NoError(t, err)
54+ } else {
55+ require.Error(t, err)
56+ }
57+ })
58+ }
59+}
60+
61+func TestCatalogueRecordEqualUsesCheckpointValuesAndOrderedTabs(t *testing.T) {
62+ committed := CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}
63+ left := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, TabNames: []string{"shell", "logs"}, Committed: &committed}
64+ right := left
65+ right.TabNames = append([]string(nil), left.TabNames...)
66+ committedCopy := committed
67+ right.Committed = &committedCopy
68+ require.True(t, left.Equal(right))
69+
70+ right.TabNames[0], right.TabNames[1] = right.TabNames[1], right.TabNames[0]
71+ require.False(t, left.Equal(right))
72+ require.True(t, (*CheckpointRef)(nil).Equal(nil))
73+ require.False(t, left.Committed.Equal(nil))
74+}
A internal/persist/catalogue_test.go +236 -0
@@ -0,0 +1,236 @@
1+package persist
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/pkg/kv"
12+ "github.com/bnema/vev/pkg/safedir"
13+)
14+
15+func TestCatalogue(t *testing.T) {
16+ t.Run("record-round-trip", testCatalogueRecordRoundTrip)
17+ t.Run("duplicate-incarnations", testCatalogueDuplicateIncarnations)
18+ t.Run("open-fails-closed", testCatalogueOpenFailsClosed)
19+ t.Run("apply-rename-replace", testCatalogueApplyRenameReplace)
20+ t.Run("metadata-update-preserves-authority", testCatalogueMetadataUpdatePreservesAuthority)
21+ t.Run("metadata-update-is-deferred", testCatalogueMetadataUpdateIsDeferred)
22+ t.Run("read-only-malformed", testCatalogueLoadReadOnlyRejectsMalformedValue)
23+ t.Run("read-only-fresh-install", testCatalogueLoadReadOnlyFreshInstallHasNoSessions)
24+ t.Run("read-only-ignores-stray-tmp", testCatalogueLoadReadOnlyIgnoresStrayTmp)
25+}
26+
27+func testCatalogueRecordRoundTrip(t *testing.T) {
28+ ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}}
29+ tests := []domain.CatalogueRecord{
30+ {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp"},
31+ {Name: "healthy", IncarnationID: domain.IncarnationID{2}, Committed: ref1},
32+ {Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref1, DegradedReason: "invalid manifest"},
33+ {Name: "other-fresh", IncarnationID: domain.IncarnationID{5}},
34+ }
35+ for _, record := range tests {
36+ t.Run(record.Name, func(t *testing.T) {
37+ encoded, err := encodeRecordValue(record)
38+ require.NoError(t, err)
39+ got, err := decodeRecordValue(record.Name, encoded)
40+ require.NoError(t, err)
41+ require.Equal(t, record, got)
42+ for prefix := range len(encoded) {
43+ _, err := decodeRecordValue(record.Name, encoded[:prefix])
44+ require.Error(t, err, "prefix length %d", prefix)
45+ }
46+ require.Error(t, func() error { _, err := decodeRecordValue(record.Name, append(encoded, 0)); return err }())
47+ })
48+ }
49+}
50+
51+func TestDecodeRecordValueRejectsMalformedCheckpointMarker(t *testing.T) {
52+ encoded, err := encodeRecordValue(validRecord("work", 1))
53+ require.NoError(t, err)
54+ encoded[checkpointMarkerOffset(t, encoded)] = 99
55+
56+ _, err = decodeRecordValue("work", encoded)
57+ require.ErrorIs(t, err, errMalformedRecord)
58+}
59+
60+func checkpointMarkerOffset(t *testing.T, encoded []byte) int {
61+ t.Helper()
62+ r := valueReader{data: encoded}
63+ _, ok := r.take(len(catalogueMagic) + 2 + len(domain.IncarnationID{}))
64+ require.True(t, ok)
65+ _, ok = r.str()
66+ require.True(t, ok)
67+ _, ok = r.take(8 + 8 + 8)
68+ require.True(t, ok)
69+ count, ok := r.u32()
70+ require.True(t, ok)
71+ for range count {
72+ _, ok = r.str()
73+ require.True(t, ok)
74+ }
75+ return len(encoded) - r.remaining()
76+}
77+
78+func testCatalogueDuplicateIncarnations(t *testing.T) {
79+ id := domain.IncarnationID{1}
80+ require.Error(t, validateUniqueIncarnations([]domain.CatalogueRecord{
81+ {Name: "one", IncarnationID: id},
82+ {Name: "two", IncarnationID: id},
83+ }))
84+}
85+
86+func testCatalogueOpenFailsClosed(t *testing.T) {
87+ dir := privateDir(t)
88+ _, _, err := openCurrentCatalogue(dir, false)
89+ require.Error(t, err)
90+
91+ require.NoError(t, safedir.EnsurePrivate(dir))
92+ raw := []byte("corrupt catalogue")
93+ require.NoError(t, os.WriteFile(StorePath(dir), raw, 0o600))
94+ _, _, err = openCurrentCatalogue(dir, false)
95+ require.ErrorIs(t, err, kv.ErrCorrupt)
96+ after, readErr := os.ReadFile(StorePath(dir))
97+ require.NoError(t, readErr)
98+ require.Equal(t, raw, after)
99+}
100+
101+func testCatalogueApplyRenameReplace(t *testing.T) {
102+ dir := privateDir(t)
103+ p, records, err := openCurrentCatalogue(dir, true)
104+ require.NoError(t, err)
105+ require.Empty(t, records)
106+ defer func() { require.NoError(t, p.Close()) }()
107+
108+ one := domain.CatalogueRecord{Name: "one", IncarnationID: domain.IncarnationID{1}}
109+ two := domain.CatalogueRecord{Name: "two", IncarnationID: domain.IncarnationID{2}}
110+ require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": &one, "two": &two}))
111+ renamed := one
112+ renamed.Name = "renamed"
113+ require.NoError(t, p.Rename("one", renamed))
114+ two.Cwd = "/next"
115+ require.NoError(t, p.Replace("two", two))
116+
117+ got, err := p.LoadCatalogue()
118+ require.NoError(t, err)
119+ require.ElementsMatch(t, []domain.CatalogueRecord{renamed, two}, got)
120+}
121+
122+func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
123+ dir := privateDir(t)
124+ p, _, err := openCurrentCatalogue(dir, true)
125+ require.NoError(t, err)
126+ defer func() { require.NoError(t, p.Close()) }()
127+
128+ committed := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}}
129+ original := domain.CatalogueRecord{
130+ Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11,
131+ UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"},
132+ Committed: committed, DegradedReason: "repair pending",
133+ }
134+ require.NoError(t, p.Create(original))
135+
136+ next := domain.CatalogueRecord{Name: "work", IncarnationID: original.IncarnationID, Cwd: "/new", UpdatedAt: 22, LastUsedSeq: 23, TabNames: []string{"editor", "logs"}}
137+ update := next.MetadataUpdate()
138+ require.NoError(t, p.UpdateMetadata(update))
139+ got, ok, err := p.Record("work")
140+ require.NoError(t, err)
141+ require.True(t, ok)
142+ expected := original
143+ expected.Cwd, expected.UpdatedAt, expected.LastUsedSeq, expected.TabNames = next.Cwd, next.UpdatedAt, next.LastUsedSeq, next.TabNames
144+ require.Equal(t, expected, got)
145+
146+ lastUsedSeq := uint64(24)
147+ require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
148+ Name: "work", IncarnationID: original.IncarnationID, LastUsedSeq: &lastUsedSeq,
149+ }))
150+ expected.LastUsedSeq = lastUsedSeq
151+ partiallyUpdated, ok, err := p.Record("work")
152+ require.NoError(t, err)
153+ require.True(t, ok)
154+ require.Equal(t, expected, partiallyUpdated, "a partial metadata update must not clear other mutable or authority-owned fields")
155+
156+ stale := update
157+ stale.IncarnationID = domain.IncarnationID{2}
158+ require.Error(t, p.UpdateMetadata(stale))
159+ unchanged, ok, err := p.Record("work")
160+ require.NoError(t, err)
161+ require.True(t, ok)
162+ require.Equal(t, expected, unchanged)
163+}
164+
165+func testCatalogueMetadataUpdateIsDeferred(t *testing.T) {
166+ dir := privateDir(t)
167+ p, _, err := openCurrentCatalogue(dir, true)
168+ require.NoError(t, err)
169+ defer func() { require.NoError(t, p.Close()) }()
170+
171+ record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old"}
172+ require.NoError(t, p.Create(record))
173+ before, err := os.ReadFile(StorePath(dir))
174+ require.NoError(t, err)
175+
176+ cwd := "/buffered"
177+ require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
178+ Name: record.Name, IncarnationID: record.IncarnationID, Cwd: &cwd,
179+ }))
180+ afterBuffered, err := os.ReadFile(StorePath(dir))
181+ require.NoError(t, err)
182+ require.Equal(t, before, afterBuffered, "metadata must remain buffered before Sync")
183+
184+ require.NoError(t, p.Sync())
185+ afterSync, err := os.ReadFile(StorePath(dir))
186+ require.NoError(t, err)
187+ require.NotEqual(t, before, afterSync)
188+
189+ cwd = "/next-identity"
190+ require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
191+ Name: record.Name, IncarnationID: record.IncarnationID, Cwd: &cwd,
192+ }))
193+ record.Cwd = cwd
194+ record.UpdatedAt = 2
195+ require.NoError(t, p.Replace(record.Name, record))
196+ afterIdentity, err := os.ReadFile(StorePath(dir))
197+ require.NoError(t, err)
198+ require.NotEqual(t, afterSync, afterIdentity, "identity writes must sync before returning")
199+}
200+
201+func testCatalogueLoadReadOnlyRejectsMalformedValue(t *testing.T) {
202+ dir := privateDir(t)
203+ store, err := kv.Open(filepath.Join(dir, filename))
204+ require.NoError(t, err)
205+ require.NoError(t, store.Set([]byte("work"), []byte("malformed")))
206+ require.NoError(t, store.Close())
207+ _, err = LoadCatalogueReadOnly(dir)
208+ require.Error(t, err)
209+}
210+
211+// testCatalogueLoadReadOnlyFreshInstallHasNoSessions covers `vev ls` on a
212+// machine with no daemon and no catalogue. This must yield an empty catalogue,
213+// not an error.
214+func testCatalogueLoadReadOnlyFreshInstallHasNoSessions(t *testing.T) {
215+ dir := privateDir(t)
216+ records, err := LoadCatalogueReadOnly(dir)
217+ require.NoError(t, err)
218+ require.Empty(t, records)
219+}
220+
221+func testCatalogueLoadReadOnlyIgnoresStrayTmp(t *testing.T) {
222+ dir := privateDir(t)
223+ p, _, err := openCurrentCatalogue(dir, true)
224+ require.NoError(t, err)
225+ want := validRecord("work", 1)
226+ require.NoError(t, p.Create(want))
227+ require.NoError(t, p.Close())
228+ require.NoError(t, os.WriteFile(StorePath(dir)+".tmp", []byte("partial rewrite"), 0o600))
229+
230+ records, err := LoadCatalogueReadOnly(dir)
231+ require.NoError(t, err)
232+ require.Equal(t, []domain.CatalogueRecord{want}, records)
233+ tmp, err := os.ReadFile(StorePath(dir) + ".tmp")
234+ require.NoError(t, err)
235+ require.Equal(t, []byte("partial rewrite"), tmp)
236+}
M internal/persist/persist.go +393 -78
@@ -1,174 +1,489 @@
11 package persist
22
33 import (
4- "log/slog"
4+ "errors"
5+ "fmt"
6+ "os"
57 "path/filepath"
68 "sort"
79 "sync"
810
11+ "github.com/bnema/vev/internal/domain"
912 "github.com/bnema/vev/internal/ports"
1013 "github.com/bnema/vev/pkg/kv"
1114 )
1215
1316 const filename = "sessions.kv"
1417
15-// StorePath returns the canonical path for the persisted session metadata store.
16-func StorePath(dir string) string {
17- return filepath.Join(dir, filename)
18+var errPersistenceUnavailable = errors.New("persist: catalogue unavailable")
19+
20+// ErrCatalogueDurability reports a failed identity write whose durability is
21+// ambiguous. A Persister remains fenced after this error so rejected state
22+// cannot be made durable by a later flush.
23+var ErrCatalogueDurability = errors.New("persist: catalogue durability rejected")
24+
25+// ErrCatalogueUnreadable reports durable state that exists but cannot be
26+// decoded. vev never repairs or erases it; the operator resets explicitly.
27+var ErrCatalogueUnreadable = errors.New("persist: catalogue unreadable")
28+
29+// OpenResult is the outcome of opening durable session state at startup.
30+type OpenResult struct {
31+ Catalogue *Persister
32+ Records []domain.CatalogueRecord
33+ NewInstall bool
1834 }
1935
20-// Persister stores named session metadata. A nil Store makes all mutating
21-// operations no-ops, so callers can keep persistence optional.
36+func StorePath(dir string) string { return filepath.Join(dir, filename) }
37+
2238 type Persister struct {
2339 store ports.Store
2440 mu sync.Mutex
41+
42+ incarnationOwners map[domain.IncarnationID]string
43+ nameIncarnations map[string]domain.IncarnationID
44+ incarnationIndex bool
45+ terminalErr error
2546 }
2647
27-// Open opens the session persister under dir.
28-func Open(dir string) (*Persister, error) {
29- store, err := kv.Open(StorePath(dir))
48+// KVStore adapts the reusable whole-file store to the persistence port.
49+type KVStore struct{ store *kv.Store }
50+
51+func OpenStore(path string) (*KVStore, error) {
52+ store, err := kv.Open(path)
3053 if err != nil {
3154 return nil, err
3255 }
33- return New(store), nil
56+ return &KVStore{store: store}, nil
57+}
58+
59+func (s *KVStore) Get(key []byte) ([]byte, bool) { return s.store.Get(key) }
60+func (s *KVStore) Set(key, value []byte) error { return s.store.Set(key, value) }
61+func (s *KVStore) Delete(key []byte) error { return s.store.Delete(key) }
62+func (s *KVStore) Range(fn func(key, value []byte) bool) {
63+ s.store.Range(fn)
64+}
65+func (s *KVStore) Sync() error { return s.store.Sync() }
66+func (s *KVStore) Close() error { return s.store.Close() }
67+
68+func (s *KVStore) CloseWithoutSync() error { return s.store.CloseWithoutSync() }
69+
70+// Open opens an existing strict catalogue and never creates an unproven empty one.
71+func Open(dir string) (*Persister, error) {
72+ p, _, err := openCurrentCatalogue(dir, false)
73+ return p, err
74+}
75+
76+// OpenOrCreate opens the catalogue, or creates a proven-empty one when no
77+// durable state exists. Any state that exists but does not decode is returned
78+// as ErrCatalogueUnreadable and left untouched on disk.
79+func OpenOrCreate(dir string) (OpenResult, error) {
80+ path := StorePath(dir)
81+ _, statErr := os.Stat(path)
82+ existed := statErr == nil
83+ if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
84+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, statErr)
85+ }
86+ catalogue, records, err := openCurrentCatalogue(dir, true)
87+ if err != nil {
88+ return OpenResult{}, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, path, err)
89+ }
90+ return OpenResult{Catalogue: catalogue, Records: records, NewInstall: !existed}, nil
3491 }
3592
36-// New wraps store in a Persister. Passing nil creates a no-op persister.
37-func New(store ports.Store) *Persister {
38- return &Persister{store: store}
93+func New(store ports.Store) *Persister { return &Persister{store: store} }
94+
95+func cataloguePresent(path string) (bool, error) {
96+ if _, err := os.Stat(path); err == nil {
97+ return true, nil
98+ } else if !errors.Is(err, os.ErrNotExist) {
99+ return false, err
100+ }
101+ return false, nil
39102 }
40103
41-// LoadReadOnly replays the session store under dir without mutating it.
42-func LoadReadOnly(dir string) ([]Record, error) {
43- data, err := kv.Replay(StorePath(dir))
104+func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []domain.CatalogueRecord, error) {
105+ path := StorePath(dir)
106+ present, err := cataloguePresent(path)
44107 if err != nil {
108+ return nil, nil, err
109+ }
110+ if !present && !createProvenEmpty {
111+ return nil, nil, fmt.Errorf("%w: no catalogue candidates", errPersistenceUnavailable)
112+ }
113+ store, err := OpenStore(path)
114+ if err != nil {
115+ return nil, nil, err
116+ }
117+ p := New(store)
118+ records, err := p.LoadCatalogue()
119+ if err != nil {
120+ return nil, nil, errors.Join(err, p.Close())
121+ }
122+ return p, records, nil
123+}
124+
125+func LoadReadOnly(dir string) ([]domain.CatalogueRecord, error) { return LoadCatalogueReadOnly(dir) }
126+
127+// LoadCatalogueReadOnly loads the catalogue without retaining a store handle.
128+// A fresh install with no main catalogue is not an error. Stray temporary
129+// files are ignored, while malformed main files fail closed.
130+func LoadCatalogueReadOnly(dir string) ([]domain.CatalogueRecord, error) {
131+ p, records, err := openCurrentCatalogue(dir, false)
132+ if err != nil {
133+ if errors.Is(err, errPersistenceUnavailable) {
134+ return []domain.CatalogueRecord{}, nil
135+ }
136+ return nil, fmt.Errorf("%w: %s: %w", ErrCatalogueUnreadable, StorePath(dir), err)
137+ }
138+ if err := p.Close(); err != nil {
45139 return nil, err
46140 }
47- return decodeAll(data)
141+ return records, nil
48142 }
49143
50-// Save persists a structural session record and syncs it to disk.
51-func (p *Persister) Save(r Record) error {
52- if p == nil || p.store == nil {
144+func (p *Persister) Save(record domain.CatalogueRecord) error { return p.Replace(record.Name, record) }
145+func (p *Persister) Create(record domain.CatalogueRecord) error {
146+ if p == nil {
53147 return nil
54148 }
149+ if p.store == nil {
150+ return errPersistenceUnavailable
151+ }
55152 p.mu.Lock()
56153 defer p.mu.Unlock()
57- value, err := encodeRecordValue(r)
58- if err != nil {
154+ if err := p.terminalLocked(); err != nil {
59155 return err
60156 }
61- if err := p.store.Set([]byte(r.Name), value); err != nil {
62- return err
157+ if _, exists := p.store.Get([]byte(record.Name)); exists {
158+ return errors.New("persist: session already exists")
63159 }
64- return p.store.Sync()
160+ return p.applyLocked(map[string]*domain.CatalogueRecord{record.Name: &record}, true)
65161 }
66162
67-// Touch updates hot-path session cwd metadata without forcing an fsync.
68-func (p *Persister) Touch(name, cwd string, at int64) error {
69- if p == nil || p.store == nil {
163+func (p *Persister) Records() ([]domain.CatalogueRecord, error) { return p.LoadCatalogue() }
164+func (p *Persister) Record(name string) (domain.CatalogueRecord, bool, error) {
165+ if p == nil {
166+ return domain.CatalogueRecord{}, false, nil
167+ }
168+ if p.store == nil {
169+ return domain.CatalogueRecord{}, false, errPersistenceUnavailable
170+ }
171+ p.mu.Lock()
172+ defer p.mu.Unlock()
173+ if err := p.terminalLocked(); err != nil {
174+ return domain.CatalogueRecord{}, false, err
175+ }
176+ value, ok := p.store.Get([]byte(name))
177+ if !ok {
178+ return domain.CatalogueRecord{}, false, nil
179+ }
180+ record, err := decodeRecordValue(name, value)
181+ if err != nil {
182+ return domain.CatalogueRecord{}, false, err
183+ }
184+ return record, true, nil
185+}
186+func (p *Persister) Apply(records map[string]*domain.CatalogueRecord) error {
187+ if p == nil {
70188 return nil
71189 }
72- if name == "" {
73- return errEmptyName
190+ if p.store == nil {
191+ return errPersistenceUnavailable
74192 }
75193 p.mu.Lock()
76194 defer p.mu.Unlock()
195+ if err := p.terminalLocked(); err != nil {
196+ return err
197+ }
198+ return p.applyLocked(records, true)
199+}
200+func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord, durable bool) error {
201+ if len(records) == 0 {
202+ return errors.New("persist: empty catalogue batch")
203+ }
204+ if err := p.ensureIncarnationIndexLocked(); err != nil {
205+ return err
206+ }
207+ type mutation struct {
208+ key []byte
209+ value []byte
210+ delete bool
211+ }
212+ changes := make([]mutation, 0, len(records))
213+ keys := make([]string, 0, len(records))
214+ for name := range records {
215+ keys = append(keys, name)
216+ }
217+ sort.Strings(keys)
218+ for _, name := range keys {
219+ record := records[name]
220+ if err := domain.ValidateSessionName(name); err != nil {
221+ return err
222+ }
223+ if record == nil {
224+ changes = append(changes, mutation{key: []byte(name), delete: true})
225+ continue
226+ }
227+ if record.Name != name {
228+ return errors.New("persist: catalogue key/name mismatch")
229+ }
230+ value, err := encodeRecordValue(*record)
231+ if err != nil {
232+ return err
233+ }
234+ changes = append(changes, mutation{key: []byte(name), value: value})
235+ }
236+ incoming := make(map[domain.IncarnationID]string, len(records))
237+ for name, record := range records {
238+ if record == nil {
239+ continue
240+ }
241+ if previous, ok := incoming[record.IncarnationID]; ok && previous != name {
242+ return fmt.Errorf("persist: duplicate incarnation for %q and %q", previous, name)
243+ }
244+ incoming[record.IncarnationID] = name
245+ if owner, ok := p.incarnationOwners[record.IncarnationID]; ok && owner != name {
246+ ownerNext, ownerTouched := records[owner]
247+ if !ownerTouched || (ownerNext != nil && ownerNext.IncarnationID == record.IncarnationID) {
248+ return fmt.Errorf("persist: duplicate incarnation for %q and %q", owner, name)
249+ }
250+ }
251+ }
252+ previous := make(map[string]storedValue, len(changes))
253+ for _, change := range changes {
254+ value, ok := p.store.Get(change.key)
255+ previous[string(change.key)] = storedValue{value: value, exists: ok}
256+ }
257+ for _, change := range changes {
258+ var err error
259+ if change.delete {
260+ err = p.store.Delete(change.key)
261+ } else {
262+ err = p.store.Set(change.key, change.value)
263+ }
264+ if err != nil {
265+ return p.fenceLocked(errors.Join(err, p.restoreLocked(previous)))
266+ }
267+ }
268+ if durable {
269+ if err := p.store.Sync(); err != nil {
270+ return p.fenceLocked(errors.Join(err, p.restoreLocked(previous)))
271+ }
272+ }
273+ for name := range records {
274+ if id, ok := p.nameIncarnations[name]; ok {
275+ delete(p.incarnationOwners, id)
276+ delete(p.nameIncarnations, name)
277+ }
278+ }
279+ for name, record := range records {
280+ if record != nil {
281+ p.incarnationOwners[record.IncarnationID] = name
282+ p.nameIncarnations[name] = record.IncarnationID
283+ }
284+ }
285+ return nil
286+}
77287
78- createdAt := at
79- lastUsedSeq := uint64(0)
80- var tabNames []string
81- if v, ok := p.store.Get([]byte(name)); ok {
82- if r, err := decodeRecordValue(name, v); err == nil {
83- createdAt = r.CreatedAt
84- lastUsedSeq = r.LastUsedSeq
85- tabNames = r.TabNames
288+type storedValue struct {
289+ value []byte
290+ exists bool
291+}
292+
293+// restoreLocked restores only keys affected by the rejected identity mutation.
294+// It intentionally does not Sync: the Persister is fenced immediately after.
295+func (p *Persister) restoreLocked(previous map[string]storedValue) error {
296+ var err error
297+ for key, old := range previous {
298+ if old.exists {
299+ err = errors.Join(err, p.store.Set([]byte(key), old.value))
300+ } else {
301+ err = errors.Join(err, p.store.Delete([]byte(key)))
86302 }
87303 }
304+ return err
305+}
88306
89- value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq, TabNames: tabNames})
90- if err != nil {
307+func (p *Persister) fenceLocked(cause error) error {
308+ if p.terminalErr == nil {
309+ p.terminalErr = fmt.Errorf("%w: %w", ErrCatalogueDurability, cause)
310+ }
311+ p.incarnationIndex = false
312+ return p.terminalErr
313+}
314+
315+func (p *Persister) terminalLocked() error { return p.terminalErr }
316+
317+func (p *Persister) ensureIncarnationIndexLocked() error {
318+ if err := p.terminalLocked(); err != nil {
91319 return err
92320 }
93- return p.store.Set([]byte(name), value)
321+ if p.incarnationIndex {
322+ return nil
323+ }
324+ owners := make(map[domain.IncarnationID]string)
325+ names := make(map[string]domain.IncarnationID)
326+ var indexErr error
327+ p.store.Range(func(key, value []byte) bool {
328+ name := string(key)
329+ record, err := decodeRecordValue(name, value)
330+ if err != nil {
331+ indexErr = err
332+ return false
333+ }
334+ if previous, ok := owners[record.IncarnationID]; ok {
335+ indexErr = fmt.Errorf("persist: duplicate incarnation for %q and %q", previous, name)
336+ return false
337+ }
338+ owners[record.IncarnationID] = name
339+ names[name] = record.IncarnationID
340+ return true
341+ })
342+ if indexErr != nil {
343+ return indexErr
344+ }
345+ p.incarnationOwners = owners
346+ p.nameIncarnations = names
347+ p.incarnationIndex = true
348+ return nil
349+}
350+func (p *Persister) Rename(oldName string, next domain.CatalogueRecord) error {
351+ if p == nil {
352+ return nil
353+ }
354+ if p.store == nil {
355+ return errPersistenceUnavailable
356+ }
357+ if oldName == next.Name {
358+ return errors.New("persist: rename requires distinct names")
359+ }
360+ return p.Apply(map[string]*domain.CatalogueRecord{oldName: nil, next.Name: &next})
361+}
362+func (p *Persister) Replace(name string, next domain.CatalogueRecord) error {
363+ return p.Apply(map[string]*domain.CatalogueRecord{name: &next})
94364 }
95365
96-// TouchMRU updates hot-path session recency without forcing an fsync.
97-func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error {
98- if p == nil || p.store == nil {
366+// UpdateMetadata buffers ordinary session metadata on the existing authoritative
367+// incarnation. Recovery and transaction fields are retained from the stored
368+// record rather than accepted from a potentially stale runtime copy. Sync or the
369+// next identity operation makes the update durable.
370+func (p *Persister) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
371+ if p == nil {
99372 return nil
100373 }
101- if name == "" {
102- return errEmptyName
374+ if p.store == nil {
375+ return errPersistenceUnavailable
103376 }
104377 p.mu.Lock()
105378 defer p.mu.Unlock()
106-
107- r := Record{Name: name, LastUsedSeq: lastUsedSeq}
108- if v, ok := p.store.Get([]byte(name)); ok {
109- if decoded, err := decodeRecordValue(name, v); err == nil {
110- r = decoded
111- r.LastUsedSeq = lastUsedSeq
112- }
379+ if err := p.terminalLocked(); err != nil {
380+ return err
113381 }
114- value, err := encodeRecordValue(r)
382+ value, ok := p.store.Get([]byte(update.Name))
383+ if !ok {
384+ return errors.New("persist: session not found")
385+ }
386+ current, err := decodeRecordValue(update.Name, value)
115387 if err != nil {
116388 return err
117389 }
118- return p.store.Set([]byte(name), value)
390+ if update.IncarnationID == (domain.IncarnationID{}) || current.IncarnationID != update.IncarnationID {
391+ return errors.New("persist: session incarnation changed")
392+ }
393+ if update.Cwd != nil {
394+ current.Cwd = *update.Cwd
395+ }
396+ if update.UpdatedAt != nil {
397+ current.UpdatedAt = *update.UpdatedAt
398+ }
399+ if update.LastUsedSeq != nil {
400+ current.LastUsedSeq = *update.LastUsedSeq
401+ }
402+ if update.TabNames != nil {
403+ current.TabNames = append([]string(nil), (*update.TabNames)...)
404+ }
405+ return p.applyLocked(map[string]*domain.CatalogueRecord{current.Name: &current}, false)
119406 }
120407
121-// Delete removes a persisted session record and syncs the structural change.
122-func (p *Persister) Delete(name string) error {
123- if p == nil || p.store == nil {
408+// Sync makes every buffered catalogue mutation durable.
409+func (p *Persister) Sync() error {
410+ if p == nil {
124411 return nil
125412 }
126- if name == "" {
127- return errEmptyName
413+ if p.store == nil {
414+ return errPersistenceUnavailable
128415 }
129416 p.mu.Lock()
130417 defer p.mu.Unlock()
131- if err := p.store.Delete([]byte(name)); err != nil {
418+ if err := p.terminalLocked(); err != nil {
132419 return err
133420 }
134- return p.store.Sync()
421+ if err := p.store.Sync(); err != nil {
422+ return p.fenceLocked(err)
423+ }
424+ return nil
135425 }
136426
137-// LoadAll returns all persisted session records sorted by name.
138-func (p *Persister) LoadAll() ([]Record, error) {
139- if p == nil || p.store == nil {
140- return nil, nil
427+func (p *Persister) Delete(name string) error {
428+ return p.Apply(map[string]*domain.CatalogueRecord{name: nil})
429+}
430+func (p *Persister) LoadAll() ([]domain.CatalogueRecord, error) { return p.LoadCatalogue() }
431+func (p *Persister) LoadCatalogue() ([]domain.CatalogueRecord, error) {
432+ if p == nil {
433+ return []domain.CatalogueRecord{}, nil
434+ }
435+ if p.store == nil {
436+ return nil, errPersistenceUnavailable
141437 }
142438 p.mu.Lock()
143439 defer p.mu.Unlock()
440+ if err := p.terminalLocked(); err != nil {
441+ return nil, err
442+ }
144443 data := make(map[string][]byte)
145- p.store.Range(func(k, v []byte) bool {
146- data[string(k)] = append([]byte(nil), v...)
147- return true
148- })
444+ p.store.Range(func(key, value []byte) bool { data[string(key)] = append([]byte(nil), value...); return true })
149445 return decodeAll(data)
150446 }
151-
152-// Close closes the underlying store.
153447 func (p *Persister) Close() error {
154448 if p == nil || p.store == nil {
155449 return nil
156450 }
157451 p.mu.Lock()
158452 defer p.mu.Unlock()
453+ if err := p.terminalLocked(); err != nil {
454+ if closeErr := p.store.CloseWithoutSync(); closeErr != nil {
455+ return errors.Join(err, closeErr)
456+ }
457+ return err
458+ }
159459 return p.store.Close()
160460 }
161461
162-func decodeAll(data map[string][]byte) ([]Record, error) {
163- records := make([]Record, 0, len(data))
462+func decodeAll(data map[string][]byte) ([]domain.CatalogueRecord, error) {
463+ records := make([]domain.CatalogueRecord, 0, len(data))
164464 for name, value := range data {
165- r, err := decodeRecordValue(name, value)
465+ record, err := decodeRecordValue(name, value)
166466 if err != nil {
167- slog.Warn("skipping malformed persisted session", "session", name, "err", err)
168- continue
467+ return nil, fmt.Errorf("persist: decode catalogue key %q: %w", name, err)
169468 }
170- records = append(records, r)
469+ records = append(records, record)
171470 }
172471 sort.Slice(records, func(i, j int) bool { return records[i].Name < records[j].Name })
472+ if err := validateUniqueIncarnations(records); err != nil {
473+ return nil, err
474+ }
173475 return records, nil
174476 }
477+func validateUniqueIncarnations(records []domain.CatalogueRecord) error {
478+ seen := make(map[domain.IncarnationID]string, len(records))
479+ for _, record := range records {
480+ if err := record.Validate(); err != nil {
481+ return err
482+ }
483+ if previous, ok := seen[record.IncarnationID]; ok {
484+ return fmt.Errorf("persist: duplicate incarnation for %q and %q", previous, record.Name)
485+ }
486+ seen[record.IncarnationID] = record.Name
487+ }
488+ return nil
489+}
M internal/persist/persist_test.go +412 -234
@@ -2,301 +2,479 @@ package persist
22
33 import (
44 "errors"
5+ "os"
56 "path/filepath"
6- "sync"
77 "testing"
8- "time"
98
109 "github.com/stretchr/testify/mock"
1110 "github.com/stretchr/testify/require"
1211
12+ "github.com/bnema/vev/internal/domain"
1313 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/pkg/safedir"
1415 )
1516
16-func privateDir(t *testing.T) string {
17- t.Helper()
18- return filepath.Join(t.TempDir(), "vev")
17+func privateDir(t *testing.T) string { t.Helper(); return filepath.Join(t.TempDir(), "vev") }
18+func validRecord(name string, id byte) domain.CatalogueRecord {
19+ return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}}
1920 }
2021
21-func TestRecordCodecRoundTrip(t *testing.T) {
22- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33, TabNames: []string{"shell", "logs"}}
23- value, err := encodeRecordValue(r)
24- require.NoError(t, err)
25-
26- got, err := decodeRecordValue(r.Name, value)
27- require.NoError(t, err)
28- require.Equal(t, r, got)
22+func TestOpenOrCreate(t *testing.T) {
23+ t.Parallel()
24+ tests := []struct {
25+ name string
26+ write []byte // nil means "no file at all"
27+ wantNew bool
28+ wantErr error
29+ }{
30+ {name: "absent file starts empty", write: nil, wantNew: true},
31+ {name: "garbage fails closed", write: []byte("not a vev catalogue"), wantErr: ErrCatalogueUnreadable},
32+ {name: "legacy headerless record fails closed", write: []byte{0x08, 0x00, 0x00, 0x00, 0xde, 0xad, 0xbe, 0xef}, wantErr: ErrCatalogueUnreadable},
33+ }
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ t.Parallel()
37+ dir := privateDir(t)
38+ if tt.write != nil {
39+ require.NoError(t, safedir.EnsurePrivate(dir))
40+ require.NoError(t, os.WriteFile(StorePath(dir), tt.write, 0o600))
41+ }
42+ got, err := OpenOrCreate(dir)
43+ if tt.wantErr != nil {
44+ require.ErrorIs(t, err, tt.wantErr)
45+ after, readErr := os.ReadFile(StorePath(dir))
46+ require.NoError(t, readErr)
47+ require.Equal(t, tt.write, after, "unreadable catalogue must remain untouched")
48+ return
49+ }
50+ require.NoError(t, err)
51+ t.Cleanup(func() { _ = got.Catalogue.Close() })
52+ require.Equal(t, tt.wantNew, got.NewInstall)
53+ require.Empty(t, got.Records)
54+ })
55+ }
2956 }
3057
31-func TestRecordCodecOldRecordDefaultsLastUsed(t *testing.T) {
32- old := []byte{0, 0, 0, 4, '/', 't', 'm', 'p'}
33- old = append(old, 0, 0, 0, 0, 0, 0, 0, 11)
34- old = append(old, 0, 0, 0, 0, 0, 0, 0, 22)
58+func TestOpenOrCreateRejectsNonCurrentDurableStateWithoutMutation(t *testing.T) {
59+ t.Parallel()
60+ tests := []struct {
61+ name string
62+ files map[string][]byte
63+ }{
64+ {
65+ name: "old VEVK version",
66+ files: map[string][]byte{
67+ filename: {'V', 'E', 'V', 'K', 0, 1},
68+ },
69+ },
70+ }
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ t.Parallel()
74+ dir := privateDir(t)
75+ require.NoError(t, safedir.EnsurePrivate(dir))
76+ for name, data := range tt.files {
77+ require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o600))
78+ }
79+
80+ before := directoryBytes(t, dir)
81+ _, err := OpenOrCreate(dir)
82+ require.ErrorIs(t, err, ErrCatalogueUnreadable)
83+ after := directoryBytes(t, dir)
84+ for name, data := range before {
85+ require.Equal(t, data, after[name], "failure must preserve durable file %q", name)
86+ }
87+ })
88+ }
89+}
3590
36- got, err := decodeRecordValue("work", old)
91+func directoryBytes(t *testing.T, dir string) map[string][]byte {
92+ t.Helper()
93+ entries, err := os.ReadDir(dir)
3794 require.NoError(t, err)
38- require.Equal(t, Record{Name: "work", Cwd: "/tmp", CreatedAt: 11, UpdatedAt: 22}, got)
95+ files := make(map[string][]byte, len(entries))
96+ for _, entry := range entries {
97+ require.False(t, entry.IsDir(), "unexpected directory %q", entry.Name())
98+ data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
99+ require.NoError(t, err)
100+ files[entry.Name()] = data
101+ }
102+ return files
39103 }
40104
41-func TestRecordCodecRejectsMalformedData(t *testing.T) {
42- _, err := encodeRecordValue(Record{Cwd: "/tmp"})
43- require.ErrorIs(t, err, errEmptyName)
105+func TestCatalogueDecodeAllFailsOnMalformedRecord(t *testing.T) {
106+ good, err := encodeRecordValue(validRecord("good", 1))
107+ require.NoError(t, err)
108+ _, err = decodeAll(map[string][]byte{"bad": {0, 1}, "good": good})
109+ require.Error(t, err)
110+}
44111
45- badValues := [][]byte{
46- nil,
47- {0, 0, 0},
48- {0, 0, 0, 5, 'a'},
49- {0, 0, 0, 1, 'a', 0},
50- {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
51- }
52- for _, value := range badValues {
53- _, err := decodeRecordValue("name", value)
54- require.ErrorIs(t, err, errMalformedRecord)
55- }
112+func TestCatalogueMutationsUseOneSync(t *testing.T) {
113+ store := portsmocks.NewMockStore(t)
114+ state := map[string][]byte{}
115+ store.EXPECT().Range(mock.Anything).Run(func(fn func([]byte, []byte) bool) {
116+ for key, value := range state {
117+ if !fn([]byte(key), value) {
118+ return
119+ }
120+ }
121+ }).Once()
122+ store.EXPECT().Get(mock.Anything).RunAndReturn(func(key []byte) ([]byte, bool) {
123+ value, ok := state[string(key)]
124+ return append([]byte(nil), value...), ok
125+ }).Times(3)
126+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error {
127+ state[string(key)] = append([]byte(nil), value...)
128+ return nil
129+ }).Times(2)
130+ store.EXPECT().Delete([]byte("one")).RunAndReturn(func(key []byte) error {
131+ delete(state, string(key))
132+ return nil
133+ }).Once()
134+ store.EXPECT().Sync().Return(nil).Twice()
135+ p := New(store)
136+ one := validRecord("one", 1)
137+ require.NoError(t, p.Replace("one", one))
138+ renamed := one
139+ renamed.Name = "renamed"
140+ renamed.Cwd = "/renamed"
141+ require.NoError(t, p.Rename("one", renamed))
142+ require.NotContains(t, state, "one")
143+ encoded, ok := state["renamed"]
144+ require.True(t, ok)
145+ got, err := decodeRecordValue("renamed", encoded)
146+ require.NoError(t, err)
147+ require.Equal(t, renamed, got)
148+}
56149
57- _, err = decodeRecordValue("", make([]byte, 20))
58- require.ErrorIs(t, err, errEmptyName)
150+func TestCatalogueMutationFailureDoesNotSync(t *testing.T) {
151+ store := portsmocks.NewMockStore(t)
152+ setErr := errors.New("set failed")
153+ store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {})
154+ store.EXPECT().Get([]byte("one")).Return(nil, false).Once()
155+ store.EXPECT().Set(mock.Anything, mock.Anything).Return(setErr)
156+ store.EXPECT().Delete([]byte("one")).Return(nil).Once()
157+ p := New(store)
158+ err := p.Replace("one", validRecord("one", 1))
159+ require.ErrorIs(t, err, setErr)
59160 }
60161
61-func TestNilPersisterNoOps(t *testing.T) {
162+// TestCatalogueNilPersisterIsNoOp covers the ephemeral/optional-persistence
163+// sentinel: a nil *Persister. Every mutating and reading method must be a
164+// silent no-op so callers that never enable persistence don't need nil
165+// checks of their own.
166+func TestCatalogueNilPersisterIsNoOp(t *testing.T) {
62167 var p *Persister
63- require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
64- require.NoError(t, p.Touch("a", "/tmp/next", 2))
65- require.NoError(t, p.Delete("a"))
168+ require.NoError(t, p.Save(validRecord("one", 1)))
169+ require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": nil}))
170+ require.NoError(t, p.UpdateMetadata(validRecord("one", 1).MetadataUpdate()))
171+ require.NoError(t, p.Delete("one"))
66172 records, err := p.LoadAll()
67173 require.NoError(t, err)
68174 require.Empty(t, records)
69175 require.NoError(t, p.Close())
70-
71- p = New(nil)
72- require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
73- require.NoError(t, p.Touch("a", "/tmp/next", 2))
74- require.NoError(t, p.Delete("a"))
75- records, err = p.LoadAll()
176+ got, err := p.Records()
76177 require.NoError(t, err)
77- require.Empty(t, records)
78- require.NoError(t, p.Close())
178+ require.Empty(t, got)
79179 }
80180
81-func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
82- dir := privateDir(t)
83- p, err := Open(dir)
84- require.NoError(t, err)
85-
86- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40, TabNames: []string{"shell"}}))
87- require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))
88- require.NoError(t, p.Touch("b", "/b/next", 30))
89- require.NoError(t, p.TouchMRU("b", 60))
90-
181+// TestCatalogueUnavailableStoreErrors covers a non-nil Persister with no
182+// backing store (e.g. New(nil)). Unlike the nil-persister sentinel above,
183+// this is a Persister that was supposed to have a store: silently no-oping
184+// here previously made recovery.Coordinator's checkpoint publish/retry/
185+// discard paths believe a durable commit happened when nothing was written.
186+// Every mutation and read-with-error must surface errPersistenceUnavailable,
187+// matching Create; only Records() (which ports.Catalogue gives no error
188+// return for) degrades to an empty slice, with the failure logged instead.
189+func TestCatalogueUnavailableStoreErrors(t *testing.T) {
190+ p := New(nil)
191+ require.ErrorIs(t, p.Save(validRecord("one", 1)), errPersistenceUnavailable)
192+ require.ErrorIs(t, p.Apply(map[string]*domain.CatalogueRecord{"one": nil}), errPersistenceUnavailable)
193+ require.ErrorIs(t, p.Rename("one", validRecord("two", 1)), errPersistenceUnavailable)
194+ require.ErrorIs(t, p.UpdateMetadata(validRecord("one", 1).MetadataUpdate()), errPersistenceUnavailable)
195+ require.ErrorIs(t, p.Delete("one"), errPersistenceUnavailable)
196+ require.ErrorIs(t, p.Create(validRecord("one", 1)), errPersistenceUnavailable)
91197 records, err := p.LoadAll()
92- require.NoError(t, err)
93- require.Equal(t, []Record{
94- {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},
95- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}},
96- }, records)
97-
98- require.NoError(t, p.Delete("a"))
99- records, err = p.LoadAll()
100- require.NoError(t, err)
101- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}}}, records)
198+ require.ErrorIs(t, err, errPersistenceUnavailable)
199+ require.Empty(t, records)
200+ got, err := p.Records()
201+ require.ErrorIs(t, err, errPersistenceUnavailable)
202+ require.Empty(t, got)
102203 require.NoError(t, p.Close())
103204 }
104205
105-func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
106- good, err := encodeRecordValue(Record{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3})
107- require.NoError(t, err)
108-
109- records, err := decodeAll(map[string][]byte{
110- "bad": []byte{0, 0, 0, 5, 'x'},
111- "good": good,
206+func TestCatalogueRecordsPropagatesMalformedRecord(t *testing.T) {
207+ store := portsmocks.NewMockStore(t)
208+ store.EXPECT().Range(mock.Anything).Run(func(fn func([]byte, []byte) bool) {
209+ fn([]byte("bad"), []byte{0, 1})
112210 })
113- require.NoError(t, err)
114- require.Equal(t, []Record{{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}}, records)
115-}
116-
117-func TestLoadReadOnlyUsesReplay(t *testing.T) {
118- dir := privateDir(t)
119- p, err := Open(dir)
120- require.NoError(t, err)
121- require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}))
122- require.NoError(t, p.Close())
123-
124- records, err := LoadReadOnly(dir)
125- require.NoError(t, err)
126- require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3}}, records)
127-
128- // Missing stores replay as empty rather than creating the WAL.
129- missing := filepath.Join(t.TempDir(), "missing")
130- records, err = LoadReadOnly(missing)
131- require.NoError(t, err)
211+ records, err := New(store).Records()
212+ require.Error(t, err)
132213 require.Empty(t, records)
133214 }
134215
135-func TestSyncBehavior(t *testing.T) {
136- store, state := newMockStore(t)
137- p := New(store)
138-
139- require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
140- require.Equal(t, 1, state.sets)
141- require.Equal(t, 1, state.syncs)
142-
143- require.NoError(t, p.Touch("work", "/work/next", 2))
144- require.Equal(t, 2, state.sets)
145- require.Equal(t, 1, state.syncs)
146-
147- require.NoError(t, p.TouchMRU("work", 3))
148- require.Equal(t, 3, state.sets)
149- require.Equal(t, 1, state.syncs)
150-
151- require.NoError(t, p.Delete("work"))
152- require.Equal(t, 1, state.deletes)
153- require.Equal(t, 2, state.syncs)
216+type failClosedStore struct {
217+ data map[string][]byte
218+ durable map[string][]byte
219+
220+ setFailures int
221+ deleteFailures int
222+ syncFailures int
223+ restoreSetFailures int
224+ restoreDeleteFailures int
225+ restoring bool
226+ failure error
227+ restoreFailure error
228+
229+ closeFlushed bool
230+ closeAborted bool
154231 }
155232
156-func TestPersisterSerializesReadModifyWriteWithStructuralSave(t *testing.T) {
157- store := newBlockingGetStore()
158- p := New(store)
159- require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, TabNames: []string{"old"}}))
160- store.blockNextGet()
161-
162- touchDone := make(chan error, 1)
163- go func() { touchDone <- p.TouchMRU("work", 2) }()
164- <-store.getStarted
165-
166- saveDone := make(chan error, 1)
167- go func() {
168- saveDone <- p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 3, LastUsedSeq: 2, TabNames: []string{"new"}})
169- }()
170- select {
171- case err := <-saveDone:
172- t.Fatalf("structural save interleaved with TouchMRU: %v", err)
173- case <-time.After(20 * time.Millisecond):
233+func newFailClosedStore(data map[string][]byte) *failClosedStore {
234+ return &failClosedStore{
235+ data: copyStoreData(data),
236+ durable: copyStoreData(data),
237+ failure: errors.New("store write failed"),
238+ restoreFailure: errors.New("store restore failed"),
174239 }
175- close(store.releaseGet)
176- require.NoError(t, <-touchDone)
177- require.NoError(t, <-saveDone)
178-
179- records, err := p.LoadAll()
180- require.NoError(t, err)
181- require.Equal(t, []string{"new"}, records[0].TabNames)
182240 }
183241
184-func TestSavePropagatesSetErrorWithoutSync(t *testing.T) {
185- store, state := newMockStore(t)
186- state.errSet = errors.New("set failed")
187- p := New(store)
188-
189- err := p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1})
190- require.ErrorIs(t, err, state.errSet)
191- require.Zero(t, state.syncs)
192-}
193-
194-type mockStoreState struct {
195- data map[string][]byte
196- sets int
197- deletes int
198- syncs int
199- closed bool
200- errSet error
242+func (s *failClosedStore) Get(key []byte) ([]byte, bool) {
243+ value, ok := s.data[string(key)]
244+ return append([]byte(nil), value...), ok
201245 }
202246
203-type blockingGetStore struct {
204- mu sync.Mutex
205- data map[string][]byte
206- block bool
207- getStarted chan struct{}
208- releaseGet chan struct{}
209- getStartOnce sync.Once
247+func (s *failClosedStore) Set(key, value []byte) error {
248+ s.data[string(key)] = append([]byte(nil), value...)
249+ if s.restoring && s.restoreSetFailures > 0 {
250+ s.restoreSetFailures--
251+ return s.restoreFailure
252+ }
253+ if s.setFailures > 0 {
254+ s.setFailures--
255+ return s.failure
256+ }
257+ return nil
210258 }
211259
212-func newBlockingGetStore() *blockingGetStore {
213- return &blockingGetStore{data: make(map[string][]byte), getStarted: make(chan struct{}), releaseGet: make(chan struct{})}
260+func (s *failClosedStore) Delete(key []byte) error {
261+ delete(s.data, string(key))
262+ if s.restoring && s.restoreDeleteFailures > 0 {
263+ s.restoreDeleteFailures--
264+ return s.restoreFailure
265+ }
266+ if s.deleteFailures > 0 {
267+ s.deleteFailures--
268+ return s.failure
269+ }
270+ return nil
214271 }
215272
216-func (s *blockingGetStore) blockNextGet() {
217- s.mu.Lock()
218- s.block = true
219- s.mu.Unlock()
273+func (s *failClosedStore) Range(fn func([]byte, []byte) bool) {
274+ for key, value := range s.data {
275+ if !fn([]byte(key), append([]byte(nil), value...)) {
276+ return
277+ }
278+ }
220279 }
221280
222-func (s *blockingGetStore) Get(key []byte) ([]byte, bool) {
223- s.mu.Lock()
224- v, ok := s.data[string(key)]
225- v = append([]byte(nil), v...)
226- block := s.block
227- if block {
228- s.block = false
229- }
230- s.mu.Unlock()
231- if block {
232- s.getStartOnce.Do(func() { close(s.getStarted) })
233- <-s.releaseGet
281+func (s *failClosedStore) Sync() error {
282+ if s.syncFailures > 0 {
283+ s.syncFailures--
284+ s.restoring = true
285+ return s.failure
234286 }
235- return v, ok
287+ s.durable = copyStoreData(s.data)
288+ return nil
236289 }
237290
238-func (s *blockingGetStore) Set(key, value []byte) error {
239- s.mu.Lock()
240- s.data[string(key)] = append([]byte(nil), value...)
241- s.mu.Unlock()
291+func (s *failClosedStore) Close() error {
292+ s.closeFlushed = true
293+ s.durable = copyStoreData(s.data)
242294 return nil
243295 }
244296
245-func (s *blockingGetStore) Delete(key []byte) error {
246- s.mu.Lock()
247- delete(s.data, string(key))
248- s.mu.Unlock()
297+func (s *failClosedStore) CloseWithoutSync() error {
298+ s.closeAborted = true
249299 return nil
250300 }
251301
252-func (s *blockingGetStore) Range(fn func(k, v []byte) bool) {
253- s.mu.Lock()
254- defer s.mu.Unlock()
255- for key, value := range s.data {
256- if !fn([]byte(key), append([]byte(nil), value...)) {
257- return
258- }
302+func copyStoreData(data map[string][]byte) map[string][]byte {
303+ cloned := make(map[string][]byte, len(data))
304+ for key, value := range data {
305+ cloned[key] = append([]byte(nil), value...)
259306 }
307+ return cloned
260308 }
261309
262-func (*blockingGetStore) Sync() error { return nil }
263-func (*blockingGetStore) Close() error { return nil }
310+func TestRejectedIdentityWriteFencesCatalogue(t *testing.T) {
311+ t.Parallel()
312+ one, err := encodeRecordValue(validRecord("work", 1))
313+ require.NoError(t, err)
264314
265-func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
266- t.Helper()
267- state := &mockStoreState{data: make(map[string][]byte)}
268- store := portsmocks.NewMockStore(t)
269- store.EXPECT().Get(mock.Anything).RunAndReturn(func(key []byte) ([]byte, bool) {
270- v, ok := state.data[string(key)]
271- return append([]byte(nil), v...), ok
272- }).Maybe()
273- store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, val []byte) error {
274- state.sets++
275- if state.errSet != nil {
276- return state.errSet
277- }
278- state.data[string(key)] = append([]byte(nil), val...)
279- return nil
280- }).Maybe()
281- store.EXPECT().Delete(mock.Anything).RunAndReturn(func(key []byte) error {
282- state.deletes++
283- delete(state.data, string(key))
284- return nil
285- }).Maybe()
286- store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) {
287- for k, v := range state.data {
288- if !fn([]byte(k), append([]byte(nil), v...)) {
289- return
290- }
291- }
292- }).Maybe()
293- store.EXPECT().Sync().RunAndReturn(func() error {
294- state.syncs++
295- return nil
296- }).Maybe()
297- store.EXPECT().Close().RunAndReturn(func() error {
298- state.closed = true
299- return nil
300- }).Maybe()
301- return store, state
315+ tests := []struct {
316+ name string
317+ initial map[string][]byte
318+ setFailures int
319+ deleteFailures int
320+ syncFailures int
321+ mutate func(*Persister) error
322+ }{
323+ {
324+ name: "create set failure",
325+ initial: map[string][]byte{},
326+ setFailures: 1,
327+ mutate: func(p *Persister) error {
328+ return p.Create(validRecord("new", 2))
329+ },
330+ },
331+ {
332+ name: "create sync failure",
333+ initial: map[string][]byte{},
334+ syncFailures: 1,
335+ mutate: func(p *Persister) error {
336+ return p.Create(validRecord("new", 2))
337+ },
338+ },
339+ {
340+ name: "replace set failure",
341+ initial: map[string][]byte{"work": one},
342+ setFailures: 1,
343+ mutate: func(p *Persister) error {
344+ next := validRecord("work", 1)
345+ next.Cwd = "/next"
346+ return p.Replace("work", next)
347+ },
348+ },
349+ {
350+ name: "replace sync failure",
351+ initial: map[string][]byte{"work": one},
352+ syncFailures: 1,
353+ mutate: func(p *Persister) error {
354+ next := validRecord("work", 1)
355+ next.Cwd = "/next"
356+ return p.Replace("work", next)
357+ },
358+ },
359+ {
360+ name: "rename set failure",
361+ initial: map[string][]byte{"work": one},
362+ setFailures: 1,
363+ mutate: func(p *Persister) error {
364+ next := validRecord("renamed", 1)
365+ return p.Rename("work", next)
366+ },
367+ },
368+ {
369+ name: "rename delete failure",
370+ initial: map[string][]byte{"work": one},
371+ deleteFailures: 1,
372+ mutate: func(p *Persister) error {
373+ next := validRecord("renamed", 1)
374+ return p.Rename("work", next)
375+ },
376+ },
377+ {
378+ name: "rename sync failure",
379+ initial: map[string][]byte{"work": one},
380+ syncFailures: 1,
381+ mutate: func(p *Persister) error {
382+ next := validRecord("renamed", 1)
383+ return p.Rename("work", next)
384+ },
385+ },
386+ {
387+ name: "delete delete failure",
388+ initial: map[string][]byte{"work": one},
389+ deleteFailures: 1,
390+ mutate: func(p *Persister) error {
391+ return p.Delete("work")
392+ },
393+ },
394+ {
395+ name: "delete sync failure",
396+ initial: map[string][]byte{"work": one},
397+ syncFailures: 1,
398+ mutate: func(p *Persister) error {
399+ return p.Delete("work")
400+ },
401+ },
402+ }
403+
404+ for _, tt := range tests {
405+ t.Run(tt.name, func(t *testing.T) {
406+ t.Parallel()
407+ store := newFailClosedStore(tt.initial)
408+ store.setFailures = tt.setFailures
409+ store.deleteFailures = tt.deleteFailures
410+ store.syncFailures = tt.syncFailures
411+ p := New(store)
412+
413+ err := tt.mutate(p)
414+ require.ErrorIs(t, err, ErrCatalogueDurability)
415+ require.ErrorIs(t, err, store.failure)
416+ require.Equal(t, tt.initial, store.data, "rejected identity keys must be restored in memory")
417+ require.Equal(t, tt.initial, store.durable, "rejected identity write must not reach disk")
418+
419+ _, _, recordErr := p.Record("work")
420+ require.ErrorIs(t, recordErr, ErrCatalogueDurability)
421+ require.ErrorIs(t, p.Sync(), ErrCatalogueDurability)
422+ require.ErrorIs(t, p.Create(validRecord("later", 3)), ErrCatalogueDurability)
423+ require.Equal(t, tt.initial, store.durable, "later Sync must be fenced")
424+
425+ require.ErrorIs(t, p.Close(), ErrCatalogueDurability)
426+ require.True(t, store.closeAborted, "Close must release without flushing rejected state")
427+ require.False(t, store.closeFlushed)
428+ require.Equal(t, tt.initial, store.durable, "Close must not make rejected state durable")
429+ })
430+ }
431+}
432+
433+func TestFailedSyncRetainsRepeatedRollbackFailuresAndAborts(t *testing.T) {
434+ t.Parallel()
435+ encoded, err := encodeRecordValue(validRecord("work", 1))
436+ require.NoError(t, err)
437+ store := newFailClosedStore(map[string][]byte{"work": encoded})
438+ store.syncFailures = 1
439+ store.restoreSetFailures = 1
440+ store.restoreDeleteFailures = 1
441+ p := New(store)
442+
443+ err = p.Rename("work", validRecord("renamed", 1))
444+ require.ErrorIs(t, err, ErrCatalogueDurability)
445+ require.ErrorIs(t, err, store.failure)
446+ require.ErrorIs(t, err, store.restoreFailure)
447+ require.Zero(t, store.restoreSetFailures, "rollback Set failure must be observed")
448+ require.Zero(t, store.restoreDeleteFailures, "rollback Delete failure must be observed")
449+
450+ terminal := p.Sync()
451+ require.Same(t, err, terminal, "the fenced terminal error must remain stable")
452+ require.ErrorIs(t, terminal, store.failure)
453+ require.ErrorIs(t, terminal, store.restoreFailure)
454+ require.ErrorIs(t, p.Close(), ErrCatalogueDurability)
455+ require.True(t, store.closeAborted)
456+ require.False(t, store.closeFlushed)
457+ require.Equal(t, map[string][]byte{"work": encoded}, store.durable,
458+ "aborting a fenced store must not persist dirty rejected state")
459+}
460+
461+func TestRejectedIdentityWriteRestoresBufferedMetadata(t *testing.T) {
462+ t.Parallel()
463+ encoded, err := encodeRecordValue(validRecord("work", 1))
464+ require.NoError(t, err)
465+ store := newFailClosedStore(map[string][]byte{"work": encoded})
466+ p := New(store)
467+
468+ cwd := "/buffered"
469+ require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
470+ Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: &cwd,
471+ }))
472+ buffered := copyStoreData(store.data)
473+ store.syncFailures = 1
474+
475+ require.ErrorIs(t, p.Rename("work", validRecord("renamed", 1)), ErrCatalogueDurability)
476+ require.Equal(t, buffered, store.data, "rollback must preserve already-buffered metadata on the affected key")
477+ require.Equal(t, map[string][]byte{"work": encoded}, store.durable)
478+ require.ErrorIs(t, p.Close(), ErrCatalogueDurability)
479+ require.Equal(t, map[string][]byte{"work": encoded}, store.durable)
302480 }
M internal/persist/record.go +181 -105
@@ -5,122 +5,198 @@ import (
55 "errors"
66 "fmt"
77 "math"
8+
9+ "github.com/bnema/vev/internal/domain"
810 )
911
10-// Record is the persisted metadata for a named session.
11-type Record struct {
12- Name string
13- Cwd string
14- CreatedAt int64
15- UpdatedAt int64
16- LastUsedSeq uint64
17- TabNames []string
18-}
12+const catalogueRecordVersion uint16 = 3
1913
20-var (
21- errEmptyName = errors.New("persist: empty session name")
22- errMalformedRecord = errors.New("persist: malformed record")
23-)
14+var errMalformedRecord = errors.New("persist: malformed catalogue record")
15+
16+var catalogueMagic = [4]byte{'V', 'E', 'V', 'C'}
17+
18+// Record is the durable catalogue record used by daemon code.
19+type Record = domain.CatalogueRecord
2420
25-func encodeRecordValue(r Record) ([]byte, error) {
26- if r.Name == "" {
27- return nil, errEmptyName
21+func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) {
22+ if err := record.Validate(); err != nil {
23+ return nil, fmt.Errorf("persist: invalid catalogue record: %w", err)
24+ }
25+ buf := make([]byte, 0, 128)
26+ buf = append(buf, catalogueMagic[:]...)
27+ buf = binary.BigEndian.AppendUint16(buf, catalogueRecordVersion)
28+ buf = append(buf, record.IncarnationID[:]...)
29+ var err error
30+ buf, err = appendCheckedString(buf, record.Cwd)
31+ if err != nil {
32+ return nil, err
33+ }
34+ buf = binary.BigEndian.AppendUint64(buf, uint64(record.CreatedAt))
35+ buf = binary.BigEndian.AppendUint64(buf, uint64(record.UpdatedAt))
36+ buf = binary.BigEndian.AppendUint64(buf, record.LastUsedSeq)
37+ if uint64(len(record.TabNames)) > math.MaxUint32 {
38+ return nil, errors.New("persist: too many tab names")
39+ }
40+ buf = binary.BigEndian.AppendUint32(buf, uint32(len(record.TabNames)))
41+ for _, name := range record.TabNames {
42+ buf, err = appendCheckedString(buf, name)
43+ if err != nil {
44+ return nil, err
45+ }
2846 }
29- if uint64(len(r.Cwd)) > math.MaxUint32 {
30- return nil, fmt.Errorf("persist: cwd too large")
47+ buf = appendRef(buf, record.Committed)
48+ buf, err = appendCheckedString(buf, record.DegradedReason)
49+ return buf, err
50+}
51+
52+func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error) {
53+ if err := domain.ValidateSessionName(name); err != nil {
54+ return domain.CatalogueRecord{}, err
55+ }
56+ reader := valueReader{data: value}
57+ magic, ok := reader.take(4)
58+ if !ok || string(magic) != string(catalogueMagic[:]) {
59+ return domain.CatalogueRecord{}, errMalformedRecord
60+ }
61+ version, ok := reader.u16()
62+ if !ok || version != catalogueRecordVersion {
63+ return domain.CatalogueRecord{}, errMalformedRecord
64+ }
65+ id, ok := reader.take(16)
66+ if !ok {
67+ return domain.CatalogueRecord{}, errMalformedRecord
68+ }
69+ record := domain.CatalogueRecord{Name: name}
70+ copy(record.IncarnationID[:], id)
71+ var valid bool
72+ if record.Cwd, valid = reader.str(); !valid {
73+ return domain.CatalogueRecord{}, errMalformedRecord
74+ }
75+ created, ok := reader.u64()
76+ if !ok {
77+ return domain.CatalogueRecord{}, errMalformedRecord
78+ }
79+ record.CreatedAt = int64(created)
80+ updated, ok := reader.u64()
81+ if !ok {
82+ return domain.CatalogueRecord{}, errMalformedRecord
83+ }
84+ record.UpdatedAt = int64(updated)
85+ if record.LastUsedSeq, ok = reader.u64(); !ok {
86+ return domain.CatalogueRecord{}, errMalformedRecord
87+ }
88+ count, ok := reader.u32()
89+ if !ok || uint64(count) > uint64(reader.remaining())/4 {
90+ return domain.CatalogueRecord{}, errMalformedRecord
91+ }
92+ if count > 0 {
93+ record.TabNames = make([]string, 0, int(count))
94+ }
95+ for range count {
96+ tab, ok := reader.str()
97+ if !ok {
98+ return domain.CatalogueRecord{}, errMalformedRecord
99+ }
100+ record.TabNames = append(record.TabNames, tab)
31101 }
32- if uint64(len(r.TabNames)) > math.MaxUint32 {
33- return nil, fmt.Errorf("persist: too many tab names")
102+ if record.Committed, ok = reader.ref(); !ok {
103+ return domain.CatalogueRecord{}, errMalformedRecord
34104 }
35- tabNamesSize := 4
36- for _, name := range r.TabNames {
37- if uint64(len(name)) > math.MaxUint32 {
38- return nil, fmt.Errorf("persist: tab name too large")
39- }
40- tabNamesSize += 4 + len(name)
105+ if record.DegradedReason, ok = reader.str(); !ok || reader.remaining() != 0 {
106+ return domain.CatalogueRecord{}, errMalformedRecord
107+ }
108+ if err := record.Validate(); err != nil {
109+ return domain.CatalogueRecord{}, errors.Join(errMalformedRecord, fmt.Errorf("persist: invalid catalogue record: %w", err))
41110 }
111+ return record, nil
112+}
42113
43- buf := make([]byte, 4+len(r.Cwd)+8+8+8+tabNamesSize)
44- binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
45- copy(buf[4:], r.Cwd)
46- off := 4 + len(r.Cwd)
47- binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
48- binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
49- binary.BigEndian.PutUint64(buf[off+16:off+24], r.LastUsedSeq)
50- off += 24
51- binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(r.TabNames)))
52- off += 4
53- for _, name := range r.TabNames {
54- binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(name)))
55- off += 4
56- copy(buf[off:], name)
57- off += len(name)
58- }
59- return buf, nil
114+func appendCheckedString(buf []byte, value string) ([]byte, error) {
115+ if uint64(len(value)) > math.MaxUint32 {
116+ return nil, errors.New("persist: string too large")
117+ }
118+ buf = binary.BigEndian.AppendUint32(buf, uint32(len(value)))
119+ return append(buf, value...), nil
120+}
121+func appendRef(buf []byte, ref *domain.CheckpointRef) []byte {
122+ if ref == nil {
123+ return append(buf, 0)
124+ }
125+ buf = append(buf, 1)
126+ buf = binary.BigEndian.AppendUint64(buf, ref.Generation)
127+ return append(buf, ref.ManifestDigest[:]...)
60128 }
61129
62-func decodeRecordValue(name string, value []byte) (Record, error) {
63- if name == "" {
64- return Record{}, errEmptyName
65- }
66- if len(value) < 4 {
67- return Record{}, errMalformedRecord
68- }
69- cwdLen32 := binary.BigEndian.Uint32(value[:4])
70- remaining := len(value) - 4
71- if uint64(cwdLen32) > uint64(remaining) {
72- return Record{}, errMalformedRecord
73- }
74- cwdLen := int(cwdLen32)
75- baseLen := 4 + cwdLen + 8 + 8
76- if len(value) < baseLen {
77- return Record{}, errMalformedRecord
78- }
79- off := 4 + cwdLen
80- r := Record{
81- Name: name,
82- Cwd: string(value[4:off]),
83- CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
84- UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
85- }
86- if len(value) == baseLen {
87- return r, nil
88- }
89- if len(value) < baseLen+8 {
90- return Record{}, errMalformedRecord
91- }
92- if len(value) == baseLen+8 {
93- r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
94- return r, nil
95- }
96- r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
97- off += 24
98- if len(value)-off < 4 {
99- return Record{}, errMalformedRecord
100- }
101- tabCount32 := binary.BigEndian.Uint32(value[off : off+4])
102- off += 4
103- if uint64(tabCount32) > uint64(len(value)-off)/4 {
104- return Record{}, errMalformedRecord
105- }
106- if tabCount32 > 0 {
107- r.TabNames = make([]string, 0, int(tabCount32))
108- }
109- for range tabCount32 {
110- if len(value)-off < 4 {
111- return Record{}, errMalformedRecord
112- }
113- nameLen32 := binary.BigEndian.Uint32(value[off : off+4])
114- off += 4
115- if uint64(nameLen32) > uint64(len(value)-off) {
116- return Record{}, errMalformedRecord
117- }
118- nameLen := int(nameLen32)
119- r.TabNames = append(r.TabNames, string(value[off:off+nameLen]))
120- off += nameLen
130+type valueReader struct {
131+ data []byte
132+ off int
133+}
134+
135+func (r *valueReader) remaining() int { return len(r.data) - r.off }
136+func (r *valueReader) take(n int) ([]byte, bool) {
137+ if n < 0 || n > r.remaining() {
138+ return nil, false
139+ }
140+ value := r.data[r.off : r.off+n]
141+ r.off += n
142+ return value, true
143+}
144+func (r *valueReader) byte() (byte, bool) {
145+ value, ok := r.take(1)
146+ if !ok {
147+ return 0, false
148+ }
149+ return value[0], true
150+}
151+func (r *valueReader) u16() (uint16, bool) {
152+ value, ok := r.take(2)
153+ if !ok {
154+ return 0, false
155+ }
156+ return binary.BigEndian.Uint16(value), true
157+}
158+func (r *valueReader) u32() (uint32, bool) {
159+ value, ok := r.take(4)
160+ if !ok {
161+ return 0, false
162+ }
163+ return binary.BigEndian.Uint32(value), true
164+}
165+func (r *valueReader) u64() (uint64, bool) {
166+ value, ok := r.take(8)
167+ if !ok {
168+ return 0, false
169+ }
170+ return binary.BigEndian.Uint64(value), true
171+}
172+func (r *valueReader) str() (string, bool) {
173+ size, ok := r.u32()
174+ if !ok || uint64(size) > uint64(r.remaining()) {
175+ return "", false
176+ }
177+ value, _ := r.take(int(size))
178+ return string(value), true
179+}
180+func (r *valueReader) ref() (*domain.CheckpointRef, bool) {
181+ present, ok := r.byte()
182+ if !ok {
183+ return nil, false
184+ }
185+ if present == 0 {
186+ return nil, true
187+ }
188+ if present != 1 {
189+ return nil, false
190+ }
191+ generation, ok := r.u64()
192+ if !ok {
193+ return nil, false
121194 }
122- if off != len(value) {
123- return Record{}, errMalformedRecord
195+ digest, ok := r.take(32)
196+ if !ok {
197+ return nil, false
124198 }
125- return r, nil
199+ ref := &domain.CheckpointRef{Generation: generation}
200+ copy(ref.ManifestDigest[:], digest)
201+ return ref, true
126202 }
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 18
4+const ProtocolVersion uint16 = 19
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 18 {
7- t.Fatalf("ProtocolVersion = %d, want 18", ProtocolVersion)
6+ if ProtocolVersion != 19 {
7+ t.Fatalf("ProtocolVersion = %d, want 19", ProtocolVersion)
88 }
99 }
1010
A internal/ports/mocks/recovery_mock.go +855 -0
@@ -0,0 +1,855 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockCatalogue creates a new instance of MockCatalogue. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockCatalogue(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockCatalogue {
21+ mock := &MockCatalogue{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockCatalogue is an autogenerated mock type for the Catalogue type
30+type MockCatalogue struct {
31+ mock.Mock
32+}
33+
34+type MockCatalogue_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockCatalogue) EXPECT() *MockCatalogue_Expecter {
39+ return &MockCatalogue_Expecter{mock: &_m.Mock}
40+}
41+
42+// Close provides a mock function for the type MockCatalogue
43+func (_mock *MockCatalogue) Close() error {
44+ ret := _mock.Called()
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for Close")
48+ }
49+
50+ var r0 error
51+ if returnFunc, ok := ret.Get(0).(func() error); ok {
52+ r0 = returnFunc()
53+ } else {
54+ r0 = ret.Error(0)
55+ }
56+ return r0
57+}
58+
59+// MockCatalogue_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
60+type MockCatalogue_Close_Call struct {
61+ *mock.Call
62+}
63+
64+// Close is a helper method to define mock.On call
65+func (_e *MockCatalogue_Expecter) Close() *MockCatalogue_Close_Call {
66+ return &MockCatalogue_Close_Call{Call: _e.mock.On("Close")}
67+}
68+
69+func (_c *MockCatalogue_Close_Call) Run(run func()) *MockCatalogue_Close_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ run()
72+ })
73+ return _c
74+}
75+
76+func (_c *MockCatalogue_Close_Call) Return(err error) *MockCatalogue_Close_Call {
77+ _c.Call.Return(err)
78+ return _c
79+}
80+
81+func (_c *MockCatalogue_Close_Call) RunAndReturn(run func() error) *MockCatalogue_Close_Call {
82+ _c.Call.Return(run)
83+ return _c
84+}
85+
86+// Create provides a mock function for the type MockCatalogue
87+func (_mock *MockCatalogue) Create(catalogueRecord domain.CatalogueRecord) error {
88+ ret := _mock.Called(catalogueRecord)
89+
90+ if len(ret) == 0 {
91+ panic("no return value specified for Create")
92+ }
93+
94+ var r0 error
95+ if returnFunc, ok := ret.Get(0).(func(domain.CatalogueRecord) error); ok {
96+ r0 = returnFunc(catalogueRecord)
97+ } else {
98+ r0 = ret.Error(0)
99+ }
100+ return r0
101+}
102+
103+// MockCatalogue_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
104+type MockCatalogue_Create_Call struct {
105+ *mock.Call
106+}
107+
108+// Create is a helper method to define mock.On call
109+// - catalogueRecord domain.CatalogueRecord
110+func (_e *MockCatalogue_Expecter) Create(catalogueRecord any) *MockCatalogue_Create_Call {
111+ return &MockCatalogue_Create_Call{Call: _e.mock.On("Create", catalogueRecord)}
112+}
113+
114+func (_c *MockCatalogue_Create_Call) Run(run func(catalogueRecord domain.CatalogueRecord)) *MockCatalogue_Create_Call {
115+ _c.Call.Run(func(args mock.Arguments) {
116+ var arg0 domain.CatalogueRecord
117+ if args[0] != nil {
118+ arg0 = args[0].(domain.CatalogueRecord)
119+ }
120+ run(
121+ arg0,
122+ )
123+ })
124+ return _c
125+}
126+
127+func (_c *MockCatalogue_Create_Call) Return(err error) *MockCatalogue_Create_Call {
128+ _c.Call.Return(err)
129+ return _c
130+}
131+
132+func (_c *MockCatalogue_Create_Call) RunAndReturn(run func(catalogueRecord domain.CatalogueRecord) error) *MockCatalogue_Create_Call {
133+ _c.Call.Return(run)
134+ return _c
135+}
136+
137+// Delete provides a mock function for the type MockCatalogue
138+func (_mock *MockCatalogue) Delete(s string) error {
139+ ret := _mock.Called(s)
140+
141+ if len(ret) == 0 {
142+ panic("no return value specified for Delete")
143+ }
144+
145+ var r0 error
146+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
147+ r0 = returnFunc(s)
148+ } else {
149+ r0 = ret.Error(0)
150+ }
151+ return r0
152+}
153+
154+// MockCatalogue_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
155+type MockCatalogue_Delete_Call struct {
156+ *mock.Call
157+}
158+
159+// Delete is a helper method to define mock.On call
160+// - s string
161+func (_e *MockCatalogue_Expecter) Delete(s any) *MockCatalogue_Delete_Call {
162+ return &MockCatalogue_Delete_Call{Call: _e.mock.On("Delete", s)}
163+}
164+
165+func (_c *MockCatalogue_Delete_Call) Run(run func(s string)) *MockCatalogue_Delete_Call {
166+ _c.Call.Run(func(args mock.Arguments) {
167+ var arg0 string
168+ if args[0] != nil {
169+ arg0 = args[0].(string)
170+ }
171+ run(
172+ arg0,
173+ )
174+ })
175+ return _c
176+}
177+
178+func (_c *MockCatalogue_Delete_Call) Return(err error) *MockCatalogue_Delete_Call {
179+ _c.Call.Return(err)
180+ return _c
181+}
182+
183+func (_c *MockCatalogue_Delete_Call) RunAndReturn(run func(s string) error) *MockCatalogue_Delete_Call {
184+ _c.Call.Return(run)
185+ return _c
186+}
187+
188+// Record provides a mock function for the type MockCatalogue
189+func (_mock *MockCatalogue) Record(s string) (domain.CatalogueRecord, bool, error) {
190+ ret := _mock.Called(s)
191+
192+ if len(ret) == 0 {
193+ panic("no return value specified for Record")
194+ }
195+
196+ var r0 domain.CatalogueRecord
197+ var r1 bool
198+ var r2 error
199+ if returnFunc, ok := ret.Get(0).(func(string) (domain.CatalogueRecord, bool, error)); ok {
200+ return returnFunc(s)
201+ }
202+ if returnFunc, ok := ret.Get(0).(func(string) domain.CatalogueRecord); ok {
203+ r0 = returnFunc(s)
204+ } else {
205+ r0 = ret.Get(0).(domain.CatalogueRecord)
206+ }
207+ if returnFunc, ok := ret.Get(1).(func(string) bool); ok {
208+ r1 = returnFunc(s)
209+ } else {
210+ r1 = ret.Get(1).(bool)
211+ }
212+ if returnFunc, ok := ret.Get(2).(func(string) error); ok {
213+ r2 = returnFunc(s)
214+ } else {
215+ r2 = ret.Error(2)
216+ }
217+ return r0, r1, r2
218+}
219+
220+// MockCatalogue_Record_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Record'
221+type MockCatalogue_Record_Call struct {
222+ *mock.Call
223+}
224+
225+// Record is a helper method to define mock.On call
226+// - s string
227+func (_e *MockCatalogue_Expecter) Record(s any) *MockCatalogue_Record_Call {
228+ return &MockCatalogue_Record_Call{Call: _e.mock.On("Record", s)}
229+}
230+
231+func (_c *MockCatalogue_Record_Call) Run(run func(s string)) *MockCatalogue_Record_Call {
232+ _c.Call.Run(func(args mock.Arguments) {
233+ var arg0 string
234+ if args[0] != nil {
235+ arg0 = args[0].(string)
236+ }
237+ run(
238+ arg0,
239+ )
240+ })
241+ return _c
242+}
243+
244+func (_c *MockCatalogue_Record_Call) Return(catalogueRecord domain.CatalogueRecord, b bool, err error) *MockCatalogue_Record_Call {
245+ _c.Call.Return(catalogueRecord, b, err)
246+ return _c
247+}
248+
249+func (_c *MockCatalogue_Record_Call) RunAndReturn(run func(s string) (domain.CatalogueRecord, bool, error)) *MockCatalogue_Record_Call {
250+ _c.Call.Return(run)
251+ return _c
252+}
253+
254+// Records provides a mock function for the type MockCatalogue
255+func (_mock *MockCatalogue) Records() ([]domain.CatalogueRecord, error) {
256+ ret := _mock.Called()
257+
258+ if len(ret) == 0 {
259+ panic("no return value specified for Records")
260+ }
261+
262+ var r0 []domain.CatalogueRecord
263+ var r1 error
264+ if returnFunc, ok := ret.Get(0).(func() ([]domain.CatalogueRecord, error)); ok {
265+ return returnFunc()
266+ }
267+ if returnFunc, ok := ret.Get(0).(func() []domain.CatalogueRecord); ok {
268+ r0 = returnFunc()
269+ } else {
270+ if ret.Get(0) != nil {
271+ r0 = ret.Get(0).([]domain.CatalogueRecord)
272+ }
273+ }
274+ if returnFunc, ok := ret.Get(1).(func() error); ok {
275+ r1 = returnFunc()
276+ } else {
277+ r1 = ret.Error(1)
278+ }
279+ return r0, r1
280+}
281+
282+// MockCatalogue_Records_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Records'
283+type MockCatalogue_Records_Call struct {
284+ *mock.Call
285+}
286+
287+// Records is a helper method to define mock.On call
288+func (_e *MockCatalogue_Expecter) Records() *MockCatalogue_Records_Call {
289+ return &MockCatalogue_Records_Call{Call: _e.mock.On("Records")}
290+}
291+
292+func (_c *MockCatalogue_Records_Call) Run(run func()) *MockCatalogue_Records_Call {
293+ _c.Call.Run(func(args mock.Arguments) {
294+ run()
295+ })
296+ return _c
297+}
298+
299+func (_c *MockCatalogue_Records_Call) Return(catalogueRecords []domain.CatalogueRecord, err error) *MockCatalogue_Records_Call {
300+ _c.Call.Return(catalogueRecords, err)
301+ return _c
302+}
303+
304+func (_c *MockCatalogue_Records_Call) RunAndReturn(run func() ([]domain.CatalogueRecord, error)) *MockCatalogue_Records_Call {
305+ _c.Call.Return(run)
306+ return _c
307+}
308+
309+// Rename provides a mock function for the type MockCatalogue
310+func (_mock *MockCatalogue) Rename(s string, catalogueRecord domain.CatalogueRecord) error {
311+ ret := _mock.Called(s, catalogueRecord)
312+
313+ if len(ret) == 0 {
314+ panic("no return value specified for Rename")
315+ }
316+
317+ var r0 error
318+ if returnFunc, ok := ret.Get(0).(func(string, domain.CatalogueRecord) error); ok {
319+ r0 = returnFunc(s, catalogueRecord)
320+ } else {
321+ r0 = ret.Error(0)
322+ }
323+ return r0
324+}
325+
326+// MockCatalogue_Rename_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Rename'
327+type MockCatalogue_Rename_Call struct {
328+ *mock.Call
329+}
330+
331+// Rename is a helper method to define mock.On call
332+// - s string
333+// - catalogueRecord domain.CatalogueRecord
334+func (_e *MockCatalogue_Expecter) Rename(s any, catalogueRecord any) *MockCatalogue_Rename_Call {
335+ return &MockCatalogue_Rename_Call{Call: _e.mock.On("Rename", s, catalogueRecord)}
336+}
337+
338+func (_c *MockCatalogue_Rename_Call) Run(run func(s string, catalogueRecord domain.CatalogueRecord)) *MockCatalogue_Rename_Call {
339+ _c.Call.Run(func(args mock.Arguments) {
340+ var arg0 string
341+ if args[0] != nil {
342+ arg0 = args[0].(string)
343+ }
344+ var arg1 domain.CatalogueRecord
345+ if args[1] != nil {
346+ arg1 = args[1].(domain.CatalogueRecord)
347+ }
348+ run(
349+ arg0,
350+ arg1,
351+ )
352+ })
353+ return _c
354+}
355+
356+func (_c *MockCatalogue_Rename_Call) Return(err error) *MockCatalogue_Rename_Call {
357+ _c.Call.Return(err)
358+ return _c
359+}
360+
361+func (_c *MockCatalogue_Rename_Call) RunAndReturn(run func(s string, catalogueRecord domain.CatalogueRecord) error) *MockCatalogue_Rename_Call {
362+ _c.Call.Return(run)
363+ return _c
364+}
365+
366+// Replace provides a mock function for the type MockCatalogue
367+func (_mock *MockCatalogue) Replace(s string, catalogueRecord domain.CatalogueRecord) error {
368+ ret := _mock.Called(s, catalogueRecord)
369+
370+ if len(ret) == 0 {
371+ panic("no return value specified for Replace")
372+ }
373+
374+ var r0 error
375+ if returnFunc, ok := ret.Get(0).(func(string, domain.CatalogueRecord) error); ok {
376+ r0 = returnFunc(s, catalogueRecord)
377+ } else {
378+ r0 = ret.Error(0)
379+ }
380+ return r0
381+}
382+
383+// MockCatalogue_Replace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Replace'
384+type MockCatalogue_Replace_Call struct {
385+ *mock.Call
386+}
387+
388+// Replace is a helper method to define mock.On call
389+// - s string
390+// - catalogueRecord domain.CatalogueRecord
391+func (_e *MockCatalogue_Expecter) Replace(s any, catalogueRecord any) *MockCatalogue_Replace_Call {
392+ return &MockCatalogue_Replace_Call{Call: _e.mock.On("Replace", s, catalogueRecord)}
393+}
394+
395+func (_c *MockCatalogue_Replace_Call) Run(run func(s string, catalogueRecord domain.CatalogueRecord)) *MockCatalogue_Replace_Call {
396+ _c.Call.Run(func(args mock.Arguments) {
397+ var arg0 string
398+ if args[0] != nil {
399+ arg0 = args[0].(string)
400+ }
401+ var arg1 domain.CatalogueRecord
402+ if args[1] != nil {
403+ arg1 = args[1].(domain.CatalogueRecord)
404+ }
405+ run(
406+ arg0,
407+ arg1,
408+ )
409+ })
410+ return _c
411+}
412+
413+func (_c *MockCatalogue_Replace_Call) Return(err error) *MockCatalogue_Replace_Call {
414+ _c.Call.Return(err)
415+ return _c
416+}
417+
418+func (_c *MockCatalogue_Replace_Call) RunAndReturn(run func(s string, catalogueRecord domain.CatalogueRecord) error) *MockCatalogue_Replace_Call {
419+ _c.Call.Return(run)
420+ return _c
421+}
422+
423+// Sync provides a mock function for the type MockCatalogue
424+func (_mock *MockCatalogue) Sync() error {
425+ ret := _mock.Called()
426+
427+ if len(ret) == 0 {
428+ panic("no return value specified for Sync")
429+ }
430+
431+ var r0 error
432+ if returnFunc, ok := ret.Get(0).(func() error); ok {
433+ r0 = returnFunc()
434+ } else {
435+ r0 = ret.Error(0)
436+ }
437+ return r0
438+}
439+
440+// MockCatalogue_Sync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sync'
441+type MockCatalogue_Sync_Call struct {
442+ *mock.Call
443+}
444+
445+// Sync is a helper method to define mock.On call
446+func (_e *MockCatalogue_Expecter) Sync() *MockCatalogue_Sync_Call {
447+ return &MockCatalogue_Sync_Call{Call: _e.mock.On("Sync")}
448+}
449+
450+func (_c *MockCatalogue_Sync_Call) Run(run func()) *MockCatalogue_Sync_Call {
451+ _c.Call.Run(func(args mock.Arguments) {
452+ run()
453+ })
454+ return _c
455+}
456+
457+func (_c *MockCatalogue_Sync_Call) Return(err error) *MockCatalogue_Sync_Call {
458+ _c.Call.Return(err)
459+ return _c
460+}
461+
462+func (_c *MockCatalogue_Sync_Call) RunAndReturn(run func() error) *MockCatalogue_Sync_Call {
463+ _c.Call.Return(run)
464+ return _c
465+}
466+
467+// UpdateMetadata provides a mock function for the type MockCatalogue
468+func (_mock *MockCatalogue) UpdateMetadata(catalogueMetadataUpdate domain.CatalogueMetadataUpdate) error {
469+ ret := _mock.Called(catalogueMetadataUpdate)
470+
471+ if len(ret) == 0 {
472+ panic("no return value specified for UpdateMetadata")
473+ }
474+
475+ var r0 error
476+ if returnFunc, ok := ret.Get(0).(func(domain.CatalogueMetadataUpdate) error); ok {
477+ r0 = returnFunc(catalogueMetadataUpdate)
478+ } else {
479+ r0 = ret.Error(0)
480+ }
481+ return r0
482+}
483+
484+// MockCatalogue_UpdateMetadata_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateMetadata'
485+type MockCatalogue_UpdateMetadata_Call struct {
486+ *mock.Call
487+}
488+
489+// UpdateMetadata is a helper method to define mock.On call
490+// - catalogueMetadataUpdate domain.CatalogueMetadataUpdate
491+func (_e *MockCatalogue_Expecter) UpdateMetadata(catalogueMetadataUpdate any) *MockCatalogue_UpdateMetadata_Call {
492+ return &MockCatalogue_UpdateMetadata_Call{Call: _e.mock.On("UpdateMetadata", catalogueMetadataUpdate)}
493+}
494+
495+func (_c *MockCatalogue_UpdateMetadata_Call) Run(run func(catalogueMetadataUpdate domain.CatalogueMetadataUpdate)) *MockCatalogue_UpdateMetadata_Call {
496+ _c.Call.Run(func(args mock.Arguments) {
497+ var arg0 domain.CatalogueMetadataUpdate
498+ if args[0] != nil {
499+ arg0 = args[0].(domain.CatalogueMetadataUpdate)
500+ }
501+ run(
502+ arg0,
503+ )
504+ })
505+ return _c
506+}
507+
508+func (_c *MockCatalogue_UpdateMetadata_Call) Return(err error) *MockCatalogue_UpdateMetadata_Call {
509+ _c.Call.Return(err)
510+ return _c
511+}
512+
513+func (_c *MockCatalogue_UpdateMetadata_Call) RunAndReturn(run func(catalogueMetadataUpdate domain.CatalogueMetadataUpdate) error) *MockCatalogue_UpdateMetadata_Call {
514+ _c.Call.Return(run)
515+ return _c
516+}
517+
518+// NewMockSnapshotRepository creates a new instance of MockSnapshotRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
519+// The first argument is typically a *testing.T value.
520+func NewMockSnapshotRepository(t interface {
521+ mock.TestingT
522+ Cleanup(func())
523+}) *MockSnapshotRepository {
524+ mock := &MockSnapshotRepository{}
525+ mock.Mock.Test(t)
526+
527+ t.Cleanup(func() { mock.AssertExpectations(t) })
528+
529+ return mock
530+}
531+
532+// MockSnapshotRepository is an autogenerated mock type for the SnapshotRepository type
533+type MockSnapshotRepository struct {
534+ mock.Mock
535+}
536+
537+type MockSnapshotRepository_Expecter struct {
538+ mock *mock.Mock
539+}
540+
541+func (_m *MockSnapshotRepository) EXPECT() *MockSnapshotRepository_Expecter {
542+ return &MockSnapshotRepository_Expecter{mock: &_m.Mock}
543+}
544+
545+// CollectGarbage provides a mock function for the type MockSnapshotRepository
546+func (_mock *MockSnapshotRepository) CollectGarbage(context1 context.Context, incarnationIDToCheckpointRef map[domain.IncarnationID]domain.CheckpointRef) error {
547+ ret := _mock.Called(context1, incarnationIDToCheckpointRef)
548+
549+ if len(ret) == 0 {
550+ panic("no return value specified for CollectGarbage")
551+ }
552+
553+ var r0 error
554+ if returnFunc, ok := ret.Get(0).(func(context.Context, map[domain.IncarnationID]domain.CheckpointRef) error); ok {
555+ r0 = returnFunc(context1, incarnationIDToCheckpointRef)
556+ } else {
557+ r0 = ret.Error(0)
558+ }
559+ return r0
560+}
561+
562+// MockSnapshotRepository_CollectGarbage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CollectGarbage'
563+type MockSnapshotRepository_CollectGarbage_Call struct {
564+ *mock.Call
565+}
566+
567+// CollectGarbage is a helper method to define mock.On call
568+// - context1 context.Context
569+// - incarnationIDToCheckpointRef map[domain.IncarnationID]domain.CheckpointRef
570+func (_e *MockSnapshotRepository_Expecter) CollectGarbage(context1 any, incarnationIDToCheckpointRef any) *MockSnapshotRepository_CollectGarbage_Call {
571+ return &MockSnapshotRepository_CollectGarbage_Call{Call: _e.mock.On("CollectGarbage", context1, incarnationIDToCheckpointRef)}
572+}
573+
574+func (_c *MockSnapshotRepository_CollectGarbage_Call) Run(run func(context1 context.Context, incarnationIDToCheckpointRef map[domain.IncarnationID]domain.CheckpointRef)) *MockSnapshotRepository_CollectGarbage_Call {
575+ _c.Call.Run(func(args mock.Arguments) {
576+ var arg0 context.Context
577+ if args[0] != nil {
578+ arg0 = args[0].(context.Context)
579+ }
580+ var arg1 map[domain.IncarnationID]domain.CheckpointRef
581+ if args[1] != nil {
582+ arg1 = args[1].(map[domain.IncarnationID]domain.CheckpointRef)
583+ }
584+ run(
585+ arg0,
586+ arg1,
587+ )
588+ })
589+ return _c
590+}
591+
592+func (_c *MockSnapshotRepository_CollectGarbage_Call) Return(err error) *MockSnapshotRepository_CollectGarbage_Call {
593+ _c.Call.Return(err)
594+ return _c
595+}
596+
597+func (_c *MockSnapshotRepository_CollectGarbage_Call) RunAndReturn(run func(context1 context.Context, incarnationIDToCheckpointRef map[domain.IncarnationID]domain.CheckpointRef) error) *MockSnapshotRepository_CollectGarbage_Call {
598+ _c.Call.Return(run)
599+ return _c
600+}
601+
602+// DeleteIncarnation provides a mock function for the type MockSnapshotRepository
603+func (_mock *MockSnapshotRepository) DeleteIncarnation(context1 context.Context, incarnationID domain.IncarnationID) error {
604+ ret := _mock.Called(context1, incarnationID)
605+
606+ if len(ret) == 0 {
607+ panic("no return value specified for DeleteIncarnation")
608+ }
609+
610+ var r0 error
611+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID) error); ok {
612+ r0 = returnFunc(context1, incarnationID)
613+ } else {
614+ r0 = ret.Error(0)
615+ }
616+ return r0
617+}
618+
619+// MockSnapshotRepository_DeleteIncarnation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteIncarnation'
620+type MockSnapshotRepository_DeleteIncarnation_Call struct {
621+ *mock.Call
622+}
623+
624+// DeleteIncarnation is a helper method to define mock.On call
625+// - context1 context.Context
626+// - incarnationID domain.IncarnationID
627+func (_e *MockSnapshotRepository_Expecter) DeleteIncarnation(context1 any, incarnationID any) *MockSnapshotRepository_DeleteIncarnation_Call {
628+ return &MockSnapshotRepository_DeleteIncarnation_Call{Call: _e.mock.On("DeleteIncarnation", context1, incarnationID)}
629+}
630+
631+func (_c *MockSnapshotRepository_DeleteIncarnation_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID)) *MockSnapshotRepository_DeleteIncarnation_Call {
632+ _c.Call.Run(func(args mock.Arguments) {
633+ var arg0 context.Context
634+ if args[0] != nil {
635+ arg0 = args[0].(context.Context)
636+ }
637+ var arg1 domain.IncarnationID
638+ if args[1] != nil {
639+ arg1 = args[1].(domain.IncarnationID)
640+ }
641+ run(
642+ arg0,
643+ arg1,
644+ )
645+ })
646+ return _c
647+}
648+
649+func (_c *MockSnapshotRepository_DeleteIncarnation_Call) Return(err error) *MockSnapshotRepository_DeleteIncarnation_Call {
650+ _c.Call.Return(err)
651+ return _c
652+}
653+
654+func (_c *MockSnapshotRepository_DeleteIncarnation_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID) error) *MockSnapshotRepository_DeleteIncarnation_Call {
655+ _c.Call.Return(run)
656+ return _c
657+}
658+
659+// LoadCheckpoint provides a mock function for the type MockSnapshotRepository
660+func (_mock *MockSnapshotRepository) LoadCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, s string, v ports.CheckpointRef) (ports.SnapshotGeneration, error) {
661+ ret := _mock.Called(context1, incarnationID, s, v)
662+
663+ if len(ret) == 0 {
664+ panic("no return value specified for LoadCheckpoint")
665+ }
666+
667+ var r0 ports.SnapshotGeneration
668+ var r1 error
669+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID, string, ports.CheckpointRef) (ports.SnapshotGeneration, error)); ok {
670+ return returnFunc(context1, incarnationID, s, v)
671+ }
672+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID, string, ports.CheckpointRef) ports.SnapshotGeneration); ok {
673+ r0 = returnFunc(context1, incarnationID, s, v)
674+ } else {
675+ r0 = ret.Get(0).(ports.SnapshotGeneration)
676+ }
677+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.IncarnationID, string, ports.CheckpointRef) error); ok {
678+ r1 = returnFunc(context1, incarnationID, s, v)
679+ } else {
680+ r1 = ret.Error(1)
681+ }
682+ return r0, r1
683+}
684+
685+// MockSnapshotRepository_LoadCheckpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadCheckpoint'
686+type MockSnapshotRepository_LoadCheckpoint_Call struct {
687+ *mock.Call
688+}
689+
690+// LoadCheckpoint is a helper method to define mock.On call
691+// - context1 context.Context
692+// - incarnationID domain.IncarnationID
693+// - s string
694+// - v ports.CheckpointRef
695+func (_e *MockSnapshotRepository_Expecter) LoadCheckpoint(context1 any, incarnationID any, s any, v any) *MockSnapshotRepository_LoadCheckpoint_Call {
696+ return &MockSnapshotRepository_LoadCheckpoint_Call{Call: _e.mock.On("LoadCheckpoint", context1, incarnationID, s, v)}
697+}
698+
699+func (_c *MockSnapshotRepository_LoadCheckpoint_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID, s string, v ports.CheckpointRef)) *MockSnapshotRepository_LoadCheckpoint_Call {
700+ _c.Call.Run(func(args mock.Arguments) {
701+ var arg0 context.Context
702+ if args[0] != nil {
703+ arg0 = args[0].(context.Context)
704+ }
705+ var arg1 domain.IncarnationID
706+ if args[1] != nil {
707+ arg1 = args[1].(domain.IncarnationID)
708+ }
709+ var arg2 string
710+ if args[2] != nil {
711+ arg2 = args[2].(string)
712+ }
713+ var arg3 ports.CheckpointRef
714+ if args[3] != nil {
715+ arg3 = args[3].(ports.CheckpointRef)
716+ }
717+ run(
718+ arg0,
719+ arg1,
720+ arg2,
721+ arg3,
722+ )
723+ })
724+ return _c
725+}
726+
727+func (_c *MockSnapshotRepository_LoadCheckpoint_Call) Return(snapshotGeneration ports.SnapshotGeneration, err error) *MockSnapshotRepository_LoadCheckpoint_Call {
728+ _c.Call.Return(snapshotGeneration, err)
729+ return _c
730+}
731+
732+func (_c *MockSnapshotRepository_LoadCheckpoint_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID, s string, v ports.CheckpointRef) (ports.SnapshotGeneration, error)) *MockSnapshotRepository_LoadCheckpoint_Call {
733+ _c.Call.Return(run)
734+ return _c
735+}
736+
737+// Publish provides a mock function for the type MockSnapshotRepository
738+func (_mock *MockSnapshotRepository) Publish(context1 context.Context, snapshotPublication ports.SnapshotPublication) error {
739+ ret := _mock.Called(context1, snapshotPublication)
740+
741+ if len(ret) == 0 {
742+ panic("no return value specified for Publish")
743+ }
744+
745+ var r0 error
746+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.SnapshotPublication) error); ok {
747+ r0 = returnFunc(context1, snapshotPublication)
748+ } else {
749+ r0 = ret.Error(0)
750+ }
751+ return r0
752+}
753+
754+// MockSnapshotRepository_Publish_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Publish'
755+type MockSnapshotRepository_Publish_Call struct {
756+ *mock.Call
757+}
758+
759+// Publish is a helper method to define mock.On call
760+// - context1 context.Context
761+// - snapshotPublication ports.SnapshotPublication
762+func (_e *MockSnapshotRepository_Expecter) Publish(context1 any, snapshotPublication any) *MockSnapshotRepository_Publish_Call {
763+ return &MockSnapshotRepository_Publish_Call{Call: _e.mock.On("Publish", context1, snapshotPublication)}
764+}
765+
766+func (_c *MockSnapshotRepository_Publish_Call) Run(run func(context1 context.Context, snapshotPublication ports.SnapshotPublication)) *MockSnapshotRepository_Publish_Call {
767+ _c.Call.Run(func(args mock.Arguments) {
768+ var arg0 context.Context
769+ if args[0] != nil {
770+ arg0 = args[0].(context.Context)
771+ }
772+ var arg1 ports.SnapshotPublication
773+ if args[1] != nil {
774+ arg1 = args[1].(ports.SnapshotPublication)
775+ }
776+ run(
777+ arg0,
778+ arg1,
779+ )
780+ })
781+ return _c
782+}
783+
784+func (_c *MockSnapshotRepository_Publish_Call) Return(err error) *MockSnapshotRepository_Publish_Call {
785+ _c.Call.Return(err)
786+ return _c
787+}
788+
789+func (_c *MockSnapshotRepository_Publish_Call) RunAndReturn(run func(context1 context.Context, snapshotPublication ports.SnapshotPublication) error) *MockSnapshotRepository_Publish_Call {
790+ _c.Call.Return(run)
791+ return _c
792+}
793+
794+// ReconcileCheckpoint provides a mock function for the type MockSnapshotRepository
795+func (_mock *MockSnapshotRepository) ReconcileCheckpoint(context1 context.Context, incarnationID domain.IncarnationID, v ports.CheckpointRef) error {
796+ ret := _mock.Called(context1, incarnationID, v)
797+
798+ if len(ret) == 0 {
799+ panic("no return value specified for ReconcileCheckpoint")
800+ }
801+
802+ var r0 error
803+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.IncarnationID, ports.CheckpointRef) error); ok {
804+ r0 = returnFunc(context1, incarnationID, v)
805+ } else {
806+ r0 = ret.Error(0)
807+ }
808+ return r0
809+}
810+
811+// MockSnapshotRepository_ReconcileCheckpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReconcileCheckpoint'
812+type MockSnapshotRepository_ReconcileCheckpoint_Call struct {
813+ *mock.Call
814+}
815+
816+// ReconcileCheckpoint is a helper method to define mock.On call
817+// - context1 context.Context
818+// - incarnationID domain.IncarnationID
819+// - v ports.CheckpointRef
820+func (_e *MockSnapshotRepository_Expecter) ReconcileCheckpoint(context1 any, incarnationID any, v any) *MockSnapshotRepository_ReconcileCheckpoint_Call {
821+ return &MockSnapshotRepository_ReconcileCheckpoint_Call{Call: _e.mock.On("ReconcileCheckpoint", context1, incarnationID, v)}
822+}
823+
824+func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) Run(run func(context1 context.Context, incarnationID domain.IncarnationID, v ports.CheckpointRef)) *MockSnapshotRepository_ReconcileCheckpoint_Call {
825+ _c.Call.Run(func(args mock.Arguments) {
826+ var arg0 context.Context
827+ if args[0] != nil {
828+ arg0 = args[0].(context.Context)
829+ }
830+ var arg1 domain.IncarnationID
831+ if args[1] != nil {
832+ arg1 = args[1].(domain.IncarnationID)
833+ }
834+ var arg2 ports.CheckpointRef
835+ if args[2] != nil {
836+ arg2 = args[2].(ports.CheckpointRef)
837+ }
838+ run(
839+ arg0,
840+ arg1,
841+ arg2,
842+ )
843+ })
844+ return _c
845+}
846+
847+func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) Return(err error) *MockSnapshotRepository_ReconcileCheckpoint_Call {
848+ _c.Call.Return(err)
849+ return _c
850+}
851+
852+func (_c *MockSnapshotRepository_ReconcileCheckpoint_Call) RunAndReturn(run func(context1 context.Context, incarnationID domain.IncarnationID, v ports.CheckpointRef) error) *MockSnapshotRepository_ReconcileCheckpoint_Call {
853+ _c.Call.Return(run)
854+ return _c
855+}
M internal/ports/mocks/store_mock.go +44 -640
@@ -5,9 +5,6 @@
55 package portsmocks
66
77 import (
8- "context"
9-
10- "github.com/bnema/vev/internal/ports"
118 mock "github.com/stretchr/testify/mock"
129 )
1310
@@ -82,6 +79,50 @@ func (_c *MockStore_Close_Call) RunAndReturn(run func() error) *MockStore_Close_
8279 return _c
8380 }
8481
82+// CloseWithoutSync provides a mock function for the type MockStore
83+func (_mock *MockStore) CloseWithoutSync() error {
84+ ret := _mock.Called()
85+
86+ if len(ret) == 0 {
87+ panic("no return value specified for CloseWithoutSync")
88+ }
89+
90+ var r0 error
91+ if returnFunc, ok := ret.Get(0).(func() error); ok {
92+ r0 = returnFunc()
93+ } else {
94+ r0 = ret.Error(0)
95+ }
96+ return r0
97+}
98+
99+// MockStore_CloseWithoutSync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseWithoutSync'
100+type MockStore_CloseWithoutSync_Call struct {
101+ *mock.Call
102+}
103+
104+// CloseWithoutSync is a helper method to define mock.On call
105+func (_e *MockStore_Expecter) CloseWithoutSync() *MockStore_CloseWithoutSync_Call {
106+ return &MockStore_CloseWithoutSync_Call{Call: _e.mock.On("CloseWithoutSync")}
107+}
108+
109+func (_c *MockStore_CloseWithoutSync_Call) Run(run func()) *MockStore_CloseWithoutSync_Call {
110+ _c.Call.Run(func(args mock.Arguments) {
111+ run()
112+ })
113+ return _c
114+}
115+
116+func (_c *MockStore_CloseWithoutSync_Call) Return(err error) *MockStore_CloseWithoutSync_Call {
117+ _c.Call.Return(err)
118+ return _c
119+}
120+
121+func (_c *MockStore_CloseWithoutSync_Call) RunAndReturn(run func() error) *MockStore_CloseWithoutSync_Call {
122+ _c.Call.Return(run)
123+ return _c
124+}
125+
85126 // Delete provides a mock function for the type MockStore
86127 func (_mock *MockStore) Delete(key []byte) error {
87128 ret := _mock.Called(key)
@@ -335,640 +376,3 @@ func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Ca
335376 _c.Call.Return(run)
336377 return _c
337378 }
338-
339-// NewMockSnapshotRepository creates a new instance of MockSnapshotRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
340-// The first argument is typically a *testing.T value.
341-func NewMockSnapshotRepository(t interface {
342- mock.TestingT
343- Cleanup(func())
344-}) *MockSnapshotRepository {
345- mock := &MockSnapshotRepository{}
346- mock.Mock.Test(t)
347-
348- t.Cleanup(func() { mock.AssertExpectations(t) })
349-
350- return mock
351-}
352-
353-// MockSnapshotRepository is an autogenerated mock type for the SnapshotRepository type
354-type MockSnapshotRepository struct {
355- mock.Mock
356-}
357-
358-type MockSnapshotRepository_Expecter struct {
359- mock *mock.Mock
360-}
361-
362-func (_m *MockSnapshotRepository) EXPECT() *MockSnapshotRepository_Expecter {
363- return &MockSnapshotRepository_Expecter{mock: &_m.Mock}
364-}
365-
366-// Delete provides a mock function for the type MockSnapshotRepository
367-func (_mock *MockSnapshotRepository) Delete(context1 context.Context, s string) error {
368- ret := _mock.Called(context1, s)
369-
370- if len(ret) == 0 {
371- panic("no return value specified for Delete")
372- }
373-
374- var r0 error
375- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
376- r0 = returnFunc(context1, s)
377- } else {
378- r0 = ret.Error(0)
379- }
380- return r0
381-}
382-
383-// MockSnapshotRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
384-type MockSnapshotRepository_Delete_Call struct {
385- *mock.Call
386-}
387-
388-// Delete is a helper method to define mock.On call
389-// - context1 context.Context
390-// - s string
391-func (_e *MockSnapshotRepository_Expecter) Delete(context1 any, s any) *MockSnapshotRepository_Delete_Call {
392- return &MockSnapshotRepository_Delete_Call{Call: _e.mock.On("Delete", context1, s)}
393-}
394-
395-func (_c *MockSnapshotRepository_Delete_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotRepository_Delete_Call {
396- _c.Call.Run(func(args mock.Arguments) {
397- var arg0 context.Context
398- if args[0] != nil {
399- arg0 = args[0].(context.Context)
400- }
401- var arg1 string
402- if args[1] != nil {
403- arg1 = args[1].(string)
404- }
405- run(
406- arg0,
407- arg1,
408- )
409- })
410- return _c
411-}
412-
413-func (_c *MockSnapshotRepository_Delete_Call) Return(err error) *MockSnapshotRepository_Delete_Call {
414- _c.Call.Return(err)
415- return _c
416-}
417-
418-func (_c *MockSnapshotRepository_Delete_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockSnapshotRepository_Delete_Call {
419- _c.Call.Return(run)
420- return _c
421-}
422-
423-// DeleteTombstone provides a mock function for the type MockSnapshotRepository
424-func (_mock *MockSnapshotRepository) DeleteTombstone(context1 context.Context, s string) error {
425- ret := _mock.Called(context1, s)
426-
427- if len(ret) == 0 {
428- panic("no return value specified for DeleteTombstone")
429- }
430-
431- var r0 error
432- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
433- r0 = returnFunc(context1, s)
434- } else {
435- r0 = ret.Error(0)
436- }
437- return r0
438-}
439-
440-// MockSnapshotRepository_DeleteTombstone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteTombstone'
441-type MockSnapshotRepository_DeleteTombstone_Call struct {
442- *mock.Call
443-}
444-
445-// DeleteTombstone is a helper method to define mock.On call
446-// - context1 context.Context
447-// - s string
448-func (_e *MockSnapshotRepository_Expecter) DeleteTombstone(context1 any, s any) *MockSnapshotRepository_DeleteTombstone_Call {
449- return &MockSnapshotRepository_DeleteTombstone_Call{Call: _e.mock.On("DeleteTombstone", context1, s)}
450-}
451-
452-func (_c *MockSnapshotRepository_DeleteTombstone_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotRepository_DeleteTombstone_Call {
453- _c.Call.Run(func(args mock.Arguments) {
454- var arg0 context.Context
455- if args[0] != nil {
456- arg0 = args[0].(context.Context)
457- }
458- var arg1 string
459- if args[1] != nil {
460- arg1 = args[1].(string)
461- }
462- run(
463- arg0,
464- arg1,
465- )
466- })
467- return _c
468-}
469-
470-func (_c *MockSnapshotRepository_DeleteTombstone_Call) Return(err error) *MockSnapshotRepository_DeleteTombstone_Call {
471- _c.Call.Return(err)
472- return _c
473-}
474-
475-func (_c *MockSnapshotRepository_DeleteTombstone_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockSnapshotRepository_DeleteTombstone_Call {
476- _c.Call.Return(run)
477- return _c
478-}
479-
480-// List provides a mock function for the type MockSnapshotRepository
481-func (_mock *MockSnapshotRepository) List(context1 context.Context) ([]string, error) {
482- ret := _mock.Called(context1)
483-
484- if len(ret) == 0 {
485- panic("no return value specified for List")
486- }
487-
488- var r0 []string
489- var r1 error
490- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
491- return returnFunc(context1)
492- }
493- if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok {
494- r0 = returnFunc(context1)
495- } else {
496- if ret.Get(0) != nil {
497- r0 = ret.Get(0).([]string)
498- }
499- }
500- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
501- r1 = returnFunc(context1)
502- } else {
503- r1 = ret.Error(1)
504- }
505- return r0, r1
506-}
507-
508-// MockSnapshotRepository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
509-type MockSnapshotRepository_List_Call struct {
510- *mock.Call
511-}
512-
513-// List is a helper method to define mock.On call
514-// - context1 context.Context
515-func (_e *MockSnapshotRepository_Expecter) List(context1 any) *MockSnapshotRepository_List_Call {
516- return &MockSnapshotRepository_List_Call{Call: _e.mock.On("List", context1)}
517-}
518-
519-func (_c *MockSnapshotRepository_List_Call) Run(run func(context1 context.Context)) *MockSnapshotRepository_List_Call {
520- _c.Call.Run(func(args mock.Arguments) {
521- var arg0 context.Context
522- if args[0] != nil {
523- arg0 = args[0].(context.Context)
524- }
525- run(
526- arg0,
527- )
528- })
529- return _c
530-}
531-
532-func (_c *MockSnapshotRepository_List_Call) Return(strings []string, err error) *MockSnapshotRepository_List_Call {
533- _c.Call.Return(strings, err)
534- return _c
535-}
536-
537-func (_c *MockSnapshotRepository_List_Call) RunAndReturn(run func(context1 context.Context) ([]string, error)) *MockSnapshotRepository_List_Call {
538- _c.Call.Return(run)
539- return _c
540-}
541-
542-// Load provides a mock function for the type MockSnapshotRepository
543-func (_mock *MockSnapshotRepository) Load(context1 context.Context, s string) (ports.SnapshotGeneration, error) {
544- ret := _mock.Called(context1, s)
545-
546- if len(ret) == 0 {
547- panic("no return value specified for Load")
548- }
549-
550- var r0 ports.SnapshotGeneration
551- var r1 error
552- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.SnapshotGeneration, error)); ok {
553- return returnFunc(context1, s)
554- }
555- if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.SnapshotGeneration); ok {
556- r0 = returnFunc(context1, s)
557- } else {
558- r0 = ret.Get(0).(ports.SnapshotGeneration)
559- }
560- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
561- r1 = returnFunc(context1, s)
562- } else {
563- r1 = ret.Error(1)
564- }
565- return r0, r1
566-}
567-
568-// MockSnapshotRepository_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
569-type MockSnapshotRepository_Load_Call struct {
570- *mock.Call
571-}
572-
573-// Load is a helper method to define mock.On call
574-// - context1 context.Context
575-// - s string
576-func (_e *MockSnapshotRepository_Expecter) Load(context1 any, s any) *MockSnapshotRepository_Load_Call {
577- return &MockSnapshotRepository_Load_Call{Call: _e.mock.On("Load", context1, s)}
578-}
579-
580-func (_c *MockSnapshotRepository_Load_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotRepository_Load_Call {
581- _c.Call.Run(func(args mock.Arguments) {
582- var arg0 context.Context
583- if args[0] != nil {
584- arg0 = args[0].(context.Context)
585- }
586- var arg1 string
587- if args[1] != nil {
588- arg1 = args[1].(string)
589- }
590- run(
591- arg0,
592- arg1,
593- )
594- })
595- return _c
596-}
597-
598-func (_c *MockSnapshotRepository_Load_Call) Return(snapshotGeneration ports.SnapshotGeneration, err error) *MockSnapshotRepository_Load_Call {
599- _c.Call.Return(snapshotGeneration, err)
600- return _c
601-}
602-
603-func (_c *MockSnapshotRepository_Load_Call) RunAndReturn(run func(context1 context.Context, s string) (ports.SnapshotGeneration, error)) *MockSnapshotRepository_Load_Call {
604- _c.Call.Return(run)
605- return _c
606-}
607-
608-// Maintain provides a mock function for the type MockSnapshotRepository
609-func (_mock *MockSnapshotRepository) Maintain(context1 context.Context) error {
610- ret := _mock.Called(context1)
611-
612- if len(ret) == 0 {
613- panic("no return value specified for Maintain")
614- }
615-
616- var r0 error
617- if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
618- r0 = returnFunc(context1)
619- } else {
620- r0 = ret.Error(0)
621- }
622- return r0
623-}
624-
625-// MockSnapshotRepository_Maintain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Maintain'
626-type MockSnapshotRepository_Maintain_Call struct {
627- *mock.Call
628-}
629-
630-// Maintain is a helper method to define mock.On call
631-// - context1 context.Context
632-func (_e *MockSnapshotRepository_Expecter) Maintain(context1 any) *MockSnapshotRepository_Maintain_Call {
633- return &MockSnapshotRepository_Maintain_Call{Call: _e.mock.On("Maintain", context1)}
634-}
635-
636-func (_c *MockSnapshotRepository_Maintain_Call) Run(run func(context1 context.Context)) *MockSnapshotRepository_Maintain_Call {
637- _c.Call.Run(func(args mock.Arguments) {
638- var arg0 context.Context
639- if args[0] != nil {
640- arg0 = args[0].(context.Context)
641- }
642- run(
643- arg0,
644- )
645- })
646- return _c
647-}
648-
649-func (_c *MockSnapshotRepository_Maintain_Call) Return(err error) *MockSnapshotRepository_Maintain_Call {
650- _c.Call.Return(err)
651- return _c
652-}
653-
654-func (_c *MockSnapshotRepository_Maintain_Call) RunAndReturn(run func(context1 context.Context) error) *MockSnapshotRepository_Maintain_Call {
655- _c.Call.Return(run)
656- return _c
657-}
658-
659-// Publish provides a mock function for the type MockSnapshotRepository
660-func (_mock *MockSnapshotRepository) Publish(context1 context.Context, snapshotPublication ports.SnapshotPublication) error {
661- ret := _mock.Called(context1, snapshotPublication)
662-
663- if len(ret) == 0 {
664- panic("no return value specified for Publish")
665- }
666-
667- var r0 error
668- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.SnapshotPublication) error); ok {
669- r0 = returnFunc(context1, snapshotPublication)
670- } else {
671- r0 = ret.Error(0)
672- }
673- return r0
674-}
675-
676-// MockSnapshotRepository_Publish_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Publish'
677-type MockSnapshotRepository_Publish_Call struct {
678- *mock.Call
679-}
680-
681-// Publish is a helper method to define mock.On call
682-// - context1 context.Context
683-// - snapshotPublication ports.SnapshotPublication
684-func (_e *MockSnapshotRepository_Expecter) Publish(context1 any, snapshotPublication any) *MockSnapshotRepository_Publish_Call {
685- return &MockSnapshotRepository_Publish_Call{Call: _e.mock.On("Publish", context1, snapshotPublication)}
686-}
687-
688-func (_c *MockSnapshotRepository_Publish_Call) Run(run func(context1 context.Context, snapshotPublication ports.SnapshotPublication)) *MockSnapshotRepository_Publish_Call {
689- _c.Call.Run(func(args mock.Arguments) {
690- var arg0 context.Context
691- if args[0] != nil {
692- arg0 = args[0].(context.Context)
693- }
694- var arg1 ports.SnapshotPublication
695- if args[1] != nil {
696- arg1 = args[1].(ports.SnapshotPublication)
697- }
698- run(
699- arg0,
700- arg1,
701- )
702- })
703- return _c
704-}
705-
706-func (_c *MockSnapshotRepository_Publish_Call) Return(err error) *MockSnapshotRepository_Publish_Call {
707- _c.Call.Return(err)
708- return _c
709-}
710-
711-func (_c *MockSnapshotRepository_Publish_Call) RunAndReturn(run func(context1 context.Context, snapshotPublication ports.SnapshotPublication) error) *MockSnapshotRepository_Publish_Call {
712- _c.Call.Return(run)
713- return _c
714-}
715-
716-// Tombstone provides a mock function for the type MockSnapshotRepository
717-func (_mock *MockSnapshotRepository) Tombstone(context1 context.Context, s string) error {
718- ret := _mock.Called(context1, s)
719-
720- if len(ret) == 0 {
721- panic("no return value specified for Tombstone")
722- }
723-
724- var r0 error
725- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
726- r0 = returnFunc(context1, s)
727- } else {
728- r0 = ret.Error(0)
729- }
730- return r0
731-}
732-
733-// MockSnapshotRepository_Tombstone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Tombstone'
734-type MockSnapshotRepository_Tombstone_Call struct {
735- *mock.Call
736-}
737-
738-// Tombstone is a helper method to define mock.On call
739-// - context1 context.Context
740-// - s string
741-func (_e *MockSnapshotRepository_Expecter) Tombstone(context1 any, s any) *MockSnapshotRepository_Tombstone_Call {
742- return &MockSnapshotRepository_Tombstone_Call{Call: _e.mock.On("Tombstone", context1, s)}
743-}
744-
745-func (_c *MockSnapshotRepository_Tombstone_Call) Run(run func(context1 context.Context, s string)) *MockSnapshotRepository_Tombstone_Call {
746- _c.Call.Run(func(args mock.Arguments) {
747- var arg0 context.Context
748- if args[0] != nil {
749- arg0 = args[0].(context.Context)
750- }
751- var arg1 string
752- if args[1] != nil {
753- arg1 = args[1].(string)
754- }
755- run(
756- arg0,
757- arg1,
758- )
759- })
760- return _c
761-}
762-
763-func (_c *MockSnapshotRepository_Tombstone_Call) Return(err error) *MockSnapshotRepository_Tombstone_Call {
764- _c.Call.Return(err)
765- return _c
766-}
767-
768-func (_c *MockSnapshotRepository_Tombstone_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockSnapshotRepository_Tombstone_Call {
769- _c.Call.Return(run)
770- return _c
771-}
772-
773-// NewMockLegacySnapshotSource creates a new instance of MockLegacySnapshotSource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
774-// The first argument is typically a *testing.T value.
775-func NewMockLegacySnapshotSource(t interface {
776- mock.TestingT
777- Cleanup(func())
778-}) *MockLegacySnapshotSource {
779- mock := &MockLegacySnapshotSource{}
780- mock.Mock.Test(t)
781-
782- t.Cleanup(func() { mock.AssertExpectations(t) })
783-
784- return mock
785-}
786-
787-// MockLegacySnapshotSource is an autogenerated mock type for the LegacySnapshotSource type
788-type MockLegacySnapshotSource struct {
789- mock.Mock
790-}
791-
792-type MockLegacySnapshotSource_Expecter struct {
793- mock *mock.Mock
794-}
795-
796-func (_m *MockLegacySnapshotSource) EXPECT() *MockLegacySnapshotSource_Expecter {
797- return &MockLegacySnapshotSource_Expecter{mock: &_m.Mock}
798-}
799-
800-// DeleteLegacy provides a mock function for the type MockLegacySnapshotSource
801-func (_mock *MockLegacySnapshotSource) DeleteLegacy(context1 context.Context, s string) error {
802- ret := _mock.Called(context1, s)
803-
804- if len(ret) == 0 {
805- panic("no return value specified for DeleteLegacy")
806- }
807-
808- var r0 error
809- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
810- r0 = returnFunc(context1, s)
811- } else {
812- r0 = ret.Error(0)
813- }
814- return r0
815-}
816-
817-// MockLegacySnapshotSource_DeleteLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteLegacy'
818-type MockLegacySnapshotSource_DeleteLegacy_Call struct {
819- *mock.Call
820-}
821-
822-// DeleteLegacy is a helper method to define mock.On call
823-// - context1 context.Context
824-// - s string
825-func (_e *MockLegacySnapshotSource_Expecter) DeleteLegacy(context1 any, s any) *MockLegacySnapshotSource_DeleteLegacy_Call {
826- return &MockLegacySnapshotSource_DeleteLegacy_Call{Call: _e.mock.On("DeleteLegacy", context1, s)}
827-}
828-
829-func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) Run(run func(context1 context.Context, s string)) *MockLegacySnapshotSource_DeleteLegacy_Call {
830- _c.Call.Run(func(args mock.Arguments) {
831- var arg0 context.Context
832- if args[0] != nil {
833- arg0 = args[0].(context.Context)
834- }
835- var arg1 string
836- if args[1] != nil {
837- arg1 = args[1].(string)
838- }
839- run(
840- arg0,
841- arg1,
842- )
843- })
844- return _c
845-}
846-
847-func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteLegacy_Call {
848- _c.Call.Return(err)
849- return _c
850-}
851-
852-func (_c *MockLegacySnapshotSource_DeleteLegacy_Call) RunAndReturn(run func(context1 context.Context, s string) error) *MockLegacySnapshotSource_DeleteLegacy_Call {
853- _c.Call.Return(run)
854- return _c
855-}
856-
857-// DeleteVerifiedLegacy provides a mock function for the type MockLegacySnapshotSource
858-func (_mock *MockLegacySnapshotSource) DeleteVerifiedLegacy(context1 context.Context, legacySnapshot ports.LegacySnapshot) error {
859- ret := _mock.Called(context1, legacySnapshot)
860-
861- if len(ret) == 0 {
862- panic("no return value specified for DeleteVerifiedLegacy")
863- }
864-
865- var r0 error
866- if returnFunc, ok := ret.Get(0).(func(context.Context, ports.LegacySnapshot) error); ok {
867- r0 = returnFunc(context1, legacySnapshot)
868- } else {
869- r0 = ret.Error(0)
870- }
871- return r0
872-}
873-
874-// MockLegacySnapshotSource_DeleteVerifiedLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteVerifiedLegacy'
875-type MockLegacySnapshotSource_DeleteVerifiedLegacy_Call struct {
876- *mock.Call
877-}
878-
879-// DeleteVerifiedLegacy is a helper method to define mock.On call
880-// - context1 context.Context
881-// - legacySnapshot ports.LegacySnapshot
882-func (_e *MockLegacySnapshotSource_Expecter) DeleteVerifiedLegacy(context1 any, legacySnapshot any) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
883- return &MockLegacySnapshotSource_DeleteVerifiedLegacy_Call{Call: _e.mock.On("DeleteVerifiedLegacy", context1, legacySnapshot)}
884-}
885-
886-func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Run(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot)) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
887- _c.Call.Run(func(args mock.Arguments) {
888- var arg0 context.Context
889- if args[0] != nil {
890- arg0 = args[0].(context.Context)
891- }
892- var arg1 ports.LegacySnapshot
893- if args[1] != nil {
894- arg1 = args[1].(ports.LegacySnapshot)
895- }
896- run(
897- arg0,
898- arg1,
899- )
900- })
901- return _c
902-}
903-
904-func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) Return(err error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
905- _c.Call.Return(err)
906- return _c
907-}
908-
909-func (_c *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call) RunAndReturn(run func(context1 context.Context, legacySnapshot ports.LegacySnapshot) error) *MockLegacySnapshotSource_DeleteVerifiedLegacy_Call {
910- _c.Call.Return(run)
911- return _c
912-}
913-
914-// LoadLegacy provides a mock function for the type MockLegacySnapshotSource
915-func (_mock *MockLegacySnapshotSource) LoadLegacy(context1 context.Context) ([]ports.LegacySnapshot, error) {
916- ret := _mock.Called(context1)
917-
918- if len(ret) == 0 {
919- panic("no return value specified for LoadLegacy")
920- }
921-
922- var r0 []ports.LegacySnapshot
923- var r1 error
924- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]ports.LegacySnapshot, error)); ok {
925- return returnFunc(context1)
926- }
927- if returnFunc, ok := ret.Get(0).(func(context.Context) []ports.LegacySnapshot); ok {
928- r0 = returnFunc(context1)
929- } else {
930- if ret.Get(0) != nil {
931- r0 = ret.Get(0).([]ports.LegacySnapshot)
932- }
933- }
934- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
935- r1 = returnFunc(context1)
936- } else {
937- r1 = ret.Error(1)
938- }
939- return r0, r1
940-}
941-
942-// MockLegacySnapshotSource_LoadLegacy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadLegacy'
943-type MockLegacySnapshotSource_LoadLegacy_Call struct {
944- *mock.Call
945-}
946-
947-// LoadLegacy is a helper method to define mock.On call
948-// - context1 context.Context
949-func (_e *MockLegacySnapshotSource_Expecter) LoadLegacy(context1 any) *MockLegacySnapshotSource_LoadLegacy_Call {
950- return &MockLegacySnapshotSource_LoadLegacy_Call{Call: _e.mock.On("LoadLegacy", context1)}
951-}
952-
953-func (_c *MockLegacySnapshotSource_LoadLegacy_Call) Run(run func(context1 context.Context)) *MockLegacySnapshotSource_LoadLegacy_Call {
954- _c.Call.Run(func(args mock.Arguments) {
955- var arg0 context.Context
956- if args[0] != nil {
957- arg0 = args[0].(context.Context)
958- }
959- run(
960- arg0,
961- )
962- })
963- return _c
964-}
965-
966-func (_c *MockLegacySnapshotSource_LoadLegacy_Call) Return(legacySnapshots []ports.LegacySnapshot, err error) *MockLegacySnapshotSource_LoadLegacy_Call {
967- _c.Call.Return(legacySnapshots, err)
968- return _c
969-}
970-
971-func (_c *MockLegacySnapshotSource_LoadLegacy_Call) RunAndReturn(run func(context1 context.Context) ([]ports.LegacySnapshot, error)) *MockLegacySnapshotSource_LoadLegacy_Call {
972- _c.Call.Return(run)
973- return _c
974-}
A internal/ports/recovery.go +48 -0
@@ -0,0 +1,48 @@
1+package ports
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+type CheckpointRef = domain.CheckpointRef
10+
11+type SnapshotPublication struct {
12+ IncarnationID domain.IncarnationID
13+ Name string
14+ Generation uint64
15+ ParentCheckpoint *domain.CheckpointRef
16+ Manifest []byte
17+ Objects []SnapshotObject
18+}
19+
20+type SnapshotGeneration struct {
21+ IncarnationID domain.IncarnationID
22+ Name string
23+ Generation uint64
24+ ParentCheckpoint *domain.CheckpointRef
25+ Manifest []byte
26+ Objects map[SnapshotDigest][]byte
27+}
28+
29+type Catalogue interface {
30+ Records() ([]domain.CatalogueRecord, error)
31+ Record(string) (domain.CatalogueRecord, bool, error)
32+ Create(domain.CatalogueRecord) error
33+ UpdateMetadata(domain.CatalogueMetadataUpdate) error
34+ Replace(string, domain.CatalogueRecord) error
35+ Rename(string, domain.CatalogueRecord) error
36+ Delete(string) error
37+ // Sync makes buffered metadata durable. Identity operations sync internally.
38+ Sync() error
39+ Close() error
40+}
41+
42+type SnapshotRepository interface {
43+ Publish(context.Context, SnapshotPublication) error
44+ LoadCheckpoint(context.Context, domain.IncarnationID, string, CheckpointRef) (SnapshotGeneration, error)
45+ ReconcileCheckpoint(context.Context, domain.IncarnationID, CheckpointRef) error
46+ DeleteIncarnation(context.Context, domain.IncarnationID) error
47+ CollectGarbage(context.Context, map[domain.IncarnationID]domain.CheckpointRef) error
48+}
M internal/ports/store.go +4 -57
@@ -1,9 +1,6 @@
11 package ports
22
3-import (
4- "context"
5- "crypto/sha256"
6-)
3+import "crypto/sha256"
74
85 // Store is a small byte-key/value persistence port.
96 //
@@ -19,6 +16,9 @@ type Store interface {
1916 // or mutate either slice after Range returns.
2017 Range(fn func(k, v []byte) bool)
2118 Sync() error
19+ // CloseWithoutSync releases the store without persisting buffered writes.
20+ // Callers use it after a rejected durable mutation.
21+ CloseWithoutSync() error
2222 Close() error
2323 }
2424
@@ -29,56 +29,3 @@ type SnapshotObject struct {
2929 Digest SnapshotDigest
3030 Data []byte
3131 }
32-
33-// SnapshotPublication atomically publishes a complete VEVM manifest and any
34-// newly reachable content-addressed VEVO objects for one named generation.
35-type SnapshotPublication struct {
36- Name string
37- Generation uint64
38- Manifest []byte
39- Objects []SnapshotObject
40-}
41-
42-// SnapshotGeneration is the caller-owned material needed to restore one
43-// named generation. Fallback indicates that the repository selected an older
44-// valid generation after the requested/current generation was unavailable.
45-type SnapshotGeneration struct {
46- Name string
47- Generation uint64
48- Manifest []byte
49- Objects map[SnapshotDigest][]byte
50- Fallback bool
51-}
52-
53-// SnapshotRepository persists content-addressed incremental snapshot
54-// generations. All returned bytes are owned by the caller.
55-type SnapshotRepository interface {
56- Publish(context.Context, SnapshotPublication) error
57- List(context.Context) ([]string, error)
58- Load(context.Context, string) (SnapshotGeneration, error)
59- Delete(context.Context, string) error
60- // Tombstone fences a named-session purge before either incremental or
61- // legacy data is deleted. It must remain durable until DeleteTombstone.
62- Tombstone(context.Context, string) error
63- DeleteTombstone(context.Context, string) error
64- Maintain(context.Context) error
65-}
66-
67-// LegacySnapshot is the pre-incremental named-session blob retained only for
68-// one-way migration. Data is caller-owned.
69-type LegacySnapshot struct {
70- Name string
71- Data []byte
72-}
73-
74-// LegacySnapshotSource exposes the v3 bridge separately from the new write
75-// contract.
76-type LegacySnapshotSource interface {
77- LoadLegacy(context.Context) ([]LegacySnapshot, error)
78- // DeleteVerifiedLegacy removes precisely the legacy blob that was verified
79- // after import. Implementations must persist its identity before unlinking.
80- DeleteVerifiedLegacy(context.Context, LegacySnapshot) error
81- // DeleteLegacy is reserved for explicit session purges, which intentionally
82- // delete by name rather than as part of an import receipt.
83- DeleteLegacy(context.Context, string) error
84-}
M internal/ports/wire.go +16 -8
@@ -190,14 +190,23 @@ type Kill struct {
190190 All bool
191191 }
192192
193+// SessionState is the client-visible lifecycle state of a session.
194+type SessionState uint8
195+
196+const (
197+ SessionRunning SessionState = iota
198+ SessionStopped
199+ SessionBroken
200+)
201+
193202 // SessionInfo describes one session in a Sessions listing.
194203 type SessionInfo struct {
195204 SessionID string
196205 Name string
206+ State SessionState
197207 Ephemeral bool
198208 Tabs uint16
199209 Attached bool
200- Stopped bool
201210 }
202211
203212 // Sessions is the daemon's reply to a List, enumerating live sessions.
@@ -875,11 +884,7 @@ func MarshalSessions(m Sessions) []byte {
875884 } else {
876885 w.putUint8(0)
877886 }
878- if s.Stopped {
879- w.putUint8(1)
880- } else {
881- w.putUint8(0)
882- }
887+ w.putUint8(uint8(s.State))
883888 }
884889 return w.b
885890 }
@@ -913,11 +918,14 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
913918 return Sessions{}, err
914919 }
915920 s.Attached = att != 0
916- stopped, err := r.getUint8()
921+ state, err := r.getUint8()
917922 if err != nil {
918923 return Sessions{}, err
919924 }
920- s.Stopped = stopped != 0
925+ s.State = SessionState(state)
926+ if s.State > SessionBroken {
927+ return Sessions{}, errors.New("ports: invalid session state")
928+ }
921929 sessions = append(sessions, s)
922930 }
923931 if err := r.done(); err != nil {
M internal/ports/wire_test.go +35 -6
@@ -122,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
122122 Env: []string{"A=B", "XY=123"},
123123 })
124124 want := []byte{
125- 0x00, 0x12, 0x00, // version, intent
125+ 0x00, 0x13, 0x00, // version, intent
126126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
336336 }
337337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
338338 require.Equal(t, want, MarshalTheme(cleared))
339- require.Equal(t, uint16(18), ProtocolVersion)
339+ require.Equal(t, uint16(19), ProtocolVersion)
340340 }
341341
342342 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -461,7 +461,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
461461 name: "minimal",
462462 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463463 want: []byte{
464- 0x00, 0x12,
464+ 0x00, 0x13,
465465 0x00,
466466 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
467467 0x00, 0x00,
@@ -482,7 +482,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
482482 JSON: true,
483483 },
484484 want: []byte{
485- 0x00, 0x12,
485+ 0x00, 0x13,
486486 0x01,
487487 0x00, 0x05, 't', 'o', 'a', 's', 't',
488488 0x00, 0x03,
@@ -741,6 +741,35 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
741741 assertTrailingGarbageFails(t, append(append([]byte(nil), legacy...), 0x00), UnmarshalKill)
742742 }
743743
744+func TestSessionInfoRecoveryState(t *testing.T) {
745+ tests := []struct {
746+ name string
747+ state SessionState
748+ want []byte
749+ }{
750+ {name: "running", state: SessionRunning, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
751+ {name: "stopped", state: SessionStopped, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}},
752+ {name: "broken", state: SessionBroken, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}},
753+ }
754+ for _, tt := range tests {
755+ t.Run(tt.name, func(t *testing.T) {
756+ payload := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: tt.state}}})
757+ require.Equal(t, tt.want, payload)
758+ got, err := UnmarshalSessions(payload)
759+ require.NoError(t, err)
760+ require.Equal(t, tt.state, got.Sessions[0].State)
761+ assertAllPrefixesFail(t, payload, UnmarshalSessions)
762+ assertTrailingGarbageFails(t, payload, UnmarshalSessions)
763+ })
764+ }
765+ for _, state := range []byte{3, 255} {
766+ payload := append([]byte(nil), tests[0].want...)
767+ payload[len(payload)-1] = state
768+ _, err := UnmarshalSessions(payload)
769+ require.Error(t, err, "state %d", state)
770+ }
771+}
772+
744773 func TestSessionsGoldenAndRoundTrip(t *testing.T) {
745774 tests := []struct {
746775 name string
@@ -755,8 +784,8 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
755784 {
756785 name: "two",
757786 msg: Sessions{Sessions: []SessionInfo{
758- {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
759- {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true, Stopped: true},
787+ {SessionID: "0", Name: "0", State: SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},
788+ {SessionID: "work", Name: "proj", State: SessionStopped, Ephemeral: false, Tabs: 5, Attached: true},
760789 }},
761790 want: []byte{
762791 0x00, 0x02,
M internal/usecase/command/command.go +13 -3
@@ -8,9 +8,9 @@ import (
88 // ErrInvalidArguments reports arguments that do not have the exact command format.
99 var ErrInvalidArguments = errors.New("invalid command arguments")
1010
11-// ParsePositiveDecimal accepts exactly one base-10 positive decimal value.
11+// ParsePositiveUint64 accepts exactly one base-10 positive decimal value.
1212 // It deliberately rejects signs, whitespace, zero, and non-canonical forms.
13-func ParsePositiveDecimal(args []string) (int, error) {
13+func ParsePositiveUint64(args []string) (uint64, error) {
1414 if len(args) != 1 || args[0] == "" || (len(args[0]) > 1 && args[0][0] == '0') {
1515 return 0, ErrInvalidArguments
1616 }
@@ -19,13 +19,22 @@ func ParsePositiveDecimal(args []string) (int, error) {
1919 return 0, ErrInvalidArguments
2020 }
2121 }
22- n, err := strconv.Atoi(args[0])
22+ n, err := strconv.ParseUint(args[0], 10, 64)
2323 if err != nil || n < 1 {
2424 return 0, ErrInvalidArguments
2525 }
2626 return n, nil
2727 }
2828
29+// ParsePositiveDecimal parses a positive decimal that must fit the platform int.
30+func ParsePositiveDecimal(args []string) (int, error) {
31+ n, err := ParsePositiveUint64(args)
32+ if err != nil || n > uint64(^uint(0)>>1) {
33+ return 0, ErrInvalidArguments
34+ }
35+ return int(n), nil
36+}
37+
2938 // Context is the set of application actions available to commands.
3039 type Context interface {
3140 CreateTab() error
@@ -124,6 +133,7 @@ type ControlContext interface {
124133 RenameSessionTo(name string) error
125134 RenameTabTo(name string) error
126135 Toast(severity, message string) error
136+ SessionRecovery(action string) (string, error)
127137 ListSessions(json bool) (string, error)
128138 ListTabs(json bool) (string, error)
129139 ListPanes(json bool) (string, error)
M internal/usecase/command/command_test.go +34 -0
@@ -5,6 +5,40 @@ import (
55 "testing"
66 )
77
8+func TestParsePositiveUint64(t *testing.T) {
9+ for _, tt := range []struct {
10+ name string
11+ args []string
12+ want uint64
13+ invalid bool
14+ }{
15+ {name: "maximum", args: []string{"18446744073709551615"}, want: ^uint64(0)},
16+ {name: "overflow", args: []string{"18446744073709551616"}, invalid: true},
17+ {name: "zero", args: []string{"0"}, invalid: true},
18+ {name: "leading zero", args: []string{"01"}, invalid: true},
19+ {name: "empty string", args: []string{""}, invalid: true},
20+ {name: "positive sign", args: []string{"+1"}, invalid: true},
21+ {name: "negative sign", args: []string{"-1"}, invalid: true},
22+ {name: "whitespace", args: []string{"1 2"}, invalid: true},
23+ {name: "non-digit", args: []string{"1x"}, invalid: true},
24+ {name: "empty arguments", invalid: true},
25+ {name: "multiple arguments", args: []string{"1", "2"}, invalid: true},
26+ } {
27+ t.Run(tt.name, func(t *testing.T) {
28+ got, err := ParsePositiveUint64(tt.args)
29+ if tt.invalid {
30+ if !errors.Is(err, ErrInvalidArguments) {
31+ t.Fatalf("error = %v", err)
32+ }
33+ return
34+ }
35+ if err != nil || got != tt.want {
36+ t.Fatalf("ParsePositiveUint64() = %d, %v", got, err)
37+ }
38+ })
39+ }
40+}
41+
842 func TestParsePositiveDecimal(t *testing.T) {
943 for _, tt := range []struct {
1044 name string
M internal/usecase/command/registry.go +16 -0
@@ -47,6 +47,7 @@ func Registry() []Command {
4747 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),
4848 listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }),
4949 listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }),
50+ sessionRecoveryCommand(),
5051 }
5152 return commands
5253 }
@@ -89,6 +90,21 @@ func toastCommand() Command {
8990 }}
9091 }
9192
93+func sessionRecoveryCommand() Command {
94+ return Command{
95+ Slug: "session-recovery", Name: "Session recovery", Desc: "Discard a broken durable session's persisted state",
96+ Usage: "session-recovery discard",
97+ Scriptable: true, Target: TargetNone,
98+ Control: func(ctx ControlContext, args []string, _ ControlOptions) (ControlResult, error) {
99+ if len(args) != 1 || args[0] != "discard" {
100+ return ControlResult{}, ErrInvalidArguments
101+ }
102+ output, err := ctx.SessionRecovery("discard")
103+ return ControlResult{Output: output}, err
104+ },
105+ }
106+}
107+
92108 func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command {
93109 return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {
94110 if len(args) != 0 {
M internal/usecase/command/registry_test.go +36 -1
@@ -105,6 +105,35 @@ func TestPaletteRegistryHidesAPIOnly(t *testing.T) {
105105 }
106106 }
107107
108+func TestSessionRecoveryCommand(t *testing.T) {
109+ cmd, ok := BySlug("session-recovery")
110+ if !ok || !cmd.Scriptable || cmd.Target != TargetNone {
111+ t.Fatalf("session-recovery command = %#v, ok=%v", cmd, ok)
112+ }
113+ tests := []struct {
114+ args []string
115+ wantCall string
116+ wantErr error
117+ }{
118+ {args: []string{"discard"}, wantCall: "session-recovery:discard"},
119+ {args: []string{"retry"}, wantErr: ErrInvalidArguments},
120+ {args: []string{"restore", "7"}, wantErr: ErrInvalidArguments},
121+ {args: []string{"export", "/tmp/export"}, wantErr: ErrInvalidArguments},
122+ {args: nil, wantErr: ErrInvalidArguments},
123+ }
124+ for _, test := range tests {
125+ ctx := &controlSpy{recoveryOutput: "recovery output"}
126+ got, err := cmd.Control(ctx, test.args, ControlOptions{})
127+ wantOutput := ""
128+ if test.wantCall != "" {
129+ wantOutput = ctx.recoveryOutput
130+ }
131+ if !errors.Is(err, test.wantErr) || ctx.call != test.wantCall || got.Output != wantOutput {
132+ t.Errorf("args %v: call/output/error = %q/%q/%v, want %q/%q/%v", test.args, ctx.call, got.Output, err, test.wantCall, wantOutput, test.wantErr)
133+ }
134+ }
135+}
136+
108137 func TestControlHandlersValidateAndDelegate(t *testing.T) {
109138 tests := []struct {
110139 name string
@@ -149,7 +178,10 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
149178 }
150179 }
151180
152-type controlSpy struct{ call string }
181+type controlSpy struct {
182+ call string
183+ recoveryOutput string
184+}
153185
154186 func (s *controlSpy) record(call string) error { s.call = call; return nil }
155187 func (s *controlSpy) CreateTab() error { return s.record("new-tab") }
@@ -178,6 +210,9 @@ func (s *controlSpy) RenameTabTo(v string) error { return s.record("renam
178210 func (s *controlSpy) Toast(level, message string) error {
179211 return s.record("toast:" + level + ":" + message)
180212 }
213+func (s *controlSpy) SessionRecovery(action string) (string, error) {
214+ return s.recoveryOutput, s.record("session-recovery:" + action)
215+}
181216 func (s *controlSpy) ListSessions(json bool) (string, error) {
182217 _ = s.record("list-sessions:" + strconv.FormatBool(json))
183218 return "sessions", nil
A internal/usecase/daemon/coderabbit_regression_test.go +160 -0
@@ -0,0 +1,160 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "sync"
8+ "syscall"
9+ "testing"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
16+)
17+
18+func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) {
19+ record := durableRecoveryRecord(0)
20+ repository := &durableRecoveryRepository{
21+ errors: map[string]error{record.Name: syscall.ENOSPC},
22+ loads: make(map[string]int),
23+ repairs: make(map[string]domain.CheckpointRef),
24+ }
25+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
26+
27+ d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
28+
29+ persisted, ok, err := catalogue.Record(record.Name)
30+ require.NoError(t, err)
31+ require.True(t, ok)
32+ require.Equal(t, "checkpoint load failed", persisted.DegradedReason)
33+ d.mu.Lock()
34+ entry := d.stopped[record.Name]
35+ d.mu.Unlock()
36+ require.Equal(t, ports.SessionBroken, entry.state)
37+ require.Equal(t, persisted, entry.record)
38+ require.Equal(t, ports.SessionBroken, listSessions(t, d).Sessions[0].State)
39+ select {
40+ case <-entry.restoreDone:
41+ default:
42+ t.Fatal("restore completion was not signaled")
43+ }
44+}
45+
46+type blockingDiscardRecoveryRepository struct {
47+ *durableRecoveryRepository
48+ deleteEntered chan struct{}
49+ releaseDelete chan struct{}
50+}
51+
52+func (r *blockingDiscardRecoveryRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
53+ close(r.deleteEntered)
54+ <-r.releaseDelete
55+ return nil
56+}
57+
58+func TestRestoreFailureCannotOverwriteDiscardReplacement(t *testing.T) {
59+ record := durableRecoveryRecord(0)
60+ record.DegradedReason = "previous restore failure"
61+ repository := &blockingDiscardRecoveryRepository{
62+ durableRecoveryRepository: &durableRecoveryRepository{},
63+ deleteEntered: make(chan struct{}),
64+ releaseDelete: make(chan struct{}),
65+ }
66+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
67+ d.recovery = recoveryusecase.NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{7}, 16)))
68+
69+ discarded := make(chan error, 1)
70+ go func() {
71+ _, err := (controlExec{ctx: t.Context(), d: d, recoveryName: record.Name}).SessionRecovery("discard")
72+ discarded <- err
73+ }()
74+ <-repository.deleteEntered // The fresh record is now authoritative behind Discard's mutation fence.
75+
76+ finished := make(chan struct{})
77+ go func() {
78+ d.finishRecordRestore(record, errors.New("late restore failure"), make(chan struct{}))
79+ close(finished)
80+ }()
81+
82+ close(repository.releaseDelete)
83+ require.NoError(t, <-discarded)
84+ <-finished
85+
86+ persisted, ok, err := catalogue.Record(record.Name)
87+ require.NoError(t, err)
88+ require.True(t, ok)
89+ require.NotEqual(t, record.IncarnationID, persisted.IncarnationID)
90+ require.Nil(t, persisted.Committed)
91+ require.Empty(t, persisted.DegradedReason)
92+ d.mu.Lock()
93+ entry := d.stopped[record.Name]
94+ d.mu.Unlock()
95+ require.Equal(t, persisted, entry.record)
96+ require.Equal(t, ports.SessionStopped, entry.state)
97+}
98+
99+func TestFinishRecordRestoreAlwaysClosesProvidedBarrier(t *testing.T) {
100+ d := newTestDaemon(t, nil, stubClock{})
101+ done := make(chan struct{})
102+
103+ d.finishRecordRestore(domain.CatalogueRecord{Name: "removed"}, nil, done)
104+
105+ select {
106+ case <-done:
107+ default:
108+ t.Fatal("restore completion was not signaled")
109+ }
110+}
111+
112+type blockingRecordCatalogue struct {
113+ ports.Catalogue
114+ record domain.CatalogueRecord
115+ entered chan struct{}
116+ release chan struct{}
117+}
118+
119+func (c *blockingRecordCatalogue) Record(string) (domain.CatalogueRecord, bool, error) {
120+ close(c.entered)
121+ <-c.release
122+ return c.record, true, nil
123+}
124+
125+func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) {
126+ record := durableRecoveryRecord(0)
127+ catalogue := &blockingRecordCatalogue{record: record, entered: make(chan struct{}), release: make(chan struct{})}
128+ d := newTestDaemon(t, nil, stubClock{})
129+ d.catalogue = catalogue
130+ d.persistEnabled = true
131+ d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionStopped, make(chan struct{}))
132+
133+ result := make(chan error, 1)
134+ go func() {
135+ _, err := createSessionForTest(d, record.Name, false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
136+ result <- err
137+ }()
138+ <-catalogue.entered
139+ d.mu.Lock()
140+ d.closing = true
141+ d.mu.Unlock()
142+ close(catalogue.release)
143+
144+ err := <-result
145+ require.ErrorContains(t, err, "daemon is shutting down")
146+ d.mu.Lock()
147+ defer d.mu.Unlock()
148+ require.Empty(t, d.sessions)
149+}
150+
151+func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) {
152+ deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
153+ _, cancel := snapshotStopContext(deadline)
154+
155+ var wg sync.WaitGroup
156+ for range 8 {
157+ wg.Go(cancel)
158+ }
159+ wg.Wait()
160+}
M internal/usecase/daemon/control.go +38 -17
@@ -1,9 +1,11 @@
11 package daemon
22
33 import (
4+ "context"
45 "encoding/json"
56 "errors"
67 "fmt"
8+ "slices"
79 "sort"
810 "strings"
911
@@ -27,14 +29,14 @@ func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) {
2729 _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"}))
2830 return
2931 }
30- _ = tr.Send(frameCommandResult(d.dispatchCommand(request)))
32+ _ = tr.Send(frameCommandResult(d.dispatchCommand(d.serveCtx, request)))
3133 }
3234
3335 func frameCommandResult(result ports.CommandResult) ports.Frame {
3436 return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}
3537 }
3638
37-func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResult {
39+func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandRequest) ports.CommandResult {
3840 cmd, ok := command.BySlug(request.Slug)
3941 if !ok {
4042 return commandFailure(ports.ErrUnknownCommand, "unknown command: "+request.Slug)
@@ -43,7 +45,7 @@ func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResu
4345 return commandFailure(ports.ErrNotScriptable, request.Slug+" requires an attached client")
4446 }
4547 if cmd.Target == command.TargetNone && !request.Self {
46- return d.runControl(cmd, controlExec{d: d}, request)
48+ return d.runControl(cmd, controlExec{ctx: ctx, d: d, recoveryName: request.TargetSession}, request)
4749 }
4850
4951 sess, code, text := d.resolveTargetSession(request)
@@ -57,7 +59,7 @@ func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResu
5759 if code != 0 {
5860 return commandFailure(code, text)
5961 }
60- return d.runControl(cmd, controlExec{d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
62+ return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
6163 }
6264
6365 func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports.CommandRequest) ports.CommandResult {
@@ -306,14 +308,7 @@ func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool {
306308 }
307309 target.session.mu.Lock()
308310 defer target.session.mu.Unlock()
309- foundTab := false
310- for _, tb := range target.session.tabs {
311- if tb == target.tab {
312- foundTab = true
313- break
314- }
315- }
316- if !foundTab {
311+ if !slices.Contains(target.session.tabs, target.tab) {
317312 return false
318313 }
319314 target.tab.mu.Lock()
@@ -332,11 +327,13 @@ func (a daemonActions) switchRelative(sess *session, delta int) error {
332327 // controlExec implements command.ControlContext against resolved daemon-owned
333328 // targets. It deliberately contains no attached-client state.
334329 type controlExec struct {
335- d *Daemon
336- sess *session
337- tab *tab
338- target daemonActionTarget
339- actions daemonActionRunner
330+ ctx context.Context
331+ d *Daemon
332+ sess *session
333+ tab *tab
334+ recoveryName string
335+ target daemonActionTarget
336+ actions daemonActionRunner
340337 }
341338
342339 func (e controlExec) runAction(request daemonActionRequest) error {
@@ -457,6 +454,30 @@ func (e controlExec) Toast(severity, message string) error {
457454 return nil
458455 }
459456
457+func (e controlExec) SessionRecovery(action string) (string, error) {
458+ if e.d == nil || e.d.recovery == nil || e.recoveryName == "" || action != "discard" {
459+ return "", command.ErrInvalidArguments
460+ }
461+ ctx := e.ctx
462+ if ctx == nil {
463+ ctx = e.d.serveCtx
464+ }
465+ if ctx == nil {
466+ ctx = context.Background()
467+ }
468+ if err := e.d.recovery.Discard(ctx, e.recoveryName); err != nil {
469+ return "", err
470+ }
471+ record, ok, err := e.d.catalogue.Record(e.recoveryName)
472+ if err != nil {
473+ return "", err
474+ }
475+ if ok {
476+ e.d.setStoppedRecovery(record, ports.SessionStopped)
477+ }
478+ return "", nil
479+}
480+
460481 func (e controlExec) ListSessions(asJSON bool) (string, error) {
461482 type row struct {
462483 Name string `json:"name"`
M internal/usecase/daemon/daemon.go +270 -86
@@ -7,15 +7,16 @@ import (
77 "log/slog"
88 "math"
99 "os"
10+ "reflect"
1011 "sort"
1112 "sync"
1213 "sync/atomic"
1314 "time"
1415
1516 "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/internal/persist"
1717 "github.com/bnema/vev/internal/ports"
1818 "github.com/bnema/vev/internal/usecase/keys"
19+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
1920 )
2021
2122 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
@@ -70,6 +71,8 @@ type Daemon struct {
7071 mu sync.Mutex
7172 sessions map[domain.SessionID]*session
7273 stopped map[string]stoppedSession
74+ // creating reserves names while durable creation I/O runs without mu.
75+ creating map[string]struct{}
7376 nextID uint64
7477 // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
7578 // mark. It is guarded by mu and prevents a wall-clock regression from
@@ -117,19 +120,25 @@ type Daemon struct {
117120 shell string
118121 shellArgs []string
119122 shellOverride bool
120- persist *persist.Persister
121123 persistEnabled bool
122- // snapshotRepository is the sole checkpoint contract. legacySnapshots is
123- // read-only migration input and is never used for new writes.
124- snapshotRepository ports.SnapshotRepository
125- legacySnapshots ports.LegacySnapshotSource
126- snapsEnabled bool
127- // legacyImportPending holds verified legacy blobs whose source deletion
128- // failed, so a later import retries only deletion rather than publication.
129- legacyImportMu sync.Mutex
130- legacyImportPending map[string][]byte
131- noticeStore ports.NoticeStore
132- snapshotJobs chan *snapshotCapture
124+ catalogue ports.Catalogue
125+ catalogueRecords []domain.CatalogueRecord
126+ catalogueRecordsProvided bool
127+ // snapshotRepository is the sole checkpoint storage contract.
128+ snapshotRepository ports.SnapshotRepository
129+ recovery *recoveryusecase.Coordinator
130+ maintenanceWorkerCancel context.CancelFunc
131+ maintenanceWorkerDone chan struct{}
132+ // restoreWorkerDone is the restoration goroutine's ownership signal. Startup
133+ // restoration reconciles durable checkpoints, so it is a durable writer and
134+ // is guarded by snapshotWorkerMu with the other two.
135+ restoreWorkerDone chan struct{}
136+ snapsEnabled bool
137+ noticeStore ports.NoticeStore
138+ snapshotJobs chan *snapshotCapture
139+ // snapshotAdmitted contains every capture accepted by either worker queue,
140+ // including captures buffered in snapshotJobs. Guarded by snapshotWorkerMu.
141+ snapshotAdmitted map[*snapshotCapture]struct{}
133142 // snapshotWake wakes the repository scheduler when a session becomes dirty
134143 // or an attempt completes. It is never closed and producers only send
135144 // non-blockingly.
@@ -153,6 +162,8 @@ type Daemon struct {
153162 // producer or a repository worker.
154163 snapshotNoticeMu sync.Mutex
155164 snapshotActiveFailureSignature string
165+ shutdownNoticeMu sync.Mutex
166+ shutdownNoticedSessions map[string]struct{}
156167 restoreDone chan struct{}
157168 restoreOnce sync.Once
158169 procCwd func(int) (string, error)
@@ -212,9 +223,13 @@ type stoppedSession struct {
212223 name string
213224 cwd string
214225 createdAt int64
226+ incarnation domain.IncarnationID
215227 lastUsedSeq uint64
216228 tabNames []string
217229 purging bool
230+ record domain.CatalogueRecord
231+ state ports.SessionState
232+ restoreDone chan struct{}
218233 }
219234
220235 type Option func(*Daemon)
@@ -237,22 +252,70 @@ func WithShell(cmd string, args []string) Option {
237252 }
238253 }
239254
240-// WithStore enables persisted named session metadata. A nil store keeps the
241-// daemon in no-op persistence mode.
242-func WithStore(store ports.Store) Option {
255+// WithCatalogue installs the singular catalogue and the strictly opened
256+// records that define the daemon's expected-session registry at startup.
257+func WithCatalogue(catalogue ports.Catalogue, records []domain.CatalogueRecord) Option {
243258 return func(d *Daemon) {
244- d.persist = persist.New(store)
245- d.persistEnabled = store != nil
259+ d.catalogue = catalogue
260+ d.persistEnabled = catalogue != nil
261+ d.catalogueRecords = append([]domain.CatalogueRecord(nil), records...)
262+ d.catalogueRecordsProvided = true
246263 }
247264 }
248265
249-// WithSnapshotRepository enables content-addressed incremental snapshots. The
250-// legacy source is read-only migration input and is never used for new writes.
251-func WithSnapshotRepository(repository ports.SnapshotRepository, legacy ports.LegacySnapshotSource) Option {
266+func (d *Daemon) catalogueRecord(name string) (domain.CatalogueRecord, bool, error) {
267+ if d == nil || d.catalogue == nil {
268+ return domain.CatalogueRecord{}, false, nil
269+ }
270+ return d.catalogue.Record(name)
271+}
272+
273+// markCatalogueDirty buffers metadata. The periodic catalogue timer, the next
274+// identity write, or shutdown provides the durability barrier.
275+func (d *Daemon) markCatalogueDirty(update domain.CatalogueMetadataUpdate) {
276+ if d == nil || d.catalogue == nil {
277+ return
278+ }
279+ if err := d.catalogue.UpdateMetadata(update); err != nil {
280+ d.log.Warn("buffering session metadata failed", "err", err, "session", update.Name)
281+ }
282+}
283+
284+func (d *Daemon) flushCatalogue() error {
285+ if d == nil || d.catalogue == nil {
286+ return nil
287+ }
288+ return d.catalogue.Sync()
289+}
290+
291+// WithRecoveryCoordinator installs the durable recovery coordinator.
292+func WithRecoveryCoordinator(coordinator *recoveryusecase.Coordinator) Option {
252293 return func(d *Daemon) {
294+ d.recovery = coordinator
295+ }
296+}
297+
298+// WithSnapshotRepository enables content-addressed incremental snapshots.
299+func WithSnapshotRepository(repository ports.SnapshotRepository) Option {
300+ return func(d *Daemon) {
301+ if isNilSnapshotRepository(repository) {
302+ return
303+ }
253304 d.snapshotRepository = repository
254- d.legacySnapshots = legacy
255- d.snapsEnabled = repository != nil
305+ d.snapsEnabled = true
306+ }
307+}
308+
309+func isNilSnapshotRepository(repository ports.SnapshotRepository) bool {
310+ if repository == nil {
311+ return true
312+ }
313+ value := reflect.ValueOf(repository)
314+ switch value.Kind() {
315+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
316+ return value.IsNil()
317+ default:
318+ return false
256319 }
257320 }
258321
@@ -262,6 +325,17 @@ func WithNoticeStore(store ports.NoticeStore) Option {
262325 return func(d *Daemon) { d.noticeStore = store }
263326 }
264327
328+// WithDurableMaintenance retains the application wiring for the one
329+// pre-publication GC pass. Standalone users without an explicitly supplied
330+// coordinator receive the same canonical coordinator path.
331+func WithDurableMaintenance(catalogue ports.Catalogue, repository ports.SnapshotRepository) Option {
332+ return func(d *Daemon) {
333+ if d.recovery == nil {
334+ d.recovery = recoveryusecase.NewCoordinator(catalogue, repository, nil)
335+ }
336+ }
337+}
338+
265339 // WithCwdReader overrides the process cwd reader used for persistence tests.
266340 func WithCwdReader(fn func(int) (string, error)) Option {
267341 return func(d *Daemon) {
@@ -347,35 +421,47 @@ func (d *Daemon) nowUnixNano() int64 {
347421 return d.clock.Now().UnixNano()
348422 }
349423
424+type systemClock struct{}
425+type systemTimer struct{ *time.Timer }
426+
427+func (systemClock) Now() time.Time { return time.Now() }
428+func (systemClock) NewTimer(delay time.Duration) ports.Timer {
429+ return systemTimer{Timer: time.NewTimer(delay)}
430+}
431+func (t systemTimer) C() <-chan time.Time { return t.Timer.C }
432+
350433 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
351434 // render debounce, and log receives diagnostics (defaults to slog.Default).
352435 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
353436 if log == nil {
354437 log = slog.Default()
355438 }
439+ if clock == nil {
440+ clock = systemClock{}
441+ }
356442 shell := os.Getenv("SHELL")
357443 if shell == "" {
358444 shell = "/bin/sh"
359445 }
360446 d := &Daemon{
361- sessions: make(map[domain.SessionID]*session),
362- stopped: make(map[string]stoppedSession),
363- parked: make(map[uint64]*parkedAttachment),
364- ptys: ptys,
365- clock: clock,
366- log: log,
367- baseEnv: os.Environ(),
368- shell: shell,
369- persist: persist.New(nil),
370- dirOrHome: dirOrHome,
371- done: make(chan struct{}),
372- restoreDone: make(chan struct{}),
373- animWake: make(chan struct{}, 1),
374- legacyImportPending: make(map[string][]byte),
375- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
376- snapshotWake: make(chan struct{}, 1),
377- notices: newNoticeCenter(),
378- resumeParkGrace: defaultResumeParkGrace,
447+ sessions: make(map[domain.SessionID]*session),
448+ stopped: make(map[string]stoppedSession),
449+ creating: make(map[string]struct{}),
450+ parked: make(map[uint64]*parkedAttachment),
451+ ptys: ptys,
452+ clock: clock,
453+ log: log,
454+ baseEnv: os.Environ(),
455+ shell: shell,
456+ dirOrHome: dirOrHome,
457+ done: make(chan struct{}),
458+ restoreDone: make(chan struct{}),
459+ animWake: make(chan struct{}, 1),
460+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
461+ snapshotAdmitted: make(map[*snapshotCapture]struct{}),
462+ snapshotWake: make(chan struct{}, 1),
463+ notices: newNoticeCenter(),
464+ resumeParkGrace: defaultResumeParkGrace,
379465 barScripts: &barScriptState{
380466 cfg: barConfigFromDomain(domain.Defaults().Bar),
381467 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -395,9 +481,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
395481 for _, o := range opts {
396482 o(d)
397483 }
398- if d.persist == nil {
399- d.persist = persist.New(nil)
400- }
401484 if d.dirOrHome == nil {
402485 d.dirOrHome = dirOrHome
403486 }
@@ -412,27 +495,29 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
412495 allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
413496 d.restoreProcessAllowlist.Store(&allow)
414497 }
415- if records, err := d.persist.LoadAll(); err != nil {
416- d.log.Warn("loading persisted sessions failed", "err", err)
417- } else {
418- var maxSeq uint64
419- var maxCreatedAt int64
420- hasCreatedAt := false
421- for _, r := range records {
422- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
423- if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
424- maxCreatedAt = r.CreatedAt
425- hasCreatedAt = true
426- }
427- if r.LastUsedSeq > maxSeq {
428- maxSeq = r.LastUsedSeq
429- }
498+ records := d.catalogueRecords
499+ var maxSeq uint64
500+ var maxCreatedAt int64
501+ hasCreatedAt := false
502+ for _, r := range records {
503+ if d.catalogueRecordsProvided {
504+ state, done := initialSessionState(r)
505+ d.stopped[r.Name] = stoppedSessionFromRecord(r, state, done)
506+ } else {
507+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, incarnation: r.IncarnationID, lastUsedSeq: r.LastUsedSeq, tabNames: append([]string(nil), r.TabNames...)}
508+ }
509+ if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
510+ maxCreatedAt = r.CreatedAt
511+ hasCreatedAt = true
430512 }
431- if hasCreatedAt {
432- d.lastAllocatedCreatedAt = maxCreatedAt
513+ if r.LastUsedSeq > maxSeq {
514+ maxSeq = r.LastUsedSeq
433515 }
434- d.mruSeq.Store(maxSeq)
435516 }
517+ if hasCreatedAt {
518+ d.lastAllocatedCreatedAt = maxCreatedAt
519+ }
520+ d.mruSeq.Store(maxSeq)
436521 return d
437522 }
438523
@@ -451,7 +536,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
451536 d.sessWg.Go(func() {
452537 d.barScriptPoller(d.serveCtx)
453538 })
454- if d.persistEnabled && d.procCwd != nil {
539+ if d.persistEnabled {
455540 d.sessWg.Go(func() {
456541 d.cwdSampler(d.serveCtx)
457542 })
@@ -461,13 +546,10 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
461546 d.sessWg.Go(func() {
462547 d.snapshotRepositorySaver(d.serveCtx)
463548 })
464- d.sessWg.Go(func() {
465- d.restoreSnapshots(d.serveCtx)
466- })
549+ d.startSnapshotRestoration()
467550 } else {
468551 d.closeRestoreDone()
469552 }
470-
471553 // Break the accept loop when either the parent context is cancelled or the
472554 // registry drains to empty: both close the listener, which fails Accept.
473555 go func() {
@@ -520,14 +602,26 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
520602 d.connWg.Wait()
521603 d.attachmentCleanupWg.Wait()
522604 // Forced terminal checkpoints run before shutdown stops producers or the
523- // worker. The same deadline bounds the worker's final drain, including a
524- // repository call that ignores cancellation.
525- d.stopSnapshotEncodeWorkerWithDeadline(snapshotDeadline)
605+ // worker. StopDurableWriters reports every session whose final capture still
606+ // owns work when the shared checkpoint budget expires. Persist one notice per
607+ // affected session, then retain ownership until every writer actually exits.
608+ stopCtx, stopCancel := snapshotStopContext(snapshotDeadline)
609+ timedOutSessions := d.StopDurableWriters(stopCtx)
610+ stopCancel()
611+ for _, name := range timedOutSessions {
612+ d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)
613+ }
614+ d.WaitDurableWriters()
526615 d.shutdownAllWithSnapshotDeadline(ports.ReasonServerShutdown, snapshotDeadline)
527616 d.waitSessionWorkersWithSnapshotDeadline(snapshotDeadline)
528617 d.waitNotifies()
529- if err := d.persist.Close(); err != nil {
530- d.log.Warn("closing session persister failed", "err", err)
618+ if err := d.flushCatalogue(); err != nil {
619+ d.log.Warn("flushing session catalogue at shutdown failed", "err", err)
620+ }
621+ if d.catalogue != nil {
622+ if err := d.catalogue.Close(); err != nil {
623+ d.log.Warn("closing session catalogue failed", "err", err)
624+ }
531625 }
532626 return nil
533627 }
@@ -557,11 +651,23 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
557651 for _, s := range snapshot {
558652 // Cancellation and PTY closure must not wait behind a teardown owner that
559653 // is blocked in snapshot publication or purge work.
654+ s.mu.Lock()
655+ name := s.name
656+ ephemeral := s.ephemeral
657+ s.mu.Unlock()
560658 s.stopInMemoryLifecycle()
561659 if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline); err != nil {
562660 checkpointIncomplete = true
563661 d.log.Error("closing session with unpersisted terminal state", "err", err)
564662 }
663+ if !ephemeral && deadline != nil {
664+ select {
665+ case <-deadline.Done():
666+ checkpointIncomplete = true
667+ d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)
668+ default:
669+ }
670+ }
565671 }
566672 return checkpointIncomplete
567673 }
@@ -628,6 +734,32 @@ func (d *snapshotShutdownDeadline) Done() <-chan struct{} {
628734 return d.done
629735 }
630736
737+func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, context.CancelFunc) {
738+ ctx, cancel := context.WithCancel(context.Background())
739+ if deadline == nil {
740+ return ctx, cancel
741+ }
742+ select {
743+ case <-deadline.Done():
744+ cancel()
745+ return ctx, cancel
746+ default:
747+ }
748+ stop := make(chan struct{})
749+ go func() {
750+ select {
751+ case <-deadline.Done():
752+ cancel()
753+ case <-stop:
754+ }
755+ }()
756+ var stopOnce sync.Once
757+ return ctx, func() {
758+ stopOnce.Do(func() { close(stop) })
759+ cancel()
760+ }
761+}
762+
631763 func (d *Daemon) sessionsSnapshotLocked() []*session {
632764 snapshot := make([]*session, 0, len(d.sessions))
633765 for _, s := range d.sessions {
@@ -678,6 +810,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
678810 info := ports.SessionInfo{
679811 SessionID: string(s.id),
680812 Name: s.name,
813+ State: ports.SessionRunning,
681814 Ephemeral: s.ephemeral,
682815 Tabs: uint16(len(s.tabs)),
683816 Attached: s.client != nil,
@@ -687,13 +820,16 @@ func (d *Daemon) handleList(tr ports.Transport) {
687820 infos = append(infos, info)
688821 }
689822 for name, stopped := range d.stopped {
690- if stopped.purging {
691- continue
692- }
693823 if _, live := liveNames[name]; live {
694824 continue
695825 }
696- infos = append(infos, ports.SessionInfo{Name: name, Stopped: true})
826+ state := ports.SessionStopped
827+ if stopped.purging || stopped.state == ports.SessionBroken {
828+ // Purge is the dominant externally visible state: restoration must
829+ // never make a deletion-reserved record appear attachable.
830+ state = ports.SessionBroken
831+ }
832+ infos = append(infos, ports.SessionInfo{Name: name, State: state})
697833 }
698834 d.mu.Unlock()
699835
@@ -722,9 +858,8 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
722858 if target == nil {
723859 if _, ok := d.stopped[k.Name]; ok {
724860 d.mu.Unlock()
725- // Stopped sessions follow the same tombstone-backed transaction as
726- // live and offline purges. In particular, a retained legacy source
727- // must never outlive metadata after an interrupted control kill.
861+ // Stopped sessions use the same catalogue-first, incarnation-second
862+ // deletion order as live and offline purges.
728863 if err := d.retryStoppedPurge(k.Name); err != nil {
729864 d.log.Warn("deleting stopped session failed", "err", err, "session", k.Name)
730865 _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session failed"))
@@ -830,6 +965,53 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
830965 return ac
831966 }
832967
968+func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
969+ d.mu.Lock()
970+ var (
971+ done chan struct{}
972+ stopped stoppedSession
973+ ok bool
974+ )
975+ if sess := d.findByNameLocked(name); sess != nil {
976+ sess.mu.Lock()
977+ done = sess.restoreDone
978+ sess.mu.Unlock()
979+ } else {
980+ stopped, ok = d.stopped[name]
981+ if !ok || stopped.purging {
982+ d.mu.Unlock()
983+ return nil
984+ }
985+ done = stopped.restoreDone
986+ }
987+ d.mu.Unlock()
988+
989+ if done != nil {
990+ select {
991+ case <-done:
992+ case <-ctx.Done():
993+ return ctx.Err()
994+ }
995+ }
996+
997+ d.mu.Lock()
998+ defer d.mu.Unlock()
999+ if d.findByNameLocked(name) != nil {
1000+ return nil
1001+ }
1002+ stopped, ok = d.stopped[name]
1003+ if !ok || stopped.record.Name == "" {
1004+ return nil
1005+ }
1006+ if stopped.state == ports.SessionBroken {
1007+ return &protoErr{ports.ErrInternal, "session durable state is broken: " + name}
1008+ }
1009+ if stopped.record.Committed == nil {
1010+ return nil
1011+ }
1012+ return &protoErr{ports.ErrInternal, "session was not restored into this daemon: " + name}
1013+}
1014+
8331015 // route resolves a Hello to a session and a freshly attached client, creating
8341016 // the session for ephemeral/new intents.
8351017 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
@@ -837,13 +1019,6 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
8371019 if !sz.Valid() {
8381020 sz = defaultSize
8391021 }
840- if d.snapsEnabled && (h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach || h.Intent == ports.IntentNew) {
841- select {
842- case <-d.restoreDone:
843- case <-d.serveCtx.Done():
844- return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
845- }
846- }
8471022 term := terminalEnv{TrueColor: h.TrueColor}
8481023
8491024 if h.Intent == ports.IntentResume {
@@ -851,6 +1026,15 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
8511026 return sess, ac, err
8521027 }
8531028 }
1029+ if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {
1030+ ctx := d.serveCtx
1031+ if ctx == nil {
1032+ ctx = context.Background()
1033+ }
1034+ if err := d.waitForTargetRestore(ctx, h.Name); err != nil {
1035+ return nil, nil, err
1036+ }
1037+ }
8541038
8551039 d.mu.Lock()
8561040 // Shutdown/create interlock: once shutdown has begun (last session removed,
M internal/usecase/daemon/daemon_test.go +6 -0
@@ -68,6 +68,12 @@ func newTestDaemon(t testing.TB, ptys ports.PTYFactory, clk ports.Clock) *Daemon
6868 return newTestDaemonWithCleanup(t, ptys, clk, true)
6969 }
7070
71+func createSessionForTest(d *Daemon, name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
72+ d.mu.Lock()
73+ defer d.mu.Unlock()
74+ return d.createSessionLocked(name, ephemeral, cwd, sz, term, env, restoredTabNames...)
75+}
76+
7177 func newTestDaemonWithCleanup(t testing.TB, ptys ports.PTYFactory, clk ports.Clock, registerCleanup bool) *Daemon {
7278 t.Helper()
7379 d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil)))
A internal/usecase/daemon/durable_authority_test.go +53 -0
@@ -0,0 +1,53 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/persist"
12+ "github.com/bnema/vev/internal/ports"
13+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
14+)
15+
16+type testLifecycleRepository struct{ ports.SnapshotRepository }
17+
18+func (testLifecycleRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
19+ return nil
20+}
21+func (testLifecycleRepository) CollectGarbage(context.Context, map[domain.IncarnationID]domain.CheckpointRef) error {
22+ return nil
23+}
24+
25+// WithStore is package-test composition glue. Production injects the same
26+// recovery coordinator explicitly from internal/app.
27+func WithStore(t testing.TB, store ports.Store) Option {
28+ t.Helper()
29+ return func(d *Daemon) {
30+ if store == nil {
31+ return
32+ }
33+ catalogue := persist.New(store)
34+ records, err := catalogue.Records()
35+ require.NoError(t, err)
36+ d.catalogue = catalogue
37+ d.persistEnabled = true
38+ d.catalogueRecords = records
39+ d.catalogueRecordsProvided = true
40+ repository := d.snapshotRepository
41+ if repository == nil {
42+ repository = testLifecycleRepository{}
43+ }
44+ d.recovery = recoveryusecase.NewCoordinator(catalogue, repository, rand.Reader)
45+ }
46+}
47+
48+func testPersister(t testing.TB, d *Daemon) *persist.Persister {
49+ t.Helper()
50+ persister, ok := d.catalogue.(*persist.Persister)
51+ require.True(t, ok, "catalogue type %T is not *persist.Persister", d.catalogue)
52+ return persister
53+}
A internal/usecase/daemon/durable_recovery_test.go +722 -0
@@ -0,0 +1,722 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "log/slog"
10+ "os"
11+ "sync"
12+ "sync/atomic"
13+ "syscall"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/mock"
18+ "github.com/stretchr/testify/require"
19+
20+ "github.com/bnema/vev/internal/domain"
21+ "github.com/bnema/vev/internal/ports"
22+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
23+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
24+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
25+)
26+
27+type durableRecoveryCatalogue struct {
28+ ports.Catalogue
29+ mu sync.Mutex
30+ records map[string]domain.CatalogueRecord
31+ metadataUpdates []domain.CatalogueMetadataUpdate
32+}
33+
34+type recordErrorCatalogue struct {
35+ *durableRecoveryCatalogue
36+ err error
37+}
38+
39+func (c recordErrorCatalogue) Record(string) (domain.CatalogueRecord, bool, error) {
40+ return domain.CatalogueRecord{}, false, c.err
41+}
42+
43+func newDurableRecoveryCatalogue(records []domain.CatalogueRecord) *durableRecoveryCatalogue {
44+ catalogue := &durableRecoveryCatalogue{records: make(map[string]domain.CatalogueRecord, len(records))}
45+ for _, record := range records {
46+ catalogue.records[record.Name] = record
47+ }
48+ return catalogue
49+}
50+
51+func (c *durableRecoveryCatalogue) Records() ([]domain.CatalogueRecord, error) {
52+ c.mu.Lock()
53+ defer c.mu.Unlock()
54+ records := make([]domain.CatalogueRecord, 0, len(c.records))
55+ for _, record := range c.records {
56+ records = append(records, record)
57+ }
58+ return records, nil
59+}
60+
61+func (c *durableRecoveryCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
62+ c.mu.Lock()
63+ defer c.mu.Unlock()
64+ record, ok := c.records[name]
65+ return record, ok, nil
66+}
67+
68+func mustDurableRecords(t *testing.T, c *durableRecoveryCatalogue) []domain.CatalogueRecord {
69+ t.Helper()
70+ records, err := c.Records()
71+ require.NoError(t, err)
72+ return records
73+}
74+
75+func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error {
76+ c.mu.Lock()
77+ defer c.mu.Unlock()
78+ if name != record.Name {
79+ return errors.New("catalogue key mismatch")
80+ }
81+ c.records[name] = record
82+ return nil
83+}
84+
85+func (c *durableRecoveryCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
86+ c.mu.Lock()
87+ defer c.mu.Unlock()
88+ record, ok := c.records[update.Name]
89+ if !ok {
90+ return errors.New("catalogue record not found")
91+ }
92+ if update.IncarnationID == (domain.IncarnationID{}) || record.IncarnationID != update.IncarnationID {
93+ return errors.New("catalogue incarnation changed")
94+ }
95+ if update.Cwd != nil {
96+ record.Cwd = *update.Cwd
97+ }
98+ if update.UpdatedAt != nil {
99+ record.UpdatedAt = *update.UpdatedAt
100+ }
101+ if update.LastUsedSeq != nil {
102+ record.LastUsedSeq = *update.LastUsedSeq
103+ }
104+ if update.TabNames != nil {
105+ record.TabNames = append([]string(nil), (*update.TabNames)...)
106+ }
107+ c.records[update.Name] = record
108+ c.metadataUpdates = append(c.metadataUpdates, update)
109+ return nil
110+}
111+
112+func (c *durableRecoveryCatalogue) MetadataUpdates() []domain.CatalogueMetadataUpdate {
113+ c.mu.Lock()
114+ defer c.mu.Unlock()
115+ return append([]domain.CatalogueMetadataUpdate(nil), c.metadataUpdates...)
116+}
117+
118+func (c *durableRecoveryCatalogue) Sync() error { return nil }
119+func (c *durableRecoveryCatalogue) Close() error { return nil }
120+
121+type durableRecoveryRepository struct {
122+ ports.SnapshotRepository
123+ mu sync.Mutex
124+ generations map[string]ports.SnapshotGeneration
125+ errors map[string]error
126+ loads map[string]int
127+ repairs map[string]domain.CheckpointRef
128+ repairErr error
129+ repairCalls int
130+}
131+
132+func (r *durableRecoveryRepository) LoadCheckpoint(_ context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) {
133+ r.mu.Lock()
134+ defer r.mu.Unlock()
135+ if r.loads == nil {
136+ r.loads = make(map[string]int)
137+ }
138+ r.loads[name]++
139+ if err := r.errors[name]; err != nil {
140+ return ports.SnapshotGeneration{}, err
141+ }
142+ generation, ok := r.generations[name]
143+ if !ok || generation.Generation != ref.Generation {
144+ return ports.SnapshotGeneration{}, errors.New("checkpoint unavailable")
145+ }
146+ return generation, nil
147+}
148+
149+func (r *durableRecoveryRepository) ReconcileCheckpoint(_ context.Context, id domain.IncarnationID, ref ports.CheckpointRef) error {
150+ r.mu.Lock()
151+ defer r.mu.Unlock()
152+ r.repairCalls++
153+ if r.repairErr != nil {
154+ return r.repairErr
155+ }
156+ if r.repairs == nil {
157+ r.repairs = make(map[string]domain.CheckpointRef)
158+ }
159+ r.repairs[id.String()] = ref
160+ return nil
161+}
162+
163+func durableRecoveryRecord(index int) domain.CatalogueRecord {
164+ id := domain.IncarnationID{0: byte(index>>8 + 1), 1: byte(index + 1)}
165+ ref := domain.CheckpointRef{Generation: uint64(index + 1), ManifestDigest: [32]byte{byte(index%255 + 1)}}
166+ return domain.CatalogueRecord{
167+ Name: fmt.Sprintf("session-%04d", index),
168+ IncarnationID: id,
169+ Cwd: "/tmp",
170+ CreatedAt: int64(index + 1),
171+ UpdatedAt: int64(index + 1),
172+ Committed: &ref,
173+ }
174+}
175+
176+func validGeneration(t testing.TB, record domain.CatalogueRecord) ports.SnapshotGeneration {
177+ t.Helper()
178+ manifest, err := snapcodec.MarshalManifest(snapcodec.Manifest{
179+ Generation: record.Committed.Generation,
180+ IncarnationID: record.IncarnationID,
181+ Name: record.Name,
182+ CreatedAt: uint64(record.CreatedAt),
183+ })
184+ require.NoError(t, err)
185+ return ports.SnapshotGeneration{
186+ IncarnationID: record.IncarnationID,
187+ Name: record.Name,
188+ Generation: record.Committed.Generation,
189+ Manifest: manifest,
190+ Objects: map[ports.SnapshotDigest][]byte{},
191+ }
192+}
193+
194+func newDurableRecoveryDaemon(t testing.TB, records []domain.CatalogueRecord, repository ports.SnapshotRepository) (*Daemon, *durableRecoveryCatalogue) {
195+ t.Helper()
196+ catalogue := newDurableRecoveryCatalogue(records)
197+ coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
198+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, records), WithSnapshotRepository(repository), WithRecoveryCoordinator(coordinator))
199+ d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
200+ t.Cleanup(d.serveCancel)
201+ return d, catalogue
202+}
203+
204+func TestFinalCheckpointFailurePreservesCatalogue(t *testing.T) {
205+ priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}
206+ prior := domain.CatalogueRecord{
207+ Name: "work",
208+ IncarnationID: domain.IncarnationID{1},
209+ Cwd: "/work",
210+ CreatedAt: 42,
211+ Committed: &priorRef,
212+ }
213+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{prior})
214+ repository := &durableRecoveryRepository{}
215+ coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
216+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, []domain.CatalogueRecord{prior}), WithSnapshotRepository(repository), WithRecoveryCoordinator(coordinator))
217+ startSnapshotEncodeWorker(t, d)
218+
219+ sess := newSnapshotTestSession(t, prior.Name, false, prior.Cwd)
220+ expired, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
221+ defer cancel()
222+ sess.snapshotMu.Lock()
223+ sess.snapshotPublishedGeneration = priorRef.Generation
224+ sess.snapshotPublishedCheckpoint = &priorRef
225+ sess.snapshotGeneration = priorRef.Generation + 1
226+ sess.snapshotPublicationContext = expired
227+ sess.snapDirty.Store(true)
228+ sess.snapshotMu.Unlock()
229+
230+ require.True(t, d.scheduleFinalSnapshot(sess))
231+ awaitSnapshotIdle(t, sess)
232+ got, ok, _ := catalogue.Record(prior.Name)
233+ require.True(t, ok)
234+ require.Equal(t, prior, got, "a timed-out final checkpoint must not change authoritative bytes")
235+ sess.snapshotMu.Lock()
236+ published := sess.snapshotPublishedCheckpoint
237+ sess.snapshotMu.Unlock()
238+ require.Equal(t, priorRef, *published)
239+}
240+
241+func TestCatalogueRestoreIndependent(t *testing.T) {
242+ t.Run("empty catalogue restoration", func(t *testing.T) {
243+ emptyDaemon, emptyCatalogue := newDurableRecoveryDaemon(t, nil, &durableRecoveryRepository{})
244+ emptyDone := make(chan struct{})
245+ go func() {
246+ emptyDaemon.restoreCatalogue(context.Background(), mustDurableRecords(t, emptyCatalogue))
247+ close(emptyDone)
248+ }()
249+ select {
250+ case <-emptyDone:
251+ case <-time.After(time.Second):
252+ t.Fatal("empty catalogue restoration blocked")
253+ }
254+ })
255+
256+ t.Run("healthy and broken restoration", func(t *testing.T) {
257+ healthy := durableRecoveryRecord(0)
258+ broken := durableRecoveryRecord(1)
259+ repository := &durableRecoveryRepository{
260+ generations: map[string]ports.SnapshotGeneration{healthy.Name: validGeneration(t, healthy)},
261+ errors: map[string]error{broken.Name: errors.New("corrupt checkpoint")},
262+ loads: make(map[string]int),
263+ repairs: make(map[string]domain.CheckpointRef),
264+ }
265+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{healthy, broken}, repository)
266+
267+ d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
268+
269+ d.mu.Lock()
270+ healthyEntry := d.stopped[healthy.Name]
271+ brokenEntry := d.stopped[broken.Name]
272+ d.mu.Unlock()
273+ require.Equal(t, ports.SessionStopped, healthyEntry.state)
274+ require.Equal(t, ports.SessionBroken, brokenEntry.state)
275+ for name, done := range map[string]<-chan struct{}{healthy.Name: healthyEntry.restoreDone, broken.Name: brokenEntry.restoreDone} {
276+ select {
277+ case <-done:
278+ default:
279+ t.Fatalf("restore completion for %q was not closed", name)
280+ }
281+ }
282+ brokenRecord, ok, _ := catalogue.Record(broken.Name)
283+ require.True(t, ok)
284+ require.Equal(t, "checkpoint validation failed", brokenRecord.DegradedReason)
285+ require.Equal(t, 1, repository.loads[healthy.Name])
286+ require.Equal(t, 1, repository.loads[broken.Name])
287+ require.Empty(t, d.sessions, "a corrupt record must never open a replacement runtime")
288+ })
289+
290+}
291+
292+func TestCatalogueRestoreAggregateOver4096(t *testing.T) {
293+ const count = 4200
294+ records := make([]domain.CatalogueRecord, count)
295+ repository := &durableRecoveryRepository{generations: make(map[string]ports.SnapshotGeneration, count), errors: make(map[string]error), loads: make(map[string]int), repairs: make(map[string]domain.CheckpointRef)}
296+ for i := range records {
297+ records[i] = durableRecoveryRecord(i)
298+ repository.generations[records[i].Name] = validGeneration(t, records[i])
299+ }
300+ d, catalogue := newDurableRecoveryDaemon(t, records, repository)
301+
302+ d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
303+
304+ require.Len(t, d.stopped, count)
305+ for _, record := range records {
306+ require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state)
307+ require.Equal(t, 1, repository.loads[record.Name])
308+ }
309+}
310+
311+func TestCatalogueRestoreSingleSessionOver4096(t *testing.T) {
312+ record := durableRecoveryRecord(0)
313+ repository := &durableRecoveryRepository{generations: map[string]ports.SnapshotGeneration{record.Name: validGeneration(t, record)}, errors: make(map[string]error), loads: make(map[string]int), repairs: make(map[string]domain.CheckpointRef)}
314+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
315+
316+ // The repository may contain any number of stale entries; restoration has no
317+ // discovery API and therefore performs exactly one direct catalogue lookup.
318+ d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
319+
320+ require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state)
321+ require.Equal(t, 1, repository.loads[record.Name])
322+}
323+
324+func TestCatalogueRestoreIncarnationMismatch(t *testing.T) {
325+ record := durableRecoveryRecord(0)
326+ generation := validGeneration(t, record)
327+ generation.IncarnationID = domain.IncarnationID{99}
328+ repository := &durableRecoveryRepository{generations: map[string]ports.SnapshotGeneration{record.Name: generation}, errors: make(map[string]error), loads: make(map[string]int), repairs: make(map[string]domain.CheckpointRef)}
329+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
330+
331+ d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
332+
333+ require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state)
334+ got, _, _ := catalogue.Record(record.Name)
335+ require.Equal(t, "checkpoint validation failed", got.DegradedReason)
336+}
337+
338+type cancellationRecoveryRepository struct {
339+ ports.SnapshotRepository
340+ started chan struct{}
341+ once sync.Once
342+}
343+
344+func (r *cancellationRecoveryRepository) LoadCheckpoint(ctx context.Context, _ domain.IncarnationID, _ string, _ ports.CheckpointRef) (ports.SnapshotGeneration, error) {
345+ r.once.Do(func() { close(r.started) })
346+ <-ctx.Done()
347+ return ports.SnapshotGeneration{}, ctx.Err()
348+}
349+
350+type recoveryCountingPTYFactory struct {
351+ calls atomic.Int32
352+}
353+
354+func (f *recoveryCountingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error) {
355+ f.calls.Add(1)
356+ return nil, errors.New("unexpected PTY open")
357+}
358+
359+func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) {
360+ type contextKey struct{}
361+ key := contextKey{}
362+ ctx := context.WithValue(t.Context(), key, "request")
363+ wantErr := errors.New("stop after context capture")
364+ record := durableRecoveryRecord(0)
365+ record.DegradedReason = "checkpoint validation failed"
366+ catalogue := portsmocks.NewMockCatalogue(t)
367+ repository := portsmocks.NewMockSnapshotRepository(t)
368+ catalogue.EXPECT().Record(record.Name).Return(record, true, nil).Once()
369+ catalogue.EXPECT().Replace(record.Name, mock.Anything).Return(nil).Once()
370+ seen := false
371+ repository.EXPECT().DeleteIncarnation(mock.Anything, record.IncarnationID).
372+ Run(func(got context.Context, _ domain.IncarnationID) { seen = got.Value(key) == "request" }).
373+ Return(wantErr).Once()
374+
375+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
376+ d.catalogue = catalogue
377+ d.recovery = recoveryusecase.NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{9}, 16)))
378+
379+ _, err := (controlExec{ctx: ctx, d: d, recoveryName: record.Name}).SessionRecovery("discard")
380+ require.ErrorIs(t, err, wantErr)
381+ require.True(t, seen)
382+}
383+
384+type temporaryRestoreError struct{}
385+
386+func (temporaryRestoreError) Error() string { return "temporary restore failure" }
387+func (temporaryRestoreError) Temporary() bool { return true }
388+
389+func TestRetryableRestoreLoadErrorClassification(t *testing.T) {
390+ tests := []struct {
391+ name string
392+ err error
393+ retryable bool
394+ }{
395+ {name: "missing path", err: fmt.Errorf("wrapped: %w", &os.PathError{Op: "open", Path: "/snapshot", Err: syscall.ENOENT})},
396+ {name: "permission denied", err: fmt.Errorf("wrapped: %w", &os.PathError{Op: "open", Path: "/snapshot", Err: syscall.EACCES})},
397+ {name: "invalid path operation", err: fmt.Errorf("wrapped: %w", &os.PathError{Op: "open", Path: "/snapshot", Err: syscall.EINVAL})},
398+ {name: "temporary path failure", err: fmt.Errorf("wrapped: %w", &os.PathError{Op: "open", Path: "/snapshot", Err: syscall.EAGAIN}), retryable: true},
399+ {name: "resource exhausted path", err: fmt.Errorf("wrapped: %w", &os.PathError{Op: "open", Path: "/snapshot", Err: syscall.ENOSPC}), retryable: true},
400+ {name: "temporary interface", err: fmt.Errorf("wrapped: %w", temporaryRestoreError{}), retryable: true},
401+ {name: "corrupt data", err: errors.New("corrupt checkpoint")},
402+ {name: "context canceled", err: context.Canceled},
403+ {name: "context deadline exceeded", err: context.DeadlineExceeded},
404+ }
405+ for _, tt := range tests {
406+ t.Run(tt.name, func(t *testing.T) {
407+ require.Equal(t, tt.retryable, isRetryableRestoreLoadError(tt.err))
408+ })
409+ }
410+}
411+
412+func TestFinishRecordRestoreDoesNotDegradeStaleCatalogueAuthority(t *testing.T) {
413+ for _, tt := range []struct {
414+ name string
415+ setup func(*Daemon, *durableRecoveryCatalogue, domain.CatalogueRecord)
416+ }{
417+ {
418+ name: "missing record",
419+ setup: func(_ *Daemon, c *durableRecoveryCatalogue, record domain.CatalogueRecord) {
420+ c.mu.Lock()
421+ delete(c.records, record.Name)
422+ c.mu.Unlock()
423+ },
424+ },
425+ {
426+ name: "replacement incarnation",
427+ setup: func(_ *Daemon, c *durableRecoveryCatalogue, record domain.CatalogueRecord) {
428+ record.IncarnationID = domain.IncarnationID{99}
429+ c.mu.Lock()
430+ c.records[record.Name] = record
431+ c.mu.Unlock()
432+ },
433+ },
434+ {
435+ name: "catalogue read error",
436+ setup: func(d *Daemon, c *durableRecoveryCatalogue, _ domain.CatalogueRecord) {
437+ d.catalogue = recordErrorCatalogue{durableRecoveryCatalogue: c, err: errors.New("read failed")}
438+ d.recovery = recoveryusecase.NewCoordinator(d.catalogue, nil, nil)
439+ },
440+ },
441+ } {
442+ t.Run(tt.name, func(t *testing.T) {
443+ record := durableRecoveryRecord(0)
444+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
445+ tt.setup(d, catalogue, record)
446+ d.mu.Lock()
447+ before := d.stopped[record.Name]
448+ d.mu.Unlock()
449+
450+ d.finishRecordRestore(record, errors.New("invalid checkpoint"), before.restoreDone)
451+
452+ d.mu.Lock()
453+ after := d.stopped[record.Name]
454+ d.mu.Unlock()
455+ require.Equal(t, before.record, after.record)
456+ require.Equal(t, before.state, after.state)
457+ })
458+ }
459+}
460+
461+func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testing.T) {
462+ record := durableRecoveryRecord(0)
463+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
464+ replacement := record
465+ replacement.IncarnationID = domain.IncarnationID{99}
466+ require.NoError(t, catalogue.Replace(record.Name, replacement))
467+ sess := newSnapshotTestSession(t, record.Name, false, record.Cwd)
468+ sess.incarnation = record.IncarnationID
469+
470+ registered, err := d.persistAndRegisterRestoredSession(t.Context(), sess)
471+ require.ErrorContains(t, err, "incarnation")
472+ require.False(t, registered)
473+ require.Empty(t, d.sessions)
474+}
475+
476+func TestSessionRecoveryCommand(t *testing.T) {
477+ degraded := durableRecoveryRecord(2)
478+ degraded.DegradedReason = "checkpoint validation failed"
479+ fresh := degraded
480+ fresh.IncarnationID = domain.IncarnationID{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9}
481+ fresh.Committed = nil
482+ fresh.DegradedReason = ""
483+ catalogue := portsmocks.NewMockCatalogue(t)
484+ repository := portsmocks.NewMockSnapshotRepository(t)
485+ catalogue.EXPECT().Record(degraded.Name).Return(degraded, true, nil).Once()
486+ catalogue.EXPECT().Replace(degraded.Name, fresh).Return(nil).Once()
487+ repository.EXPECT().DeleteIncarnation(mock.Anything, degraded.IncarnationID).Return(nil).Once()
488+ catalogue.EXPECT().Record(degraded.Name).Return(fresh, true, nil).Once()
489+ coordinator := recoveryusecase.NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{9}, 16)))
490+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)),
491+ WithCatalogue(catalogue, []domain.CatalogueRecord{degraded}),
492+ WithSnapshotRepository(repository), WithRecoveryCoordinator(coordinator))
493+
494+ _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")
495+ require.NoError(t, err)
496+ require.Equal(t, ports.SessionStopped, d.stopped[degraded.Name].state)
497+ require.Equal(t, fresh, d.stopped[degraded.Name].record)
498+}
499+
500+func TestListShowsDegraded(t *testing.T) {
501+ fresh := durableRecoveryRecord(0)
502+ fresh.Committed = nil
503+ restoring := durableRecoveryRecord(1)
504+ degraded := durableRecoveryRecord(2)
505+ degraded.DegradedReason = "checkpoint validation failed"
506+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{fresh, restoring, degraded}, &durableRecoveryRepository{})
507+
508+ listed := listSessions(t, d)
509+ require.Equal(t, []ports.SessionInfo{
510+ {Name: fresh.Name, State: ports.SessionStopped},
511+ {Name: restoring.Name, State: ports.SessionStopped},
512+ {Name: degraded.Name, State: ports.SessionBroken},
513+ }, listed.Sessions)
514+}
515+
516+func TestListPurgingDominatesRestoringState(t *testing.T) {
517+ record := durableRecoveryRecord(0)
518+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
519+ d.mu.Lock()
520+ entry := d.stopped[record.Name]
521+ entry.purging = true
522+ entry.state = ports.SessionStopped
523+ d.stopped[record.Name] = entry
524+ d.mu.Unlock()
525+
526+ listed := listSessions(t, d)
527+ require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionBroken}}, listed.Sessions)
528+}
529+
530+func TestAttachWaitsForRestore(t *testing.T) {
531+ record := durableRecoveryRecord(0)
532+ factory := &recoveryCountingPTYFactory{}
533+ d := New(factory, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), []domain.CatalogueRecord{record}))
534+ d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
535+ defer d.serveCancel()
536+
537+ result := make(chan error, 1)
538+ go func() {
539+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)
540+ result <- err
541+ }()
542+
543+ select {
544+ case err := <-result:
545+ t.Fatalf("attach returned before target restoration completed: %v", err)
546+ case <-time.After(25 * time.Millisecond):
547+ }
548+ require.Zero(t, factory.calls.Load())
549+
550+ d.setStoppedRecovery(record, ports.SessionBroken)
551+ d.mu.Lock()
552+ closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)
553+ d.mu.Unlock()
554+ var protocolError *protoErr
555+ require.ErrorAs(t, <-result, &protocolError)
556+ require.Equal(t, ports.ErrInternal, protocolError.code)
557+ require.Zero(t, factory.calls.Load())
558+}
559+
560+func TestAttachBarrierSurvivesLiveRegistryPublication(t *testing.T) {
561+ record := durableRecoveryRecord(0)
562+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
563+ d.mu.Lock()
564+ done := d.stopped[record.Name].restoreDone
565+ d.mu.Unlock()
566+ sess := &session{name: record.Name, incarnation: record.IncarnationID}
567+
568+ registered, err := d.persistAndRegisterRestoredSession(t.Context(), sess)
569+ require.NoError(t, err)
570+ require.True(t, registered)
571+
572+ cancelled, cancel := context.WithCancel(context.Background())
573+ cancel()
574+ require.ErrorIs(t, d.waitForTargetRestore(cancelled, record.Name), context.Canceled,
575+ "an attach racing after live publication must still observe the restore barrier")
576+
577+ d.finishRecordRestore(record, nil, done)
578+ require.NoError(t, d.waitForTargetRestore(context.Background(), record.Name),
579+ "attach must proceed after successful restoration completes")
580+}
581+
582+func TestAttachRestoreWaitHonorsContextCancellation(t *testing.T) {
583+ record := durableRecoveryRecord(0)
584+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
585+ ctx, cancel := context.WithCancel(context.Background())
586+ result := make(chan error, 1)
587+ go func() { result <- d.waitForTargetRestore(ctx, record.Name) }()
588+
589+ select {
590+ case err := <-result:
591+ t.Fatalf("restore wait returned before cancellation: %v", err)
592+ case <-time.After(25 * time.Millisecond):
593+ }
594+ cancel()
595+ require.ErrorIs(t, <-result, context.Canceled)
596+}
597+
598+func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) {
599+ record := durableRecoveryRecord(0)
600+ repository := &cancellationRecoveryRepository{started: make(chan struct{})}
601+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
602+ restoreCtx, cancelRestore := context.WithCancel(context.Background())
603+ restored := make(chan struct{})
604+ go func() {
605+ d.restoreCatalogue(restoreCtx, mustDurableRecords(t, catalogue))
606+ close(restored)
607+ }()
608+ <-repository.started
609+
610+ attached := make(chan error, 1)
611+ go func() {
612+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)
613+ attached <- err
614+ }()
615+ cancelRestore()
616+
617+ var protocolError *protoErr
618+ require.ErrorAs(t, <-attached, &protocolError)
619+ require.Equal(t, ports.ErrInternal, protocolError.code)
620+ <-restored
621+ entry := d.stopped[record.Name]
622+ require.Equal(t, ports.SessionBroken, entry.state)
623+ require.Equal(t, "restore interrupted", entry.record.DegradedReason)
624+ select {
625+ case <-entry.restoreDone:
626+ default:
627+ t.Fatal("restore completion was not signaled")
628+ }
629+ committed, _, _ := catalogue.Record(record.Name)
630+ require.Equal(t, entry.record, committed)
631+}
632+
633+func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) {
634+ record := durableRecoveryRecord(0)
635+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
636+ d.mu.Lock()
637+ closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)
638+ d.mu.Unlock()
639+
640+ result := make(chan error, 1)
641+ go func() { result <- d.waitForTargetRestore(context.Background(), record.Name) }()
642+
643+ select {
644+ case err := <-result:
645+ var protocolError *protoErr
646+ require.ErrorAs(t, err, &protocolError)
647+ require.Equal(t, ports.ErrInternal, protocolError.code)
648+ case <-time.After(time.Second):
649+ t.Fatal("waiter spun on a closed restore channel")
650+ }
651+}
652+
653+// A healthy catalogue record whose runtime registration was skipped is not
654+// degraded durable state, and must not send the operator to durable recovery.
655+func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) {
656+ record := durableRecoveryRecord(0)
657+ tests := []struct {
658+ name string
659+ state ports.SessionState
660+ message string
661+ }{
662+ {name: "healthy record without runtime", state: ports.SessionStopped, message: "session was not restored into this daemon: " + record.Name},
663+ {name: "broken durable state", state: ports.SessionBroken, message: "session durable state is broken: " + record.Name},
664+ }
665+ for _, tt := range tests {
666+ t.Run(tt.name, func(t *testing.T) {
667+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
668+ d.setStoppedRecovery(record, tt.state)
669+ d.mu.Lock()
670+ closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)
671+ d.mu.Unlock()
672+
673+ var protocolError *protoErr
674+ require.ErrorAs(t, d.waitForTargetRestore(context.Background(), record.Name), &protocolError)
675+ require.Equal(t, ports.ErrInternal, protocolError.code)
676+ require.Equal(t, tt.message, protocolError.Error())
677+ })
678+ }
679+}
680+
681+func TestAttachRejectsDegraded(t *testing.T) {
682+ record := durableRecoveryRecord(0)
683+ record.DegradedReason = "checkpoint validation failed"
684+ factory := &recoveryCountingPTYFactory{}
685+ d := New(factory, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), []domain.CatalogueRecord{record}))
686+ d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
687+ defer d.serveCancel()
688+
689+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)
690+ var protocolError *protoErr
691+ require.ErrorAs(t, err, &protocolError)
692+ require.Equal(t, ports.ErrInternal, protocolError.code)
693+ require.Zero(t, factory.calls.Load())
694+}
695+
696+func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) {
697+ record := durableRecoveryRecord(0)
698+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, nil)
699+ d.closeRestoreDone()
700+
701+ d.mu.Lock()
702+ entry := d.stopped[record.Name]
703+ d.mu.Unlock()
704+ require.Equal(t, ports.SessionBroken, entry.state)
705+ select {
706+ case <-entry.restoreDone:
707+ default:
708+ t.Fatal("per-record restore waiter remained blocked when repository was unavailable")
709+ }
710+}
711+
712+func TestCatalogueRestoreDegradedVisible(t *testing.T) {
713+ record := durableRecoveryRecord(0)
714+ record.DegradedReason = "checkpoint validation failed"
715+ repository := &durableRecoveryRepository{generations: make(map[string]ports.SnapshotGeneration), errors: make(map[string]error), loads: make(map[string]int), repairs: make(map[string]domain.CheckpointRef)}
716+ d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
717+
718+ d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
719+
720+ require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state)
721+ require.Zero(t, repository.loads[record.Name])
722+}
A internal/usecase/daemon/maintenance.go +20 -0
@@ -0,0 +1,20 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "fmt"
6+)
7+
8+// CollectStartupGarbage runs the one coordinator-owned GC pass before the
9+// daemon socket is published.
10+func (d *Daemon) CollectStartupGarbage(ctx context.Context) error {
11+ if d == nil || d.recovery == nil {
12+ return nil
13+ }
14+ incarnations, err := d.recovery.CollectGarbage(ctx)
15+ if err != nil {
16+ return fmt.Errorf("collect startup snapshots: %w", err)
17+ }
18+ d.log.Info("snapshot_garbage_collection_complete", "incarnations", incarnations)
19+ return nil
20+}
A internal/usecase/daemon/maintenance_test.go +41 -0
@@ -0,0 +1,41 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestStartupGarbageCollectionUsesValidatedCatalogue(t *testing.T) {
15+ committed := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}
16+ records := []domain.CatalogueRecord{
17+ {Name: "work", IncarnationID: domain.IncarnationID{1}, Committed: &committed},
18+ {Name: "fresh", IncarnationID: domain.IncarnationID{2}},
19+ }
20+ catalogue := portsmocks.NewMockCatalogue(t)
21+ catalogue.EXPECT().Records().Return(records, nil).Once()
22+ repository := portsmocks.NewMockSnapshotRepository(t)
23+ repository.EXPECT().CollectGarbage(mock.Anything, mock.MatchedBy(func(keep map[domain.IncarnationID]domain.CheckpointRef) bool {
24+ return len(keep) == 2 && keep[records[0].IncarnationID] == committed && keep[records[1].IncarnationID] == (domain.CheckpointRef{})
25+ })).Return(nil).Once()
26+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
27+ WithDurableMaintenance(catalogue, repository)(d)
28+
29+ require.NoError(t, d.CollectStartupGarbage(context.Background()))
30+}
31+
32+func TestStartupGarbageCollectionSkipsUnknownCatalogueState(t *testing.T) {
33+ catalogue := portsmocks.NewMockCatalogue(t)
34+ catalogue.EXPECT().Records().Return(nil, errors.New("catalogue read failed")).Once()
35+ repository := portsmocks.NewMockSnapshotRepository(t)
36+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
37+ WithDurableMaintenance(catalogue, repository)(d)
38+
39+ require.Error(t, d.CollectStartupGarbage(context.Background()))
40+ repository.AssertNotCalled(t, "CollectGarbage", mock.Anything, mock.Anything)
41+}
A internal/usecase/daemon/metadata_order_test.go +95 -0
@@ -0,0 +1,95 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
13+)
14+
15+type syncCountingCatalogue struct {
16+ *durableRecoveryCatalogue
17+
18+ muSync sync.Mutex
19+ syncs int
20+}
21+
22+func (c *syncCountingCatalogue) Sync() error {
23+ c.muSync.Lock()
24+ defer c.muSync.Unlock()
25+ c.syncs++
26+ return nil
27+}
28+
29+func (c *syncCountingCatalogue) syncCount() int {
30+ c.muSync.Lock()
31+ defer c.muSync.Unlock()
32+ return c.syncs
33+}
34+
35+func (c *syncCountingCatalogue) Create(record domain.CatalogueRecord) error {
36+ c.mu.Lock()
37+ if _, exists := c.records[record.Name]; exists {
38+ c.mu.Unlock()
39+ return errSessionNameInUse
40+ }
41+ c.records[record.Name] = record
42+ c.mu.Unlock()
43+ return c.Sync()
44+}
45+
46+func TestMetadataWritesAreDeferred(t *testing.T) {
47+ t.Parallel()
48+ sess := newSnapshotTestSession(t, "work", false, "/work")
49+ sess.mu.Lock()
50+ record := sess.persistRecordLocked(sess.createdAt)
51+ sess.mu.Unlock()
52+ cat := &syncCountingCatalogue{durableRecoveryCatalogue: newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})}
53+ d := newTestDaemon(t, nil, stubClock{})
54+ d.catalogue = cat
55+ d.persistEnabled = true
56+ d.sessions[sess.id] = sess
57+
58+ syncsBefore := cat.syncCount()
59+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "build"))
60+ d.touchMRU(sess)
61+ require.Equal(t, syncsBefore, cat.syncCount(), "metadata updates must not force a durable write")
62+
63+ require.NoError(t, d.flushCatalogue())
64+ require.Greater(t, cat.syncCount(), syncsBefore)
65+
66+ got, ok, err := cat.Record("work")
67+ require.NoError(t, err)
68+ require.True(t, ok)
69+ require.Equal(t, []string{"build"}, got.TabNames)
70+}
71+
72+func TestIdentityWritesAreSynchronous(t *testing.T) {
73+ t.Parallel()
74+ sess := newSnapshotTestSession(t, "0", true, "/work")
75+ cat := &syncCountingCatalogue{durableRecoveryCatalogue: newDurableRecoveryCatalogue(nil)}
76+ d := newTestDaemon(t, nil, stubClock{})
77+ d.catalogue = cat
78+ d.persistEnabled = true
79+ d.recovery = recoveryusecase.NewCoordinator(cat, nil, bytes.NewReader(bytes.Repeat([]byte{1}, 16)))
80+ d.sessions[sess.id] = sess
81+
82+ syncsBefore := cat.syncCount()
83+ require.NoError(t, d.renameSession(sess, "work"))
84+ require.Greater(t, cat.syncCount(), syncsBefore, "creating a named session must be durable before it is exposed")
85+
86+ d.mu.Lock()
87+ published := d.findByNameLocked("work")
88+ d.mu.Unlock()
89+ require.Same(t, sess, published)
90+ sess.mu.Lock()
91+ require.False(t, sess.ephemeral)
92+ sess.mu.Unlock()
93+}
94+
95+var _ ports.Catalogue = (*syncCountingCatalogue)(nil)
M internal/usecase/daemon/notify.go +14 -1
@@ -198,6 +198,16 @@ func (d *Daemon) persistShutdownSnapshotFailure(name string, cause error) {
198198 if d.noticeStore == nil {
199199 return
200200 }
201+ d.shutdownNoticeMu.Lock()
202+ if d.shutdownNoticedSessions == nil {
203+ d.shutdownNoticedSessions = make(map[string]struct{})
204+ }
205+ if _, exists := d.shutdownNoticedSessions[name]; exists {
206+ d.shutdownNoticeMu.Unlock()
207+ return
208+ }
209+ d.shutdownNoticedSessions[name] = struct{}{}
210+ d.shutdownNoticeMu.Unlock()
201211 if err := d.noticeStore.Append(domain.Notification{
202212 Code: domain.NoticeSnapshotWrite,
203213 Severity: domain.NoticeError,
@@ -205,7 +215,10 @@ func (d *Daemon) persistShutdownSnapshotFailure(name string, cause error) {
205215 Details: noticeDetails(cause),
206216 Time: d.clock.Now(),
207217 }); err != nil {
208- d.log.Warn("persisting shutdown notice failed", "err", err, "session", name)
218+ d.shutdownNoticeMu.Lock()
219+ delete(d.shutdownNoticedSessions, name)
220+ d.shutdownNoticeMu.Unlock()
221+ d.log.Warn("persisting shutdown notice failed", "err", err, "session", name, "action", "final-checkpoint-timeout")
209222 }
210223 }
211224
M internal/usecase/daemon/performance_benchmark_test.go +19 -12
@@ -627,12 +627,18 @@ func publishBenchmarkSnapshot(b testing.TB, fixture *performanceFixture, generat
627627 if err := fixture.snapshots.Publish(context.Background(), publication); err != nil {
628628 b.Fatal(err)
629629 }
630+ checkpoint := domain.CheckpointRef{Generation: generation, ManifestDigest: snapcodec.ManifestDigest(publication.Manifest)}
631+ capture.checkpoint = checkpoint
632+ fixture.sess.snapshotMu.Lock()
633+ fixture.sess.snapshotPublishedGeneration = generation
634+ fixture.sess.snapshotPublishedCheckpoint = &checkpoint
635+ fixture.sess.snapshotMu.Unlock()
630636 markSnapshotCaptureObjectsPublished(capture)
631637 }
632638
633639 func mutateBenchmarkVisible(fixture *performanceFixture, operation int) {
634640 fixture.activePane.mu.Lock()
635- fixture.activePane.screen.Write([]byte(fmt.Sprintf("\x1b[1;1Hvisible-%08d", operation)))
641+ fixture.activePane.screen.Write(fmt.Appendf(nil, "\x1b[1;1Hvisible-%08d", operation))
636642 fixture.activePane.mu.Unlock()
637643 }
638644
@@ -647,7 +653,7 @@ func mutateBenchmarkTail(fixture *performanceFixture, operation int) {
647653 }
648654
649655 func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) {
650- for row := 0; row < 256; row++ {
656+ for row := range 256 {
651657 mutateBenchmarkTail(fixture, operation*256+row)
652658 }
653659 }
@@ -719,7 +725,14 @@ func snapshotGeneration(publication ports.SnapshotPublication) ports.SnapshotGen
719725 for _, object := range publication.Objects {
720726 objects[object.Digest] = object.Data
721727 }
722- return ports.SnapshotGeneration{Name: publication.Name, Generation: publication.Generation, Manifest: publication.Manifest, Objects: objects}
728+ return ports.SnapshotGeneration{
729+ IncarnationID: publication.IncarnationID,
730+ Name: publication.Name,
731+ Generation: publication.Generation,
732+ ParentCheckpoint: publication.ParentCheckpoint,
733+ Manifest: publication.Manifest,
734+ Objects: objects,
735+ }
723736 }
724737
725738 func snapshotObjectBytes(publication ports.SnapshotPublication) uint64 {
@@ -1036,6 +1049,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
10361049 ac.size = config.size
10371050 sess.name = "performance"
10381051 sess.ephemeral = false
1052+ sess.incarnation = domain.IncarnationID{1}
10391053 sess.snapEligible.Store(true)
10401054
10411055 fixture := &performanceFixture{
@@ -1051,7 +1065,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
10511065 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
10521066 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
10531067 }
1054- WithSnapshotRepository(fixture.snapshots, nil)(d)
1068+ WithSnapshotRepository(fixture.snapshots)(d)
10551069 d.startSnapshotEncodeWorker()
10561070 if registerCleanup {
10571071 t.Cleanup(d.stopSnapshotEncodeWorker)
@@ -1376,6 +1390,7 @@ type countingSnapshotMetrics struct {
13761390 writes, objectBytes, historyBlobBytes, suppliedObjectBytes, suppliedHistoryBytes, manifestBytes, headBytes uint64
13771391 }
13781392 type countingSnapshotRepository struct {
1393+ noOpSnapshotRepository
13791394 mu sync.Mutex
13801395 countingSnapshotMetrics
13811396 objects map[string]map[ports.SnapshotDigest]struct{}
@@ -1418,14 +1433,6 @@ func (s *countingSnapshotRepository) Publish(_ context.Context, publication port
14181433 s.last = publication.Manifest
14191434 return nil
14201435 }
1421-func (*countingSnapshotRepository) List(context.Context) ([]string, error) { return nil, nil }
1422-func (*countingSnapshotRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
1423- return ports.SnapshotGeneration{}, nil
1424-}
1425-func (*countingSnapshotRepository) Delete(context.Context, string) error { return nil }
1426-func (*countingSnapshotRepository) Tombstone(context.Context, string) error { return nil }
1427-func (*countingSnapshotRepository) DeleteTombstone(context.Context, string) error { return nil }
1428-func (*countingSnapshotRepository) Maintain(context.Context) error { return nil }
14291436 func (s *countingSnapshotRepository) reset() {
14301437 s.mu.Lock()
14311438 s.countingSnapshotMetrics = countingSnapshotMetrics{}
M internal/usecase/daemon/picker.go +16 -0
@@ -2,6 +2,7 @@
22 package daemon
33
44 import (
5+ "context"
56 "sort"
67
78 "github.com/bnema/vev/internal/domain"
@@ -358,6 +359,17 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
358359 // switchToTargetGuarded is the navigation-only variant whose guard is checked
359360 // as part of the source ownership transfer.
360361 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
362+ if target.Name != "" {
363+ ctx := d.serveCtx
364+ if ctx == nil {
365+ ctx = context.Background()
366+ }
367+ if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
368+ d.invalidateRender(from, ac, true, "picker.go")
369+ return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
370+ }
371+ }
372+
361373 d.mu.Lock()
362374 var (
363375 targetSess *session
@@ -508,6 +520,10 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
508520 // the handoff while d.mu is held. Creation failure leaves the source client
509521 // and stopped record untouched.
510522 func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) {
523+ if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
524+ return nil, nil, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
525+ }
526+
511527 // The caller already holds d.mu. Keep handoff ownership atomic with global
512528 // routing so it cannot select an attachment midway between sessions.
513529 d.notices.routingMu.Lock()
M internal/usecase/daemon/picker_test.go +174 -34
@@ -11,11 +11,13 @@ import (
1111 "github.com/stretchr/testify/require"
1212
1313 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/persist"
1415 "github.com/bnema/vev/internal/ports"
1516 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1617 "github.com/bnema/vev/internal/usecase/keys"
1718 "github.com/bnema/vev/internal/usecase/layout"
1819 "github.com/bnema/vev/internal/usecase/picker"
20+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
1921 themeui "github.com/bnema/vev/internal/usecase/theme"
2022 "github.com/bnema/vev/pkg/renderer"
2123 "github.com/bnema/vev/pkg/vt"
@@ -23,33 +25,17 @@ import (
2325
2426 // --- test doubles -----------------------------------------------------------
2527
26-// failingDeleteStore is a ports.Store whose Delete always fails, used to
27-// exercise the picker's kill-target error path without disturbing the
28-// generated mock's default Delete expectation.
29-type failingDeleteStore struct{ err error }
30-
31-func (failingDeleteStore) Get([]byte) ([]byte, bool) { return nil, false }
32-func (failingDeleteStore) Set([]byte, []byte) error { return nil }
33-func (s failingDeleteStore) Delete([]byte) error { return s.err }
34-func (failingDeleteStore) Range(func(k, v []byte) bool) {}
35-func (failingDeleteStore) Sync() error { return nil }
36-func (failingDeleteStore) Close() error { return nil }
37-
38-type refusingSnapshotDeleteRepository struct{ err error }
28+type refusingSnapshotDeleteRepository struct {
29+ noOpSnapshotRepository
30+ err error
31+}
3932
4033 func (refusingSnapshotDeleteRepository) Publish(context.Context, ports.SnapshotPublication) error {
4134 return nil
4235 }
43-func (refusingSnapshotDeleteRepository) List(context.Context) ([]string, error) { return nil, nil }
44-func (refusingSnapshotDeleteRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
45- return ports.SnapshotGeneration{}, nil
36+func (s refusingSnapshotDeleteRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
37+ return s.err
4638 }
47-func (s refusingSnapshotDeleteRepository) Delete(context.Context, string) error { return s.err }
48-func (refusingSnapshotDeleteRepository) Tombstone(context.Context, string) error { return nil }
49-func (refusingSnapshotDeleteRepository) DeleteTombstone(context.Context, string) error {
50- return nil
51-}
52-func (refusingSnapshotDeleteRepository) Maintain(context.Context) error { return nil }
5339
5440 func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
5541 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
@@ -234,6 +220,148 @@ func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
234220 require.Equal(t, []picker.TabEntry{{Name: "1", Detail: " (vim)"}, {Name: "logs", Detail: " (sh)"}}, views[0].Tabs)
235221 }
236222
223+func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
224+ p, release := newBlockingPTY(t)
225+ defer release()
226+ d, from, ac, _ := newManualSessionWithPTYs(t, p)
227+ record := durableRecoveryRecord(0)
228+ record.Name = "restoring"
229+ state, done := initialSessionState(record)
230+ d.stopped[record.Name] = stoppedSession{
231+ name: record.Name,
232+ createdAt: record.CreatedAt,
233+ incarnation: record.IncarnationID,
234+ record: record,
235+ state: state,
236+ restoreDone: done,
237+ }
238+ factory := &recoveryCountingPTYFactory{}
239+ d.ptys = factory
240+
241+ result := make(chan error, 1)
242+ go func() {
243+ result <- d.switchToTarget(from, ac, picker.Target{Name: record.Name, Stopped: true})
244+ }()
245+
246+ select {
247+ case err := <-result:
248+ t.Fatalf("picker switch returned before target restoration completed: %v", err)
249+ case <-time.After(25 * time.Millisecond):
250+ }
251+ require.Zero(t, factory.calls.Load())
252+ require.Same(t, from, ac.currentSession())
253+
254+ ctx, cancel := context.WithCancel(d.serveCtx)
255+ defer cancel()
256+ target := &session{id: "restored", name: record.Name, createdAt: record.CreatedAt, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}}
257+ d.mu.Lock()
258+ delete(d.stopped, record.Name)
259+ d.sessions[target.id] = target
260+ closeRuntimeRestoreDoneLocked(done)
261+ d.mu.Unlock()
262+
263+ require.NoError(t, <-result)
264+ require.Same(t, target, ac.currentSession())
265+}
266+
267+func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
268+ p, release := newBlockingPTY(t)
269+ defer release()
270+ d, from, ac, _ := newManualSessionWithPTYs(t, p)
271+ record := durableRecoveryRecord(0)
272+ record.Name = "restoring"
273+ repository := &cancellationRecoveryRepository{started: make(chan struct{})}
274+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
275+ coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
276+ WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
277+ WithSnapshotRepository(repository)(d)
278+ WithRecoveryCoordinator(coordinator)(d)
279+ restoreCtx, cancelRestore := context.WithCancel(context.Background())
280+ restored := make(chan struct{})
281+ go func() {
282+ d.restoreCatalogue(restoreCtx, mustDurableRecords(t, catalogue))
283+ close(restored)
284+ }()
285+ <-repository.started
286+
287+ result := make(chan error, 1)
288+ go func() {
289+ result <- d.switchToTarget(from, ac, picker.Target{Name: record.Name, Stopped: true})
290+ }()
291+ cancelRestore()
292+
293+ require.Error(t, <-result)
294+ <-restored
295+ require.Same(t, from, ac.currentSession())
296+ d.mu.Lock()
297+ entry := d.stopped[record.Name]
298+ d.mu.Unlock()
299+ require.Equal(t, ports.SessionBroken, entry.state)
300+ require.Equal(t, "restore interrupted", entry.record.DegradedReason)
301+ select {
302+ case <-entry.restoreDone:
303+ default:
304+ t.Fatal("restore completion was not signaled")
305+ }
306+}
307+
308+func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
309+ tests := []struct {
310+ name string
311+ broken bool
312+ runtimeState ports.SessionState
313+ }{
314+ {name: "broken", broken: true, runtimeState: ports.SessionBroken},
315+ {name: "healthy without restored runtime", runtimeState: ports.SessionStopped},
316+ }
317+
318+ for _, tt := range tests {
319+ t.Run(tt.name, func(t *testing.T) {
320+ p, release := newBlockingPTY(t)
321+ defer release()
322+ d, from, ac, _ := newManualSessionWithPTYs(t, p)
323+ store, storeState := newMockStore(t)
324+ record := durableRecoveryRecord(0)
325+ record.Name = "unsafe"
326+ if tt.broken {
327+ record.DegradedReason = "checkpoint validation failed"
328+ }
329+ catalogue := persist.New(store)
330+ WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
331+ d.stopped[record.Name] = stoppedSession{
332+ name: record.Name,
333+ createdAt: record.CreatedAt,
334+ incarnation: record.IncarnationID,
335+ record: record,
336+ state: tt.runtimeState,
337+ }
338+ factory := &recoveryCountingPTYFactory{}
339+ d.ptys = factory
340+
341+ err := d.switchToTarget(from, ac, picker.Target{Name: record.Name, Stopped: true})
342+
343+ var userError *domain.UserError
344+ require.ErrorAs(t, err, &userError)
345+ require.Equal(t, domain.NoticeSessionUnavailable, userError.Code)
346+ var protocolError *protoErr
347+ require.ErrorAs(t, err, &protocolError)
348+ require.Equal(t, ports.ErrInternal, protocolError.code)
349+ require.Zero(t, factory.calls.Load(), "unsafe target must not open a PTY")
350+ require.Same(t, from, ac.currentSession())
351+
352+ storeState.mu.Lock()
353+ sets := storeState.sets
354+ storeState.mu.Unlock()
355+ require.Zero(t, sets, "unsafe target must not mutate the catalogue")
356+ d.mu.Lock()
357+ entry := d.stopped[record.Name]
358+ d.mu.Unlock()
359+ require.Equal(t, record, entry.record)
360+ require.Equal(t, tt.runtimeState, entry.state)
361+ })
362+ }
363+}
364+
237365 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
238366 p1, release1 := newBlockingPTY(t)
239367 p2, release2 := newBlockingPTY(t)
@@ -243,7 +371,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
243371 defer release3()
244372 d, from, ac, sends := newManualSessionWithPTYs(t, p1)
245373 d.ptys = newFactorySeq(t, p2, p3)
246- d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
374+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionStopped}
247375
248376 d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
249377 awaitFrame(t, sends, ports.MsgOutput)
@@ -1007,8 +1135,17 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
10071135 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
10081136 defer releaseAll(releases)
10091137 cause := errors.New("snapshot delete refused")
1010- WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause}, nil)(d)
1138+ WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause})(d)
10111139 target := d.sessions[domain.SessionID("recent")]
1140+ store, _ := newMockStore(t)
1141+ WithStore(t, store)(d)
1142+ target.mu.Lock()
1143+ if target.incarnation == (domain.IncarnationID{}) {
1144+ target.incarnation = domain.IncarnationID{1}
1145+ }
1146+ record := target.persistRecordLocked(1)
1147+ target.mu.Unlock()
1148+ require.NoError(t, d.catalogue.Create(record))
10121149
10131150 d.enterPicker(from, ac)
10141151 awaitFrame(t, sends, ports.MsgOutput)
@@ -1041,8 +1178,14 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
10411178 defer release()
10421179 d, from, ac, sends := newManualSessionWithPTYs(t, p)
10431180 cause := errors.New("delete failed")
1044- WithStore(failingDeleteStore{err: cause})(d)
1045- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7}
1181+ store, state := newMockStore(t)
1182+ WithStore(t, store)(d)
1183+ record := domain.CatalogueRecord{Name: "stopped", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/stopped", CreatedAt: 7}
1184+ require.NoError(t, d.catalogue.Create(record))
1185+ state.mu.Lock()
1186+ state.deleteErr = func(string) error { return cause }
1187+ state.mu.Unlock()
1188+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7, incarnation: record.IncarnationID, record: record}
10461189
10471190 d.enterPicker(from, ac)
10481191 awaitFrame(t, sends, ports.MsgOutput)
@@ -1059,12 +1202,9 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
10591202 toasts := awaitToastCount(t, ac, 1)
10601203 require.Equal(t, domain.NoticePersistDelete, toasts[0].Code)
10611204
1062- views, _ := d.pickerViews(from)
1063- var stillListed bool
1064- for _, v := range views {
1065- if v.Stopped && v.Name == "stopped" {
1066- stillListed = true
1067- }
1068- }
1069- require.True(t, stillListed, "entry must remain listed after refreshPicker when the delete failed")
1205+ d.mu.Lock()
1206+ stopped, retained := d.stopped["stopped"]
1207+ d.mu.Unlock()
1208+ require.True(t, retained, "failed deletion must retain the reserved name")
1209+ require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")
10701210 }
A internal/usecase/daemon/recovery_observability.go +61 -0
@@ -0,0 +1,61 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+func (d *Daemon) logSessionRestoreComplete(record domain.CatalogueRecord, generation uint64, fallback bool) {
6+ d.log.Info("session_restore_complete",
7+ "session", record.Name,
8+ "incarnation", record.IncarnationID.String(),
9+ "generation", generation,
10+ "fallback", fallback,
11+ )
12+}
13+
14+func (d *Daemon) logSessionDegraded(record domain.CatalogueRecord, reasonCode string) {
15+ d.log.Warn("session_degraded",
16+ "session", record.Name,
17+ "incarnation", record.IncarnationID.String(),
18+ "reason_code", reasonCode,
19+ )
20+}
21+
22+func (d *Daemon) logStartupRecoveryCounts(restoring int) {
23+ var records []domain.CatalogueRecord
24+ if d.persistEnabled && d.catalogue != nil {
25+ var err error
26+ records, err = d.catalogue.Records()
27+ if err != nil {
28+ d.log.Error("daemon_startup_catalogue_read_failed", "err", err)
29+ records = d.stoppedRecords()
30+ }
31+ } else {
32+ records = d.stoppedRecords()
33+ }
34+ healthy, fresh, broken := 0, 0, 0
35+ for _, record := range records {
36+ switch {
37+ case record.DegradedReason != "":
38+ broken++
39+ case record.Committed == nil:
40+ fresh++
41+ default:
42+ healthy++
43+ }
44+ }
45+ d.log.Info("daemon_startup_complete",
46+ "healthy", healthy,
47+ "fresh", fresh,
48+ "restoring", restoring,
49+ "broken", broken,
50+ )
51+}
52+
53+func (d *Daemon) stoppedRecords() []domain.CatalogueRecord {
54+ d.mu.Lock()
55+ defer d.mu.Unlock()
56+ records := make([]domain.CatalogueRecord, 0, len(d.stopped))
57+ for _, entry := range d.stopped {
58+ records = append(records, entry.record)
59+ }
60+ return records
61+}
A internal/usecase/daemon/recovery_observability_test.go +92 -0
@@ -0,0 +1,92 @@
1+package daemon
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "encoding/json"
7+ "errors"
8+ "log/slog"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestStartupRecoveryCounts(t *testing.T) {
16+ var buffer bytes.Buffer
17+ log := slog.New(slog.NewJSONHandler(&buffer, nil))
18+ ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
19+ records := []domain.CatalogueRecord{
20+ {Name: "one", IncarnationID: domain.IncarnationID{1}, Committed: ref},
21+ {Name: "two", IncarnationID: domain.IncarnationID{2}, Committed: ref},
22+ {Name: "new", IncarnationID: domain.IncarnationID{3}},
23+ {Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref, DegradedReason: "terminal contents must not appear"},
24+ }
25+ d := New(nil, stubClock{}, log, WithCatalogue(newDurableRecoveryCatalogue(records), records))
26+ d.logSessionRestoreComplete(records[0], 7, false)
27+ d.logSessionDegraded(records[3], "checkpoint-invalid")
28+ d.logStartupRecoveryCounts(0)
29+
30+ entries := daemonJSONLogs(t, buffer.Bytes())
31+ restore := daemonRequireEvent(t, entries, "session_restore_complete")
32+ require.Equal(t, "one", restore["session"])
33+ require.Equal(t, records[0].IncarnationID.String(), restore["incarnation"])
34+ require.EqualValues(t, 7, restore["generation"])
35+ degraded := daemonRequireEvent(t, entries, "session_degraded")
36+ require.Equal(t, "checkpoint-invalid", degraded["reason_code"])
37+ require.NotContains(t, degraded, "reason")
38+ startup := daemonRequireEvent(t, entries, "daemon_startup_complete")
39+ require.EqualValues(t, 2, startup["healthy"])
40+ require.EqualValues(t, 1, startup["fresh"])
41+ require.EqualValues(t, 0, startup["restoring"])
42+ require.EqualValues(t, 1, startup["broken"])
43+}
44+
45+type failingRecordsCatalogue struct {
46+ *durableRecoveryCatalogue
47+ err error
48+}
49+
50+func (c failingRecordsCatalogue) Records() ([]domain.CatalogueRecord, error) {
51+ return nil, c.err
52+}
53+
54+func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) {
55+ var buffer bytes.Buffer
56+ record := domain.CatalogueRecord{Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}, DegradedReason: "invalid"}
57+ d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&buffer, nil)))
58+ d.persistEnabled = true
59+ d.catalogue = failingRecordsCatalogue{durableRecoveryCatalogue: newDurableRecoveryCatalogue(nil), err: errors.New("read failed")}
60+ d.stopped[record.Name] = stoppedSession{record: record}
61+
62+ d.logStartupRecoveryCounts(0)
63+
64+ entries := daemonJSONLogs(t, buffer.Bytes())
65+ daemonRequireEvent(t, entries, "daemon_startup_catalogue_read_failed")
66+ startup := daemonRequireEvent(t, entries, "daemon_startup_complete")
67+ require.EqualValues(t, 1, startup["broken"])
68+}
69+
70+func daemonJSONLogs(t *testing.T, data []byte) []map[string]any {
71+ t.Helper()
72+ var entries []map[string]any
73+ scanner := bufio.NewScanner(bytes.NewReader(data))
74+ for scanner.Scan() {
75+ var entry map[string]any
76+ require.NoError(t, json.Unmarshal(scanner.Bytes(), &entry))
77+ entries = append(entries, entry)
78+ }
79+ require.NoError(t, scanner.Err())
80+ return entries
81+}
82+
83+func daemonRequireEvent(t *testing.T, entries []map[string]any, name string) map[string]any {
84+ t.Helper()
85+ for _, entry := range entries {
86+ if entry["msg"] == name {
87+ return entry
88+ }
89+ }
90+ t.Fatalf("event %q not found", name)
91+ return nil
92+}
M internal/usecase/daemon/render_capture.go +37 -12
@@ -17,15 +17,21 @@ import (
1717 // capture/compose/emit transaction holds that attachment's sendMu, so these
1818 // buffers cannot be observed or reused before emission completes.
1919 type renderCaptureScratch struct {
20- state capturedRenderState
21- panes []capturedPaneRenderState
22- placements []layout.Placement
23- dividers []layout.Divider
24- statusTabs []statusTab
25- mru []recentSession
26- ranked []rankedRecent
27- titleIDs []layout.PaneID
28- receipts []damageReceipt
20+ state capturedRenderState
21+ panes []capturedPaneRenderState
22+ placements []layout.Placement
23+ dividers []layout.Divider
24+ layoutTab *tab
25+ layoutGeneration uint64
26+ layoutArea domain.Rect
27+ layoutFocus layout.PaneID
28+ layoutFingerprint string
29+ layoutValid bool
30+ statusTabs []statusTab
31+ mru []recentSession
32+ ranked []rankedRecent
33+ titleIDs []layout.PaneID
34+ receipts []damageReceipt
2935 }
3036
3137 type damageReceipt struct {
@@ -225,9 +231,28 @@ func capturePrimaryRenderState(
225231
226232 tb.mu.Lock()
227233 defer tb.mu.Unlock()
228- layoutSnap := solveTabLayoutLocked(tb)
229- scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
230- scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
234+ area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
235+ var focus layout.PaneID
236+ if tb.tree != nil {
237+ focus = tb.tree.Focus
238+ }
239+ layoutSnap := tabLayoutSnapshot{
240+ area: area, focus: focus, placements: scratch.placements,
241+ dividers: scratch.dividers, fingerprint: scratch.layoutFingerprint, ok: scratch.layoutValid,
242+ }
243+ if scratch.layoutTab != tb || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
244+ layoutSnap = solveTabLayoutLocked(tb)
245+ scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
246+ scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
247+ scratch.layoutTab = tb
248+ scratch.layoutGeneration = tb.layoutGeneration
249+ scratch.layoutArea = area
250+ scratch.layoutFocus = focus
251+ scratch.layoutFingerprint = layoutSnap.fingerprint
252+ scratch.layoutValid = layoutSnap.ok
253+ layoutSnap.placements = scratch.placements
254+ layoutSnap.dividers = scratch.dividers
255+ }
231256 state := &scratch.state
232257 *state = capturedRenderState{
233258 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
M internal/usecase/daemon/session.go +175 -189
@@ -3,6 +3,7 @@ package daemon
33
44 import (
55 "context"
6+ "crypto/rand"
67 "errors"
78 "fmt"
89 "os"
@@ -14,7 +15,6 @@ import (
1415 "time"
1516
1617 "github.com/bnema/vev/internal/domain"
17- "github.com/bnema/vev/internal/persist"
1818 "github.com/bnema/vev/internal/ports"
1919 "github.com/bnema/vev/internal/usecase/layout"
2020 )
@@ -46,26 +46,29 @@ type session struct {
4646 // tab, or pane locks. The owner performs teardown without holding teardownMu;
4747 // duplicate daemon-shutdown callers can therefore bound their ownership wait
4848 // with Serve's shared snapshot deadline.
49- teardownMu sync.Mutex
50- teardownActive bool
51- teardownDone chan struct{}
52- teardownWaiters uint
53- teardownChanged chan struct{}
54- lifecycleStopOnce sync.Once
55- mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
49+ teardownMu sync.Mutex
50+ teardownActive bool
51+ teardownDone chan struct{}
52+ teardownWaiters uint
53+ teardownChanged chan struct{}
54+ lifecycleStopOnce sync.Once
55+ mu sync.Mutex // guards tabs, active, client, restoreDone, clipFiles, and clipboard queue state
56+ // restoreDone remains attached to a restored session after it is published in
57+ // the live registry, so racing attaches cannot bypass restoration completion.
58+ restoreDone chan struct{}
5659 themeMu sync.Mutex
5760 tabs []*tab
5861 active int
5962 client *attachedClient
6063 clipboardQueue []clipboardForward
6164 clipboardWorkerRunning bool
62- renameInProgress bool
6365 cwd string
6466 terminal terminalEnv
6567 // env is the authoritative immutable environment snapshot for future PTY children.
6668 // It is guarded by mu and is always copied on ingress and egress.
6769 env []string
6870 createdAt int64
71+ incarnation domain.IncarnationID
6972 mruAt atomic.Uint64
7073 snapDirty atomic.Bool
7174 snapEligible atomic.Bool
@@ -75,6 +78,7 @@ type session struct {
7578 snapshotMu sync.Mutex
7679 snapshotGeneration uint64 // newest mutation revision
7780 snapshotPublishedGeneration uint64 // newest repository generation
81+ snapshotPublishedCheckpoint *domain.CheckpointRef
7882 snapshotPublishedMutationRevision uint64
7983 snapshotNextEligibleAt time.Time
8084 // The coordinator state below is guarded by snapshotMu. A capture can be
@@ -93,7 +97,6 @@ type session struct {
9397 snapshotPublicationContext context.Context
9498 snapshotPublicationCancel context.CancelFunc
9599 snapshotQuarantined bool
96- snapshotQuarantineEpoch uint64
97100 // snapshotChunkCache retains encoded sealed history only for this named
98101 // session. snapshotMu owns it so cache touches never contend with pane
99102 // state or mutate bytes retained by queued/in-flight publications.
@@ -164,40 +167,57 @@ func (d *Daemon) touchMRU(sess *session) {
164167 return
165168 }
166169 seq := d.mruSeq.Add(1)
167- updated := false
168170 for {
169171 old := sess.mruAt.Load()
170172 if old >= seq {
171173 return
172174 }
173175 if sess.mruAt.CompareAndSwap(old, seq) {
174- updated = true
175176 break
176177 }
177178 }
178- if !updated {
179- return
180- }
181179 sess.mu.Lock()
182- name := sess.name
183- ephemeral := sess.ephemeral
184- sess.mu.Unlock()
185- if !ephemeral && d.persist != nil {
186- if err := d.persist.TouchMRU(name, seq); err != nil {
187- d.log.Warn("touching persisted session recency failed", "err", err, "session", name)
188- }
180+ if !sess.ephemeral {
181+ d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
189182 }
183+ sess.mu.Unlock()
190184 }
191185
192186 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
193187 env = copyEnvironment(env)
188+ if _, reserved := d.creating[name]; reserved {
189+ return nil, errSessionNameInUse
190+ }
191+ d.creating[name] = struct{}{}
192+ defer delete(d.creating, name)
194193 stopped, resuming := d.stopped[name]
194+ var authoritative domain.CatalogueRecord
195+ var authoritativeExists bool
196+ if !ephemeral && resuming && d.persistEnabled {
197+ d.mu.Unlock()
198+ var err error
199+ authoritative, authoritativeExists, err = d.catalogueRecord(name)
200+ d.mu.Lock()
201+ if err != nil {
202+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't read session catalogue", err)
203+ }
204+ if d.closing {
205+ return nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
206+ }
207+ stopped, resuming = d.stopped[name]
208+ }
195209 var createdAt int64
210+ var incarnation domain.IncarnationID
196211 if !ephemeral {
197212 if resuming {
198213 // A stopped session is the same lifecycle when resumed; it must retain
199214 // the persisted identity rather than receive a fresh timestamp.
200215 createdAt = stopped.createdAt
216+ incarnation = stopped.incarnation
217+ if authoritativeExists {
218+ createdAt = authoritative.CreatedAt
219+ incarnation = authoritative.IncarnationID
220+ }
201221 if createdAt > d.lastAllocatedCreatedAt {
202222 d.lastAllocatedCreatedAt = createdAt
203223 }
@@ -208,6 +228,13 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
208228 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
209229 }
210230 }
231+ if incarnation == (domain.IncarnationID{}) {
232+ var err error
233+ incarnation, err = domain.NewIncarnationID(rand.Reader)
234+ if err != nil {
235+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", fmt.Errorf("generate durable identity: %w", err))
236+ }
237+ }
211238 }
212239
213240 tbSize := tabSize(sz)
@@ -247,6 +274,9 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
247274 if !ephemeral && resuming {
248275 lastUsedSeq = stopped.lastUsedSeq
249276 }
277+ if lastUsedSeq == 0 {
278+ lastUsedSeq = d.mruSeq.Add(1)
279+ }
250280 sess := &session{
251281 id: id,
252282 name: name,
@@ -258,27 +288,41 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
258288 terminal: term,
259289 env: env,
260290 createdAt: createdAt,
291+ incarnation: incarnation,
261292 snapshotWake: d.snapshotWake,
262293 }
263294 if !ephemeral && name != "" {
264295 sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
265296 }
266- if lastUsedSeq > 0 {
267- sess.mruAt.Store(lastUsedSeq)
268- }
297+ sess.mruAt.Store(lastUsedSeq)
269298 sess.snapEligible.Store(!ephemeral && name != "")
270299 if !ephemeral {
271- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}); err != nil {
272- closeTabs(tabs)
273- cancel()
274- return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
300+ if d.persistEnabled {
301+ record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}
302+ if d.recovery == nil {
303+ closeTabs(tabs)
304+ cancel()
305+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured"))
306+ }
307+ var err error
308+ if resuming && authoritativeExists {
309+ err = d.catalogue.UpdateMetadata(record.MetadataUpdate())
310+ if err == nil {
311+ err = d.catalogue.Sync()
312+ }
313+ } else {
314+ record, err = d.recovery.Create(d.serveCtx, record)
315+ }
316+ if err != nil {
317+ closeTabs(tabs)
318+ cancel()
319+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
320+ }
321+ sess.incarnation = record.IncarnationID
275322 }
276323 delete(d.stopped, name)
277324 }
278325 d.sessions[id] = sess
279- if lastUsedSeq == 0 {
280- d.touchMRU(sess)
281- }
282326 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
283327 for i, tb := range tabs {
284328 d.log.Info("tab created", "session", name, "tab", i)
@@ -406,29 +450,16 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
406450 p.ctx, p.cancel = context.WithCancel(tb.ctx)
407451 }
408452 sess.mu.Lock()
409- oldActive := sess.active
410453 sess.tabs = append(sess.tabs, tb)
411454 sess.active = len(sess.tabs) - 1
412455 tabIndex := sess.active
413- record := sess.persistRecordLocked(time.Now().UnixNano())
414- ephemeral := sess.ephemeral
415- if !ephemeral {
416- if err := d.persist.Save(record); err != nil {
417- sess.tabs = sess.tabs[:len(sess.tabs)-1]
418- sess.active = oldActive
419- if tb.cancel != nil {
420- tb.cancel()
421- }
422- sess.mu.Unlock()
423- d.mu.Unlock()
424- _ = pty.Close()
425- return err
426- }
456+ if !sess.ephemeral {
457+ d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
427458 }
428459 sess.mu.Unlock()
460+ d.mu.Unlock()
429461 d.log.Info("tab created", "session", name, "tab", tabIndex)
430462 d.startTabGoroutines(sess, tb)
431- d.mu.Unlock()
432463 d.activateTab(sess, tb)
433464 markSnapshotDirty(sess)
434465 return nil
@@ -612,21 +643,14 @@ func (d *Daemon) renameSession(sess *session, name string) error {
612643 return err
613644 }
614645
615- // Reserve the rename while state is inspected, then perform persistence and
616- // repository I/O without daemon/session locks. The old identity is first
617- // quarantined and durably purged; committing the new record before that
618- // transaction completes could leave a restorable old legacy source.
646+ // Hold daemon then session state across the durable identity write. The
647+ // incarnation-keyed snapshot namespace is never relocated.
619648 d.mu.Lock()
620649 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
621650 d.mu.Unlock()
622651 return errSessionNameInUse
623652 }
624653 sess.mu.Lock()
625- if sess.renameInProgress {
626- sess.mu.Unlock()
627- d.mu.Unlock()
628- return errors.New("session rename already in progress")
629- }
630654 if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {
631655 sess.mu.Unlock()
632656 d.mu.Unlock()
@@ -635,6 +659,7 @@ func (d *Daemon) renameSession(sess *session, name string) error {
635659 oldName := sess.name
636660 wasEphemeral := sess.ephemeral
637661 createdAt := sess.createdAt
662+ priorIncarnation := sess.incarnation
638663 if wasEphemeral {
639664 var err error
640665 createdAt, err = d.allocateLifecycleCreatedAtLocked()
@@ -643,71 +668,54 @@ func (d *Daemon) renameSession(sess *session, name string) error {
643668 d.mu.Unlock()
644669 return err
645670 }
671+ sess.incarnation, err = domain.NewIncarnationID(rand.Reader)
672+ if err != nil {
673+ sess.mu.Unlock()
674+ d.mu.Unlock()
675+ return fmt.Errorf("generate durable identity: %w", err)
676+ }
646677 }
647678 lastUsedSeq := sess.mruAt.Load()
648- record := sess.persistRecordLocked(d.nowUnixNano())
679+ record := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
649680 record.Name = name
650681 record.CreatedAt = createdAt
651682 record.LastUsedSeq = lastUsedSeq
652- sess.renameInProgress = true
653- sess.mu.Unlock()
654- d.mu.Unlock()
655-
656683 rollback := func(err error) error {
657- sess.mu.Lock()
658- sess.renameInProgress = false
684+ sess.incarnation = priorIncarnation
659685 sess.mu.Unlock()
686+ d.mu.Unlock()
660687 return err
661688 }
662- var quarantine snapshotCoordinatorQuarantine
663- if !wasEphemeral && oldName != name {
664- quarantine = quarantineSnapshotCoordinatorWithEpoch(sess)
665- <-quarantine.done
666- if err := d.beginSnapshotPurge(oldName); err != nil {
667- return rollback(err)
689+ // Durable named renames are one atomic catalogue batch. The incarnation-keyed
690+ // snapshot namespace is intentionally untouched.
691+ if (wasEphemeral || oldName != name) && d.persistEnabled {
692+ if d.recovery == nil {
693+ return rollback(errors.New("durable session authority is not configured"))
668694 }
669- // finishSnapshotPurge preserves the tombstone through both source
670- // deletions and the old metadata delete, including a retained legacy
671- // import whose verified source delete is still pending.
672- if err := d.finishSnapshotPurge(oldName); err != nil {
673- return rollback(err)
674- }
675- }
676- // For a named rename this is the durable commit point, deliberately after
677- // the old tombstone transaction. Ephemeral promotion has no old durable
678- // identity and can create its first record directly.
679- if wasEphemeral || oldName != name {
680- if err := d.persist.Save(record); err != nil {
681- return rollback(err)
695+ {
696+ var committed domain.CatalogueRecord
697+ var err error
698+ if wasEphemeral {
699+ committed, err = d.recovery.Create(d.serveCtx, record)
700+ } else {
701+ committed, err = d.recovery.Rename(d.serveCtx, oldName, name)
702+ }
703+ if err != nil {
704+ return rollback(err)
705+ }
706+ record = committed
682707 }
683708 }
684709
685- d.mu.Lock()
686- sess.mu.Lock()
687- // The reservation prevents another rename. A closing session is allowed to
688- // finish this short commit; killSession subsequently owns its teardown.
689710 delete(d.stopped, oldName)
690711 delete(d.stopped, name)
691712 sess.name = name
692713 sess.createdAt = createdAt
714+ sess.incarnation = record.IncarnationID
693715 sess.ephemeral = false
694- sess.renameInProgress = false
695- // Keep the coordinator in quarantine until the new in-memory identity is
696- // fully committed. The fresh coordinator below makes the new repository
697- // start at generation one.
698- if !wasEphemeral && oldName != name {
699- sess.snapEligible.Store(false)
700- } else {
701- sess.snapEligible.Store(name != "")
702- }
716+ sess.snapEligible.Store(name != "")
703717 sess.mu.Unlock()
704718 d.mu.Unlock()
705- if !wasEphemeral && oldName != name {
706- // A concurrent teardown may have quarantined after this rename did. In
707- // that case its newer epoch owns the stopped coordinator.
708- resumeSnapshotCoordinatorForNewIdentity(sess, quarantine)
709- return nil
710- }
711719 if wasEphemeral {
712720 sess.snapshotMu.Lock()
713721 sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
@@ -732,24 +740,16 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
732740 d.mu.Unlock()
733741 return errors.New("tab not found")
734742 }
735- oldName := tb.name
736743 tb.name = name
737- record := sess.persistRecordLocked(time.Now().UnixNano())
738- ephemeral := sess.ephemeral
739- if !ephemeral {
740- if err := d.persist.Save(record); err != nil {
741- tb.name = oldName
742- sess.mu.Unlock()
743- d.mu.Unlock()
744- return err
745- }
744+ if !sess.ephemeral {
745+ d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
746746 }
747747 sess.mu.Unlock()
748748 d.mu.Unlock()
749749 return nil
750750 }
751751
752-func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
752+func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
753753 createdAt := s.createdAt
754754 tabNames := make([]string, len(s.tabs))
755755 lastCustom := -1
@@ -764,7 +764,7 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
764764 } else {
765765 tabNames = tabNames[:lastCustom+1]
766766 }
767- return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
767+ return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
768768 }
769769
770770 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
@@ -813,12 +813,8 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
813813 destination := sess.tabs[sess.active]
814814 ac := sess.client
815815 name := sess.name
816- record := sess.persistRecordLocked(time.Now().UnixNano())
817- ephemeral := sess.ephemeral
818- if !ephemeral {
819- if err := d.persist.Save(record); err != nil {
820- d.log.Warn("persisting closed tab failed", "err", err, "session", name)
821- }
816+ if !sess.ephemeral {
817+ d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
822818 }
823819 sess.mu.Unlock()
824820 d.mu.Unlock()
@@ -860,68 +856,58 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
860856 // beginSnapshotPurge commits a logical named-session kill before its live
861857 // identity or persisted metadata is removed. It never runs under daemon or
862858 // session locks.
863-func (d *Daemon) beginSnapshotPurge(name string) error {
864- if d.snapshotRepository == nil || name == "" {
865- return nil
859+func (d *Daemon) beginSnapshotPurge(_ string, _ domain.IncarnationID) error {
860+ if d.persistEnabled && d.recovery == nil {
861+ return errors.New("durable session authority is not configured")
866862 }
867- return d.snapshotRepository.Tombstone(context.Background(), name)
863+ // The lifecycle coordinator performs the complete ordered protocol after
864+ // snapshot writers have joined.
865+ return nil
868866 }
869867
870-// finishSnapshotPurge deletes both independently durable snapshot sources,
871-// then metadata, and only then clears the tombstone. Any failure deliberately
872-// leaves the marker in place so startup cannot restore or import the name and
873-// a later live/offline kill can retry the idempotent source deletes.
874-func (d *Daemon) finishSnapshotPurge(name string) error {
875- if d.snapshotRepository == nil {
876- return d.persist.Delete(name)
877- }
878- incrementalErr := d.snapshotRepository.Delete(context.Background(), name)
879- var legacyErr error
880- if d.legacySnapshots != nil {
881- legacyErr = d.legacySnapshots.DeleteLegacy(context.Background(), name)
868+// finishSnapshotPurge removes catalogue metadata first, then deletes the
869+// incarnation directory. Startup garbage collection removes the directory if
870+// the second step is interrupted.
871+func (d *Daemon) finishSnapshotPurge(ctx context.Context, name string, _ domain.IncarnationID) error {
872+ if !d.persistEnabled {
873+ return nil
882874 }
883- if incrementalErr != nil || legacyErr != nil {
884- return errors.Join(incrementalErr, legacyErr)
875+ if d.recovery == nil {
876+ return errors.New("durable session authority is not configured")
885877 }
886- if err := d.persist.Delete(name); err != nil {
887- return err
878+ if ctx == nil {
879+ ctx = d.serveCtx
888880 }
889- return d.snapshotRepository.DeleteTombstone(context.Background(), name)
881+ purgeCtx, cancel := context.WithTimeout(ctx, snapshotFinalFlushTimeout)
882+ defer cancel()
883+ return d.recovery.Delete(purgeCtx, name)
890884 }
891885
892886 // retryStoppedPurge completes a previously closed session's durable purge.
893887 // It keeps the stopped record hidden until all sources and metadata cross their
894888 // deletion boundaries.
895889 func (d *Daemon) retryStoppedPurge(name string) error {
890+ return d.retryStoppedPurgeContext(d.serveCtx, name)
891+}
892+
893+func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error {
896894 d.mu.Lock()
897895 stopped, ok := d.stopped[name]
898896 if !ok {
899897 d.mu.Unlock()
900898 return nil
901899 }
902- // Without a repository there is no restore/import source to fence, so the
903- // stopped record retains the metadata-only deletion behavior.
904- if d.snapshotRepository == nil {
905- d.mu.Unlock()
906- if err := d.persist.Delete(name); err != nil {
907- return err
908- }
909- d.mu.Lock()
910- delete(d.stopped, name)
911- d.mu.Unlock()
912- return nil
913- }
914900 stopped.purging = true
915901 d.stopped[name] = stopped
916- d.mu.Unlock()
917902
918- if err := d.beginSnapshotPurge(name); err != nil {
903+ if err := d.beginSnapshotPurge(name, stopped.incarnation); err != nil {
904+ d.mu.Unlock()
919905 return err
920906 }
921- if err := d.finishSnapshotPurge(name); err != nil {
907+ if err := d.finishSnapshotPurge(ctx, name, stopped.incarnation); err != nil {
908+ d.mu.Unlock()
922909 return err
923910 }
924- d.mu.Lock()
925911 if current, ok := d.stopped[name]; ok && current.purging {
926912 delete(d.stopped, name)
927913 }
@@ -1063,13 +1049,12 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
10631049 sess.mu.Lock()
10641050 isEphemeral := sess.ephemeral
10651051 name := sess.name
1052+ incarnation := sess.incarnation
10661053 sess.mu.Unlock()
1067- // Commit the tombstone before changing live identity or metadata. A
1068- // repository delete must never race an older publication that could recreate
1069- // the just-quarantined name. Do not hold daemon or session locks while
1070- // tombstoning or joining: a repository implementation may block on I/O.
1054+ // Join snapshot publication before changing live identity or deleting the
1055+ // incarnation directory, so an older publication cannot recreate it.
10711056 if purge && !isEphemeral {
1072- if err := d.beginSnapshotPurge(name); err != nil {
1057+ if err := d.beginSnapshotPurge(name, incarnation); err != nil {
10731058 return err
10741059 }
10751060 if d.snapshotRepository != nil {
@@ -1145,7 +1130,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
11451130 ephemeral := sess.ephemeral
11461131 sess.mu.Unlock()
11471132 if !ephemeral {
1148- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
1133+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
11491134 d.stopped[stoppedName] = stopped
11501135 }
11511136 empty := len(d.sessions) == 0
@@ -1201,20 +1186,20 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
12011186 }
12021187 }
12031188 // The coordinator and all panes are now stopped, so no producer can publish
1204- // another generation after this destructive source deletion. Keep the
1205- // tombstone and hidden stopped record when any source or metadata operation
1206- // fails; retryStoppedPurge (including a repeated live kill) resumes safely.
1189+ // another generation after this destructive source deletion. Keep the hidden
1190+ // stopped record when deletion fails so a repeated live kill can retry.
12071191 if !ephemeral && purge {
1208- if err := d.finishSnapshotPurge(stoppedName); err != nil {
1192+ d.mu.Lock()
1193+ sess.mu.Lock()
1194+ err := d.finishSnapshotPurge(d.serveCtx, stoppedName, incarnation)
1195+ sess.mu.Unlock()
1196+ if err != nil {
12091197 purgeErr = errors.Join(purgeErr, err)
12101198 d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName)
1211- } else {
1212- d.mu.Lock()
1213- if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging {
1214- delete(d.stopped, stoppedName)
1215- }
1216- d.mu.Unlock()
1199+ } else if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging {
1200+ delete(d.stopped, stoppedName)
12171201 }
1202+ d.mu.Unlock()
12181203 }
12191204 if empty {
12201205 d.doneOnce.Do(func() { close(d.done) })
@@ -1259,8 +1244,11 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
12591244 if d.findByNameLocked(name) != nil {
12601245 return true
12611246 }
1262- _, ok := d.stopped[name]
1263- return ok
1247+ if _, ok := d.stopped[name]; ok {
1248+ return true
1249+ }
1250+ _, reserved := d.creating[name]
1251+ return reserved
12641252 }
12651253
12661254 func (d *Daemon) cwdSampler(ctx context.Context) {
@@ -1272,7 +1260,12 @@ func (d *Daemon) cwdSampler(ctx context.Context) {
12721260 return
12731261 case <-t.C():
12741262 }
1275- d.refreshNamedSessionCwds()
1263+ if d.procCwd != nil {
1264+ d.refreshNamedSessionCwds()
1265+ }
1266+ if err := d.flushCatalogue(); err != nil {
1267+ d.log.Warn("flushing session catalogue failed", "err", err)
1268+ }
12761269 t.Reset(5 * time.Second)
12771270 }
12781271 }
@@ -1321,16 +1314,12 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
13211314 return
13221315 }
13231316 sess.cwd = cwd
1324- name := sess.name
1325- ephemeral := sess.ephemeral
1326- sess.mu.Unlock()
1327- if !ephemeral {
1328- if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil {
1329- d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)
1330- }
1331- markSnapshotDirty(sess)
1317+ if !sess.ephemeral {
1318+ d.markCatalogueDirty(sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).MetadataUpdate())
13321319 }
1320+ sess.mu.Unlock()
13331321 d.mu.Unlock()
1322+ markSnapshotDirty(sess)
13341323 }
13351324
13361325 // childEnv retains the daemon-environment helper for daemon-local legacy callers.
@@ -1344,11 +1333,8 @@ func copyEnvironment(env []string) []string {
13441333 }
13451334
13461335 func environmentEntry(entry string) (name, value string, ok bool) {
1347- i := strings.IndexByte(entry, '=')
1348- if i < 0 {
1349- return "", "", false
1350- }
1351- return entry[:i], entry[i+1:], true
1336+ name, value, ok = strings.Cut(entry, "=")
1337+ return name, value, ok
13521338 }
13531339
13541340 func (d *Daemon) ptyCommand(env []string) (string, []string) {
M internal/usecase/daemon/session_purge_control_test.go +11 -26
@@ -4,48 +4,33 @@ import (
44 "errors"
55 "testing"
66
7- "github.com/stretchr/testify/mock"
87 "github.com/stretchr/testify/require"
98
10- "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/persist"
1111 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1212 )
1313
14-func TestHandleKillStoppedUsesDurableBothSourcePurge(t *testing.T) {
15- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
16- repository := &retryablePurgeRepository{legacyErr: errors.New("legacy delete failed")}
17- WithSnapshotRepository(repository, repository)(d)
18- d.stopped["work"] = stoppedSession{name: "work"}
19-
20- transport := portsmocks.NewMockTransport(t)
21- transport.EXPECT().Send(mock.Anything).Return(nil).Once()
22- transport.EXPECT().Close().Return(nil).Once()
23- d.handleKill(transport, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
24-
25- require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls)
26- require.True(t, repository.tombstoned["work"])
27- require.True(t, d.stopped["work"].purging)
28-}
29-
30-func TestStoppedPurgeMetadataFailureRemainsFencedForRetry(t *testing.T) {
14+func TestStoppedPurgeMetadataFailureFencesCatalogue(t *testing.T) {
3115 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
3216 repository := &retryablePurgeRepository{}
33- WithSnapshotRepository(repository, repository)(d)
17+ WithSnapshotRepository(repository)(d)
3418 store, state := newMockStore(t)
35- WithStore(store)(d)
19+ WithStore(t, store)(d)
20+ record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}}
21+ require.NoError(t, d.catalogue.Create(record))
3622 metadataErr := errors.New("metadata delete failed")
3723 state.mu.Lock()
3824 state.deleteErr = func(string) error { return metadataErr }
3925 state.mu.Unlock()
40- d.stopped["work"] = stoppedSession{name: "work"}
26+ d.stopped["work"] = stoppedSession{name: "work", incarnation: record.IncarnationID, record: record}
4127
4228 require.ErrorIs(t, d.retryStoppedPurge("work"), metadataErr)
43- require.True(t, repository.tombstoned["work"])
44- require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls)
29+ require.Empty(t, repository.calls, "snapshot deletion must wait for catalogue removal")
4530
4631 state.mu.Lock()
4732 state.deleteErr = nil
4833 state.mu.Unlock()
49- require.NoError(t, d.retryStoppedPurge("work"))
50- require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "clear tombstone"}, repository.calls)
34+ require.ErrorIs(t, d.retryStoppedPurge("work"), persist.ErrCatalogueDurability)
35+ require.Empty(t, repository.calls, "a fenced catalogue must not retry deletion")
5136 }
M internal/usecase/daemon/session_purge_retry_test.go +32 -80
@@ -13,85 +13,37 @@ import (
1313 )
1414
1515 type retryablePurgeRepository struct {
16- incrementalErr error
17- legacyErr error
18- tombstoned map[string]bool
16+ noOpSnapshotRepository
17+ deleteErr error
1918 calls []string
20-}
21-
22-func (r *retryablePurgeRepository) Publish(context.Context, ports.SnapshotPublication) error {
23- return nil
24-}
25-func (r *retryablePurgeRepository) List(context.Context) ([]string, error) { return nil, nil }
26-func (r *retryablePurgeRepository) Load(context.Context, string) (ports.SnapshotGeneration, error) {
27- return ports.SnapshotGeneration{}, errors.New("unused")
28-}
29-func (r *retryablePurgeRepository) Maintain(context.Context) error { return nil }
30-func (r *retryablePurgeRepository) Delete(_ context.Context, name string) error {
31- r.calls = append(r.calls, "incremental")
32- return r.incrementalErr
33-}
34-func (r *retryablePurgeRepository) LoadLegacy(context.Context) ([]ports.LegacySnapshot, error) {
35- return nil, nil
36-}
37-func (r *retryablePurgeRepository) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
38- return r.DeleteLegacy(ctx, blob.Name)
39-}
40-func (r *retryablePurgeRepository) DeleteLegacy(_ context.Context, name string) error {
41- r.calls = append(r.calls, "legacy")
42- return r.legacyErr
43-}
44-func (r *retryablePurgeRepository) Tombstone(_ context.Context, name string) error {
45- if r.tombstoned == nil {
46- r.tombstoned = make(map[string]bool)
47- }
48- r.calls = append(r.calls, "tombstone")
49- r.tombstoned[name] = true
50- return nil
51-}
52-func (r *retryablePurgeRepository) DeleteTombstone(_ context.Context, name string) error {
53- r.calls = append(r.calls, "clear tombstone")
54- delete(r.tombstoned, name)
55- return nil
56-}
57-
58-func TestLivePurgeRetainsTombstoneAcrossPartialSourceDeletion(t *testing.T) {
59- for _, tt := range []struct {
60- name string
61- incremental error
62- legacy error
63- }{
64- {name: "incremental failure", incremental: errors.New("incremental failed")},
65- {name: "legacy failure", legacy: errors.New("legacy failed")},
66- } {
67- t.Run(tt.name, func(t *testing.T) {
68- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69- repository := &retryablePurgeRepository{incrementalErr: tt.incremental, legacyErr: tt.legacy}
70- WithSnapshotRepository(repository, repository)(d)
71- sess := newSnapshotTestSession(t, "work", false, "/work")
72- pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
73- require.True(t, ok, "snapshot test pane must use MockPTY")
74- pty.EXPECT().Close().Return(nil).Once()
75- d.sessions = map[domain.SessionID]*session{sess.id: sess}
76-
77- require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))
78- require.True(t, repository.tombstoned["work"], "partial purge must fence startup restore/import")
79- require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls)
80- d.mu.Lock()
81- stopped, ok := d.stopped["work"]
82- d.mu.Unlock()
83- require.True(t, ok)
84- require.True(t, stopped.purging)
85-
86- repository.incrementalErr = nil
87- repository.legacyErr = nil
88- require.NoError(t, d.retryStoppedPurge("work"))
89- require.False(t, repository.tombstoned["work"])
90- require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "clear tombstone"}, repository.calls)
91- d.mu.Lock()
92- _, ok = d.stopped["work"]
93- d.mu.Unlock()
94- require.False(t, ok, "metadata is removed only after both source deletes and tombstone clear")
95- })
96- }
19+ deadlineScoped []bool
20+}
21+
22+func (r *retryablePurgeRepository) DeleteIncarnation(ctx context.Context, _ domain.IncarnationID) error {
23+ _, hasDeadline := ctx.Deadline()
24+ r.deadlineScoped = append(r.deadlineScoped, hasDeadline)
25+ r.calls = append(r.calls, "delete incarnation")
26+ return r.deleteErr
27+}
28+
29+func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *testing.T) {
30+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
31+ repository := &retryablePurgeRepository{deleteErr: errors.New("delete failed")}
32+ WithSnapshotRepository(repository)(d)
33+ store, _ := newMockStore(t)
34+ WithStore(t, store)(d)
35+ sess := newSnapshotTestSession(t, "work", false, "/work")
36+ record := sess.persistRecordLocked(1)
37+ require.NoError(t, d.catalogue.Create(record))
38+ pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
39+ require.True(t, ok)
40+ pty.EXPECT().Close().Return(nil).Once()
41+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
42+
43+ require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))
44+ require.Equal(t, []string{"delete incarnation"}, repository.calls)
45+ _, exists, err := d.catalogue.Record("work")
46+ require.NoError(t, err)
47+ require.False(t, exists, "catalogue removal commits before best-effort directory deletion")
48+ require.Equal(t, []bool{true}, repository.deadlineScoped)
9749 }
M internal/usecase/daemon/session_rename_purge_test.go +9 -24
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "errors"
54 "testing"
65
76 "github.com/stretchr/testify/require"
@@ -10,36 +9,22 @@ import (
109 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1110 )
1211
13-func TestRenamePurgesOldIncrementalAndLegacyBeforeCommittingNewIdentity(t *testing.T) {
12+func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
1413 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1514 repository := &retryablePurgeRepository{}
16- WithSnapshotRepository(repository, repository)(d)
15+ WithSnapshotRepository(repository)(d)
1716 store, state := newMockStore(t)
18- WithStore(store)(d)
17+ WithStore(t, store)(d)
1918 sess := newSnapshotTestSession(t, "old", false, "/work")
2019 d.sessions = map[domain.SessionID]*session{sess.id: sess}
21- require.NoError(t, d.persist.Save(sess.persistRecordLocked(1)))
20+ sess.mu.Lock()
21+ record := sess.persistRecordLocked(1)
22+ sess.mu.Unlock()
23+ require.NoError(t, testPersister(t, d).Save(record))
2224
2325 require.NoError(t, d.renameSession(sess, "new"))
24- require.Equal(t, []string{"tombstone", "incremental", "legacy", "clear tombstone"}, repository.calls)
26+ require.Empty(t, repository.calls, "rename must not delete incarnation-keyed snapshots")
2527 require.False(t, state.has("old"))
2628 require.True(t, state.has("new"))
27- require.False(t, repository.tombstoned["old"])
28-}
29-
30-func TestRenameLegacyFailureLeavesOldIdentityFenced(t *testing.T) {
31- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
32- repository := &retryablePurgeRepository{legacyErr: errors.New("legacy delete failed")}
33- WithSnapshotRepository(repository, repository)(d)
34- store, state := newMockStore(t)
35- WithStore(store)(d)
36- sess := newSnapshotTestSession(t, "old", false, "/work")
37- d.sessions = map[domain.SessionID]*session{sess.id: sess}
38- require.NoError(t, d.persist.Save(sess.persistRecordLocked(1)))
39-
40- require.Error(t, d.renameSession(sess, "new"))
41- require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls)
42- require.True(t, repository.tombstoned["old"])
43- require.True(t, state.has("old"))
44- require.False(t, state.has("new"))
29+ require.Equal(t, sess.incarnation, state.record(t, "new").IncarnationID)
4530 }
M internal/usecase/daemon/session_test.go +160 -63
@@ -772,8 +772,14 @@ func (s newStaticStore) Get(key []byte) ([]byte, bool) {
772772 v, ok := s[string(key)]
773773 return append([]byte(nil), v...), ok
774774 }
775-func (s newStaticStore) Set(_, _ []byte) error { return nil }
776-func (s newStaticStore) Delete(_ []byte) error { return nil }
775+func (s newStaticStore) Set(key, value []byte) error {
776+ s[string(key)] = append([]byte(nil), value...)
777+ return nil
778+}
779+func (s newStaticStore) Delete(key []byte) error {
780+ delete(s, string(key))
781+ return nil
782+}
777783 func (s newStaticStore) Range(fn func(k, v []byte) bool) {
778784 for k, v := range s {
779785 if !fn([]byte(k), append([]byte(nil), v...)) {
@@ -781,24 +787,59 @@ func (s newStaticStore) Range(fn func(k, v []byte) bool) {
781787 }
782788 }
783789 }
784-func (s newStaticStore) Sync() error { return nil }
785-func (s newStaticStore) Close() error { return nil }
790+func (s newStaticStore) Sync() error { return nil }
791+func (s newStaticStore) CloseWithoutSync() error { return nil }
792+func (s newStaticStore) Close() error { return nil }
793+
794+func TestNamedSessionCreationIsAllowedWithNilStore(t *testing.T) {
795+ pty, release := newBlockingPTY(t)
796+ defer release()
797+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
798+ WithStore(t, nil)(d)
799+
800+ sess, err := createSessionForTest(d, "work", false, "/tmp/work", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
801+ require.NoError(t, err)
802+ require.Equal(t, "work", sess.name)
803+ require.False(t, d.persistEnabled)
804+}
786805
787806 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
788807 store, _ := newMockStore(t)
789- seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
790- require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9}))
808+ seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
809+ incarnation := domain.IncarnationID{1}
810+ require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9}))
791811
792- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
812+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
793813 d.mu.Lock()
794814 stopped := d.stopped["work"]
795815 d.mu.Unlock()
796- require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, lastUsedSeq: 9}, stopped)
816+ require.Equal(t, "work", stopped.name)
817+ require.Equal(t, "/tmp/work", stopped.cwd)
818+ require.Equal(t, int64(7), stopped.createdAt)
819+ require.Equal(t, incarnation, stopped.incarnation)
820+ require.Equal(t, uint64(9), stopped.lastUsedSeq)
821+ require.Equal(t, ports.SessionStopped, stopped.state)
822+ require.NotNil(t, stopped.restoreDone)
797823 require.Equal(t, uint64(9), d.mruSeq.Load())
798824 }
799825
826+// TestDaemonNewWithoutPersistenceLogsNoLoadWarning covers the default,
827+// persistence-free construction path (no WithStore/WithCatalogue option):
828+// testPersister(t, d) is the persist.New(nil) sentinel, so LoadAll now returns
829+// errPersistenceUnavailable instead of an empty slice. That must not surface
830+// as a "loading persisted sessions failed" warning, since nothing failed —
831+// persistence simply was never configured.
832+func TestDaemonNewWithoutPersistenceLogsNoLoadWarning(t *testing.T) {
833+ var logBuf strings.Builder
834+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logBuf, nil)))
835+ require.NotNil(t, d)
836+ require.False(t, d.persistEnabled)
837+ require.Empty(t, d.stopped)
838+ require.NotContains(t, logBuf.String(), "loading persisted sessions failed")
839+}
840+
800841 func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
801- d := &Daemon{}
842+ d := &Daemon{clock: stubClock{}}
802843 sess := &session{}
803844 const goroutines = 16
804845 const iterations = 1000
@@ -845,9 +886,9 @@ func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
845886 func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
846887 store, state := newMockStore(t)
847888 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
848- WithStore(store)(d)
849- named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1}
850- require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
889+ WithStore(t, store)(d)
890+ named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}}
891+ require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
851892
852893 d.touchMRU(named)
853894 require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
@@ -865,11 +906,11 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
865906 defer release()
866907 store, state := newMockStore(t)
867908 d := newTestDaemon(t, newFactory(t, p), stubClock{})
868- WithStore(store)(d)
909+ WithStore(t, store)(d)
869910 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}
870911 d.mruSeq.Store(42)
871912
872- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
913+ sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
873914 require.NoError(t, err)
874915 require.Equal(t, uint64(42), sess.mruAt.Load())
875916 require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq)
@@ -883,11 +924,14 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
883924 defer release2()
884925 store, state := newMockStore(t)
885926 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
886- WithStore(store)(d)
927+ WithStore(t, store)(d)
887928
888- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
929+ sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
889930 require.NoError(t, err)
890931 require.True(t, state.has("work"))
932+ created := state.record(t, "work")
933+ require.NotEqual(t, domain.IncarnationID{}, created.IncarnationID)
934+ require.NoError(t, created.Validate())
891935 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
892936 require.NoError(t, d.createTab(sess, sz))
893937 require.NoError(t, d.renameTab(sess, sess.tabs[1], "logs"))
@@ -895,7 +939,10 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
895939 require.NoError(t, d.renameSession(sess, "renamed"))
896940 require.False(t, state.has("work"))
897941 require.True(t, state.has("renamed"))
898- require.Equal(t, []string{"shell", "logs"}, state.record(t, "renamed").TabNames)
942+ renamed := state.record(t, "renamed")
943+ require.Equal(t, []string{"shell", "logs"}, renamed.TabNames)
944+ require.Equal(t, created.IncarnationID, renamed.IncarnationID)
945+ require.NoError(t, renamed.Validate())
899946
900947 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
901948 require.False(t, state.has("renamed"))
@@ -907,13 +954,13 @@ func TestRenameTabPersistsForNamedSession(t *testing.T) {
907954 defer release()
908955 store, _ := newMockStore(t)
909956 d := newTestDaemon(t, newFactory(t, p), stubClock{})
910- WithStore(store)(d)
957+ WithStore(t, store)(d)
911958
912- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
959+ sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
913960 require.NoError(t, err)
914961 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
915962
916- records, err := d.persist.LoadAll()
963+ records, err := testPersister(t, d).LoadAll()
917964 require.NoError(t, err)
918965 require.Len(t, records, 1)
919966 require.Equal(t, "work", records[0].Name)
@@ -923,6 +970,21 @@ func TestRenameTabPersistsForNamedSession(t *testing.T) {
923970 require.Equal(t, []string{"shell"}, records[0].TabNames)
924971 }
925972
973+func TestRenameTabBuffersMetadataUnderStateLocks(t *testing.T) {
974+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
975+ sess := newSnapshotTestSession(t, "work", false, "/work")
976+ d.sessions[sess.id] = sess
977+ catalogue := portsmocks.NewMockCatalogue(t)
978+ WithCatalogue(catalogue, nil)(d)
979+ catalogue.EXPECT().UpdateMetadata(mock.Anything).RunAndReturn(func(domain.CatalogueMetadataUpdate) error {
980+ require.False(t, d.mu.TryLock(), "daemon mutex must guard the metadata snapshot")
981+ require.False(t, sess.mu.TryLock(), "session mutex must guard the metadata snapshot")
982+ return nil
983+ }).Once()
984+
985+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
986+}
987+
926988 func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
927989 tests := []struct {
928990 name string
@@ -943,10 +1005,10 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
9431005 }
9441006 store, _ := newMockStore(t)
9451007 d := newTestDaemon(t, newFactorySeq(t, ptys...), stubClock{})
946- WithStore(store)(d)
1008+ WithStore(t, store)(d)
9471009 sz := domain.Size{Cols: 80, Rows: 24}
9481010
949- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1011+ sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
9501012 require.NoError(t, err)
9511013 require.NoError(t, d.createTab(sess, sz))
9521014 require.NoError(t, d.createTab(sess, sz))
@@ -955,7 +1017,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
9551017 }
9561018
9571019 require.NoError(t, d.closeTab(sess, sess.tabs[tt.closeIndex], false))
958- records, err := d.persist.LoadAll()
1020+ records, err := testPersister(t, d).LoadAll()
9591021 require.NoError(t, err)
9601022 require.Len(t, records, 1)
9611023 require.Equal(t, tt.want, records[0].TabNames)
@@ -992,15 +1054,15 @@ func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
9921054 defer release()
9931055 store, state := newMockStore(t)
9941056 d := newTestDaemon(t, newFactory(t, p), stubClock{})
995- WithStore(store)(d)
1057+ WithStore(t, store)(d)
9961058
997- sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1059+ sess, err := createSessionForTest(d, "0", true, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
9981060 require.NoError(t, err)
9991061 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
10001062 require.Equal(t, "shell", sess.tabs[0].name)
10011063
10021064 require.False(t, state.has("0"))
1003- records, err := d.persist.LoadAll()
1065+ records, err := testPersister(t, d).LoadAll()
10041066 require.NoError(t, err)
10051067 require.Empty(t, records)
10061068 }
@@ -1013,8 +1075,8 @@ func TestAttachRestoresPersistedTabNames(t *testing.T) {
10131075 defer release2()
10141076 store, _ := newMockStore(t)
10151077 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1016- WithStore(store)(d)
1017- require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
1078+ WithStore(t, store)(d)
1079+ require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
10181080 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
10191081 tr := portsmocks.NewMockTransport(t)
10201082 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -1034,10 +1096,10 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
10341096 defer release()
10351097 store, state := newMockStore(t)
10361098 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1037- WithStore(store)(d)
1099+ WithStore(t, store)(d)
10381100 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
10391101
1040- sess, err := d.createSessionLocked("0", true, "/tmp/e", sz, terminalEnv{}, d.baseEnv)
1102+ sess, err := createSessionForTest(d, "0", true, "/tmp/e", sz, terminalEnv{}, d.baseEnv)
10411103 require.NoError(t, err)
10421104 require.False(t, state.has("0"))
10431105 require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")
@@ -1051,9 +1113,9 @@ func TestEphemeralPromotionAssignsAndPersistsLifecycleIdentity(t *testing.T) {
10511113 store, state := newMockStore(t)
10521114 clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}}
10531115 d := newTestDaemon(t, newFactory(t, p), clock)
1054- WithStore(store)(d)
1116+ WithStore(t, store)(d)
10551117
1056- sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1118+ sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
10571119 require.NoError(t, err)
10581120 require.Zero(t, sess.createdAt, "regression fixture: ephemerals have no lifecycle identity")
10591121
@@ -1078,9 +1140,9 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin
10781140 clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}
10791141 d.clock = clock
10801142 d.ptys = newFactorySeq(t, firstPTY, secondPTY)
1081- WithStore(store)(d)
1143+ WithStore(t, store)(d)
10821144
1083- first, err := d.createSessionLocked("0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv)
1145+ first, err := createSessionForTest(d, "0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv)
10841146 require.NoError(t, err)
10851147 require.NoError(t, d.renameSession(first, "named"))
10861148 staleCreatedAt := first.createdAt
@@ -1088,7 +1150,7 @@ func TestEphemeralPromotionLifecyclePreventsStaleSameNamePaletteTarget(t *testin
10881150 require.NoError(t, d.killSession(first, ports.ReasonSessionKilled, true))
10891151 d.sessWg.Wait()
10901152
1091- second, err := d.createSessionLocked("0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv)
1153+ second, err := createSessionForTest(d, "0", true, "/tmp", ac.size, terminalEnv{}, d.baseEnv)
10921154 require.NoError(t, err)
10931155 require.NoError(t, d.renameSession(second, "named"))
10941156 require.NotEqual(t, staleCreatedAt, second.createdAt)
@@ -1105,10 +1167,10 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T)
11051167 defer release()
11061168 store, state := newMockStore(t)
11071169 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1108- WithStore(store)(d)
1170+ WithStore(t, store)(d)
11091171 d.lastAllocatedCreatedAt = math.MaxInt64
11101172
1111- sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1173+ sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
11121174 require.NoError(t, err)
11131175 require.EqualError(t, d.renameSession(sess, "named"), "daemon: lifecycle identities exhausted")
11141176 require.Equal(t, "0", sess.name)
@@ -1123,17 +1185,22 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T)
11231185 defer release()
11241186 store := portsmocks.NewMockStore(t)
11251187 var attempted map[string][]byte
1188+ store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {}).Times(2)
1189+ store.EXPECT().Get([]byte("named")).Return(nil, false).Times(3)
11261190 store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error {
11271191 attempted = map[string][]byte{string(key): append([]byte(nil), value...)}
11281192 return errors.New("disk full")
11291193 }).Once()
1194+ store.EXPECT().Delete([]byte("named")).Return(nil).Once()
11301195 clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}
11311196 d := newTestDaemon(t, newFactory(t, p), clock)
1132- WithStore(store)(d)
1197+ WithStore(t, store)(d)
11331198
1134- sess, err := d.createSessionLocked("0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1199+ sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
11351200 require.NoError(t, err)
1136- require.EqualError(t, d.renameSession(sess, "named"), "disk full")
1201+ err = d.renameSession(sess, "named")
1202+ require.ErrorIs(t, err, persist.ErrCatalogueDurability)
1203+ require.ErrorContains(t, err, "disk full")
11371204 require.Equal(t, "0", sess.name)
11381205 require.True(t, sess.ephemeral)
11391206 require.Zero(t, sess.createdAt)
@@ -1143,10 +1210,8 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T)
11431210 require.Len(t, records, 1)
11441211 require.Equal(t, int64(100), records[0].CreatedAt, "a promotion must never attempt to persist a zero lifecycle identity")
11451212
1146- store.EXPECT().Set(mock.Anything, mock.Anything).Return(nil).Once()
1147- store.EXPECT().Sync().Return(nil).Once()
1148- require.NoError(t, d.renameSession(sess, "named"))
1149- require.Equal(t, int64(101), sess.createdAt, "the high-water mark must remain monotonic after a failed promotion")
1213+ require.ErrorIs(t, d.renameSession(sess, "named"), persist.ErrCatalogueDurability)
1214+ require.Equal(t, int64(101), d.lastAllocatedCreatedAt, "the lifecycle identity is allocated before the fenced catalogue rejects promotion")
11501215 })
11511216 }
11521217
@@ -1156,11 +1221,11 @@ func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
11561221 defer release()
11571222 store, state := newMockStore(t)
11581223 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1159- WithStore(store)(d)
1224+ WithStore(t, store)(d)
11601225 cwd := "/tmp/work"
11611226 WithCwdReader(func(int) (string, error) { return cwd, nil })(d)
11621227
1163- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1228+ sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
11641229 require.NoError(t, err)
11651230 state.mu.Lock()
11661231 setsAfterCreate := state.sets
@@ -1184,7 +1249,7 @@ func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) {
11841249 defer release()
11851250 store, _ := newMockStore(t)
11861251 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1187- WithStore(store)(d)
1252+ WithStore(t, store)(d)
11881253 cwd := t.TempDir()
11891254 d.stopped["work"] = stoppedSession{name: "work", cwd: cwd, createdAt: 1}
11901255 tr := portsmocks.NewMockTransport(t)
@@ -1208,7 +1273,7 @@ func TestAttachStoppedMissingCwdFallsBackToHome(t *testing.T) {
12081273 defer release()
12091274 store, _ := newMockStore(t)
12101275 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1211- WithStore(store)(d)
1276+ WithStore(t, store)(d)
12121277 d.stopped["work"] = stoppedSession{name: "work", cwd: "/definitely/missing/vev", createdAt: 1}
12131278 tr := portsmocks.NewMockTransport(t)
12141279 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -1222,8 +1287,8 @@ func TestAttachStoppedMissingCwdFallsBackToHome(t *testing.T) {
12221287 func TestPickerStoppedTargetKillPurges(t *testing.T) {
12231288 store, state := newMockStore(t)
12241289 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1225- WithStore(store)(d)
1226- require.NoError(t, d.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
1290+ WithStore(t, store)(d)
1291+ require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
12271292 d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}
12281293 require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))
12291294 require.False(t, state.has("old"))
@@ -1257,7 +1322,7 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
12571322 d := newTestDaemon(t, f, stubClock{})
12581323 d.baseEnv = []string{"KEEP=1", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
12591324
1260- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{TrueColor: true}, d.baseEnv)
1325+ sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{TrueColor: true}, d.baseEnv)
12611326 require.NoError(t, err)
12621327 defer func() {
12631328 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -1313,7 +1378,7 @@ func TestRenameSessionUnsafeNameRejected(t *testing.T) {
13131378 p, release := newBlockingPTY(t)
13141379 defer release()
13151380 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1316- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1381+ sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
13171382 require.NoError(t, err)
13181383 require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)
13191384 }
@@ -1323,7 +1388,7 @@ func TestCreateTabPtyFailureIsUserError(t *testing.T) {
13231388 p, release := newBlockingPTY(t)
13241389 defer release()
13251390 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1326- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1391+ sess, err := createSessionForTest(d, "work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
13271392 require.NoError(t, err)
13281393
13291394 cause := errors.New("fork/exec: no such file")
@@ -1350,12 +1415,12 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
13501415 defer release2()
13511416 store, state := newMockStore(t)
13521417 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1353- WithStore(store)(d)
1418+ WithStore(t, store)(d)
13541419 WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)
13551420
1356- natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz, terminalEnv{}, d.baseEnv)
1421+ natural, err := createSessionForTest(d, "natural", false, "/tmp/old", sz, terminalEnv{}, d.baseEnv)
13571422 require.NoError(t, err)
1358- other, err := d.createSessionLocked("other", false, "/tmp/other", sz, terminalEnv{}, d.baseEnv)
1423+ other, err := createSessionForTest(d, "other", false, "/tmp/other", sz, terminalEnv{}, d.baseEnv)
13591424 require.NoError(t, err)
13601425 _ = d.killSession(natural, ports.ReasonSessionKilled, false)
13611426 require.True(t, state.has("natural"))
@@ -1671,7 +1736,7 @@ func TestNamedSessionLifecycleTimestampsAreMonotonicAcrossClockRegression(t *tes
16711736
16721737 var got []int64
16731738 for _, name := range []string{"one", "two", "three"} {
1674- sess, err := d.createSessionLocked(name, false, "/tmp", sz, terminalEnv{}, d.baseEnv)
1739+ sess, err := createSessionForTest(d, name, false, "/tmp", sz, terminalEnv{}, d.baseEnv)
16751740 require.NoError(t, err)
16761741 got = append(got, sess.createdAt)
16771742 }
@@ -1684,7 +1749,7 @@ func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T)
16841749 d.nextID = 17
16851750 d.stopped["retained"] = stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}
16861751
1687- sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1752+ sess, err := createSessionForTest(d, "new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
16881753
16891754 require.ErrorContains(t, err, "lifecycle identities exhausted")
16901755 require.Nil(t, sess)
@@ -1700,20 +1765,52 @@ func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T)
17001765 require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"])
17011766 }
17021767
1768+func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
1769+ alphaRef := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
1770+ workRef := &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}
1771+ records := []domain.CatalogueRecord{
1772+ {Name: "alpha", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/alpha", CreatedAt: 7, LastUsedSeq: 11, TabNames: []string{"shell"}, Committed: alphaRef},
1773+ {Name: "work", IncarnationID: domain.IncarnationID{2}, Cwd: "/tmp/work", CreatedAt: 9, LastUsedSeq: 13, TabNames: []string{"editor", "logs"}, Committed: workRef, DegradedReason: "uncertain legacy checkpoint"},
1774+ }
1775+
1776+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(persist.New(nil), records))
1777+
1778+ alpha := d.stopped["alpha"]
1779+ require.Equal(t, records[0], alpha.record)
1780+ require.Equal(t, ports.SessionStopped, alpha.state)
1781+ require.NotNil(t, alpha.restoreDone)
1782+ select {
1783+ case <-alpha.restoreDone:
1784+ t.Fatal("healthy catalogue record must begin in restoring state")
1785+ default:
1786+ }
1787+ work := d.stopped["work"]
1788+ require.Equal(t, records[1], work.record)
1789+ require.Equal(t, ports.SessionBroken, work.state)
1790+ require.NotNil(t, work.restoreDone)
1791+ select {
1792+ case <-work.restoreDone:
1793+ default:
1794+ t.Fatal("degraded catalogue record must not wait for restoration")
1795+ }
1796+ require.Equal(t, int64(9), d.lastAllocatedCreatedAt)
1797+ require.Equal(t, uint64(13), d.mruSeq.Load())
1798+}
1799+
17031800 func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {
17041801 store, _ := newMockStore(t)
1705- seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1706- require.NoError(t, seed.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900}))
1802+ seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
1803+ require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900}))
17071804
17081805 p, release := newBlockingPTY(t)
17091806 defer release()
17101807 clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100)}}
17111808 // Constructing with persistence must establish the lifecycle high-water mark.
1712- d := New(newFactory(t, p), clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1809+ d := New(newFactory(t, p), clock, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
17131810 d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
17141811 t.Cleanup(d.serveCancel)
17151812
1716- sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
1813+ sess, err := createSessionForTest(d, "new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
17171814 require.NoError(t, err)
17181815 require.Equal(t, int64(901), sess.createdAt)
17191816 }
@@ -1725,7 +1822,7 @@ func TestResumingStoppedSessionPreservesLifecycleIdentityInPersistence(t *testin
17251822 defer releaseTarget()
17261823 d, from, ac, _ := newManualSessionWithPTYs(t, fromPTY)
17271824 store, state := newMockStore(t)
1728- WithStore(store)(d)
1825+ WithStore(t, store)(d)
17291826 d.ptys = newFactory(t, targetPTY)
17301827 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 77}
17311828
M internal/usecase/daemon/snapshot.go +10 -0
@@ -7,6 +7,7 @@ import (
77 "sync"
88 "time"
99
10+ "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/usecase/layout"
1112 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1213 "github.com/bnema/vev/pkg/vt"
@@ -35,14 +36,20 @@ type snapshotCapture struct {
3536 session *session
3637 attemptKind snapshotAttemptKind
3738 generation uint64 // repository publication generation
39+ parentCheckpoint *domain.CheckpointRef
40+ checkpoint domain.CheckpointRef // set by the encoder before publication
3841 mutationRevision uint64
3942 name string
43+ incarnation domain.IncarnationID
4044 createdAt uint64
4145 active uint16
4246 tabs []snapshotCaptureTab
4347 publicationContext context.Context
4448 sealedRefs map[*vt.HistoryChunk]snapcodec.ObjectRef // set by the single encoder worker
4549 coordinatorDiscarded bool // guarded by session.snapshotMu
50+ // normalWorkerAdmitted is set before snapshotJobs admission and immutable
51+ // until completion; final-queue captures leave it false.
52+ normalWorkerAdmitted bool
4653 finishOnce sync.Once
4754 }
4855
@@ -94,3 +101,6 @@ const (
94101 snapshotInterval = 2 * time.Minute
95102 snapshotFinalFlushTimeout = time.Second
96103 )
104+
105+// SnapshotShutdownTimeout reports the shared final-checkpoint shutdown budget.
106+func SnapshotShutdownTimeout() time.Duration { return snapshotFinalFlushTimeout }
M internal/usecase/daemon/snapshot_acceptance_test.go +4 -3
@@ -63,9 +63,10 @@ func snapshotCacheHistory(t *testing.T, chunks int, first rune) vt.HistorySnapsh
6363
6464 func snapshotCacheCapture(sess *session, history vt.HistorySnapshotView, generation uint64) *snapshotCapture {
6565 return &snapshotCapture{
66- session: sess,
67- name: sess.name,
68- generation: generation,
66+ session: sess,
67+ name: sess.name,
68+ incarnation: sess.incarnation,
69+ generation: generation,
6970 tabs: []snapshotCaptureTab{{
7071 stableID: "tab",
7172 cols: 1,
M internal/usecase/daemon/snapshot_capture.go +6 -5
@@ -8,11 +8,12 @@ import snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
88 func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
99 sess.mu.Lock()
1010 capture := &snapshotCapture{
11- session: sess,
12- generation: generation,
13- name: sess.name,
14- createdAt: uint64(sess.createdAt),
15- active: uint16(max(sess.active, 0)),
11+ session: sess,
12+ generation: generation,
13+ name: sess.name,
14+ incarnation: sess.incarnation,
15+ createdAt: uint64(sess.createdAt),
16+ active: uint16(max(sess.active, 0)),
1617 }
1718 ephemeral := sess.ephemeral
1819 fallbackCwd := sess.cwd
M internal/usecase/daemon/snapshot_coordinator.go +23 -63
@@ -1,23 +1,11 @@
11 package daemon
22
3-import (
4- "context"
5- "time"
6-)
7-
8-// snapshotCoordinatorQuarantine identifies one stop request. A later teardown
9-// can supersede it, in which case only that later owner may resume publication.
10-type snapshotCoordinatorQuarantine struct {
11- done <-chan struct{}
12- epoch uint64
13-}
14-
153 // quarantineSnapshotCoordinator prevents a deleted session from starting any
164 // further publication. The returned join channel is closed after an already
175 // started repository call returns; callers must wait without daemon or session
186 // locks before deleting repository state.
197 func quarantineSnapshotCoordinator(sess *session) <-chan struct{} {
20- return quarantineSnapshotCoordinatorWithEpoch(sess).done
8+ return quarantineSnapshotCoordinatorWithOptions(sess, false)
219 }
2210
2311 // quarantineSnapshotCoordinatorRetainingQueuedCapture is used after a forced
@@ -26,22 +14,16 @@ func quarantineSnapshotCoordinator(sess *session) <-chan struct{} {
2614 // quarantined, and the worker eventually discards it after its blocked call
2715 // returns.
2816 func quarantineSnapshotCoordinatorRetainingQueuedCapture(sess *session) <-chan struct{} {
29- return quarantineSnapshotCoordinatorWithOptions(sess, true).done
30-}
31-
32-func quarantineSnapshotCoordinatorWithEpoch(sess *session) snapshotCoordinatorQuarantine {
33- return quarantineSnapshotCoordinatorWithOptions(sess, false)
17+ return quarantineSnapshotCoordinatorWithOptions(sess, true)
3418 }
3519
36-func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture bool) snapshotCoordinatorQuarantine {
20+func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture bool) <-chan struct{} {
3721 if sess == nil {
3822 done := make(chan struct{})
3923 close(done)
40- return snapshotCoordinatorQuarantine{done: done}
24+ return done
4125 }
4226 sess.snapshotMu.Lock()
43- sess.snapshotQuarantineEpoch++
44- epoch := sess.snapshotQuarantineEpoch
4527 sess.snapshotQuarantined = true
4628 sess.snapEligible.Store(false)
4729 if sess.snapshotPublicationCancel != nil {
@@ -65,47 +47,7 @@ func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture
6547 }
6648 sess.signalSnapshotChangedLocked()
6749 sess.snapshotMu.Unlock()
68- return snapshotCoordinatorQuarantine{done: done, epoch: epoch}
69-}
70-
71-// resumeSnapshotCoordinatorForNewIdentity starts a fresh repository lineage
72-// only after rename committed its new in-memory name. The epoch prevents a
73-// concurrent teardown from being undone by a late rename completion.
74-func resumeSnapshotCoordinatorForNewIdentity(sess *session, quarantine snapshotCoordinatorQuarantine) bool {
75- if sess == nil {
76- return false
77- }
78- sess.snapshotMu.Lock()
79- if !sess.snapshotQuarantined || sess.snapshotQuarantineEpoch != quarantine.epoch {
80- sess.snapshotMu.Unlock()
81- return false
82- }
83- if sess.snapshotPublicationCancel != nil {
84- sess.snapshotPublicationCancel()
85- }
86- sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
87- sess.snapshotQuarantined = false
88- // A repository name owns its own generation stream. Reset all scheduler
89- // state while eligibility remains false, then atomically make generation 1
90- // dirty and wake the scheduler.
91- sess.snapshotGeneration = 1
92- sess.snapshotPublishedGeneration = 0
93- sess.snapshotPublishedMutationRevision = 0
94- sess.snapshotForcedGeneration = 0
95- sess.snapshotNextEligibleAt = time.Time{}
96- sess.snapshotChunkCache = newSnapshotChunkCache(snapshotChunkCacheLimit)
97- sess.snapDirty.Store(true)
98- sess.snapEligible.Store(true)
99- sess.signalSnapshotChangedLocked()
100- wake := sess.snapshotWake
101- sess.snapshotMu.Unlock()
102- if wake != nil {
103- select {
104- case wake <- struct{}{}:
105- default:
106- }
107- }
108- return true
50+ return done
10951 }
11052
11153 func markSnapshotDirty(sess *session) {
@@ -166,6 +108,14 @@ func (d *Daemon) waitForSnapshotGenerationWithDeadline(sess *session, generation
166108 return d.waitForSnapshotGeneration(sess, generation)
167109 }
168110 for {
111+ // Once the shared deadline is observable, timeout wins even if publication
112+ // completion becomes ready concurrently. This preserves the timed-out
113+ // session identity and avoids nondeterministic success after the budget.
114+ select {
115+ case <-deadline.Done():
116+ return false
117+ default:
118+ }
169119 sess.snapshotMu.Lock()
170120 published := sess.snapshotPublishedMutationRevision >= generation
171121 changed := sess.snapshotChangeLocked()
@@ -238,6 +188,11 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
238188 mutationRevision = capture.generation
239189 }
240190 if succeeded {
191+ if capture.generation >= capture.session.snapshotPublishedGeneration &&
192+ capture.checkpoint.Generation == capture.generation && capture.checkpoint.ManifestDigest != ([32]byte{}) {
193+ checkpoint := capture.checkpoint
194+ capture.session.snapshotPublishedCheckpoint = &checkpoint
195+ }
241196 capture.session.snapshotPublishedGeneration = max(capture.session.snapshotPublishedGeneration, capture.generation)
242197 capture.session.snapshotPublishedMutationRevision = max(capture.session.snapshotPublishedMutationRevision, mutationRevision)
243198 }
@@ -260,6 +215,11 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
260215 capture.session.signalSnapshotChangedLocked()
261216 wake := capture.session.snapshotWake
262217 capture.session.snapshotMu.Unlock()
218+ if capture.normalWorkerAdmitted {
219+ d.snapshotWorkerMu.Lock()
220+ delete(d.snapshotAdmitted, capture)
221+ d.snapshotWorkerMu.Unlock()
222+ }
263223 if wake != nil {
264224 select {
265225 case wake <- struct{}{}:
M internal/usecase/daemon/snapshot_generation_test.go +2 -2
@@ -17,7 +17,7 @@ import (
1717 func TestSnapshotMultipleMutationsPublishNextRepositoryGeneration(t *testing.T) {
1818 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1919 repository := portsmocks.NewMockSnapshotRepository(t)
20- WithSnapshotRepository(repository, nil)(d)
20+ WithSnapshotRepository(repository)(d)
2121 startSnapshotEncodeWorker(t, d)
2222
2323 publications := make(chan ports.SnapshotPublication, 1)
@@ -44,7 +44,7 @@ func TestSnapshotMultipleMutationsPublishNextRepositoryGeneration(t *testing.T)
4444 func TestSnapshotRetryKeepsRepositoryGeneration(t *testing.T) {
4545 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
4646 repository := portsmocks.NewMockSnapshotRepository(t)
47- WithSnapshotRepository(repository, nil)(d)
47+ WithSnapshotRepository(repository)(d)
4848 startSnapshotEncodeWorker(t, d)
4949
5050 publications := make(chan ports.SnapshotPublication, 2)
D internal/usecase/daemon/snapshot_import_test.go +0 -215
@@ -1,215 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "encoding/base64"
6- "errors"
7- "os"
8- "path/filepath"
9- "testing"
10-
11- "github.com/stretchr/testify/require"
12-
13- "github.com/bnema/vev/internal/ports"
14- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15-)
16-
17-type snapshotAcceptanceLegacySource struct {
18- blobs []ports.LegacySnapshot
19- loadCalls int
20- deleteCalls []string
21- deleteErr error
22-}
23-
24-func (s *snapshotAcceptanceLegacySource) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) {
25- if err := ctx.Err(); err != nil {
26- return nil, err
27- }
28- s.loadCalls++
29- out := make([]ports.LegacySnapshot, len(s.blobs))
30- for i, blob := range s.blobs {
31- out[i] = ports.LegacySnapshot{Name: blob.Name, Data: append([]byte(nil), blob.Data...)}
32- }
33- return out, nil
34-}
35-
36-func (s *snapshotAcceptanceLegacySource) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
37- return s.DeleteLegacy(ctx, blob.Name)
38-}
39-
40-func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name string) error {
41- if err := ctx.Err(); err != nil {
42- return err
43- }
44- s.deleteCalls = append(s.deleteCalls, name)
45- if s.deleteErr != nil {
46- return s.deleteErr
47- }
48- for i, blob := range s.blobs {
49- if blob.Name == name {
50- s.blobs = append(s.blobs[:i], s.blobs[i+1:]...)
51- break
52- }
53- }
54- return nil
55-}
56-
57-func legacyAcceptanceSnapshot(t *testing.T) snapcodec.Session {
58- t.Helper()
59- data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64"))
60- require.NoError(t, err)
61- encoded, err := base64.StdEncoding.DecodeString(string(data))
62- require.NoError(t, err)
63- snapshot, err := snapcodec.Unmarshal(encoded)
64- require.NoError(t, err)
65- return snapshot
66-}
67-
68-func legacyAcceptanceBlob(t *testing.T, snapshot snapcodec.Session) ports.LegacySnapshot {
69- t.Helper()
70- fixture := legacyAcceptanceSnapshot(t)
71- require.Equal(t, fixture, snapshot, "legacy fixture must be the imported session")
72- data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64"))
73- require.NoError(t, err)
74- encoded, err := base64.StdEncoding.DecodeString(string(data))
75- require.NoError(t, err)
76- return ports.LegacySnapshot{Name: snapshot.Name, Data: encoded}
77-}
78-
79-func TestLegacyPublicationMarshalObjectErrorsIdentifyPaneAndStage(t *testing.T) {
80- for _, tt := range []struct {
81- name string
82- mutate func(*snapcodec.Pane)
83- stage string
84- }{
85- {name: "sealed chunk", mutate: func(pane *snapcodec.Pane) { pane.SealedChunks = [][]byte{nil} }, stage: "sealed chunk 0"},
86- {name: "tail", mutate: func(pane *snapcodec.Pane) { pane.Tail = nil }, stage: "tail"},
87- {name: "visible", mutate: func(pane *snapcodec.Pane) { pane.Visible = nil }, stage: "visible"},
88- } {
89- t.Run(tt.name, func(t *testing.T) {
90- pane := snapcodec.Pane{ID: "pane-1", StableID: "pane-stable", Tail: []byte("tail"), Visible: []byte("visible")}
91- tt.mutate(&pane)
92- snapshot := snapcodec.Session{Name: "legacy", Tabs: []snapcodec.Tab{{StableID: "tab-1", Panes: []snapcodec.Pane{pane}}}}
93-
94- _, err := legacyPublication(snapshot)
95- require.Error(t, err)
96- require.ErrorContains(t, err, `tab "tab-1"`)
97- require.ErrorContains(t, err, `pane "pane-1"`)
98- require.ErrorContains(t, err, tt.stage)
99- require.ErrorContains(t, err, "marshal object")
100- })
101- }
102-}
103-
104-func TestImportLegacyPublishesCompleteGenerationBeforeDeletingSource(t *testing.T) {
105- snapshot := legacyAcceptanceSnapshot(t)
106- repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
107- legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
108- d := newTestDaemon(t, nil, stubClock{})
109- WithSnapshotRepository(repository, legacy)(d)
110- expected, verifyErr := legacyPublication(snapshot)
111- require.NoError(t, verifyErr)
112- _, verifyErr = snapcodec.Unmarshal(legacy.blobs[0].Data)
113- require.NoError(t, verifyErr)
114- require.NoError(t, verifyLegacyImportGeneration(acceptanceGeneration(t, snapshot, 1), expected))
115-
116- d.importLegacySnapshots(context.Background())
117-
118- require.Len(t, repository.publishes, 1)
119- publication := repository.publishes[0]
120- require.Equal(t, snapshot.Name, publication.Name)
121- require.EqualValues(t, 1, publication.Generation)
122- manifest, err := snapcodec.UnmarshalManifest(publication.Manifest)
123- require.NoError(t, err)
124- require.Equal(t, uint64(1), manifest.Generation)
125- require.Empty(t, manifest.Tabs)
126- for _, object := range publication.Objects {
127- kind, _, err := snapcodec.UnmarshalObject(object.Data)
128- require.NoError(t, err)
129- require.Contains(t, []snapcodec.ObjectKind{snapcodec.HistoryChunk, snapcodec.HistoryTail, snapcodec.Visible}, kind)
130- require.Equal(t, object.Digest, snapcodec.ManifestDigest(object.Data))
131- }
132- require.Equal(t, []string{snapshot.Name}, legacy.deleteCalls)
133- require.Empty(t, legacy.blobs)
134-}
135-
136-func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) {
137- snapshot := legacyAcceptanceSnapshot(t)
138- repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: make(map[string]ports.SnapshotGeneration)}
139- legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
140- d := newTestDaemon(t, nil, stubClock{})
141- WithSnapshotRepository(repository, legacy)(d)
142-
143- d.importLegacySnapshots(context.Background())
144-
145- require.Empty(t, repository.publishes)
146- require.Empty(t, legacy.deleteCalls)
147- require.Len(t, legacy.blobs, 1)
148-}
149-
150-func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) {
151- snapshot := legacyAcceptanceSnapshot(t)
152- for _, test := range []struct {
153- name string
154- publish error
155- load error
156- mutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration
157- }{
158- {"publish failure", errors.New("publish failed"), nil, nil},
159- {"reload failure", nil, errors.New("reload failed"), nil},
160- {"manifest metadata mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {
161- g.Generation++
162- return g
163- }},
164- {"manifest bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {
165- g.Manifest[len(g.Manifest)-1] ^= 1
166- return g
167- }},
168- } {
169- t.Run(test.name, func(t *testing.T) {
170- repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration), publishErr: test.publish, loadErr: test.load, loadMutate: test.mutate}
171- legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
172- d := newTestDaemon(t, nil, stubClock{})
173- WithSnapshotRepository(repository, legacy)(d)
174- d.importLegacySnapshots(context.Background())
175- require.Empty(t, legacy.deleteCalls)
176- require.Len(t, legacy.blobs, 1)
177- })
178- }
179-}
180-
181-func TestImportLegacyDeleteFailureRetriesWithoutRepublishing(t *testing.T) {
182- snapshot := legacyAcceptanceSnapshot(t)
183- repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
184- legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}, deleteErr: errors.New("sync failed")}
185- d := newTestDaemon(t, nil, stubClock{})
186- WithSnapshotRepository(repository, legacy)(d)
187-
188- d.importLegacySnapshots(context.Background())
189- legacy.deleteErr = nil
190- d.importLegacySnapshots(context.Background())
191-
192- require.Len(t, repository.publishes, 1, "a retry must only retry source deletion")
193- require.Equal(t, []string{snapshot.Name, snapshot.Name}, legacy.deleteCalls)
194- require.Empty(t, legacy.blobs)
195-}
196-
197-func TestImportLegacyCancellationAndPerSessionContinuation(t *testing.T) {
198- bad := legacyAcceptanceSnapshot(t)
199- good := legacyAcceptanceSnapshot(t)
200- badBlob := legacyAcceptanceBlob(t, bad)
201- badBlob.Data = []byte("not-a-v3-snapshot")
202- repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
203- legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{badBlob, legacyAcceptanceBlob(t, good)}}
204- d := newTestDaemon(t, nil, stubClock{})
205- WithSnapshotRepository(repository, legacy)(d)
206-
207- d.importLegacySnapshots(context.Background())
208- require.Len(t, repository.publishes, 1, "a corrupt session must not block later sessions")
209- require.Equal(t, []string{good.Name}, legacy.deleteCalls)
210-
211- cancelled, cancel := context.WithCancel(context.Background())
212- cancel()
213- d.importLegacySnapshots(cancelled)
214- require.Equal(t, 1, legacy.loadCalls, "a canceled import must not touch the legacy source")
215-}
M internal/usecase/daemon/snapshot_incremental.go +4 -2
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "fmt"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/internal/ports"
78 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
89 "github.com/bnema/vev/pkg/vt"
@@ -113,7 +114,7 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
113114 if err := prepareSnapshotChunkCache(capture); err != nil {
114115 return ports.SnapshotPublication{}, err
115116 }
116- manifest := snapcodec.Manifest{Generation: capture.generation, Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.ManifestTab, 0, len(capture.tabs))}
117+ manifest := snapcodec.Manifest{Generation: capture.generation, IncarnationID: capture.incarnation, ParentCheckpoint: capture.parentCheckpoint, Name: capture.name, CreatedAt: capture.createdAt, Active: capture.active, Tabs: make([]snapcodec.ManifestTab, 0, len(capture.tabs))}
117118 objects := make([]ports.SnapshotObject, 0)
118119 capture.sealedRefs = make(map[*vt.HistoryChunk]snapcodec.ObjectRef)
119120 for _, tab := range capture.tabs {
@@ -160,7 +161,8 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
160161 if err != nil {
161162 return ports.SnapshotPublication{}, err
162163 }
163- return ports.SnapshotPublication{Name: capture.name, Generation: capture.generation, Manifest: encoded, Objects: objects}, nil
164+ capture.checkpoint = domain.CheckpointRef{Generation: capture.generation, ManifestDigest: snapcodec.ManifestDigest(encoded)}
165+ return ports.SnapshotPublication{IncarnationID: capture.incarnation, Name: capture.name, Generation: capture.generation, ParentCheckpoint: capture.parentCheckpoint, Manifest: encoded, Objects: objects}, nil
164166 }
165167
166168 // marshalSnapshotTail selects the canonical empty-tail encoding before any
M internal/usecase/daemon/snapshot_incremental_test.go +8 -1
@@ -6,6 +6,7 @@ import (
66
77 "github.com/stretchr/testify/require"
88
9+ "github.com/bnema/vev/internal/domain"
910 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1011 "github.com/bnema/vev/pkg/renderer"
1112 "github.com/bnema/vev/pkg/vt"
@@ -124,12 +125,14 @@ func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
124125 visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot()
125126 d := New(nil, nil, nil)
126127 sess := newSnapshotTestSession(t, "work", false, "/work")
127- capture := &snapshotCapture{session: sess, name: "work", generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), visible: visible}}}}}
128+ capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), visible: visible}}}}}
128129 first, err := d.incrementalPublication(capture)
129130 if err != nil {
130131 t.Fatal(err)
131132 }
133+ firstRef := domain.CheckpointRef{Generation: first.Generation, ManifestDigest: snapcodec.ManifestDigest(first.Manifest)}
132134 capture.generation = 2
135+ capture.parentCheckpoint = &firstRef
133136 second, err := d.incrementalPublication(capture)
134137 if err != nil {
135138 t.Fatal(err)
@@ -137,6 +140,10 @@ func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
137140 if len(first.Objects) == 0 || len(second.Objects) == 0 {
138141 t.Fatal("publication omitted required objects")
139142 }
143+ require.Equal(t, &firstRef, second.ParentCheckpoint)
144+ manifest, err := snapcodec.UnmarshalManifest(second.Manifest)
145+ require.NoError(t, err)
146+ require.Equal(t, &firstRef, manifest.ParentCheckpoint)
140147 if got, want := sess.snapshotChunkCache.used, sess.snapshotChunkCache.limit; got > want {
141148 t.Fatalf("cache bytes = %d, limit = %d", got, want)
142149 }
M internal/usecase/daemon/snapshot_queue.go +17 -1
@@ -1,6 +1,10 @@
11 package daemon
22
3-import "context"
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
48
59 func (d *Daemon) scheduleSnapshot(sess *session) bool {
610 return d.scheduleSnapshotWithFinalFallback(sess, false)
@@ -54,6 +58,11 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
5458 sess.snapshotGeneration = mutationRevision
5559 }
5660 publicationContext := sess.snapshotPublicationContext
61+ var parentCheckpoint *domain.CheckpointRef
62+ if sess.snapshotPublishedCheckpoint != nil {
63+ parent := *sess.snapshotPublishedCheckpoint
64+ parentCheckpoint = &parent
65+ }
5766 sess.snapshotPendingCaptures++
5867 sess.snapshotPending = true
5968 sess.signalSnapshotChangedLocked()
@@ -64,6 +73,7 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
6473 capture.mutationRevision = mutationRevision
6574 capture.attemptKind = kind
6675 capture.publicationContext = publicationContext
76+ capture.parentCheckpoint = parentCheckpoint
6777 }
6878 if !ok {
6979 d.finishSnapshotCapture(capture, false)
@@ -97,10 +107,16 @@ func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
97107 if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
98108 return false
99109 }
110+ capture.normalWorkerAdmitted = true
100111 select {
101112 case d.snapshotJobs <- capture:
113+ if d.snapshotAdmitted == nil {
114+ d.snapshotAdmitted = make(map[*snapshotCapture]struct{})
115+ }
116+ d.snapshotAdmitted[capture] = struct{}{}
102117 return true
103118 default:
119+ capture.normalWorkerAdmitted = false
104120 return false
105121 }
106122 }
M internal/usecase/daemon/snapshot_restore.go +77 -3
@@ -2,23 +2,68 @@ package daemon
22
33 import (
44 "context"
5+ "crypto/rand"
6+ "errors"
57 "fmt"
68 "math"
79
810 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
912 "github.com/bnema/vev/internal/usecase/layout"
1013 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1114 "github.com/bnema/vev/pkg/vt"
1215 )
1316
1417 func (d *Daemon) closeRestoreDone() {
18+ d.mu.Lock()
19+ for name, entry := range d.stopped {
20+ if entry.restoreDone == nil {
21+ continue
22+ }
23+ select {
24+ case <-entry.restoreDone:
25+ continue
26+ default:
27+ }
28+ entry.state = ports.SessionBroken
29+ if entry.record.DegradedReason == "" {
30+ entry.record.DegradedReason = "restore unavailable"
31+ }
32+ d.stopped[name] = entry
33+ closeRuntimeRestoreDoneLocked(entry.restoreDone)
34+ }
35+ d.mu.Unlock()
1536 d.restoreOnce.Do(func() { close(d.restoreDone) })
1637 }
1738
39+// startSnapshotRestoration launches catalogue-driven restoration as a durable
40+// writer. Restoration reconciles committed checkpoints, so lifecycle ownership
41+// must outlive it exactly as it outlives the snapshot and maintenance workers. The completion
42+// channel is registered before the goroutine starts, so shutdown can never
43+// begin waiting while this writer is still unregistered.
44+func (d *Daemon) startSnapshotRestoration() {
45+ if d == nil {
46+ return
47+ }
48+ d.snapshotWorkerMu.Lock()
49+ if d.restoreWorkerDone != nil {
50+ d.snapshotWorkerMu.Unlock()
51+ return
52+ }
53+ done := make(chan struct{})
54+ d.restoreWorkerDone = done
55+ d.snapshotWorkerMu.Unlock()
56+ d.sessWg.Go(func() {
57+ defer close(done)
58+ d.restoreSnapshots(d.serveCtx)
59+ })
60+}
61+
1862 func (d *Daemon) restoreSnapshots(ctx context.Context) {
1963 if d == nil {
2064 return
2165 }
66+ defer d.logStartupRecoveryCounts(0)
2267 defer d.closeRestoreDone()
2368 if ns := d.noticeStore; ns != nil {
2469 claimed, err := ns.Claim()
@@ -64,7 +109,7 @@ func validateRestoreSessionSnapshot(snap snapcodec.Session) error {
64109
65110 // restoreSession owns the restored tabs until registration succeeds. Every
66111 // unsuccessful path closes their PTYs and cancels their shared session context.
67-func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session, repositoryGeneration uint64) error {
112+func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session, repositoryGeneration uint64, checkpoint domain.CheckpointRef) error {
68113 if err := ctx.Err(); err != nil {
69114 return err
70115 }
@@ -101,6 +146,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session, rep
101146 // The loaded manifest is the repository head for this name. Future dirty
102147 // checkpoints must continue from it rather than reuse generation one.
103148 sess.snapshotPublishedGeneration = repositoryGeneration
149+ sess.snapshotPublishedCheckpoint = &checkpoint
104150 registered, err := d.persistAndRegisterRestoredSession(ctx, sess)
105151 if err != nil || !registered {
106152 return err
@@ -253,8 +299,32 @@ func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context
253299 // persistAndRegisterRestoredSession atomically transfers a successfully
254300 // persisted session to the daemon. The caller retains cleanup on false/error.
255301 func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) {
256- if err := d.persist.Save(sess.persistRecordLocked(sess.createdAt)); err != nil {
257- return false, err
302+ if sess.incarnation == (domain.IncarnationID{}) {
303+ d.mu.Lock()
304+ stopped := d.stopped[sess.name]
305+ d.mu.Unlock()
306+ sess.incarnation = stopped.incarnation
307+ if sess.incarnation == (domain.IncarnationID{}) {
308+ var err error
309+ sess.incarnation, err = domain.NewIncarnationID(rand.Reader)
310+ if err != nil {
311+ return false, fmt.Errorf("snapshot: generate durable identity: %w", err)
312+ }
313+ }
314+ }
315+ // Restoration is catalogue-authorized. Missing or unreadable authority is a
316+ // hard failure.
317+ if d.persistEnabled {
318+ record, ok, err := d.catalogueRecord(sess.name)
319+ if err != nil {
320+ return false, err
321+ }
322+ if !ok {
323+ return false, errors.New("snapshot: restored session is absent from catalogue")
324+ }
325+ if record.IncarnationID != sess.incarnation {
326+ return false, errors.New("snapshot: restored session incarnation differs from catalogue")
327+ }
258328 }
259329 d.mu.Lock()
260330 defer d.mu.Unlock()
@@ -263,6 +333,10 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se
263333 }
264334 sess.id = domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
265335 d.nextID++
336+ stopped := d.stopped[sess.name]
337+ sess.mu.Lock()
338+ sess.restoreDone = stopped.restoreDone
339+ sess.mu.Unlock()
266340 delete(d.stopped, sess.name)
267341 d.sessions[sess.id] = sess
268342 sess.snapDirty.Store(false)
R internal/usecase/daemon/snapshot_legacy_import.go → internal/usecase/daemon/snapshot_restore_fixture_test.go +10 -7 renamed, 54% similar
@@ -3,12 +3,15 @@ package daemon
33 import (
44 "fmt"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/internal/ports"
78 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
89 )
910
10-func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {
11- manifest := snapcodec.Manifest{Generation: 1, Name: snapshot.Name, CreatedAt: snapshot.CreatedAt, Active: snapshot.Active, Tabs: make([]snapcodec.ManifestTab, 0, len(snapshot.Tabs))}
11+func acceptancePublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {
12+ // acceptancePublication converts a restore fixture to repository form.
13+ incarnation := domain.IncarnationID{1}
14+ manifest := snapcodec.Manifest{Generation: 1, IncarnationID: incarnation, Name: snapshot.Name, CreatedAt: snapshot.CreatedAt, Active: snapshot.Active, Tabs: make([]snapcodec.ManifestTab, 0, len(snapshot.Tabs))}
1215 objects := make([]ports.SnapshotObject, 0)
1316 for _, tab := range snapshot.Tabs {
1417 outTab := snapcodec.ManifestTab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.Panes))}
@@ -17,18 +20,18 @@ func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, e
1720 for i, payload := range pane.SealedChunks {
1821 object, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, payload)
1922 if err != nil {
20- return ports.SnapshotPublication{}, fmt.Errorf("snapshot legacy import: tab %q pane %q sealed chunk %d: marshal object: %w", tab.StableID, pane.ID, i, err)
23+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q sealed chunk %d: marshal object: %w", tab.StableID, pane.ID, i, err)
2124 }
2225 outPane.Sealed = append(outPane.Sealed, objectRef(snapcodec.HistoryChunk, object))
2326 objects = append(objects, object)
2427 }
2528 tail, err := snapcodec.MarshalObject(snapcodec.HistoryTail, pane.Tail)
2629 if err != nil {
27- return ports.SnapshotPublication{}, fmt.Errorf("snapshot legacy import: tab %q pane %q tail: marshal object: %w", tab.StableID, pane.ID, err)
30+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q tail: marshal object: %w", tab.StableID, pane.ID, err)
2831 }
2932 visible, err := snapcodec.MarshalObject(snapcodec.Visible, pane.Visible)
3033 if err != nil {
31- return ports.SnapshotPublication{}, fmt.Errorf("snapshot legacy import: tab %q pane %q visible: marshal object: %w", tab.StableID, pane.ID, err)
34+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q visible: marshal object: %w", tab.StableID, pane.ID, err)
3235 }
3336 outPane.Tail, outPane.Visible = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.Visible, visible)
3437 objects = append(objects, tail, visible)
@@ -38,7 +41,7 @@ func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, e
3841 }
3942 encoded, err := snapcodec.MarshalManifest(manifest)
4043 if err != nil {
41- return ports.SnapshotPublication{}, fmt.Errorf("snapshot: marshal legacy import manifest: %w", err)
44+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: marshal manifest: %w", err)
4245 }
43- return ports.SnapshotPublication{Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil
46+ return ports.SnapshotPublication{IncarnationID: incarnation, Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil
4447 }
M internal/usecase/daemon/snapshot_restore_incremental.go +228 -143
@@ -1,55 +1,254 @@
11 package daemon
22
33 import (
4- "bytes"
54 "context"
65 "crypto/sha256"
6+ "errors"
77 "fmt"
8+ "net"
9+ "sync"
10+ "syscall"
811
912 "github.com/bnema/vev/internal/domain"
1013 "github.com/bnema/vev/internal/ports"
1114 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1215 )
1316
14-// restoreIncrementalSnapshots imports v3 only before listing repository
15-// sessions, then restores each independently. A corrupt session therefore
16-// cannot prevent an unrelated saved session from starting.
17+const catalogueRestoreConcurrency = 8
18+
19+var errRetryableRestoreLoad = errors.New("snapshot: retryable checkpoint load")
20+
21+func initialSessionState(record domain.CatalogueRecord) (ports.SessionState, chan struct{}) {
22+ done := make(chan struct{})
23+ state := ports.SessionStopped
24+ if record.DegradedReason != "" {
25+ state = ports.SessionBroken
26+ close(done)
27+ } else if record.Committed == nil {
28+ close(done)
29+ }
30+ return state, done
31+}
32+
33+// restoreIncrementalSnapshots restores only records named by the authoritative
34+// catalogue. Snapshot repository discovery is deliberately not part of this
35+// path, so stale or oversized namespaces cannot affect unrelated sessions.
1736 func (d *Daemon) restoreIncrementalSnapshots(ctx context.Context) {
18- d.importLegacySnapshots(ctx)
19- names, err := d.snapshotRepository.List(ctx)
37+ if d.catalogue == nil {
38+ return
39+ }
40+ records, err := d.catalogue.Records()
2041 if err != nil {
21- d.log.Warn("listing session snapshots failed", "err", err)
22- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't load saved sessions after restart", err)
42+ d.log.Error("loading catalogue for snapshot restoration failed", "err", err)
2343 return
2444 }
25- for _, name := range names {
26- if err := ctx.Err(); err != nil {
27- return
45+ d.restoreCatalogue(ctx, records)
46+}
47+
48+// restoreCatalogue gives every expected record an independent bounded job.
49+// A failed record is degraded without cancelling any sibling restoration.
50+func (d *Daemon) restoreCatalogue(ctx context.Context, records []domain.CatalogueRecord) {
51+ jobs := make(chan domain.CatalogueRecord)
52+ workers := min(catalogueRestoreConcurrency, len(records))
53+ var wg sync.WaitGroup
54+ for range workers {
55+ wg.Go(func() {
56+ for record := range jobs {
57+ done := d.ensureCatalogueRegistryEntry(record)
58+ err := d.restoreRecord(ctx, record)
59+ d.finishRecordRestore(record, err, done)
60+ }
61+ })
62+ }
63+ for _, record := range records {
64+ jobs <- record
65+ }
66+ close(jobs)
67+ wg.Wait()
68+}
69+
70+func stoppedSessionFromRecord(record domain.CatalogueRecord, state ports.SessionState, done chan struct{}) stoppedSession {
71+ return stoppedSession{
72+ name: record.Name,
73+ cwd: record.Cwd,
74+ createdAt: record.CreatedAt,
75+ incarnation: record.IncarnationID,
76+ lastUsedSeq: record.LastUsedSeq,
77+ tabNames: append([]string(nil), record.TabNames...),
78+ record: record,
79+ state: state,
80+ restoreDone: done,
81+ }
82+}
83+
84+func (d *Daemon) ensureCatalogueRegistryEntry(record domain.CatalogueRecord) chan struct{} {
85+ d.mu.Lock()
86+ defer d.mu.Unlock()
87+ if entry, ok := d.stopped[record.Name]; ok {
88+ return entry.restoreDone
89+ }
90+ state, done := initialSessionState(record)
91+ d.stopped[record.Name] = stoppedSessionFromRecord(record, state, done)
92+ return done
93+}
94+
95+// closeRuntimeRestoreDoneLocked closes a per-record restoration barrier.
96+// Caller must hold d.mu so state transition and waiter release are atomic.
97+func closeRuntimeRestoreDoneLocked(done chan struct{}) {
98+ if done == nil {
99+ return
100+ }
101+ select {
102+ case <-done:
103+ default:
104+ close(done)
105+ }
106+}
107+
108+func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state ports.SessionState) {
109+ d.mu.Lock()
110+ defer d.mu.Unlock()
111+ entry, ok := d.stopped[record.Name]
112+ if !ok {
113+ return
114+ }
115+ d.stopped[record.Name] = stoppedSessionFromRecord(record, state, entry.restoreDone)
116+}
117+
118+func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr error, done chan struct{}) {
119+ retryable := errors.Is(restoreErr, errRetryableRestoreLoad)
120+ degraded := false
121+ if restoreErr != nil {
122+ reason := "checkpoint validation failed"
123+ switch {
124+ case retryable:
125+ reason = "checkpoint load failed"
126+ case errors.Is(restoreErr, context.Canceled) || errors.Is(restoreErr, context.DeadlineExceeded):
127+ reason = "restore interrupted"
28128 }
29- generation, err := d.snapshotRepository.Load(ctx, name)
30- if err != nil {
31- d.snapshotRestoreFailure(name, err)
32- continue
129+ if d.recovery == nil {
130+ d.log.Error("marking session degraded failed", "session", record.Name, "err", errors.New("recovery coordinator unavailable"))
131+ } else if current, marked, err := d.recovery.MarkBroken(context.Background(), record.Name, record.IncarnationID, reason); err != nil {
132+ d.log.Error("marking session degraded failed", "session", record.Name, "err", err)
133+ } else if marked {
134+ record = current
135+ degraded = true
33136 }
34- if generation.Fallback {
35- d.NotifyGlobal(domain.NoticeWarn, domain.NoticeSnapshotRestore, "restored an older saved checkpoint for session "+name, nil)
137+ }
138+
139+ d.mu.Lock()
140+ if entry, ok := d.stopped[record.Name]; ok && degraded {
141+ d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionBroken, entry.restoreDone)
142+ }
143+ closeRuntimeRestoreDoneLocked(done)
144+ d.mu.Unlock()
145+ if degraded {
146+ reasonCode := "checkpoint-invalid"
147+ switch {
148+ case retryable:
149+ reasonCode = "checkpoint-load-failed"
150+ case errors.Is(restoreErr, context.Canceled) || errors.Is(restoreErr, context.DeadlineExceeded):
151+ reasonCode = "restore-interrupted"
36152 }
37- snapshot, err := sessionFromGeneration(generation)
38- if err != nil {
39- d.snapshotRestoreFailure(name, err)
40- continue
153+ d.logSessionDegraded(record, reasonCode)
154+ }
155+}
156+
157+func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecord) error {
158+ if record.DegradedReason != "" {
159+ d.setStoppedRecovery(record, ports.SessionBroken)
160+ d.logSessionDegraded(record, "persisted-broken")
161+ return nil
162+ }
163+ if record.Committed == nil {
164+ d.setStoppedRecovery(record, ports.SessionStopped)
165+ d.logSessionRestoreComplete(record, 0, false)
166+ return nil
167+ }
168+ if d.snapshotRepository == nil {
169+ return errors.New("snapshot: repository unavailable")
170+ }
171+
172+ if record.Committed == nil {
173+ return errors.New("snapshot: healthy record has no checkpoint")
174+ }
175+ selected := *record.Committed
176+ selectedGeneration, err := d.snapshotRepository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, selected)
177+ if err != nil {
178+ if ctxErr := ctx.Err(); ctxErr != nil {
179+ return ctxErr
41180 }
42- if err := d.restoreSession(ctx, snapshot, generation.Generation); err != nil {
43- if ctx.Err() == nil {
44- d.snapshotRestoreFailure(name, err)
45- }
181+ if isRetryableRestoreLoadError(err) {
182+ return fmt.Errorf("%w: %w", errRetryableRestoreLoad, err)
46183 }
184+ d.logRejectedRestoreCandidate(record, selected, false, "load-failed", err)
185+ return errors.New("snapshot: committed checkpoint did not validate")
186+ }
187+ selectedSnapshot, err := sessionFromGeneration(selectedGeneration)
188+ if err != nil {
189+ d.logRejectedRestoreCandidate(record, selected, false, "decode-failed", err)
190+ return errors.New("snapshot: committed checkpoint did not validate")
191+ }
192+ if selectedGeneration.IncarnationID != record.IncarnationID {
193+ d.logRejectedRestoreCandidate(record, selected, false, "incarnation-mismatch", nil)
194+ return errors.New("snapshot: committed checkpoint did not validate")
195+ }
196+ if selectedGeneration.Name != record.Name {
197+ d.logRejectedRestoreCandidate(record, selected, false, "name-mismatch", nil)
198+ return errors.New("snapshot: committed checkpoint did not validate")
47199 }
200+ if selectedGeneration.Generation != selected.Generation {
201+ d.logRejectedRestoreCandidate(record, selected, false, "generation-mismatch", nil)
202+ return errors.New("snapshot: committed checkpoint did not validate")
203+ }
204+ if err := d.snapshotRepository.ReconcileCheckpoint(ctx, record.IncarnationID, selected); err != nil {
205+ d.log.Warn("snapshot_checkpoint_reconciliation_pending", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation, "reason_code", "reconcile-failed")
206+ } else {
207+ d.log.Info("snapshot_checkpoint_reconciliation_complete", "session", record.Name, "incarnation", record.IncarnationID.String(), "generation", selected.Generation)
208+ }
209+
210+ if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation, selected); err != nil {
211+ return err
212+ }
213+ d.setStoppedRecovery(record, ports.SessionStopped)
214+ d.logSessionRestoreComplete(record, selected.Generation, false)
215+ return nil
216+}
217+
218+func isRetryableRestoreLoadError(err error) bool {
219+ if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
220+ return false
221+ }
222+ if errors.Is(err, syscall.ENOSPC) ||
223+ errors.Is(err, syscall.ENOMEM) ||
224+ errors.Is(err, syscall.EMFILE) ||
225+ errors.Is(err, syscall.ENFILE) ||
226+ errors.Is(err, syscall.EDQUOT) {
227+ return true
228+ }
229+ if netErr, ok := errors.AsType[net.Error](err); ok && netErr.Timeout() {
230+ return true
231+ }
232+ temporary, ok := errors.AsType[interface {
233+ error
234+ Temporary() bool
235+ }](err)
236+ return ok && temporary.Temporary()
48237 }
49238
50-func (d *Daemon) snapshotRestoreFailure(name string, err error) {
51- d.log.Warn("restoring session snapshot failed", "err", err, "session", name)
52- d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't restore session "+name+" after restart", err)
239+func (d *Daemon) logRejectedRestoreCandidate(record domain.CatalogueRecord, candidate domain.CheckpointRef, fallback bool, reason string, err error) {
240+ kind := "committed"
241+ if fallback {
242+ kind = "fallback"
243+ }
244+ d.log.Warn("snapshot_restore_candidate_rejected",
245+ "session", record.Name,
246+ "incarnation", record.IncarnationID.String(),
247+ "generation", candidate.Generation,
248+ "candidate", kind,
249+ "reason_code", reason,
250+ "err", err,
251+ )
53252 }
54253
55254 func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) {
@@ -57,7 +256,7 @@ func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Sessi
57256 if err != nil {
58257 return snapcodec.Session{}, err
59258 }
60- if manifest.Name != generation.Name || manifest.Generation != generation.Generation {
259+ if manifest.IncarnationID != generation.IncarnationID || manifest.Name != generation.Name || manifest.Generation != generation.Generation {
61260 return snapcodec.Session{}, fmt.Errorf("snapshot: generation identity mismatch")
62261 }
63262 result := snapcodec.Session{Name: manifest.Name, CreatedAt: manifest.CreatedAt, Active: manifest.Active, Tabs: make([]snapcodec.Tab, 0, len(manifest.Tabs))}
@@ -99,117 +298,3 @@ func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectR
99298 }
100299 return append([]byte(nil), payload...), nil
101300 }
102-
103-// importLegacySnapshots is intentionally best effort per session. It deletes
104-// a legacy blob only after publication and a full repository reload verify the
105-// exact converted session, leaving failures retryable on the next startup.
106-func (d *Daemon) importLegacySnapshots(ctx context.Context) {
107- if d.legacySnapshots == nil || d.snapshotRepository == nil || ctx.Err() != nil {
108- return
109- }
110- existing, err := d.snapshotRepository.List(ctx)
111- if err != nil {
112- d.log.Warn("listing incremental snapshots before import failed", "err", err)
113- return
114- }
115- present := make(map[string]struct{}, len(existing))
116- for _, name := range existing {
117- present[name] = struct{}{}
118- }
119- legacy, err := d.legacySnapshots.LoadLegacy(ctx)
120- if err != nil {
121- d.log.Warn("loading legacy snapshots failed", "err", err)
122- return
123- }
124- for _, blob := range legacy {
125- if ctx.Err() != nil {
126- return
127- }
128- if _, ok := present[blob.Name]; ok {
129- d.retryLegacyDelete(ctx, blob)
130- continue
131- }
132- snapshot, err := snapcodec.Unmarshal(blob.Data)
133- if err != nil || snapshot.Name != blob.Name {
134- d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err)
135- continue
136- }
137- publication, err := legacyPublication(snapshot)
138- if err == nil {
139- err = d.snapshotRepository.Publish(ctx, publication)
140- }
141- if err == nil {
142- var generation ports.SnapshotGeneration
143- generation, err = d.snapshotRepository.Load(ctx, snapshot.Name)
144- if err == nil {
145- err = verifyLegacyImportGeneration(generation, publication)
146- }
147- }
148- if err != nil {
149- d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err)
150- continue
151- }
152- d.rememberLegacyDelete(blob)
153- if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil {
154- d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
155- continue
156- }
157- d.forgetLegacyDelete(blob.Name)
158- present[blob.Name] = struct{}{}
159- }
160-}
161-
162-func verifyLegacyImportGeneration(generation ports.SnapshotGeneration, publication ports.SnapshotPublication) error {
163- if generation.Name != publication.Name || generation.Generation != publication.Generation || !bytes.Equal(generation.Manifest, publication.Manifest) {
164- return fmt.Errorf("snapshot: legacy import verification mismatch")
165- }
166- expected := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))
167- for _, object := range publication.Objects {
168- if sha256.Sum256(object.Data) != object.Digest {
169- return fmt.Errorf("snapshot: legacy import publication digest mismatch")
170- }
171- expected[object.Digest] = object.Data
172- }
173- if len(generation.Objects) != len(expected) {
174- return fmt.Errorf("snapshot: legacy import verification mismatch")
175- }
176- for digest, expectedData := range expected {
177- actual, ok := generation.Objects[digest]
178- if !ok || !bytes.Equal(actual, expectedData) || sha256.Sum256(actual) != digest {
179- return fmt.Errorf("snapshot: legacy import verification mismatch")
180- }
181- }
182- return nil
183-}
184-
185-func (d *Daemon) rememberLegacyDelete(blob ports.LegacySnapshot) {
186- d.legacyImportMu.Lock()
187- if d.legacyImportPending == nil {
188- d.legacyImportPending = make(map[string][]byte)
189- }
190- d.legacyImportPending[blob.Name] = append([]byte(nil), blob.Data...)
191- d.legacyImportMu.Unlock()
192-}
193-
194-func (d *Daemon) forgetLegacyDelete(name string) {
195- d.legacyImportMu.Lock()
196- delete(d.legacyImportPending, name)
197- d.legacyImportMu.Unlock()
198-}
199-
200-// retryLegacyDelete only acts on a blob which this daemon has already
201-// published and exactly reloaded. An unrelated pre-existing generation must
202-// continue to leave its legacy counterpart untouched.
203-func (d *Daemon) retryLegacyDelete(ctx context.Context, blob ports.LegacySnapshot) {
204- d.legacyImportMu.Lock()
205- pending, ok := d.legacyImportPending[blob.Name]
206- d.legacyImportMu.Unlock()
207- if !ok || !bytes.Equal(pending, blob.Data) {
208- return
209- }
210- if err := d.legacySnapshots.DeleteVerifiedLegacy(ctx, blob); err != nil {
211- d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
212- return
213- }
214- d.forgetLegacyDelete(blob.Name)
215-}
M internal/usecase/daemon/snapshot_restore_test.go +74 -21
@@ -12,12 +12,14 @@ import (
1212 "github.com/bnema/vev/internal/domain"
1313 "github.com/bnema/vev/internal/ports"
1414 "github.com/bnema/vev/internal/usecase/layout"
15+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
1516 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1617 "github.com/bnema/vev/pkg/renderer"
1718 "github.com/bnema/vev/pkg/vt"
1819 )
1920
2021 type snapshotAcceptanceRepository struct {
22+ noOpSnapshotRepository
2123 mu sync.Mutex
2224 names []string
2325 generations map[string]ports.SnapshotGeneration
@@ -42,7 +44,7 @@ func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.Snap
4244 for _, object := range p.Objects {
4345 objects[object.Digest] = append([]byte(nil), object.Data...)
4446 }
45- r.generations[p.Name] = ports.SnapshotGeneration{Name: p.Name, Generation: p.Generation, Manifest: append([]byte(nil), p.Manifest...), Objects: objects}
47+ r.generations[p.Name] = ports.SnapshotGeneration{IncarnationID: p.IncarnationID, Name: p.Name, Generation: p.Generation, ParentCheckpoint: p.ParentCheckpoint, Manifest: append([]byte(nil), p.Manifest...), Objects: objects}
4648 found := false
4749 for _, name := range r.names {
4850 found = found || name == p.Name
@@ -85,10 +87,16 @@ func (r *snapshotAcceptanceRepository) Load(ctx context.Context, name string) (p
8587 return cloneAcceptanceGeneration(generation), nil
8688 }
8789
88-func (*snapshotAcceptanceRepository) Delete(context.Context, string) error { return nil }
89-func (*snapshotAcceptanceRepository) Tombstone(context.Context, string) error { return nil }
90-func (*snapshotAcceptanceRepository) DeleteTombstone(context.Context, string) error { return nil }
91-func (*snapshotAcceptanceRepository) Maintain(context.Context) error { return nil }
90+func (r *snapshotAcceptanceRepository) LoadCheckpoint(ctx context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) {
91+ generation, err := r.Load(ctx, name)
92+ if err != nil {
93+ return ports.SnapshotGeneration{}, err
94+ }
95+ if generation.IncarnationID != id || generation.Generation != ref.Generation || snapcodec.ManifestDigest(generation.Manifest) != ref.ManifestDigest {
96+ return ports.SnapshotGeneration{}, errors.New("checkpoint unavailable")
97+ }
98+ return generation, nil
99+}
92100
93101 func TestSnapshotAcceptanceRepositoryLoadMutationDoesNotChangeStoredGeneration(t *testing.T) {
94102 digest := ports.SnapshotDigest{1}
@@ -167,7 +175,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
167175
168176 func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation uint64) ports.SnapshotGeneration {
169177 t.Helper()
170- publication, err := legacyPublication(snapshot)
178+ publication, err := acceptancePublication(snapshot)
171179 require.NoError(t, err)
172180 manifest, err := snapcodec.UnmarshalManifest(publication.Manifest)
173181 require.NoError(t, err)
@@ -178,7 +186,7 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u
178186 for _, object := range publication.Objects {
179187 objects[object.Digest] = append([]byte(nil), object.Data...)
180188 }
181- return ports.SnapshotGeneration{Name: snapshot.Name, Generation: generation, Manifest: encoded, Objects: objects}
189+ return ports.SnapshotGeneration{IncarnationID: publication.IncarnationID, Name: snapshot.Name, Generation: generation, ParentCheckpoint: publication.ParentCheckpoint, Manifest: encoded, Objects: objects}
182190 }
183191
184192 func TestValidateRestoreSessionSnapshot(t *testing.T) {
@@ -210,9 +218,12 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
210218 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}
211219 pty, release := newBlockingPTY(t)
212220 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
213- store, _ := newMockStore(t)
214- WithStore(store)(d)
215- WithSnapshotRepository(repository, nil)(d)
221+ checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
222+ record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), Committed: &checkpoint}
223+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
224+ WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
225+ WithSnapshotRepository(repository)(d)
226+ WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
216227 t.Cleanup(func() { release(); d.sessWg.Wait() })
217228
218229 d.restoreIncrementalSnapshots(context.Background())
@@ -246,20 +257,63 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
246257 require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")
247258 }
248259
249-func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
250- snapshot := restoreAcceptanceSession(t, "fallback")
251- valid := acceptanceGeneration(t, snapshot, 3)
252- valid.Fallback = true
260+func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
261+ snapshot := restoreAcceptanceSession(t, "restored")
262+ generation := acceptanceGeneration(t, snapshot, 9)
263+ repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}
264+ committed := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
265+ record := domain.CatalogueRecord{
266+ Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd",
267+ CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17,
268+ TabNames: []string{"before"}, Committed: &committed,
269+ }
270+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
271+
253272 pty, release := newBlockingPTY(t)
254273 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
255- store, _ := newMockStore(t)
256- WithStore(store)(d)
257- repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: valid}}
258- WithSnapshotRepository(repository, nil)(d)
274+ coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
275+ WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
276+ WithSnapshotRepository(repository)(d)
277+ WithRecoveryCoordinator(coordinator)(d)
259278 t.Cleanup(func() { release(); d.sessWg.Wait() })
279+
260280 d.restoreIncrementalSnapshots(context.Background())
261- require.Len(t, d.notices.history(), 1)
262- require.Equal(t, domain.NoticeWarn, d.notices.history()[0].Severity)
281+ d.mu.Lock()
282+ restored := d.findByNameLocked(snapshot.Name)
283+ d.mu.Unlock()
284+ require.NotNil(t, restored)
285+ require.NoError(t, d.renameTab(restored, restored.tabs[0], "after"))
286+
287+ updates := catalogue.MetadataUpdates()
288+ require.Len(t, updates, 1, "the authoritative Catalogue port must receive restored-session metadata updates")
289+ require.Equal(t, record.IncarnationID, updates[0].IncarnationID)
290+ updated, ok, err := catalogue.Record(record.Name)
291+ require.NoError(t, err)
292+ require.True(t, ok)
293+ require.Equal(t, record.IncarnationID, updated.IncarnationID)
294+ require.Equal(t, record.Committed, updated.Committed)
295+ require.Equal(t, record.DegradedReason, updated.DegradedReason)
296+ require.Equal(t, []string{"after"}, updated.TabNames)
297+
298+ startSnapshotEncodeWorker(t, d)
299+ markSnapshotDirty(restored)
300+ require.True(t, d.scheduleSnapshot(restored))
301+ awaitSnapshotIdle(t, restored)
302+ d.snapshotNoticeMu.Lock()
303+ failure := d.snapshotActiveFailureSignature
304+ d.snapshotNoticeMu.Unlock()
305+ require.Empty(t, failure)
306+ require.Len(t, repository.publishes, 1)
307+ require.Equal(t, record.Committed, repository.publishes[0].ParentCheckpoint)
308+ published, ok, err := catalogue.Record(record.Name)
309+ require.NoError(t, err)
310+ require.True(t, ok)
311+ require.Equal(t, uint64(10), published.Committed.Generation)
312+}
313+
314+func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
315+ snapshot := restoreAcceptanceSession(t, "fallback")
316+ valid := acceptanceGeneration(t, snapshot, 3)
263317
264318 for _, mutate := range []struct {
265319 name string
@@ -300,7 +354,6 @@ func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
300354 } {
301355 t.Run(mutate.name, func(t *testing.T) {
302356 generation := cloneAcceptanceGeneration(valid)
303- generation.Fallback = false
304357 mutate.fn(&generation)
305358 _, err := sessionFromGeneration(generation)
306359 require.Error(t, err)
M internal/usecase/daemon/snapshot_scheduler_test.go +50 -196
@@ -2,7 +2,6 @@ package daemon
22
33 import (
44 "context"
5- "errors"
65 "io"
76 "sync"
87 "sync/atomic"
@@ -20,7 +19,7 @@ import (
2019 func TestSnapshotCoordinatorQuarantineCancelsPublicationBeforeDelete(t *testing.T) {
2120 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
2221 repository := portsmocks.NewMockSnapshotRepository(t)
23- WithSnapshotRepository(repository, nil)(d)
22+ WithSnapshotRepository(repository)(d)
2423 startSnapshotEncodeWorker(t, d)
2524
2625 started := make(chan struct{})
@@ -49,7 +48,7 @@ func TestSnapshotCoordinatorQuarantineCancelsPublicationBeforeDelete(t *testing.
4948 func TestSnapshotCoordinatorQuarantineJoinsInFlightPublication(t *testing.T) {
5049 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
5150 repository := portsmocks.NewMockSnapshotRepository(t)
52- WithSnapshotRepository(repository, nil)(d)
51+ WithSnapshotRepository(repository)(d)
5352 startSnapshotEncodeWorker(t, d)
5453
5554 started := make(chan struct{})
@@ -82,166 +81,10 @@ func TestSnapshotCoordinatorQuarantineJoinsInFlightPublication(t *testing.T) {
8281 }
8382 }
8483
85-func TestRenameKeepsSnapshotCoordinatorQuarantinedUntilNewIdentityCommits(t *testing.T) {
86- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
87- store, state := newMockStore(t)
88- WithStore(store)(d)
89- repository := portsmocks.NewMockSnapshotRepository(t)
90- WithSnapshotRepository(repository, nil)(d)
91- startSnapshotEncodeWorker(t, d)
92-
93- deleteEntered := make(chan struct{})
94- allowDelete := make(chan struct{})
95- deleteCompleted := make(chan struct{})
96- state.deleteDone = deleteCompleted
97- published := make(chan ports.SnapshotPublication, 2)
98- repository.EXPECT().Tombstone(mock.Anything, "work").Return(nil).Once()
99- repository.EXPECT().Delete(mock.Anything, "work").RunAndReturn(func(context.Context, string) error {
100- close(deleteEntered)
101- <-allowDelete
102- return nil
103- }).Once()
104- repository.EXPECT().DeleteTombstone(mock.Anything, "work").Return(nil).Once()
105- repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
106- published <- publication
107- return nil
108- }).Once()
109-
110- pty, release := newBlockingPTY(t)
111- defer release()
112- d.ptys = newFactory(t, pty)
113- sess, err := d.createSessionLocked("work", false, "/work", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
114- require.NoError(t, err)
115- markSnapshotDirty(sess)
116-
117- renameDone := make(chan error, 1)
118- go func() { renameDone <- d.renameSession(sess, "renamed") }()
119- select {
120- case <-deleteEntered:
121- case <-time.After(testWaitTimeout):
122- t.Fatal("rename did not begin deleting the old snapshot identity")
123- }
124-
125- // Hold the daemon identity commit after Delete has returned. Scheduler work
126- // during this window must not capture or publish the old name.
127- d.mu.Lock()
128- close(allowDelete)
129- select {
130- case <-deleteCompleted:
131- case <-time.After(testWaitTimeout):
132- t.Fatal("old snapshot identity was not deleted")
133- }
134- require.False(t, state.has("work"))
135- sess.snapshotMu.Lock()
136- quarantined := sess.snapshotQuarantined
137- sess.snapshotMu.Unlock()
138- require.True(t, quarantined, "old Delete returned before the coordinator resumed")
139-
140- schedulerCtx, cancelScheduler := context.WithCancel(context.Background())
141- defer cancelScheduler()
142- go d.snapshotRepositorySaver(schedulerCtx)
143- select {
144- case d.snapshotWake <- struct{}{}:
145- default:
146- }
147- require.True(t, d.scheduleSnapshot(sess), "old work may be coalesced while the coordinator is quarantined")
148- d.mu.Unlock()
149-
150- require.NoError(t, <-renameDone)
151- select {
152- case publication := <-published:
153- require.Equal(t, "renamed", publication.Name)
154- require.Equal(t, uint64(1), publication.Generation)
155- case <-time.After(testWaitTimeout):
156- t.Fatal("new identity was not published")
157- }
158-}
159-
160-func TestRenameRollbackAfterOldDeleteLeavesOldCoordinatorStopped(t *testing.T) {
161- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
162- store, state := newMockStore(t)
163- WithStore(store)(d)
164- repository := portsmocks.NewMockSnapshotRepository(t)
165- WithSnapshotRepository(repository, nil)(d)
166- startSnapshotEncodeWorker(t, d)
167-
168- deleteOldRecordErr := errors.New("old record delete failed")
169- state.mu.Lock()
170- state.deleteErr = func(key string) error {
171- if key == "work" {
172- return deleteOldRecordErr
173- }
174- return nil
175- }
176- state.mu.Unlock()
177- repository.EXPECT().Tombstone(mock.Anything, "work").Return(nil).Once()
178- repository.EXPECT().Delete(mock.Anything, "work").Return(nil).Once()
179- // No Publish expectation is installed: any publication is an unexpected mock
180- // call. The saver handshake below joins the scheduler before this test ends.
181-
182- pty, release := newBlockingPTY(t)
183- defer release()
184- d.ptys = newFactory(t, pty)
185- sess, err := d.createSessionLocked("work", false, "/work", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
186- require.NoError(t, err)
187- markSnapshotDirty(sess)
188-
189- require.ErrorIs(t, d.renameSession(sess, "renamed"), deleteOldRecordErr)
190- sess.mu.Lock()
191- require.Equal(t, "work", sess.name)
192- require.False(t, sess.ephemeral)
193- require.False(t, sess.renameInProgress)
194- sess.mu.Unlock()
195- require.True(t, state.has("work"))
196- require.False(t, state.has("renamed"))
197- sess.snapshotMu.Lock()
198- quarantined := sess.snapshotQuarantined
199- sess.snapshotMu.Unlock()
200- require.True(t, quarantined)
201-
202- // Drain stale producer notifications, then use the timer-reset handshake to
203- // prove the saver consumed this wake and completed its scheduling pass. Stop
204- // and join it before asserting: no publisher can start after this point.
205- for {
206- select {
207- case <-d.snapshotWake:
208- default:
209- goto wakeDrained
210- }
211- }
212-
213-wakeDrained:
214- clock := newDeterministicSnapshotClock(time.Unix(100, 0))
215- d.clock = clock
216- schedulerCtx, cancelScheduler := context.WithCancel(context.Background())
217- schedulerDone := make(chan struct{})
218- go func() {
219- d.snapshotRepositorySaver(schedulerCtx)
220- close(schedulerDone)
221- }()
222- timer := clock.timer()
223- require.Equal(t, 24*time.Hour, <-timer.resets)
224- select {
225- case d.snapshotWake <- struct{}{}:
226- default:
227- t.Fatal("snapshot wake unexpectedly remained full")
228- }
229- require.Equal(t, 24*time.Hour, <-timer.resets, "scheduler did not process the rollback wake")
230- cancelScheduler()
231- select {
232- case <-schedulerDone:
233- case <-time.After(testWaitTimeout):
234- t.Fatal("snapshot scheduler did not stop")
235- }
236- // The deterministic scheduler clock has no independently firing deadline;
237- // restore the daemon's normal test clock before its session cleanup runs.
238- d.clock = stubClock{}
239-}
240-
24184 func TestForcedSnapshotSchedulesSuccessorAfterRoutineInFlight(t *testing.T) {
24285 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
24386 repository := portsmocks.NewMockSnapshotRepository(t)
244- WithSnapshotRepository(repository, nil)(d)
87+ WithSnapshotRepository(repository)(d)
24588 startSnapshotEncodeWorker(t, d)
24689
24790 started := make(chan struct{})
@@ -280,7 +123,7 @@ func TestForcedSnapshotSchedulesSuccessorAfterRoutineInFlight(t *testing.T) {
280123 func TestForcedSnapshotSchedulesSuccessorForRoutineCaptureQueuedBehindWorker(t *testing.T) {
281124 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
282125 repository := portsmocks.NewMockSnapshotRepository(t)
283- WithSnapshotRepository(repository, nil)(d)
126+ WithSnapshotRepository(repository)(d)
284127 startSnapshotEncodeWorker(t, d)
285128
286129 blockerStarted := make(chan struct{})
@@ -321,7 +164,7 @@ func TestForcedSnapshotSchedulesSuccessorForRoutineCaptureQueuedBehindWorker(t *
321164 func TestMultipleForcedSnapshotsCoalesceToOneSuccessor(t *testing.T) {
322165 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
323166 repository := portsmocks.NewMockSnapshotRepository(t)
324- WithSnapshotRepository(repository, nil)(d)
167+ WithSnapshotRepository(repository)(d)
325168 startSnapshotEncodeWorker(t, d)
326169
327170 started := make(chan struct{})
@@ -364,7 +207,7 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
364207 clock := newSnapshotDeadlineClock()
365208 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
366209 repository := portsmocks.NewMockSnapshotRepository(t)
367- WithSnapshotRepository(repository, nil)(d)
210+ WithSnapshotRepository(repository)(d)
368211 notices := portsmocks.NewMockNoticeStore(t)
369212 WithNoticeStore(notices)(d)
370213 startSnapshotEncodeWorker(t, d)
@@ -442,13 +285,12 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
442285 }
443286 }
444287
445-func TestServeShutdownDeadlineBoundsPaneExitTeardownOwner(t *testing.T) {
288+func TestServeShutdownDeadlineStillJoinsPaneExitWriter(t *testing.T) {
446289 pty, releasePTY := newBlockingPTY(t)
447290 clock := newServeShutdownClock()
448291 d := newTestDaemon(t, newFactory(t, pty), clock)
449292 repository := portsmocks.NewMockSnapshotRepository(t)
450- WithSnapshotRepository(repository, nil)(d)
451- repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
293+ WithSnapshotRepository(repository)(d)
452294
453295 publicationStarted := make(chan struct{})
454296 releasePublication := make(chan struct{})
@@ -463,7 +305,7 @@ func TestServeShutdownDeadlineBoundsPaneExitTeardownOwner(t *testing.T) {
463305
464306 tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
465307 listener := serveSnapshotListener(t, tr)
466- ctx, cancel := context.WithCancel(context.Background())
308+ ctx, cancel := context.WithCancel(t.Context())
467309 defer cancel()
468310 served := make(chan error, 1)
469311 go func() { served <- d.Serve(ctx, listener) }()
@@ -486,9 +328,9 @@ func TestServeShutdownDeadlineBoundsPaneExitTeardownOwner(t *testing.T) {
486328 // deadline.
487329 ownerDeadlineTimer := clock.nextFinalTimer(t)
488330
489- // Cleanup is the only place that releases the competing owner. It also fires
490- // that owner's independent budget and joins the real pane reader, proving no
491- // teardown goroutine is leaked without making Serve depend on repository I/O.
331+ // Cleanup fires the pane-exit owner's independent budget and joins the real
332+ // pane reader. Serve itself must remain owned until the repository writer is
333+ // released below, even after its checkpoint deadline expires.
492334 t.Cleanup(func() {
493335 releaseSnapshot()
494336 ownerDeadlineTimer.fire()
@@ -499,11 +341,17 @@ func TestServeShutdownDeadlineBoundsPaneExitTeardownOwner(t *testing.T) {
499341 shutdownDeadlineTimer := clock.nextFinalTimer(t)
500342 shutdownDeadlineTimer.fire()
501343
344+ select {
345+ case err := <-served:
346+ t.Fatalf("Serve returned with a durable writer alive: %v", err)
347+ default:
348+ }
349+ releaseSnapshot()
502350 select {
503351 case err := <-served:
504352 require.NoError(t, err)
505353 case <-time.After(testWaitTimeout):
506- t.Fatal("Serve did not return after its shared deadline while the pane-exit owner remained blocked")
354+ t.Fatal("Serve did not return after the durable writer exited")
507355 }
508356
509357 sess.snapshotMu.Lock()
@@ -534,7 +382,7 @@ func awaitTeardownWaiters(t *testing.T, sess *session, want uint) {
534382 func TestConcurrentSessionTeardownPublishesOneFinalSnapshot(t *testing.T) {
535383 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
536384 repository := portsmocks.NewMockSnapshotRepository(t)
537- WithSnapshotRepository(repository, nil)(d)
385+ WithSnapshotRepository(repository)(d)
538386 startSnapshotEncodeWorker(t, d)
539387
540388 publicationStarted := make(chan struct{})
@@ -608,8 +456,7 @@ func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
608456 defer releasePTY()
609457 d := newTestDaemon(t, newFactory(t, p), stubClock{})
610458 repository := portsmocks.NewMockSnapshotRepository(t)
611- WithSnapshotRepository(repository, nil)(d)
612- repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
459+ WithSnapshotRepository(repository)(d)
613460
614461 published := make(chan ports.SnapshotPublication, 2)
615462 allowTerminalPublication := make(chan struct{})
@@ -623,7 +470,7 @@ func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
623470
624471 tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
625472 l := serveSnapshotListener(t, tr)
626- ctx, cancel := context.WithCancel(context.Background())
473+ ctx, cancel := context.WithCancel(t.Context())
627474 defer cancel()
628475 served := make(chan error, 1)
629476 go func() { served <- d.Serve(ctx, l) }()
@@ -647,21 +494,21 @@ func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
647494 }
648495 }
649496
650-func TestServeShutdownDeadlineDoesNotWaitTwiceForUncooperativeSnapshotRepository(t *testing.T) {
497+func TestServeShutdownDeadlineStillJoinsUncooperativeSnapshotRepository(t *testing.T) {
651498 p, releasePTY := newBlockingPTY(t)
652499 defer releasePTY()
653500 clock := newServeShutdownClock()
654501 d := newTestDaemon(t, newFactory(t, p), clock)
655502 repository := portsmocks.NewMockSnapshotRepository(t)
656- WithSnapshotRepository(repository, nil)(d)
657- repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
503+ WithSnapshotRepository(repository)(d)
658504 noticeStore := portsmocks.NewMockNoticeStore(t)
659505 WithNoticeStore(noticeStore)(d)
660506 noticeStore.EXPECT().Claim().Return(nil, nil).Maybe()
661507 noticeStore.EXPECT().Ack().Return(nil).Maybe()
508+ noticePersisted := make(chan struct{})
662509 noticeStore.EXPECT().Append(mock.MatchedBy(func(n domain.Notification) bool {
663510 return n.Code == domain.NoticeSnapshotWrite && n.Severity == domain.NoticeError
664- })).Return(nil).Once()
511+ })).Run(func(domain.Notification) { close(noticePersisted) }).Return(nil).Once()
665512
666513 firstPublicationCompleted := make(chan struct{})
667514 terminalPublicationStarted := make(chan struct{})
@@ -680,7 +527,7 @@ func TestServeShutdownDeadlineDoesNotWaitTwiceForUncooperativeSnapshotRepository
680527
681528 tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
682529 l := serveSnapshotListener(t, tr)
683- ctx, cancel := context.WithCancel(context.Background())
530+ ctx, cancel := context.WithCancel(t.Context())
684531 defer cancel()
685532 served := make(chan error, 1)
686533 go func() { served <- d.Serve(ctx, l) }()
@@ -704,21 +551,29 @@ func TestServeShutdownDeadlineDoesNotWaitTwiceForUncooperativeSnapshotRepository
704551 workerDone := d.snapshotWorkerDone
705552 d.snapshotWorkerMu.Unlock()
706553 require.NotNil(t, workerDone)
707- defer func() {
708- close(releaseTerminalPublication)
709- select {
710- case <-workerDone:
711- case <-time.After(testWaitTimeout):
712- t.Error("snapshot worker leaked after its repository call was released")
713- }
714- }()
715554 clock.nextFinalTimer(t).fire()
555+ select {
556+ case <-noticePersisted:
557+ case <-time.After(testWaitTimeout):
558+ t.Fatal("shutdown timeout notice was not persisted before the writer join")
559+ }
716560
561+ select {
562+ case err := <-served:
563+ t.Fatalf("Serve returned with an uncooperative durable writer alive: %v", err)
564+ default:
565+ }
566+ close(releaseTerminalPublication)
567+ select {
568+ case <-workerDone:
569+ case <-time.After(testWaitTimeout):
570+ t.Fatal("snapshot worker leaked after its repository call was released")
571+ }
717572 select {
718573 case err := <-served:
719574 require.NoError(t, err)
720575 case <-time.After(testWaitTimeout):
721- t.Fatal("Serve waited beyond the terminal checkpoint deadline")
576+ t.Fatal("Serve did not return after the uncooperative writer exited")
722577 }
723578 require.LessOrEqual(t, clock.finalBudget.Load(), int64(snapshotFinalFlushTimeout), "shutdown must consume at most one snapshot deadline")
724579 require.Equal(t, uint64(1), lastValid.Load(), "the last valid checkpoint remains available after the terminal attempt times out")
@@ -783,7 +638,7 @@ func TestRoutineSnapshotEligibilityStartsAtCompletionAndForcedDoesNotMoveIt(t *t
783638 clock := &snapshotSchedulerClock{now: time.Unix(100, 0)}
784639 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
785640 repository := portsmocks.NewMockSnapshotRepository(t)
786- WithSnapshotRepository(repository, nil)(d)
641+ WithSnapshotRepository(repository)(d)
787642 startSnapshotEncodeWorker(t, d)
788643
789644 var publishes atomic.Int32
@@ -931,7 +786,7 @@ func TestSnapshotCompletionSchedulesRoutineRetriesFromCompletion(t *testing.T) {
931786 clock := newDeterministicSnapshotClock(now)
932787 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
933788 repository := portsmocks.NewMockSnapshotRepository(t)
934- WithSnapshotRepository(repository, nil)(d)
789+ WithSnapshotRepository(repository)(d)
935790
936791 for _, tt := range []struct {
937792 name string
@@ -964,12 +819,11 @@ func TestSnapshotSchedulerImmediateEligibilityAndStaleCapturesRemainDirty(t *tes
964819 now := time.Unix(100, 0)
965820 clock := newDeterministicSnapshotClock(now)
966821 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
967- WithSnapshotRepository(portsmocks.NewMockSnapshotRepository(t), nil)(d)
968822
969823 t.Run("no prior attempt is immediately eligible", func(t *testing.T) {
970824 published := make(chan ports.SnapshotPublication, 1)
971825 repository := portsmocks.NewMockSnapshotRepository(t)
972- WithSnapshotRepository(repository, nil)(d)
826+ WithSnapshotRepository(repository)(d)
973827 startSnapshotEncodeWorker(t, d)
974828 repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
975829 published <- publication
@@ -1024,7 +878,7 @@ func TestSnapshotWorkerQueueIsBoundedAndForcedCapturesCoalesce(t *testing.T) {
1024878 clock := newDeterministicSnapshotClock(now)
1025879 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
1026880 repository := portsmocks.NewMockSnapshotRepository(t)
1027- WithSnapshotRepository(repository, nil)(d)
881+ WithSnapshotRepository(repository)(d)
1028882 startSnapshotEncodeWorker(t, d)
1029883
1030884 started := make(chan struct{})
@@ -1082,7 +936,7 @@ func TestSnapshotRepositorySaverUsesEarliestDeadlineAndRecomputes(t *testing.T)
1082936 clock := newDeterministicSnapshotClock(now)
1083937 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
1084938 repository := portsmocks.NewMockSnapshotRepository(t)
1085- WithSnapshotRepository(repository, nil)(d)
939+ WithSnapshotRepository(repository)(d)
1086940 startSnapshotEncodeWorker(t, d)
1087941
1088942 published := make(chan ports.SnapshotPublication, 1)
@@ -1097,7 +951,7 @@ func TestSnapshotRepositorySaverUsesEarliestDeadlineAndRecomputes(t *testing.T)
1097951 later.snapshotNextEligibleAt = now.Add(10 * time.Minute)
1098952 d.sessions[later.id] = later
1099953
1100- ctx, cancel := context.WithCancel(context.Background())
954+ ctx, cancel := context.WithCancel(t.Context())
1101955 done := make(chan struct{})
1102956 go func() {
1103957 d.snapshotRepositorySaver(ctx)
M internal/usecase/daemon/snapshot_test.go +68 -1
@@ -12,15 +12,59 @@ import (
1212 "github.com/bnema/vev/internal/domain"
1313 "github.com/bnema/vev/internal/ports"
1414 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
1516 "github.com/bnema/vev/pkg/renderer"
1617 )
1718
19+// TestWithSnapshotRepositoryRejectsTypedNil verifies typed nil repositories are disabled.
20+func TestWithSnapshotRepositoryRejectsTypedNil(t *testing.T) {
21+ var repository *snapshotAcceptanceRepository
22+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
23+
24+ WithSnapshotRepository(repository)(d)
25+
26+ require.Nil(t, d.snapshotRepository)
27+ require.False(t, d.snapsEnabled)
28+}
29+
30+func TestDurableWriterFailureNamesIncludesBufferedCapture(t *testing.T) {
31+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
32+ capture := &snapshotCapture{name: "work", session: newSnapshotTestSession(t, "work", false, "/work")}
33+ d.snapshotWorkerMu.Lock()
34+ d.snapshotAdmitted[capture] = struct{}{}
35+ d.snapshotJobs <- capture
36+ d.snapshotWorkerMu.Unlock()
37+
38+ require.Equal(t, []string{"work"}, d.durableWriterFailureNames())
39+}
40+
41+func TestCheckpointCatalogueFailureKeepsCaptureRetryable(t *testing.T) {
42+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
43+ catalogue := portsmocks.NewMockCatalogue(t)
44+ repository := portsmocks.NewMockSnapshotRepository(t)
45+ record := domain.CatalogueRecord{
46+ Name: "work",
47+ IncarnationID: domain.IncarnationID{1},
48+ }
49+ catalogue.EXPECT().Record("work").Return(record, true, nil).Once()
50+ repository.EXPECT().Publish(mock.Anything, mock.Anything).Return(nil).Once()
51+ catalogue.EXPECT().Replace("work", mock.Anything).Return(errors.New("catalogue unavailable")).Once()
52+ WithSnapshotRepository(repository)(d)
53+ WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
54+ startSnapshotEncodeWorker(t, d)
55+
56+ sess := newSnapshotTestSession(t, "work", false, "/work")
57+ require.True(t, d.captureSession(sess))
58+ awaitSnapshotIdle(t, sess)
59+ require.True(t, sess.snapDirty.Load(), "failed catalogue commit must remain retryable")
60+}
61+
1862 // TestSnapshotWorkerPublishesContentAddressedCapture verifies that all new
1963 // checkpoints use the repository publication contract.
2064 func TestSnapshotWorkerPublishesContentAddressedCapture(t *testing.T) {
2165 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
2266 repository := portsmocks.NewMockSnapshotRepository(t)
23- WithSnapshotRepository(repository, nil)(d)
67+ WithSnapshotRepository(repository)(d)
2468 startSnapshotEncodeWorker(t, d)
2569
2670 repository.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(p ports.SnapshotPublication) bool {
@@ -46,10 +90,33 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
4690 p.screen.Write([]byte("hello"))
4791 appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
4892 sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}
93+ if !ephemeral {
94+ sess.incarnation = domain.IncarnationID{1}
95+ }
4996 sess.snapEligible.Store(!ephemeral && name != "")
5097 return sess
5198 }
5299
100+// noOpSnapshotRepository supplies the current durable repository contract to
101+// focused test sinks that only need to observe publication or deletion calls.
102+type noOpSnapshotRepository struct{}
103+
104+var _ ports.SnapshotRepository = noOpSnapshotRepository{}
105+
106+func (noOpSnapshotRepository) Publish(context.Context, ports.SnapshotPublication) error { return nil }
107+func (noOpSnapshotRepository) LoadCheckpoint(context.Context, domain.IncarnationID, string, ports.CheckpointRef) (ports.SnapshotGeneration, error) {
108+ return ports.SnapshotGeneration{}, errors.New("unused")
109+}
110+func (noOpSnapshotRepository) ReconcileCheckpoint(context.Context, domain.IncarnationID, ports.CheckpointRef) error {
111+ return nil
112+}
113+func (noOpSnapshotRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
114+ return nil
115+}
116+func (noOpSnapshotRepository) CollectGarbage(context.Context, map[domain.IncarnationID]domain.CheckpointRef) error {
117+ return nil
118+}
119+
53120 func awaitSnapshotIdle(t testing.TB, sess *session) {
54121 t.Helper()
55122 timer := time.NewTimer(testWaitTimeout)
M internal/usecase/daemon/snapshot_worker.go +106 -29
@@ -2,6 +2,8 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
6+ "sort"
57
68 "github.com/bnema/vev/internal/domain"
79 )
@@ -130,7 +132,19 @@ func (d *Daemon) publishSnapshotCapture(workerCtx context.Context, workerID uint
130132 }
131133 if err == nil && workerCtx.Err() == nil {
132134 if err = publicationContext.Err(); err == nil {
133- err = d.snapshotRepository.Publish(publicationContext, publication)
135+ if d.recovery != nil {
136+ var record domain.CatalogueRecord
137+ record, err = d.recovery.PublishCheckpoint(publicationContext, capture.name, publication)
138+ if err == nil {
139+ if record.Committed == nil {
140+ err = errors.New("snapshot: checkpoint publication returned no committed checkpoint")
141+ } else {
142+ capture.checkpoint = *record.Committed
143+ }
144+ }
145+ } else {
146+ err = d.snapshotRepository.Publish(publicationContext, publication)
147+ }
134148 }
135149 }
136150 d.clearSnapshotWorkerInFlight(workerID, capture)
@@ -185,45 +199,108 @@ func (d *Daemon) stopSnapshotEncodeWorker() {
185199 if d == nil {
186200 return
187201 }
188- deadline := newSnapshotShutdownDeadline(d.clock)
189- defer deadline.stop()
190- d.stopSnapshotEncodeWorkerWithDeadline(deadline)
202+ _ = d.StopDurableWriters(context.Background())
203+ d.WaitDurableWriters()
191204 }
192205
193-// stopSnapshotEncodeWorkerWithDeadline performs the one deadline-aware join
194-// for a Serve shutdown. Queues are never closed, and detached writers only
195-// retain immutable captures plus contexts/channels that remain live, so an
196-// uncooperative repository call cannot race teardown or block process exit.
197-func (d *Daemon) stopSnapshotEncodeWorkerWithDeadline(deadline *snapshotShutdownDeadline) {
206+// StopDurableWriters stops maintenance scheduling, cancels cooperative
207+// maintenance calls, stops snapshot admission, and requests the snapshot
208+// worker's final drain. The context bounds checkpoint success only:
209+// cancellation never detaches a writer from its owner.
210+func (d *Daemon) StopDurableWriters(ctx context.Context) []string {
211+ if d == nil {
212+ return nil
213+ }
214+ cancel, done := d.requestDurableWriterStop()
215+ if done == nil {
216+ return nil
217+ }
218+ if ctx == nil {
219+ ctx = context.Background()
220+ }
221+ select {
222+ case <-done:
223+ return nil
224+ case <-ctx.Done():
225+ // Snapshot identity before cancellation can let a cooperative worker clear
226+ // its in-flight capture. The caller persists timeout notices before the
227+ // unconditional ownership join.
228+ names := d.durableWriterFailureNames()
229+ cancel()
230+ return names
231+ }
232+}
233+
234+// WaitDurableWriters is the unconditional ownership barrier. It has no timeout
235+// or status branch: every writer goroutine must exit before Serve may return.
236+// Restoration is joined with the others because it mutates durable state too;
237+// it observes serveCtx cancellation, so waiting for it cannot outlive an
238+// uncooperative repository call any longer than the snapshot worker does.
239+func (d *Daemon) WaitDurableWriters() {
198240 if d == nil {
199241 return
200242 }
201243 d.snapshotWorkerMu.Lock()
244+ snapshotDone := d.snapshotWorkerDone
245+ maintenanceDone := d.maintenanceWorkerDone
246+ restoreDone := d.restoreWorkerDone
247+ d.snapshotWorkerMu.Unlock()
248+ if snapshotDone != nil {
249+ <-snapshotDone
250+ d.finishStoppedSnapshotWorker(false)
251+ }
252+ if maintenanceDone != nil {
253+ <-maintenanceDone
254+ }
255+ // Restoration never blocks on the workers above (snapshot admission is
256+ // non-blocking), so joining it last cannot deadlock.
257+ if restoreDone != nil {
258+ <-restoreDone
259+ }
260+}
261+
262+func (d *Daemon) requestDurableWriterStop() (context.CancelFunc, <-chan struct{}) {
263+ d.snapshotWorkerMu.Lock()
264+ defer d.snapshotWorkerMu.Unlock()
265+ if d.maintenanceWorkerCancel != nil {
266+ d.maintenanceWorkerCancel()
267+ }
202268 cancel := d.snapshotWorkerCancel
203- if cancel == nil || d.snapshotWorkerClosing {
204- d.snapshotWorkerMu.Unlock()
205- return
269+ if cancel == nil {
270+ return nil, nil
206271 }
207- // Stop accepting producers, then let the single worker drain its bounded
208- // queue. A non-context-aware store may still block indefinitely, so the
209- // shared shutdown deadline bounds the one and only join.
210- d.snapshotWorkerClosing = true
211- flush := d.snapshotWorkerFlush
212- done := d.snapshotWorkerDone
213- close(flush)
214- d.snapshotWorkerMu.Unlock()
272+ if !d.snapshotWorkerClosing {
273+ d.snapshotWorkerClosing = true
274+ close(d.snapshotWorkerFlush)
275+ }
276+ return cancel, d.snapshotWorkerDone
277+}
215278
216- if deadline == nil {
217- deadline = newSnapshotShutdownDeadline(d.clock)
218- defer deadline.stop()
279+// durableWriterFailureNames includes admitted buffered captures as well as the
280+// active and final queues. snapshotAdmitted tracks normal captures from queue
281+// admission through completion, so worker dequeue cannot make one disappear.
282+func (d *Daemon) durableWriterFailureNames() []string {
283+ d.snapshotWorkerMu.Lock()
284+ defer d.snapshotWorkerMu.Unlock()
285+ seen := make(map[string]struct{})
286+ add := func(capture *snapshotCapture) {
287+ if capture != nil && capture.name != "" {
288+ seen[capture.name] = struct{}{}
289+ }
219290 }
220- select {
221- case <-done:
222- d.finishStoppedSnapshotWorker(false)
223- case <-deadline.Done():
224- cancel()
225- d.finishStoppedSnapshotWorker(true)
291+ add(d.snapshotWorkerInFlight)
292+ for _, capture := range d.snapshotFinalJobs {
293+ add(capture)
294+ }
295+ for capture := range d.snapshotAdmitted {
296+ add(capture)
297+ }
298+ names := make([]string, 0, len(seen))
299+ for name := range seen {
300+ names = append(names, name)
226301 }
302+ sort.Strings(names)
303+ return names
227304 }
228305
229306 func (d *Daemon) finishStoppedSnapshotWorker(abandoned bool) {
M internal/usecase/daemon/status_test.go +2 -2
@@ -1066,9 +1066,9 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
10661066 cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}
10671067 d.sessions[cur.id] = cur
10681068
1069- _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv)
1069+ _, err := createSessionForTest(d, "zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv)
10701070 require.NoError(t, err)
1071- _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz, terminalEnv{}, d.baseEnv)
1071+ _, err = createSessionForTest(d, "alpha", false, "/tmp/alpha", sz, terminalEnv{}, d.baseEnv)
10721072 require.NoError(t, err)
10731073
10741074 state := d.barStateFor(cur, "")
A internal/usecase/recovery/broken.go +47 -0
@@ -0,0 +1,47 @@
1+package recovery
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+// MarkBroken records a restoration failure only when name still identifies the
12+// incarnation that failed. It is fenced with Discard so a late restore cannot
13+// degrade the replacement incarnation.
14+func (c *Coordinator) MarkBroken(ctx context.Context, name string, expected domain.IncarnationID, reason string) (domain.CatalogueRecord, bool, error) {
15+ if c == nil || c.catalogue == nil || c.locks == nil {
16+ return domain.CatalogueRecord{}, false, errors.New("recovery: incomplete broken-session dependencies")
17+ }
18+ if err := ctx.Err(); err != nil {
19+ return domain.CatalogueRecord{}, false, err
20+ }
21+ if reason == "" {
22+ return domain.CatalogueRecord{}, false, errors.New("recovery: empty broken-session reason")
23+ }
24+ c.mutationMu.Lock()
25+ defer c.mutationMu.Unlock()
26+ unlock := c.locks.Lock([]string{name})
27+ defer unlock()
28+
29+ record, ok, err := c.catalogue.Record(name)
30+ if err != nil {
31+ return domain.CatalogueRecord{}, false, err
32+ }
33+ if !ok || record.IncarnationID != expected {
34+ return domain.CatalogueRecord{}, false, nil
35+ }
36+ if record.DegradedReason == reason {
37+ return record, true, nil
38+ }
39+ record.DegradedReason = reason
40+ if err := record.Validate(); err != nil {
41+ return domain.CatalogueRecord{}, false, fmt.Errorf("recovery: invalid broken-session transition: %w", err)
42+ }
43+ if err := c.catalogue.Replace(name, record); err != nil {
44+ return domain.CatalogueRecord{}, false, err
45+ }
46+ return record, true, nil
47+}
A internal/usecase/recovery/checkpoint.go +74 -0
@@ -0,0 +1,74 @@
1+package recovery
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "errors"
7+ "fmt"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+var (
14+ ErrCheckpointRecordNotFound = errors.New("recovery: checkpoint catalogue record not found")
15+ ErrCheckpointConflict = errors.New("recovery: checkpoint publication conflicts with catalogue")
16+)
17+
18+// PublishCheckpoint publishes repository data before atomically making its
19+// reference authoritative in the catalogue. A catalogue failure deliberately
20+// leaves the durable publication as a same-incarnation forward orphan.
21+func (c *Coordinator) PublishCheckpoint(ctx context.Context, name string, publication ports.SnapshotPublication) (domain.CatalogueRecord, error) {
22+ if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil {
23+ return domain.CatalogueRecord{}, errors.New("recovery: incomplete checkpoint coordinator dependencies")
24+ }
25+ if err := ctx.Err(); err != nil {
26+ return domain.CatalogueRecord{}, err
27+ }
28+ c.mutationMu.Lock()
29+ defer c.mutationMu.Unlock()
30+ unlock := c.locks.Lock([]string{name})
31+ defer unlock()
32+
33+ record, ok, err := c.catalogue.Record(name)
34+ if err != nil {
35+ return domain.CatalogueRecord{}, err
36+ }
37+ if !ok {
38+ return domain.CatalogueRecord{}, ErrCheckpointRecordNotFound
39+ }
40+ currentGeneration := uint64(0)
41+ if record.Committed != nil {
42+ currentGeneration = record.Committed.Generation
43+ }
44+ if publication.Name != name || publication.Name != record.Name || publication.IncarnationID != record.IncarnationID ||
45+ publication.Generation <= currentGeneration || len(publication.Manifest) == 0 ||
46+ !equalCheckpointRefs(publication.ParentCheckpoint, record.Committed) {
47+ return domain.CatalogueRecord{}, ErrCheckpointConflict
48+ }
49+ if err := c.repository.Publish(ctx, publication); err != nil {
50+ return domain.CatalogueRecord{}, err
51+ }
52+
53+ ref := domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)}
54+ next := record
55+ next.Committed = copyCheckpointRef(&ref)
56+ next.DegradedReason = ""
57+ if err := next.Validate(); err != nil {
58+ return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid checkpoint transition: %w", err)
59+ }
60+ if err := c.catalogue.Replace(name, next); err != nil {
61+ return domain.CatalogueRecord{}, err
62+ }
63+ return next, nil
64+}
65+
66+func equalCheckpointRefs(a, b *domain.CheckpointRef) bool { return a.Equal(b) }
67+
68+func copyCheckpointRef(ref *domain.CheckpointRef) *domain.CheckpointRef {
69+ if ref == nil {
70+ return nil
71+ }
72+ cloned := *ref
73+ return &cloned
74+}
A internal/usecase/recovery/checkpoint_test.go +183 -0
@@ -0,0 +1,183 @@
1+package recovery
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type checkpointCatalogue struct {
16+ mu sync.Mutex
17+ record domain.CatalogueRecord
18+ replaceErr error
19+ replaces int
20+ events *[]string
21+}
22+
23+func (c *checkpointCatalogue) Records() ([]domain.CatalogueRecord, error) {
24+ c.mu.Lock()
25+ defer c.mu.Unlock()
26+ return []domain.CatalogueRecord{c.record}, nil
27+}
28+func (c *checkpointCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
29+ c.mu.Lock()
30+ defer c.mu.Unlock()
31+ return c.record, c.record.Name == name, nil
32+}
33+func (c *checkpointCatalogue) Create(domain.CatalogueRecord) error { return nil }
34+func (c *checkpointCatalogue) UpdateMetadata(domain.CatalogueMetadataUpdate) error {
35+ return nil
36+}
37+func (c *checkpointCatalogue) Sync() error { return nil }
38+func (c *checkpointCatalogue) Replace(name string, record domain.CatalogueRecord) error {
39+ c.mu.Lock()
40+ defer c.mu.Unlock()
41+ c.replaces++
42+ if c.events != nil {
43+ *c.events = append(*c.events, "catalogue")
44+ }
45+ if c.replaceErr != nil {
46+ return c.replaceErr
47+ }
48+ if name != c.record.Name || record.Name != name {
49+ return errors.New("catalogue key mismatch")
50+ }
51+ c.record = record
52+ return nil
53+}
54+func (c *checkpointCatalogue) Rename(string, domain.CatalogueRecord) error { return nil }
55+func (c *checkpointCatalogue) Delete(string) error { return nil }
56+func (c *checkpointCatalogue) Close() error { return nil }
57+
58+type checkpointRepository struct {
59+ ports.SnapshotRepository
60+ publications []ports.SnapshotPublication
61+ generations map[uint64]ports.SnapshotGeneration
62+ loadErrors map[uint64]error
63+ loads []uint64
64+ repairs []domain.CheckpointRef
65+ repairErr error
66+ events *[]string
67+}
68+
69+func (r *checkpointRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error {
70+ if r.events != nil {
71+ *r.events = append(*r.events, "repository")
72+ }
73+ r.publications = append(r.publications, publication)
74+ return nil
75+}
76+
77+func (r *checkpointRepository) LoadCheckpoint(_ context.Context, _ domain.IncarnationID, _ string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) {
78+ r.loads = append(r.loads, ref.Generation)
79+ if err := r.loadErrors[ref.Generation]; err != nil {
80+ return ports.SnapshotGeneration{}, err
81+ }
82+ generation, ok := r.generations[ref.Generation]
83+ if !ok {
84+ return ports.SnapshotGeneration{}, errors.New("checkpoint unavailable")
85+ }
86+ return generation, nil
87+}
88+
89+func (r *checkpointRepository) ReconcileCheckpoint(_ context.Context, _ domain.IncarnationID, ref ports.CheckpointRef) error {
90+ r.repairs = append(r.repairs, ref)
91+ return r.repairErr
92+}
93+
94+func checkpointRecord() domain.CatalogueRecord {
95+ return domain.CatalogueRecord{
96+ Name: "work",
97+ IncarnationID: domain.IncarnationID{1},
98+ }
99+}
100+
101+func checkpointPublication(record domain.CatalogueRecord, generation uint64) ports.SnapshotPublication {
102+ return ports.SnapshotPublication{
103+ IncarnationID: record.IncarnationID,
104+ Name: record.Name,
105+ Generation: generation,
106+ ParentCheckpoint: record.Committed,
107+ Manifest: []byte{byte(generation), 0xa5},
108+ }
109+}
110+
111+func runCheckpointCommit(t *testing.T, count int) (domain.CatalogueRecord, *checkpointCatalogue, *checkpointRepository) {
112+ t.Helper()
113+ events := make([]string, 0, count*2)
114+ catalogue := &checkpointCatalogue{record: checkpointRecord(), events: &events}
115+ repository := &checkpointRepository{events: &events}
116+ coordinator := NewCoordinator(catalogue, repository, nil)
117+ for generation := uint64(1); generation <= uint64(count); generation++ {
118+ var err error
119+ catalogue.record, err = coordinator.PublishCheckpoint(context.Background(), "work", checkpointPublication(catalogue.record, generation))
120+ require.NoError(t, err)
121+ }
122+ for i := range count {
123+ require.Equal(t, []string{"repository", "catalogue"}, events[i*2:i*2+2])
124+ }
125+ return catalogue.record, catalogue, repository
126+}
127+
128+func TestCheckpointCommits(t *testing.T) {
129+ for _, tc := range []struct {
130+ name string
131+ count int
132+ wantCommitted uint64
133+ }{
134+ {name: "first", count: 1, wantCommitted: 1},
135+ {name: "second", count: 2, wantCommitted: 2},
136+ {name: "third", count: 3, wantCommitted: 3},
137+ {name: "fourth", count: 4, wantCommitted: 4},
138+ } {
139+ t.Run(tc.name, func(t *testing.T) {
140+ record, catalogue, _ := runCheckpointCommit(t, tc.count)
141+ require.Equal(t, tc.wantCommitted, record.Committed.Generation)
142+ require.Empty(t, record.DegradedReason)
143+ require.Equal(t, tc.count, catalogue.replaces)
144+ })
145+ }
146+}
147+
148+func TestCheckpointRejectsNonIncreasingGeneration(t *testing.T) {
149+ prior := checkpointRecord()
150+ priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}
151+ prior.Committed = &priorRef
152+
153+ for _, generation := range []uint64{7, 6} {
154+ t.Run(fmt.Sprintf("generation-%d", generation), func(t *testing.T) {
155+ catalogue := &checkpointCatalogue{record: prior}
156+ repository := &checkpointRepository{}
157+ coordinator := NewCoordinator(catalogue, repository, nil)
158+
159+ _, err := coordinator.PublishCheckpoint(context.Background(), prior.Name, checkpointPublication(prior, generation))
160+
161+ require.ErrorIs(t, err, ErrCheckpointConflict)
162+ require.Empty(t, repository.publications)
163+ require.Zero(t, catalogue.replaces)
164+ })
165+ }
166+}
167+
168+func TestCheckpointCommitPublishOrphan(t *testing.T) {
169+ prior := checkpointRecord()
170+ priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}
171+ prior.Committed = &priorRef
172+ cause := errors.New("catalogue sync failed")
173+ catalogue := &checkpointCatalogue{record: prior, replaceErr: cause}
174+ repository := &checkpointRepository{}
175+ coordinator := NewCoordinator(catalogue, repository, nil)
176+ publication := checkpointPublication(prior, 8)
177+
178+ _, err := coordinator.PublishCheckpoint(context.Background(), prior.Name, publication)
179+ require.ErrorIs(t, err, cause)
180+ require.Equal(t, prior, catalogue.record, "failed catalogue commit must preserve the authoritative record")
181+ require.Len(t, repository.publications, 1, "the durable publication remains a forward orphan")
182+ require.Equal(t, publication, repository.publications[0])
183+}
A internal/usecase/recovery/coordinator.go +227 -0
@@ -0,0 +1,227 @@
1+// Package recovery coordinates idempotent operations across the catalogue and
2+// snapshot repository without depending on filesystem adapters.
3+package recovery
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "sort"
11+ "sync"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+)
16+
17+type KeyLocks struct {
18+ mu sync.Mutex
19+ refs map[string]*keyLockRef
20+}
21+
22+type keyLockRef struct {
23+ mu sync.Mutex
24+ refs int
25+}
26+
27+func NewKeyLocks() *KeyLocks {
28+ return &KeyLocks{refs: make(map[string]*keyLockRef)}
29+}
30+
31+// Lock acquires each distinct stable key in lexical order. The returned
32+// function releases in reverse order and removes unused lock entries.
33+func (k *KeyLocks) Lock(names []string) func() {
34+ if k == nil {
35+ return func() {}
36+ }
37+ ordered := append([]string(nil), names...)
38+ sort.Strings(ordered)
39+ ordered = compactStrings(ordered)
40+
41+ refs := make([]*keyLockRef, len(ordered))
42+ k.mu.Lock()
43+ for i, name := range ordered {
44+ ref := k.refs[name]
45+ if ref == nil {
46+ ref = &keyLockRef{}
47+ k.refs[name] = ref
48+ }
49+ ref.refs++
50+ refs[i] = ref
51+ }
52+ k.mu.Unlock()
53+ for _, ref := range refs {
54+ ref.mu.Lock()
55+ }
56+ return func() {
57+ for i := len(refs) - 1; i >= 0; i-- {
58+ refs[i].mu.Unlock()
59+ }
60+ k.mu.Lock()
61+ for i, name := range ordered {
62+ refs[i].refs--
63+ if refs[i].refs == 0 {
64+ delete(k.refs, name)
65+ }
66+ }
67+ k.mu.Unlock()
68+ }
69+}
70+
71+func compactStrings(values []string) []string {
72+ if len(values) == 0 {
73+ return values
74+ }
75+ out := values[:1]
76+ for _, value := range values[1:] {
77+ if value != out[len(out)-1] {
78+ out = append(out, value)
79+ }
80+ }
81+ return out
82+}
83+
84+type Coordinator struct {
85+ catalogue ports.Catalogue
86+ repository ports.SnapshotRepository
87+ locks *KeyLocks
88+ // mutationMu serializes the startup GC snapshot and collection with every
89+ // catalogue or checkpoint mutation. Startup normally provides this fence by
90+ // running before socket publication; retaining it here also makes the
91+ // coordinator safe when used directly by a composition root.
92+ mutationMu sync.Mutex
93+ random io.Reader
94+}
95+
96+func NewCoordinator(catalogue ports.Catalogue, repository ports.SnapshotRepository, random io.Reader) *Coordinator {
97+ return &Coordinator{catalogue: catalogue, repository: repository, locks: NewKeyLocks(), random: random}
98+}
99+
100+// Create commits fresh catalogue metadata before the caller creates or exposes
101+// any runtime session.
102+func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord) (domain.CatalogueRecord, error) {
103+ if c == nil || c.catalogue == nil || c.locks == nil || c.random == nil {
104+ return domain.CatalogueRecord{}, errors.New("recovery: incomplete create dependencies")
105+ }
106+ if err := ctx.Err(); err != nil {
107+ return domain.CatalogueRecord{}, err
108+ }
109+ c.mutationMu.Lock()
110+ defer c.mutationMu.Unlock()
111+ unlock := c.locks.Lock([]string{record.Name})
112+ defer unlock()
113+ if _, exists, err := c.catalogue.Record(record.Name); err != nil {
114+ return domain.CatalogueRecord{}, err
115+ } else if exists {
116+ return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", record.Name)
117+ }
118+ id, err := domain.NewIncarnationID(c.random)
119+ if err != nil {
120+ return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err)
121+ }
122+ record.IncarnationID = id
123+ record.Committed = nil
124+ record.DegradedReason = ""
125+ if err := record.Validate(); err != nil {
126+ return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid fresh record: %w", err)
127+ }
128+ if err := c.catalogue.Create(record); err != nil {
129+ return domain.CatalogueRecord{}, err
130+ }
131+ return record, nil
132+}
133+
134+// Rename atomically moves one catalogue key while retaining the immutable
135+// incarnation and all checkpoint references.
136+func (c *Coordinator) Rename(ctx context.Context, oldName, newName string) (domain.CatalogueRecord, error) {
137+ if c == nil || c.catalogue == nil || c.locks == nil {
138+ return domain.CatalogueRecord{}, errors.New("recovery: incomplete rename dependencies")
139+ }
140+ if err := ctx.Err(); err != nil {
141+ return domain.CatalogueRecord{}, err
142+ }
143+ c.mutationMu.Lock()
144+ defer c.mutationMu.Unlock()
145+ unlock := c.locks.Lock([]string{oldName, newName})
146+ defer unlock()
147+ record, ok, err := c.catalogue.Record(oldName)
148+ if err != nil {
149+ return domain.CatalogueRecord{}, err
150+ }
151+ if !ok {
152+ return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q not found", oldName)
153+ }
154+ if existing, exists, err := c.catalogue.Record(newName); err != nil {
155+ return domain.CatalogueRecord{}, err
156+ } else if exists && (newName != oldName || existing.IncarnationID != record.IncarnationID) {
157+ return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", newName)
158+ }
159+ record.Name = newName
160+ if err := record.Validate(); err != nil {
161+ return domain.CatalogueRecord{}, fmt.Errorf("recovery: invalid rename: %w", err)
162+ }
163+ if oldName != newName {
164+ if err := c.catalogue.Rename(oldName, record); err != nil {
165+ return domain.CatalogueRecord{}, err
166+ }
167+ }
168+ return record, nil
169+}
170+
171+// Delete removes the catalogue record before deleting its incarnation. A crash
172+// between those steps leaves an orphan that startup garbage collection removes.
173+func (c *Coordinator) Delete(ctx context.Context, name string) error {
174+ if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil {
175+ return errors.New("recovery: incomplete delete dependencies")
176+ }
177+ if err := ctx.Err(); err != nil {
178+ return err
179+ }
180+ c.mutationMu.Lock()
181+ defer c.mutationMu.Unlock()
182+ unlock := c.locks.Lock([]string{name})
183+ defer unlock()
184+ record, ok, err := c.catalogue.Record(name)
185+ if err != nil {
186+ return err
187+ }
188+ if !ok {
189+ return nil
190+ }
191+ if err := c.catalogue.Delete(record.Name); err != nil {
192+ return err
193+ }
194+ return c.repository.DeleteIncarnation(ctx, record.IncarnationID)
195+}
196+
197+// CollectGarbage takes a catalogue snapshot and applies retention while all
198+// catalogue mutations and checkpoint publications are fenced. Its keep map is
199+// therefore never stale relative to an operation that creates an incarnation
200+// or commits a new generation.
201+func (c *Coordinator) CollectGarbage(ctx context.Context) (int, error) {
202+ if c == nil || c.catalogue == nil || c.repository == nil {
203+ return 0, errors.New("recovery: incomplete garbage collection dependencies")
204+ }
205+ if err := ctx.Err(); err != nil {
206+ return 0, err
207+ }
208+ c.mutationMu.Lock()
209+ defer c.mutationMu.Unlock()
210+
211+ records, err := c.catalogue.Records()
212+ if err != nil {
213+ return 0, err
214+ }
215+ keep := make(map[domain.IncarnationID]domain.CheckpointRef, len(records))
216+ for _, record := range records {
217+ if record.Committed != nil {
218+ keep[record.IncarnationID] = *record.Committed
219+ continue
220+ }
221+ keep[record.IncarnationID] = domain.CheckpointRef{}
222+ }
223+ if err := c.repository.CollectGarbage(ctx, keep); err != nil {
224+ return len(keep), err
225+ }
226+ return len(keep), nil
227+}
A internal/usecase/recovery/discard.go +62 -0
@@ -0,0 +1,62 @@
1+package recovery
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+var (
12+ ErrRecoveryRecordNotFound = errors.New("recovery: session not found")
13+ ErrSessionNotBroken = errors.New("recovery: session is not broken")
14+)
15+
16+// Discard throws away a broken session's persisted state and replaces it with a
17+// fresh incarnation. It is retry-idempotent, not crash-resumable: a crash
18+// leaves either the old record (rerun discard) or a new record plus an orphan
19+// directory that startup garbage collection removes.
20+func (c *Coordinator) Discard(ctx context.Context, name string) error {
21+ if c == nil || c.catalogue == nil || c.repository == nil || c.locks == nil || c.random == nil {
22+ return errors.New("recovery: incomplete discard dependencies")
23+ }
24+ if err := ctx.Err(); err != nil {
25+ return err
26+ }
27+ c.mutationMu.Lock()
28+ defer c.mutationMu.Unlock()
29+ unlock := c.locks.Lock([]string{name})
30+ defer unlock()
31+
32+ record, ok, err := c.catalogue.Record(name)
33+ if err != nil {
34+ return err
35+ }
36+ if !ok {
37+ return fmt.Errorf("discard %q: %w", name, ErrRecoveryRecordNotFound)
38+ }
39+ if record.Committed == nil {
40+ return nil
41+ }
42+ if record.DegradedReason == "" {
43+ return ErrSessionNotBroken
44+ }
45+
46+ old := record.IncarnationID
47+ next, err := domain.NewIncarnationID(c.random)
48+ if err != nil {
49+ return fmt.Errorf("recovery: generate replacement incarnation: %w", err)
50+ }
51+ fresh := record
52+ fresh.IncarnationID = next
53+ fresh.Committed = nil
54+ fresh.DegradedReason = ""
55+ if err := fresh.Validate(); err != nil {
56+ return fmt.Errorf("recovery: invalid discard replacement: %w", err)
57+ }
58+ if err := c.catalogue.Replace(name, fresh); err != nil {
59+ return err
60+ }
61+ return c.repository.DeleteIncarnation(ctx, old)
62+}
A internal/usecase/recovery/transactions_test.go +381 -0
@@ -0,0 +1,381 @@
1+package recovery
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "sync"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type transactionCatalogue struct {
16+ mu sync.Mutex
17+ records map[string]domain.CatalogueRecord
18+ events []string
19+ replaceErrOnce error
20+ onCreate func(domain.CatalogueRecord)
21+}
22+
23+func newTransactionCatalogue(records ...domain.CatalogueRecord) *transactionCatalogue {
24+ catalogue := &transactionCatalogue{records: make(map[string]domain.CatalogueRecord)}
25+ for _, record := range records {
26+ catalogue.records[record.Name] = record
27+ }
28+ return catalogue
29+}
30+
31+func (c *transactionCatalogue) Records() ([]domain.CatalogueRecord, error) {
32+ c.mu.Lock()
33+ defer c.mu.Unlock()
34+ out := make([]domain.CatalogueRecord, 0, len(c.records))
35+ for _, record := range c.records {
36+ out = append(out, record)
37+ }
38+ return out, nil
39+}
40+
41+func (c *transactionCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
42+ c.mu.Lock()
43+ defer c.mu.Unlock()
44+ record, ok := c.records[name]
45+ return record, ok, nil
46+}
47+
48+func (c *transactionCatalogue) Create(record domain.CatalogueRecord) error {
49+ c.mu.Lock()
50+ defer c.mu.Unlock()
51+ if _, exists := c.records[record.Name]; exists {
52+ return errors.New("record exists")
53+ }
54+ c.records[record.Name] = record
55+ c.events = append(c.events, "create")
56+ if c.onCreate != nil {
57+ c.onCreate(record)
58+ }
59+ return nil
60+}
61+
62+func (c *transactionCatalogue) UpdateMetadata(domain.CatalogueMetadataUpdate) error { return nil }
63+func (c *transactionCatalogue) Sync() error { return nil }
64+
65+func (c *transactionCatalogue) Replace(name string, record domain.CatalogueRecord) error {
66+ c.mu.Lock()
67+ defer c.mu.Unlock()
68+ if c.replaceErrOnce != nil {
69+ err := c.replaceErrOnce
70+ c.replaceErrOnce = nil
71+ return err
72+ }
73+ if _, exists := c.records[name]; !exists {
74+ return errors.New("record missing")
75+ }
76+ c.records[name] = record
77+ c.events = append(c.events, "replace:"+name)
78+ return nil
79+}
80+
81+func (c *transactionCatalogue) Rename(oldName string, record domain.CatalogueRecord) error {
82+ c.mu.Lock()
83+ defer c.mu.Unlock()
84+ if _, exists := c.records[oldName]; !exists {
85+ return errors.New("record missing")
86+ }
87+ if _, exists := c.records[record.Name]; exists && record.Name != oldName {
88+ return errors.New("record exists")
89+ }
90+ delete(c.records, oldName)
91+ c.records[record.Name] = record
92+ c.events = append(c.events, "rename:"+oldName+":"+record.Name)
93+ return nil
94+}
95+
96+func (c *transactionCatalogue) Delete(name string) error {
97+ c.mu.Lock()
98+ defer c.mu.Unlock()
99+ delete(c.records, name)
100+ c.events = append(c.events, "delete:"+name)
101+ return nil
102+}
103+
104+func (c *transactionCatalogue) Close() error { return nil }
105+
106+type transactionRepository struct {
107+ ports.SnapshotRepository
108+ mu sync.Mutex
109+ deleteErrOnce error
110+ deletedIDs []domain.IncarnationID
111+ deleteEntered chan struct{}
112+ releaseDelete chan struct{}
113+}
114+
115+type garbageCollectionFenceRepository struct {
116+ ports.SnapshotRepository
117+ mu sync.Mutex
118+ events []string
119+ collectionEntered chan struct{}
120+ releaseCollection chan struct{}
121+ collectionFinished bool
122+ publishBeforeGC bool
123+ incarnations map[domain.IncarnationID]struct{}
124+ generations map[domain.IncarnationID]map[uint64]struct{}
125+}
126+
127+func (r *garbageCollectionFenceRepository) CollectGarbage(_ context.Context, keep map[domain.IncarnationID]domain.CheckpointRef) error {
128+ close(r.collectionEntered)
129+ <-r.releaseCollection
130+ r.mu.Lock()
131+ defer r.mu.Unlock()
132+ for id := range r.incarnations {
133+ if _, known := keep[id]; !known {
134+ delete(r.incarnations, id)
135+ }
136+ }
137+ for id, generations := range r.generations {
138+ committed, known := keep[id]
139+ if !known {
140+ delete(r.generations, id)
141+ continue
142+ }
143+ for generation := range generations {
144+ if generation != committed.Generation && generation+1 != committed.Generation {
145+ delete(generations, generation)
146+ }
147+ }
148+ }
149+ r.collectionFinished = true
150+ r.events = append(r.events, "collect")
151+ return nil
152+}
153+
154+func (r *garbageCollectionFenceRepository) Publish(_ context.Context, publication ports.SnapshotPublication) error {
155+ r.mu.Lock()
156+ defer r.mu.Unlock()
157+ if !r.collectionFinished {
158+ r.publishBeforeGC = true
159+ }
160+ if r.generations == nil {
161+ r.generations = make(map[domain.IncarnationID]map[uint64]struct{})
162+ }
163+ if r.generations[publication.IncarnationID] == nil {
164+ r.generations[publication.IncarnationID] = make(map[uint64]struct{})
165+ }
166+ r.generations[publication.IncarnationID][publication.Generation] = struct{}{}
167+ r.events = append(r.events, "publish")
168+ return nil
169+}
170+
171+func (r *garbageCollectionFenceRepository) addIncarnation(record domain.CatalogueRecord) {
172+ r.mu.Lock()
173+ defer r.mu.Unlock()
174+ if r.incarnations == nil {
175+ r.incarnations = make(map[domain.IncarnationID]struct{})
176+ }
177+ r.incarnations[record.IncarnationID] = struct{}{}
178+}
179+
180+func (r *garbageCollectionFenceRepository) hasIncarnation(id domain.IncarnationID) bool {
181+ r.mu.Lock()
182+ defer r.mu.Unlock()
183+ _, ok := r.incarnations[id]
184+ return ok
185+}
186+
187+func (r *garbageCollectionFenceRepository) hasGeneration(id domain.IncarnationID, generation uint64) bool {
188+ r.mu.Lock()
189+ defer r.mu.Unlock()
190+ _, ok := r.generations[id][generation]
191+ return ok
192+}
193+
194+func (r *garbageCollectionFenceRepository) eventLog() ([]string, bool) {
195+ r.mu.Lock()
196+ defer r.mu.Unlock()
197+ return append([]string(nil), r.events...), r.publishBeforeGC
198+}
199+
200+func (r *transactionRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error {
201+ if r.deleteEntered != nil {
202+ close(r.deleteEntered)
203+ <-r.releaseDelete
204+ }
205+ r.mu.Lock()
206+ defer r.mu.Unlock()
207+ if r.deleteErrOnce != nil {
208+ err := r.deleteErrOnce
209+ r.deleteErrOnce = nil
210+ return err
211+ }
212+ r.deletedIDs = append(r.deletedIDs, id)
213+ return nil
214+}
215+
216+func degradedTransactionRecord() domain.CatalogueRecord {
217+ return domain.CatalogueRecord{
218+ Name: "broken", IncarnationID: domain.IncarnationID{1}, Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}},
219+ DegradedReason: "checkpoint unreadable",
220+ }
221+}
222+
223+func TestStartupGarbageCollectionFencesNewIncarnationsAndCheckpointPublications(t *testing.T) {
224+ t.Parallel()
225+
226+ t.Run("a stale keep snapshot cannot remove a newly created incarnation", func(t *testing.T) {
227+ t.Parallel()
228+ catalogue := newTransactionCatalogue()
229+ repository := &garbageCollectionFenceRepository{
230+ collectionEntered: make(chan struct{}),
231+ releaseCollection: make(chan struct{}),
232+ }
233+ catalogue.onCreate = repository.addIncarnation
234+ coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{2}, 16)))
235+
236+ collected := make(chan error, 1)
237+ go func() {
238+ _, err := coordinator.CollectGarbage(context.Background())
239+ collected <- err
240+ }()
241+ <-repository.collectionEntered
242+
243+ created := make(chan error, 1)
244+ go func() {
245+ _, err := coordinator.Create(context.Background(), domain.CatalogueRecord{Name: "new"})
246+ created <- err
247+ }()
248+ close(repository.releaseCollection)
249+
250+ require.NoError(t, <-collected)
251+ require.NoError(t, <-created)
252+ got, ok, err := catalogue.Record("new")
253+ require.NoError(t, err)
254+ require.True(t, ok)
255+ require.NotZero(t, got.IncarnationID)
256+ require.True(t, repository.hasIncarnation(got.IncarnationID), "GC's stale keep snapshot must finish before the new incarnation is published")
257+ events, _ := repository.eventLog()
258+ require.Equal(t, []string{"collect"}, events)
259+ })
260+
261+ t.Run("a stale keep snapshot cannot prune a newly committed generation", func(t *testing.T) {
262+ t.Parallel()
263+ committed := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
264+ record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, Committed: &committed}
265+ catalogue := newTransactionCatalogue(record)
266+ repository := &garbageCollectionFenceRepository{
267+ collectionEntered: make(chan struct{}),
268+ releaseCollection: make(chan struct{}),
269+ generations: map[domain.IncarnationID]map[uint64]struct{}{
270+ record.IncarnationID: {1: {}},
271+ },
272+ }
273+ coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(nil))
274+
275+ collected := make(chan error, 1)
276+ go func() {
277+ _, err := coordinator.CollectGarbage(context.Background())
278+ collected <- err
279+ }()
280+ <-repository.collectionEntered
281+
282+ published := make(chan error, 1)
283+ go func() {
284+ _, err := coordinator.PublishCheckpoint(context.Background(), "work", ports.SnapshotPublication{
285+ Name: "work",
286+ IncarnationID: record.IncarnationID,
287+ Generation: 2,
288+ ParentCheckpoint: &committed,
289+ Manifest: []byte("generation two"),
290+ })
291+ published <- err
292+ }()
293+ close(repository.releaseCollection)
294+
295+ require.NoError(t, <-collected)
296+ require.NoError(t, <-published)
297+ events, publishedBeforeGC := repository.eventLog()
298+ require.Equal(t, []string{"collect", "publish"}, events)
299+ require.False(t, publishedBeforeGC)
300+ require.True(t, repository.hasGeneration(record.IncarnationID, 2), "GC's stale keep snapshot must finish before generation two is published")
301+ })
302+}
303+
304+func TestMarkBrokenCannotOverwriteDiscardReplacement(t *testing.T) {
305+ t.Parallel()
306+ old := degradedTransactionRecord()
307+ catalogue := newTransactionCatalogue(old)
308+ repository := &transactionRepository{deleteEntered: make(chan struct{}), releaseDelete: make(chan struct{})}
309+ coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(bytes.Repeat([]byte{2}, 16)))
310+
311+ discarded := make(chan error, 1)
312+ go func() { discarded <- coordinator.Discard(t.Context(), old.Name) }()
313+ <-repository.deleteEntered // Replace committed the fresh incarnation while Discard retains the mutation fence.
314+
315+ marked := make(chan struct {
316+ marked bool
317+ err error
318+ }, 1)
319+ go func() {
320+ _, markedSession, err := coordinator.MarkBroken(t.Context(), old.Name, old.IncarnationID, "late restore failure")
321+ marked <- struct {
322+ marked bool
323+ err error
324+ }{markedSession, err}
325+ }()
326+
327+ close(repository.releaseDelete)
328+ require.NoError(t, <-discarded)
329+ result := <-marked
330+ require.NoError(t, result.err)
331+ require.False(t, result.marked, "a late restore must not degrade the replacement incarnation")
332+
333+ got, ok, err := catalogue.Record(old.Name)
334+ require.NoError(t, err)
335+ require.True(t, ok)
336+ require.NotEqual(t, old.IncarnationID, got.IncarnationID)
337+ require.Nil(t, got.Committed)
338+ require.Empty(t, got.DegradedReason)
339+}
340+
341+func TestDiscardIsRetryIdempotent(t *testing.T) {
342+ t.Parallel()
343+ failure := errors.New("injected discard failure")
344+ tests := []struct {
345+ name string
346+ failBefore bool
347+ }{
348+ {name: "retry from old catalogue record", failBefore: true},
349+ {name: "retry from fresh catalogue record"},
350+ }
351+ for _, tt := range tests {
352+ t.Run(tt.name, func(t *testing.T) {
353+ t.Parallel()
354+ old := degradedTransactionRecord()
355+ catalogue := newTransactionCatalogue(old)
356+ repository := &transactionRepository{}
357+ if tt.failBefore {
358+ catalogue.replaceErrOnce = failure
359+ } else {
360+ repository.deleteErrOnce = failure
361+ }
362+ ids := append(bytes.Repeat([]byte{2}, 16), bytes.Repeat([]byte{3}, 16)...)
363+ coordinator := NewCoordinator(catalogue, repository, bytes.NewReader(ids))
364+
365+ err := coordinator.Discard(context.Background(), old.Name)
366+ require.ErrorIs(t, err, failure)
367+ require.NoError(t, coordinator.Discard(context.Background(), old.Name))
368+
369+ got, ok, err := catalogue.Record(old.Name)
370+ require.NoError(t, err)
371+ require.True(t, ok)
372+ require.Nil(t, got.Committed)
373+ require.NotEqual(t, old.IncarnationID, got.IncarnationID)
374+ if tt.failBefore {
375+ require.Contains(t, repository.deletedIDs, old.IncarnationID)
376+ } else {
377+ require.Empty(t, repository.deletedIDs, "startup garbage collection owns the orphaned old directory")
378+ }
379+ })
380+ }
381+}
M internal/usecase/snapshot/codec.go +9 -0
@@ -185,6 +185,15 @@ func writeProcess(w *payloadWriter, p *Process) error {
185185
186186 type payloadReader struct{ b []byte }
187187
188+func (r *payloadReader) getBytes(n int) ([]byte, error) {
189+ if n < 0 || len(r.b) < n {
190+ return nil, ErrShortPayload
191+ }
192+ b := r.b[:n]
193+ r.b = r.b[n:]
194+ return b, nil
195+}
196+
188197 func (r *payloadReader) getUint8() (uint8, error) {
189198 if len(r.b) < 1 {
190199 return 0, ErrShortPayload
M internal/usecase/snapshot/codec_test.go +29 -0
@@ -14,6 +14,35 @@ import (
1414 "github.com/bnema/vev/pkg/vt"
1515 )
1616
17+func TestPayloadReaderGetBytes(t *testing.T) {
18+ for _, tc := range []struct {
19+ name string
20+ input []byte
21+ n int
22+ want []byte
23+ remaining []byte
24+ wantErr error
25+ }{
26+ {name: "reads and advances", input: []byte{1, 2, 3}, n: 2, want: []byte{1, 2}, remaining: []byte{3}},
27+ {name: "reads empty", input: []byte{1}, n: 0, want: []byte{}, remaining: []byte{1}},
28+ {name: "short payload does not advance", input: []byte{1}, n: 2, remaining: []byte{1}, wantErr: ErrShortPayload},
29+ } {
30+ t.Run(tc.name, func(t *testing.T) {
31+ r := payloadReader{b: append([]byte(nil), tc.input...)}
32+ got, err := r.getBytes(tc.n)
33+ if !errors.Is(err, tc.wantErr) {
34+ t.Fatalf("getBytes(%d) error = %v, want %v", tc.n, err, tc.wantErr)
35+ }
36+ if !bytes.Equal(got, tc.want) {
37+ t.Fatalf("getBytes(%d) = %v, want %v", tc.n, got, tc.want)
38+ }
39+ if !bytes.Equal(r.b, tc.remaining) {
40+ t.Fatalf("remaining bytes = %v, want %v", r.b, tc.remaining)
41+ }
42+ })
43+ }
44+}
45+
1746 func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
1847 indexed := renderer.DefaultStyle()
1948 indexed.Bold, indexed.Inverse = true, true
M internal/usecase/snapshot/manifest.go +65 -9
@@ -7,6 +7,7 @@ import (
77 "hash/crc32"
88 "math"
99
10+ "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/ports"
1112 "github.com/bnema/vev/internal/usecase/layout"
1213 )
@@ -15,7 +16,7 @@ const (
1516 manifestMagic = "VEVM"
1617 headMagic = "VEVH"
1718 objectMagic = "VEVO"
18- ManifestVersion = uint16(1)
19+ ManifestVersion = uint16(2)
1920 manifestHeaderSize = 16
2021 objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length
2122 minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1
@@ -96,11 +97,13 @@ func UnmarshalHead(encoded []byte) (Head, error) {
9697
9798 // Manifest is the complete VEVM payload carried directly in a publication.
9899 type Manifest struct {
99- Generation uint64
100- Name string
101- CreatedAt uint64
102- Active uint16
103- Tabs []ManifestTab
100+ Generation uint64
101+ IncarnationID domain.IncarnationID
102+ ParentCheckpoint *domain.CheckpointRef
103+ Name string
104+ CreatedAt uint64
105+ Active uint16
106+ Tabs []ManifestTab
104107 }
105108
106109 type ManifestTab struct {
@@ -129,6 +132,14 @@ func MarshalManifest(m Manifest) ([]byte, error) {
129132 }
130133 var w payloadWriter
131134 w.putUint64(m.Generation)
135+ w.b = append(w.b, m.IncarnationID[:]...)
136+ if m.ParentCheckpoint == nil {
137+ w.putUint8(0)
138+ } else {
139+ w.putUint8(1)
140+ w.putUint64(m.ParentCheckpoint.Generation)
141+ w.b = append(w.b, m.ParentCheckpoint.ManifestDigest[:]...)
142+ }
132143 if err := w.putString(m.Name); err != nil {
133144 return nil, err
134145 }
@@ -168,6 +179,33 @@ func decodeManifest(body []byte) (Manifest, error) {
168179 if err != nil {
169180 return Manifest{}, err
170181 }
182+ incarnationBytes, err := r.getBytes(len(domain.IncarnationID{}))
183+ if err != nil {
184+ return Manifest{}, err
185+ }
186+ var incarnationID domain.IncarnationID
187+ copy(incarnationID[:], incarnationBytes)
188+ parentPresent, err := r.getUint8()
189+ if err != nil {
190+ return Manifest{}, err
191+ }
192+ var parent *domain.CheckpointRef
193+ switch parentPresent {
194+ case 0:
195+ case 1:
196+ parent = &domain.CheckpointRef{}
197+ parent.Generation, err = r.getUint64()
198+ if err != nil {
199+ return Manifest{}, err
200+ }
201+ digest, err := r.getBytes(len(parent.ManifestDigest))
202+ if err != nil {
203+ return Manifest{}, err
204+ }
205+ copy(parent.ManifestDigest[:], digest)
206+ default:
207+ return Manifest{}, fmt.Errorf("%w: parent checkpoint presence", ErrInvalidData)
208+ }
171209 name, err := r.getString()
172210 if err != nil {
173211 return Manifest{}, err
@@ -184,7 +222,7 @@ func decodeManifest(body []byte) (Manifest, error) {
184222 if err != nil {
185223 return Manifest{}, err
186224 }
187- m := Manifest{Generation: generation, Name: name, CreatedAt: createdAt, Active: active, Tabs: make([]ManifestTab, n)}
225+ m := Manifest{Generation: generation, IncarnationID: incarnationID, ParentCheckpoint: parent, Name: name, CreatedAt: createdAt, Active: active, Tabs: make([]ManifestTab, n)}
188226 for i := range m.Tabs {
189227 if m.Tabs[i], err = readManifestTab(&r); err != nil {
190228 return Manifest{}, err
@@ -257,13 +295,20 @@ func marshalManifestEnvelope(magic string, body []byte) ([]byte, error) {
257295 }
258296 out := make([]byte, manifestHeaderSize+len(body))
259297 copy(out[:4], magic)
260- binary.BigEndian.PutUint16(out[4:6], ManifestVersion)
298+ binary.BigEndian.PutUint16(out[4:6], envelopeVersion(magic))
261299 binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
262300 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
263301 copy(out[16:], body)
264302 return out, nil
265303 }
266304
305+func envelopeVersion(magic string) uint16 {
306+ if magic == manifestMagic {
307+ return ManifestVersion
308+ }
309+ return 1
310+}
311+
267312 func unmarshalManifestEnvelope(encoded []byte, expectedMagic string) ([]byte, error) {
268313 if len(encoded) < manifestHeaderSize {
269314 return nil, ErrShortPayload
@@ -271,7 +316,7 @@ func unmarshalManifestEnvelope(encoded []byte, expectedMagic string) ([]byte, er
271316 if string(encoded[:4]) != expectedMagic {
272317 return nil, ErrBadMagic
273318 }
274- if binary.BigEndian.Uint16(encoded[4:6]) != ManifestVersion {
319+ if binary.BigEndian.Uint16(encoded[4:6]) != envelopeVersion(expectedMagic) {
275320 return nil, ErrBadVersion
276321 }
277322 if binary.BigEndian.Uint16(encoded[6:8]) != 0 {
@@ -542,6 +587,17 @@ func parseObjectRef(r *payloadReader) (ObjectKind, []byte, uint32, error) {
542587 }
543588
544589 func validateManifest(m Manifest) error {
590+ if m.Generation == 0 {
591+ return fmt.Errorf("%w: manifest generation", ErrInvalidData)
592+ }
593+ if m.IncarnationID == (domain.IncarnationID{}) {
594+ return fmt.Errorf("%w: manifest incarnation", ErrInvalidData)
595+ }
596+ if m.ParentCheckpoint != nil {
597+ if m.ParentCheckpoint.Generation == 0 || m.ParentCheckpoint.Generation >= m.Generation || isZeroDigestBytes(m.ParentCheckpoint.ManifestDigest[:]) {
598+ return fmt.Errorf("%w: parent checkpoint", ErrInvalidData)
599+ }
600+ }
545601 if m.Name == "" {
546602 return fmt.Errorf("%w: manifest name", ErrInvalidData)
547603 }
M internal/usecase/snapshot/manifest_preflight.go +36 -1
@@ -3,6 +3,7 @@ package snapshot
33 import (
44 "fmt"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/internal/usecase/layout"
78 )
89
@@ -11,9 +12,43 @@ import (
1112 func preflightManifest(body []byte) error {
1213 r := payloadReader{b: body}
1314 budget := manifestPreflightBudget{}
14- if _, err := r.getUint64(); err != nil {
15+ generation, err := r.getUint64()
16+ if err != nil {
17+ return err
18+ }
19+ if generation == 0 {
20+ return fmt.Errorf("%w: manifest generation", ErrInvalidData)
21+ }
22+ incarnationBytes, err := r.getBytes(len(domain.IncarnationID{}))
23+ if err != nil {
24+ return err
25+ }
26+ var incarnationID domain.IncarnationID
27+ copy(incarnationID[:], incarnationBytes)
28+ if incarnationID == (domain.IncarnationID{}) {
29+ return fmt.Errorf("%w: manifest incarnation", ErrInvalidData)
30+ }
31+ parentPresent, err := r.getUint8()
32+ if err != nil {
1533 return err
1634 }
35+ switch parentPresent {
36+ case 0:
37+ case 1:
38+ parentGeneration, err := r.getUint64()
39+ if err != nil {
40+ return err
41+ }
42+ digest, err := r.getBytes(len(SnapshotDigest{}))
43+ if err != nil {
44+ return err
45+ }
46+ if parentGeneration == 0 || parentGeneration >= generation || isZeroDigestBytes(digest) {
47+ return fmt.Errorf("%w: parent checkpoint", ErrInvalidData)
48+ }
49+ default:
50+ return fmt.Errorf("%w: parent checkpoint presence", ErrInvalidData)
51+ }
1752 name, err := preflightManifestStringBytes(&r, &budget)
1853 if err != nil {
1954 return err
M internal/usecase/snapshot/manifest_preflight_test.go +68 -4
@@ -6,6 +6,7 @@ import (
66 "hash/crc32"
77 "testing"
88
9+ "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/internal/usecase/layout"
1011 )
1112
@@ -39,6 +40,7 @@ func TestManifestEnvelopePreflightParity(t *testing.T) {
3940 if err != nil {
4041 t.Fatal(err)
4142 }
43+ nameOffset, nodeKindOffset := manifestFieldOffsets(t, encoded)
4244 mutations := []struct {
4345 name string
4446 edit func([]byte) []byte
@@ -50,9 +52,21 @@ func TestManifestEnvelopePreflightParity(t *testing.T) {
5052 {"body length", func(b []byte) []byte { binary.BigEndian.PutUint32(b[8:12], uint32(len(b))); return b }},
5153 {"crc", func(b []byte) []byte { b[12] ^= 1; return b }},
5254 {"trailing", func(b []byte) []byte { return append(b, 0) }},
53- {"body name", func(b []byte) []byte { b[26] ^= 1; binary.BigEndian.PutUint32(b[12:16], checksum(b[16:])); return b }},
54- {"body generation", func(b []byte) []byte { b[16] ^= 1; binary.BigEndian.PutUint32(b[12:16], checksum(b[16:])); return b }},
55- {"body node kind", func(b []byte) []byte { b[65] = 99; binary.BigEndian.PutUint32(b[12:16], checksum(b[16:])); return b }},
55+ {"body name", func(b []byte) []byte {
56+ b[nameOffset] = 0
57+ binary.BigEndian.PutUint32(b[12:16], checksum(b[16:]))
58+ return b
59+ }},
60+ {"body generation", func(b []byte) []byte {
61+ clear(b[manifestHeaderSize : manifestHeaderSize+8])
62+ binary.BigEndian.PutUint32(b[12:16], checksum(b[16:]))
63+ return b
64+ }},
65+ {"body node kind", func(b []byte) []byte {
66+ b[nodeKindOffset] = 99
67+ binary.BigEndian.PutUint32(b[12:16], checksum(b[16:]))
68+ return b
69+ }},
5670 }
5771 for _, tc := range mutations {
5872 t.Run(tc.name, func(t *testing.T) {
@@ -67,6 +81,56 @@ func TestManifestEnvelopePreflightParity(t *testing.T) {
6781 }
6882 }
6983
84+func manifestFieldOffsets(t *testing.T, encoded []byte) (nameOffset, nodeKindOffset int) {
85+ t.Helper()
86+ r := payloadReader{b: encoded[manifestHeaderSize:]}
87+ must := func(err error) {
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ }
92+ _, err := r.getUint64()
93+ must(err)
94+ if len(r.b) < len(domain.IncarnationID{}) {
95+ t.Fatalf("manifest body has %d incarnation bytes, want at least %d", len(r.b), len(domain.IncarnationID{}))
96+ }
97+ r.b = r.b[len(domain.IncarnationID{}):]
98+ _, err = r.getUint8()
99+ must(err)
100+ nameLengthOffset := len(encoded) - len(r.b)
101+ name, err := r.getString()
102+ must(err)
103+ nameOffset = nameLengthOffset + 2
104+ if len(name) == 0 {
105+ t.Fatal("test manifest has empty name")
106+ }
107+ _, err = r.getUint64()
108+ must(err)
109+ _, err = r.getUint16()
110+ must(err)
111+ _, err = r.getUint16()
112+ must(err)
113+ _, err = r.getString()
114+ must(err)
115+ _, err = r.getUint16()
116+ must(err)
117+ _, err = r.getUint16()
118+ must(err)
119+ _, err = r.getUint64()
120+ must(err)
121+ _, err = r.getString()
122+ must(err)
123+ nodeKindOffset = len(encoded) - len(r.b)
124+ const wantName = "named"
125+ if nameOffset < 0 || nameOffset+len(wantName) > len(encoded) || string(encoded[nameOffset:nameOffset+len(wantName)]) != wantName {
126+ t.Fatalf("name offset %d does not point to %q", nameOffset, wantName)
127+ }
128+ if nodeKindOffset < 0 || nodeKindOffset >= len(encoded) || encoded[nodeKindOffset] != manifestNodeLeaf {
129+ t.Fatalf("node kind offset %d does not point to leaf kind", nodeKindOffset)
130+ }
131+ return nameOffset, nodeKindOffset
132+}
133+
70134 func TestManifestObjectRefKinds(t *testing.T) {
71135 for _, tc := range []struct {
72136 name string
@@ -163,7 +227,7 @@ func checksum(body []byte) uint32 {
163227 }
164228
165229 func testManifest(tree *layout.Tree, process *Process) Manifest {
166- return Manifest{Name: "named", Tabs: []ManifestTab{{
230+ return Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{
167231 StableID: "tab", Cols: 80, Rows: 24, Focus: "one", Tree: tree,
168232 Panes: []ManifestPane{
169233 {ID: "one", StableID: "one", Cwd: "/one", Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize}}, Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{3}, Size: minObjectEnvelopeSize}, Process: process},
M internal/usecase/snapshot/manifest_test.go +49 -4
@@ -3,14 +3,59 @@ package snapshot
33 import (
44 "bytes"
55 "crypto/sha256"
6+ "encoding/binary"
67 "errors"
78 "math"
89 "testing"
910
11+ "github.com/bnema/vev/internal/domain"
1012 "github.com/bnema/vev/internal/ports"
1113 "github.com/bnema/vev/internal/usecase/layout"
1214 )
1315
16+func TestManifestIncarnation(t *testing.T) {
17+ manifest := manifestWithTailSize(minObjectEnvelopeSize)
18+ manifest.Generation = 2
19+ manifest.IncarnationID = domain.IncarnationID{1}
20+ manifest.ParentCheckpoint = &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{2}}
21+
22+ encoded, err := MarshalManifest(manifest)
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ decoded, err := UnmarshalManifest(encoded)
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ if decoded.IncarnationID != manifest.IncarnationID || decoded.ParentCheckpoint == nil || *decoded.ParentCheckpoint != *manifest.ParentCheckpoint {
31+ t.Fatalf("round trip = %#v", decoded)
32+ }
33+ reencoded, err := MarshalManifest(decoded)
34+ if err != nil || !bytes.Equal(encoded, reencoded) {
35+ t.Fatalf("manifest bytes are not canonical: %v", err)
36+ }
37+ if got := binary.BigEndian.Uint16(encoded[4:6]); got != ManifestVersion {
38+ t.Fatalf("manifest version = %d", got)
39+ }
40+ for n := range len(encoded) {
41+ if _, err := UnmarshalManifest(encoded[:n]); err == nil {
42+ t.Fatalf("prefix %d accepted", n)
43+ }
44+ }
45+ if _, err := UnmarshalManifest(append(encoded, 0)); err == nil {
46+ t.Fatal("trailing data accepted")
47+ }
48+ versionOne := append([]byte(nil), encoded...)
49+ binary.BigEndian.PutUint16(versionOne[4:6], 1)
50+ if _, err := UnmarshalManifest(versionOne); !errors.Is(err, ErrBadVersion) {
51+ t.Fatalf("manifest v1 error = %v", err)
52+ }
53+ manifest.IncarnationID = domain.IncarnationID{}
54+ if _, err := MarshalManifest(manifest); err == nil {
55+ t.Fatal("zero incarnation accepted")
56+ }
57+}
58+
1459 func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) {
1560 chunk, err := MarshalObject(HistoryChunk, []byte("history"))
1661 if err != nil {
@@ -24,7 +69,7 @@ func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) {
2469 if err != nil {
2570 t.Fatal(err)
2671 }
27- manifest := Manifest{Generation: 7, Name: "named", CreatedAt: 9, Active: 0, Tabs: []ManifestTab{{
72+ manifest := Manifest{Generation: 7, IncarnationID: domain.IncarnationID{1}, Name: "named", CreatedAt: 9, Active: 0, Tabs: []ManifestTab{{
2873 StableID: "tab", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "pane", Tree: layout.NewTree("pane"),
2974 Panes: []ManifestPane{{ID: "pane", StableID: "pane-stable", Cwd: "/tmp",
3075 Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}},
@@ -97,7 +142,7 @@ func TestManifestDimensionBoundaries(t *testing.T) {
97142 {"hostile product", math.MaxUint16, math.MaxUint16, true},
98143 } {
99144 t.Run(tc.name, func(t *testing.T) {
100- m := Manifest{Name: "safe", Tabs: []ManifestTab{{Cols: tc.cols, Rows: tc.rows, Panes: []ManifestPane{{ID: "p", Tail: ref, Visible: visible}}}}}
145+ m := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "safe", Tabs: []ManifestTab{{Cols: tc.cols, Rows: tc.rows, Panes: []ManifestPane{{ID: "p", Tail: ref, Visible: visible}}}}}
101146 _, err := MarshalManifest(m)
102147 if (err != nil) != tc.wantErr {
103148 t.Fatalf("MarshalManifest() error = %v, want error=%v", err, tc.wantErr)
@@ -107,7 +152,7 @@ func TestManifestDimensionBoundaries(t *testing.T) {
107152 }
108153
109154 func TestManifestRejectsImpossibleObjectEnvelopeSize(t *testing.T) {
110- manifest := Manifest{Name: "named", Tabs: []ManifestTab{{Cols: 1, Rows: 1, Panes: []ManifestPane{{
155+ manifest := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{Cols: 1, Rows: 1, Panes: []ManifestPane{{
111156 ID: "pane",
112157 Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: 1},
113158 Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize},
@@ -171,7 +216,7 @@ func TestObjectRefEnvelopeSizeBoundaries(t *testing.T) {
171216 }
172217
173218 func manifestWithTailSize(size uint32) Manifest {
174- return Manifest{Name: "named", Tabs: []ManifestTab{{
219+ return Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{
175220 Cols: 1,
176221 Rows: 1,
177222 Panes: []ManifestPane{{
M internal/usecase/snapshot/weights_test.go +3 -3
@@ -86,10 +86,10 @@ func TestSnapshotEnvelopeCompatibility(t *testing.T) {
8686 const (
8787 headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000"
8888 objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674"
89- manifestGolden = "5645564d0001000000000122f8f8f0af000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"
89+ manifestGolden = "5645564d0002000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"
9090 )
91- if ManifestVersion != 1 {
92- t.Fatalf("ManifestVersion = %d, want 1", ManifestVersion)
91+ if ManifestVersion != 2 {
92+ t.Fatalf("ManifestVersion = %d, want 2", ManifestVersion)
9393 }
9494
9595 head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}})
M pkg/kv/codec.go +84 -55
@@ -5,77 +5,106 @@ import (
55 "errors"
66 "hash/crc32"
77 "math"
8+ "sort"
89 )
910
10-const (
11- opSet byte = 0
12- opDel byte = 1
13-
14- headerLen = 8
15- payloadPrefixLen = 3
16- compactThreshold = 64
17- compactWasteRatio = 0.5
18- maxKeyLen = 1<<16 - 1
19-)
11+var ErrCorrupt = errors.New("kv: corrupt store file")
2012
21-var errBadRecord = errors.New("bad record")
13+var fileMagic = [4]byte{'V', 'E', 'V', 'K'}
2214
23-type record struct {
24- op byte
25- key []byte
26- value []byte
27-}
15+const (
16+ fileVersion uint16 = 3
17+ filePrefixLen = 4 + 2 + 4
18+ entryPrefixLen = 4 + 4
19+ crcLen = 4
20+)
2821
29-func encodeRecord(op byte, key, value []byte) ([]byte, error) {
30- if len(key) > maxKeyLen {
31- return nil, errors.New("key too large")
32- }
33- if op != opSet && op != opDel {
34- return nil, errBadRecord
22+// encodeFile writes magic | version | count | (keyLen key valLen val)* | crc32.
23+func encodeFile(data map[string][]byte) []byte {
24+ keys := make([]string, 0, len(data))
25+ for key := range data {
26+ keys = append(keys, key)
3527 }
28+ sort.Strings(keys)
3629
37- payloadLen := payloadPrefixLen + len(key)
38- if op == opSet {
39- payloadLen += len(value)
30+ size := filePrefixLen + crcLen
31+ for _, key := range keys {
32+ size += entryPrefixLen + len(key) + len(data[key])
4033 }
41- if uint64(payloadLen) > math.MaxUint32 {
42- return nil, errors.New("payload too large")
43- }
44-
45- buf := make([]byte, headerLen+payloadLen)
46- payload := buf[headerLen:]
47- payload[0] = op
48- binary.BigEndian.PutUint16(payload[1:3], uint16(len(key)))
49- copy(payload[3:], key)
50- if op == opSet {
51- copy(payload[3+len(key):], value)
34+ buf := make([]byte, 0, size)
35+ buf = append(buf, fileMagic[:]...)
36+ buf = binary.BigEndian.AppendUint16(buf, fileVersion)
37+ buf = binary.BigEndian.AppendUint32(buf, uint32(len(keys)))
38+ for _, key := range keys {
39+ value := data[key]
40+ buf = binary.BigEndian.AppendUint32(buf, uint32(len(key)))
41+ buf = append(buf, key...)
42+ buf = binary.BigEndian.AppendUint32(buf, uint32(len(value)))
43+ buf = append(buf, value...)
5244 }
45+ return appendCRC(buf)
46+}
5347
54- binary.BigEndian.PutUint32(buf[0:4], uint32(payloadLen))
55- binary.BigEndian.PutUint32(buf[4:8], crc32.ChecksumIEEE(payload))
56- return buf, nil
48+func appendCRC(payload []byte) []byte {
49+ return binary.BigEndian.AppendUint32(payload, crc32.ChecksumIEEE(payload))
5750 }
5851
59-func decodePayload(payload []byte) (record, error) {
60- if len(payload) < payloadPrefixLen {
61- return record{}, errBadRecord
52+// decodeFile validates the single trailing CRC and rejects every malformed
53+// existing file. Callers must not replace or truncate a file that fails here.
54+func decodeFile(raw []byte) (map[string][]byte, error) {
55+ if len(raw) < filePrefixLen+crcLen {
56+ return nil, ErrCorrupt
6257 }
63- op := payload[0]
64- if op != opSet && op != opDel {
65- return record{}, errBadRecord
58+ payload := raw[:len(raw)-crcLen]
59+ wantCRC := binary.BigEndian.Uint32(raw[len(raw)-crcLen:])
60+ if crc32.ChecksumIEEE(payload) != wantCRC {
61+ return nil, ErrCorrupt
6662 }
67- keyLen := int(binary.BigEndian.Uint16(payload[1:3]))
68- if len(payload) < payloadPrefixLen+keyLen {
69- return record{}, errBadRecord
63+ if string(payload[:4]) != string(fileMagic[:]) || binary.BigEndian.Uint16(payload[4:6]) != fileVersion {
64+ return nil, ErrCorrupt
7065 }
71- if op == opDel && len(payload) != payloadPrefixLen+keyLen {
72- return record{}, errBadRecord
66+
67+ count := binary.BigEndian.Uint32(payload[6:10])
68+ off := filePrefixLen
69+ data := make(map[string][]byte)
70+ for range count {
71+ keyLen, next, ok := decodeLength(payload, off)
72+ if !ok {
73+ return nil, ErrCorrupt
74+ }
75+ off = next
76+ if keyLen > uint64(len(payload)-off) {
77+ return nil, ErrCorrupt
78+ }
79+ key := string(payload[off : off+int(keyLen)])
80+ off += int(keyLen)
81+ valueLen, next, ok := decodeLength(payload, off)
82+ if !ok {
83+ return nil, ErrCorrupt
84+ }
85+ off = next
86+ if valueLen > uint64(len(payload)-off) {
87+ return nil, ErrCorrupt
88+ }
89+ if _, duplicate := data[key]; duplicate {
90+ return nil, ErrCorrupt
91+ }
92+ data[key] = append([]byte(nil), payload[off:off+int(valueLen)]...)
93+ off += int(valueLen)
7394 }
95+ if off != len(payload) {
96+ return nil, ErrCorrupt
97+ }
98+ return data, nil
99+}
74100
75- key := append([]byte(nil), payload[payloadPrefixLen:payloadPrefixLen+keyLen]...)
76- var val []byte
77- if op == opSet {
78- val = append([]byte(nil), payload[payloadPrefixLen+keyLen:]...)
101+func decodeLength(payload []byte, off int) (uint64, int, bool) {
102+ if off < 0 || len(payload)-off < 4 {
103+ return 0, off, false
104+ }
105+ length := uint64(binary.BigEndian.Uint32(payload[off : off+4]))
106+ if length > uint64(math.MaxInt) {
107+ return 0, off, false
79108 }
80- return record{op: op, key: key, value: val}, nil
109+ return length, off + 4, true
81110 }
M pkg/kv/kv.go +114 -285
@@ -1,10 +1,7 @@
11 package kv
22
33 import (
4- "encoding/binary"
54 "errors"
6- "fmt"
7- "hash/crc32"
85 "io"
96 "os"
107 "path/filepath"
@@ -15,369 +12,201 @@ import (
1512 "github.com/bnema/vev/pkg/safedir"
1613 )
1714
18-// Store is an append-only WAL-backed key/value store.
15+// Store is an in-memory key/value map persisted by whole-file atomic rewrite.
16+// Set and Delete mutate memory only; Sync is the durability barrier.
1917 type Store struct {
20- mu sync.Mutex
21- path string
22- file *os.File
23- lockFile *os.File
24- data map[string][]byte
25- entrySize map[string]int64
26- total int64
27- live int64
28- closed bool
18+ mu sync.Mutex
19+ path string
20+ lockFile *os.File
21+ data map[string][]byte
22+ dirty bool
23+ closed bool
2924 }
3025
31-// Open opens or creates a store at path, replays valid records, truncates any
32-// corrupt or torn tail, and compacts if the file contains enough obsolete data.
26+// Open reads path, or starts empty when it does not exist. Stray .tmp files
27+// are ignored. Any invalid existing main file fails closed and is untouched.
3328 func Open(path string) (*Store, error) {
3429 if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
3530 return nil, err
3631 }
37-
3832 lockFile, err := acquireLock(path)
3933 if err != nil {
4034 return nil, err
4135 }
42-
43- f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
44- if err != nil {
45- _ = releaseLock(lockFile)
46- return nil, err
47- }
48-
49- data, sizes, total, live, lastGood, err := replayFile(f)
50- if err != nil {
51- _ = f.Close()
52- _ = releaseLock(lockFile)
53- return nil, err
54- }
55- if err := f.Truncate(lastGood); err != nil {
56- _ = f.Close()
57- _ = releaseLock(lockFile)
58- return nil, err
59- }
60- if _, err := f.Seek(0, io.SeekEnd); err != nil {
61- _ = f.Close()
62- _ = releaseLock(lockFile)
63- return nil, err
64- }
65-
66- s := &Store{path: path, file: f, lockFile: lockFile, data: data, entrySize: sizes, total: total, live: live}
67- if s.shouldCompact() {
68- if err := s.compactLocked(); err != nil {
69- _ = f.Close()
70- _ = releaseLock(lockFile)
71- return nil, err
72- }
36+ fail := func(err error) (*Store, error) {
37+ return nil, errors.Join(err, releaseLock(lockFile))
7338 }
74- return s, nil
75-}
7639
77-// Replay reads path without mutating it and returns the state from valid records.
78-func Replay(path string) (map[string][]byte, error) {
79- f, err := os.Open(path)
40+ raw, err := os.ReadFile(path)
8041 if errors.Is(err, os.ErrNotExist) {
81- return map[string][]byte{}, nil
42+ store := &Store{path: path, lockFile: lockFile, data: make(map[string][]byte), dirty: true}
43+ if err := store.syncLocked(); err != nil {
44+ return fail(err)
45+ }
46+ return store, nil
8247 }
8348 if err != nil {
84- return nil, err
49+ return fail(err)
8550 }
86- defer func() { _ = f.Close() }()
87-
88- data, _, _, _, _, err := replayFile(f)
51+ data, err := decodeFile(raw)
8952 if err != nil {
90- return nil, err
53+ return fail(err)
9154 }
92- return data, nil
55+ return &Store{path: path, lockFile: lockFile, data: data}, nil
9356 }
9457
95-// Get returns a copy of the value for key.
9658 func (s *Store) Get(key []byte) ([]byte, bool) {
9759 s.mu.Lock()
9860 defer s.mu.Unlock()
99-
100- v, ok := s.data[string(key)]
101- if !ok {
102- return nil, false
103- }
104- return append([]byte(nil), v...), true
61+ value, ok := s.data[string(key)]
62+ return append([]byte(nil), value...), ok
10563 }
10664
107-// Set appends a SET record. It does not fsync.
108-func (s *Store) Set(key, val []byte) error {
109- return s.appendRecord(opSet, key, val)
65+func (s *Store) Set(key, value []byte) error {
66+ s.mu.Lock()
67+ defer s.mu.Unlock()
68+ if s.closed {
69+ return os.ErrClosed
70+ }
71+ s.data[string(key)] = append([]byte(nil), value...)
72+ s.dirty = true
73+ return nil
11074 }
11175
112-// Delete appends a DEL tombstone. It does not fsync.
11376 func (s *Store) Delete(key []byte) error {
114- return s.appendRecord(opDel, key, nil)
77+ s.mu.Lock()
78+ defer s.mu.Unlock()
79+ if s.closed {
80+ return os.ErrClosed
81+ }
82+ if _, exists := s.data[string(key)]; exists {
83+ delete(s.data, string(key))
84+ s.dirty = true
85+ }
86+ return nil
11587 }
11688
117-// Range calls fn for each key/value pair, using copies. Iteration stops when fn returns false.
11889 func (s *Store) Range(fn func(k, v []byte) bool) {
11990 s.mu.Lock()
12091 keys := make([]string, 0, len(s.data))
121- for k := range s.data {
122- keys = append(keys, k)
92+ for key := range s.data {
93+ keys = append(keys, key)
12394 }
12495 sort.Strings(keys)
125- pairs := make([]struct{ k, v []byte }, 0, len(keys))
126- for _, k := range keys {
127- pairs = append(pairs, struct{ k, v []byte }{[]byte(k), append([]byte(nil), s.data[k]...)})
96+ values := make([][]byte, len(keys))
97+ for i, key := range keys {
98+ values[i] = append([]byte(nil), s.data[key]...)
12899 }
129100 s.mu.Unlock()
130-
131- for _, p := range pairs {
132- if !fn(p.k, p.v) {
101+ for i, key := range keys {
102+ if !fn([]byte(key), values[i]) {
133103 return
134104 }
135105 }
136106 }
137107
138-// Sync fsyncs the WAL file.
108+// Sync writes path.tmp, fsyncs it, renames it over path, then fsyncs the
109+// directory. The main pathname therefore contains either the previous whole
110+// map or the next whole map, never a subset of mutations.
139111 func (s *Store) Sync() error {
140112 s.mu.Lock()
141113 defer s.mu.Unlock()
142114 if s.closed {
143115 return os.ErrClosed
144116 }
145- return s.file.Sync()
117+ return s.syncLocked()
146118 }
147119
148-// Close fsyncs and closes the store.
149-func (s *Store) Close() error {
150- s.mu.Lock()
151- defer s.mu.Unlock()
152- if s.closed {
120+func (s *Store) syncLocked() error {
121+ if !s.dirty {
153122 return nil
154123 }
155- errSync := s.file.Sync()
156- errClose := s.file.Close()
157- errUnlock := releaseLock(s.lockFile)
158- s.closed = true
159- if errSync != nil {
160- return errSync
161- }
162- if errClose != nil {
163- return errClose
164- }
165- return errUnlock
166-}
167-
168-func acquireLock(path string) (*os.File, error) {
169- lf, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
124+ data := encodeFile(s.data)
125+ tmp := s.path + ".tmp"
126+ file, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
170127 if err != nil {
171- return nil, err
172- }
173- if err := syscall.Flock(int(lf.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
174- _ = lf.Close()
175- return nil, err
128+ return err
176129 }
177- return lf, nil
178-}
179-
180-func releaseLock(f *os.File) error {
181- if f == nil {
182- return nil
130+ if err := writeAll(file, data); err != nil {
131+ return errors.Join(err, file.Close())
183132 }
184- errUnlock := syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
185- errClose := f.Close()
186- if errUnlock != nil {
187- return errUnlock
133+ if err := file.Sync(); err != nil {
134+ return errors.Join(err, file.Close())
188135 }
189- return errClose
190-}
191-
192-func (s *Store) appendRecord(op byte, key, val []byte) error {
193- buf, err := encodeRecord(op, key, val)
194- if err != nil {
136+ if err := file.Close(); err != nil {
195137 return err
196138 }
197-
198- s.mu.Lock()
199- defer s.mu.Unlock()
200- if s.closed {
201- return os.ErrClosed
202- }
203- pos, err := s.file.Seek(0, io.SeekCurrent)
204- if err != nil {
139+ if err := os.Rename(tmp, s.path); err != nil {
205140 return err
206141 }
207- n, err := s.file.Write(buf)
208- if err != nil || n != len(buf) {
209- writeErr := err
210- if writeErr == nil {
211- writeErr = io.ErrShortWrite
212- }
213- if tErr := s.file.Truncate(pos); tErr != nil {
214- s.closed = true
215- return fmt.Errorf("write failed (%w) and recovery truncate failed: %w", writeErr, tErr)
216- }
217- if _, sErr := s.file.Seek(pos, io.SeekStart); sErr != nil {
218- s.closed = true
219- return fmt.Errorf("write failed (%w) and recovery seek failed: %w", writeErr, sErr)
220- }
221- return writeErr
222- }
223-
224- k := string(key)
225- if old, ok := s.entrySize[k]; ok {
226- s.live -= old
227- }
228- s.total += int64(len(buf))
229- if op == opSet {
230- s.data[k] = append([]byte(nil), val...)
231- s.entrySize[k] = int64(len(buf))
232- s.live += int64(len(buf))
233- } else {
234- delete(s.data, k)
235- delete(s.entrySize, k)
236- }
237- if s.shouldCompact() {
238- return s.compactLocked()
142+ if err := syncDir(filepath.Dir(s.path)); err != nil {
143+ return err
239144 }
145+ s.dirty = false
240146 return nil
241147 }
242148
243-func (s *Store) shouldCompact() bool {
244- return s.total > compactThreshold && float64(s.total-s.live)/float64(s.total) > compactWasteRatio
245-}
246-
247-func (s *Store) compactLocked() error {
248- tmp := s.path + ".compact"
249- tf, err := os.OpenFile(tmp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
250- if err != nil {
251- return err
149+// Close syncs buffered mutations before releasing the process lock.
150+func (s *Store) Close() error {
151+ s.mu.Lock()
152+ defer s.mu.Unlock()
153+ if s.closed {
154+ return nil
252155 }
156+ err := s.syncLocked()
157+ s.closed = true
158+ return errors.Join(err, releaseLock(s.lockFile))
159+}
253160
254- keys := make([]string, 0, len(s.data))
255- for k := range s.data {
256- keys = append(keys, k)
161+// CloseWithoutSync releases the process lock without making buffered mutations
162+// durable. It is used when a higher-level transaction rejected those mutations.
163+func (s *Store) CloseWithoutSync() error {
164+ s.mu.Lock()
165+ defer s.mu.Unlock()
166+ if s.closed {
167+ return nil
257168 }
258- sort.Strings(keys)
169+ s.closed = true
170+ return releaseLock(s.lockFile)
171+}
259172
260- newSizes := make(map[string]int64, len(keys))
261- var newTotal, newLive int64
262- for _, k := range keys {
263- buf, err := encodeRecord(opSet, []byte(k), s.data[k])
173+func writeAll(file *os.File, data []byte) error {
174+ for len(data) > 0 {
175+ n, err := file.Write(data)
264176 if err != nil {
265- _ = tf.Close()
266- _ = os.Remove(tmp)
267177 return err
268178 }
269- if _, err := tf.Write(buf); err != nil {
270- _ = tf.Close()
271- _ = os.Remove(tmp)
272- return err
179+ if n == 0 {
180+ return io.ErrShortWrite
273181 }
274- sz := int64(len(buf))
275- newSizes[k] = sz
276- newTotal += sz
277- newLive += sz
278- }
279- if err := tf.Sync(); err != nil {
280- _ = tf.Close()
281- _ = os.Remove(tmp)
282- return err
182+ data = data[n:]
283183 }
284- if err := tf.Close(); err != nil {
285- _ = os.Remove(tmp)
286- return err
287- }
288- bak := s.path + ".bak"
289- _ = os.Remove(bak)
290- if err := os.Rename(s.path, bak); err != nil {
291- _ = os.Remove(tmp)
292- return err
293- }
294- if err := os.Rename(tmp, s.path); err != nil {
295- _ = os.Rename(bak, s.path)
296- _ = os.Remove(tmp)
297- return err
298- }
299- f, err := os.OpenFile(s.path, os.O_RDWR, 0o600)
300- if err != nil {
301- _ = os.Remove(s.path)
302- _ = os.Rename(bak, s.path)
303- return err
304- }
305- if _, err := f.Seek(0, io.SeekEnd); err != nil {
306- _ = f.Close()
307- _ = os.Remove(s.path)
308- _ = os.Rename(bak, s.path)
309- return err
310- }
311- old := s.file
312- s.file = f
313- s.entrySize = newSizes
314- s.total = newTotal
315- s.live = newLive
316- _ = old.Close()
317- _ = os.Remove(bak)
318184 return nil
319185 }
320186
321-func replayFile(f *os.File) (map[string][]byte, map[string]int64, int64, int64, int64, error) {
322- info, err := f.Stat()
187+func acquireLock(path string) (*os.File, error) {
188+ file, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
323189 if err != nil {
324- return nil, nil, 0, 0, 0, err
190+ return nil, err
325191 }
326- fileSize := info.Size()
327- if _, err := f.Seek(0, io.SeekStart); err != nil {
328- return nil, nil, 0, 0, 0, err
192+ if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
193+ _ = file.Close()
194+ return nil, err
329195 }
330- data := make(map[string][]byte)
331- sizes := make(map[string]int64)
332- var total, live, off int64
333- header := make([]byte, headerLen)
334- for {
335- n, err := io.ReadFull(f, header)
336- if errors.Is(err, io.EOF) {
337- break
338- }
339- if errors.Is(err, io.ErrUnexpectedEOF) {
340- _ = n
341- break
342- }
343- if err != nil {
344- return nil, nil, 0, 0, 0, err
345- }
196+ return file, nil
197+}
346198
347- payloadLen := binary.BigEndian.Uint32(header[0:4])
348- if int64(payloadLen) > fileSize-off-headerLen {
349- break
350- }
351- wantCRC := binary.BigEndian.Uint32(header[4:8])
352- payload := make([]byte, int(payloadLen))
353- if _, err := io.ReadFull(f, payload); errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
354- break
355- } else if err != nil {
356- return nil, nil, 0, 0, 0, err
357- }
358- if crc32.ChecksumIEEE(payload) != wantCRC {
359- break
360- }
361- rec, err := decodePayload(payload)
362- if err != nil {
363- break
364- }
199+func releaseLock(file *os.File) error {
200+ if file == nil {
201+ return nil
202+ }
203+ return errors.Join(syscall.Flock(int(file.Fd()), syscall.LOCK_UN), file.Close())
204+}
365205
366- sz := int64(headerLen + len(payload))
367- k := string(rec.key)
368- if old, ok := sizes[k]; ok {
369- live -= old
370- }
371- total += sz
372- if rec.op == opSet {
373- data[k] = rec.value
374- sizes[k] = sz
375- live += sz
376- } else {
377- delete(data, k)
378- delete(sizes, k)
379- }
380- off += sz
206+func syncDir(dir string) error {
207+ file, err := os.Open(dir)
208+ if err != nil {
209+ return err
381210 }
382- return data, sizes, total, live, off, nil
211+ return errors.Join(file.Sync(), file.Close())
383212 }
M pkg/kv/kv_test.go +192 -230
@@ -1,259 +1,221 @@
11 package kv
22
33 import (
4- "bytes"
54 "encoding/binary"
5+ "errors"
66 "os"
77 "path/filepath"
88 "testing"
9-)
109
11-func privatePath(t *testing.T) string {
12- t.Helper()
13- return filepath.Join(t.TempDir(), "vev", "store.kv")
14-}
10+ "github.com/stretchr/testify/require"
11+)
1512
16-func closeStore(t *testing.T, s *Store) {
17- t.Helper()
18- if err := s.Close(); err != nil {
19- t.Fatal(err)
13+func TestSyncCrashBoundaries(t *testing.T) {
14+ t.Parallel()
15+ tests := []struct {
16+ name string
17+ leaveTmp bool
18+ corrupt bool
19+ wantOpen bool
20+ wantValue string
21+ }{
22+ {name: "clean file opens", wantOpen: true, wantValue: "v1"},
23+ {name: "stray tmp is ignored", leaveTmp: true, wantOpen: true, wantValue: "v1"},
24+ {name: "corrupt file fails closed", corrupt: true},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ t.Parallel()
29+ path := privateStorePath(t)
30+ store, err := Open(path)
31+ require.NoError(t, err)
32+ require.NoError(t, store.Set([]byte("k"), []byte("v1")))
33+ require.NoError(t, store.Sync())
34+ require.NoError(t, store.Close())
35+
36+ if tt.leaveTmp {
37+ require.NoError(t, os.WriteFile(path+".tmp", []byte("half written"), 0o600))
38+ }
39+ if tt.corrupt {
40+ data, err := os.ReadFile(path)
41+ require.NoError(t, err)
42+ data[len(data)-1] ^= 0xff
43+ require.NoError(t, os.WriteFile(path, data, 0o600))
44+ }
45+
46+ reopened, err := Open(path)
47+ if !tt.wantOpen {
48+ require.ErrorIs(t, err, ErrCorrupt)
49+ return
50+ }
51+ require.NoError(t, err)
52+ t.Cleanup(func() { _ = reopened.Close() })
53+ got, ok := reopened.Get([]byte("k"))
54+ require.True(t, ok)
55+ require.Equal(t, tt.wantValue, string(got))
56+ })
2057 }
2158 }
2259
23-func TestSetGetDeleteRange(t *testing.T) {
24- path := privatePath(t)
25- s, err := Open(path)
26- if err != nil {
27- t.Fatal(err)
28- }
29- defer closeStore(t, s)
30-
31- if err := s.Set([]byte("b"), []byte("two")); err != nil {
32- t.Fatal(err)
33- }
34- if err := s.Set([]byte("a"), []byte("one")); err != nil {
35- t.Fatal(err)
36- }
37- got, ok := s.Get([]byte("a"))
38- if !ok || string(got) != "one" {
39- t.Fatalf("Get(a) = %q, %v", got, ok)
40- }
41- got[0] = 'X'
42- got, _ = s.Get([]byte("a"))
43- if string(got) != "one" {
44- t.Fatalf("Get returned mutable value: %q", got)
45- }
46- if err := s.Delete([]byte("b")); err != nil {
47- t.Fatal(err)
48- }
49- if _, ok := s.Get([]byte("b")); ok {
50- t.Fatal("deleted key still present")
51- }
52-
53- seen := map[string]string{}
54- s.Range(func(k, v []byte) bool {
55- seen[string(k)] = string(v)
56- return true
57- })
58- if len(seen) != 1 || seen["a"] != "one" {
59- t.Fatalf("Range saw %#v", seen)
60- }
60+func TestSyncIsAtomicAcrossKeys(t *testing.T) {
61+ t.Parallel()
62+ path := privateStorePath(t)
63+ store, err := Open(path)
64+ require.NoError(t, err)
65+ require.NoError(t, store.Set([]byte("old"), []byte("x")))
66+ require.NoError(t, store.Sync())
67+ require.NoError(t, store.Delete([]byte("old")))
68+ require.NoError(t, store.Set([]byte("new"), []byte("x")))
69+ require.NoError(t, store.Sync())
70+ require.NoError(t, store.Close())
71+
72+ reopened, err := Open(path)
73+ require.NoError(t, err)
74+ t.Cleanup(func() { _ = reopened.Close() })
75+ _, hadOld := reopened.Get([]byte("old"))
76+ _, hasNew := reopened.Get([]byte("new"))
77+ require.False(t, hadOld)
78+ require.True(t, hasNew)
6179 }
6280
63-func TestReopenDurability(t *testing.T) {
64- path := privatePath(t)
65- s, err := Open(path)
66- if err != nil {
67- t.Fatal(err)
68- }
69- if err := s.Set([]byte("k"), []byte("v")); err != nil {
70- t.Fatal(err)
71- }
72- if err := s.Close(); err != nil {
73- t.Fatal(err)
74- }
75-
76- s, err = Open(path)
77- if err != nil {
78- t.Fatal(err)
79- }
80- defer closeStore(t, s)
81- got, ok := s.Get([]byte("k"))
82- if !ok || string(got) != "v" {
83- t.Fatalf("after reopen Get(k) = %q, %v", got, ok)
81+func TestOpenFailsClosedWithoutMutation(t *testing.T) {
82+ t.Parallel()
83+ tests := []struct {
84+ name string
85+ raw []byte
86+ }{
87+ {name: "empty", raw: []byte{}},
88+ {name: "short magic", raw: []byte("VE")},
89+ {name: "unknown magic", raw: func() []byte {
90+ payload := append([]byte(nil), validEmptyFile()[:filePrefixLen]...)
91+ copy(payload[:4], "NOPE")
92+ return appendCRC(payload)
93+ }()},
94+ {name: "unknown version", raw: func() []byte {
95+ payload := append([]byte(nil), validEmptyFile()[:filePrefixLen]...)
96+ binary.BigEndian.PutUint16(payload[4:6], 99)
97+ return appendCRC(payload)
98+ }()},
99+ {name: "truncated entry", raw: func() []byte {
100+ payload := append([]byte(nil), validEmptyFile()[:filePrefixLen]...)
101+ binary.BigEndian.PutUint32(payload[6:10], 1)
102+ payload = append(payload, 0, 0, 0, 1)
103+ return appendCRC(payload)
104+ }()},
105+ }
106+ for _, tt := range tests {
107+ t.Run(tt.name, func(t *testing.T) {
108+ t.Parallel()
109+ path := privateStorePath(t)
110+ require.NoError(t, os.WriteFile(path, tt.raw, 0o600))
111+ _, err := Open(path)
112+ require.ErrorIs(t, err, ErrCorrupt)
113+ after, readErr := os.ReadFile(path)
114+ require.NoError(t, readErr)
115+ require.Equal(t, tt.raw, after)
116+ })
84117 }
85118 }
86119
87-func TestCompactionShrinksWithDataIntact(t *testing.T) {
88- path := privatePath(t)
89- s, err := Open(path)
90- if err != nil {
91- t.Fatal(err)
92- }
93- if err := s.Set([]byte("keep"), bytes.Repeat([]byte("x"), 128)); err != nil {
94- t.Fatal(err)
95- }
96- info, err := os.Stat(path)
97- if err != nil {
98- t.Fatal(err)
99- }
100- before := info.Size()
101- if err := s.Set([]byte("keep"), []byte("y")); err != nil {
102- t.Fatal(err)
103- }
104- info, err = os.Stat(path)
105- if err != nil {
106- t.Fatal(err)
107- }
108- after := info.Size()
109- if after >= before {
110- t.Fatalf("expected compaction to shrink file: before=%d after=%d", before, after)
111- }
112- got, ok := s.Get([]byte("keep"))
113- if !ok || string(got) != "y" {
114- t.Fatalf("compacted value = %q, %v", got, ok)
115- }
116- if err := s.Close(); err != nil {
117- t.Fatal(err)
118- }
120+func TestCloseSyncsDirtyStore(t *testing.T) {
121+ t.Parallel()
122+ path := privateStorePath(t)
123+ store, err := Open(path)
124+ require.NoError(t, err)
125+ require.NoError(t, store.Set([]byte("k"), []byte("v")))
126+ require.NoError(t, store.Close())
127+
128+ reopened, err := Open(path)
129+ require.NoError(t, err)
130+ t.Cleanup(func() { _ = reopened.Close() })
131+ got, ok := reopened.Get([]byte("k"))
132+ require.True(t, ok)
133+ require.Equal(t, []byte("v"), got)
119134 }
120135
121-func TestOpenTruncatesMidRecordTail(t *testing.T) {
122- path := privatePath(t)
123- first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
124- second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
125- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
126- t.Fatal(err)
127- }
128- if err := os.WriteFile(path, append(first, second[:len(second)/2]...), 0o600); err != nil {
129- t.Fatal(err)
130- }
131-
132- s, err := Open(path)
133- if err != nil {
134- t.Fatal(err)
135- }
136- defer closeStore(t, s)
137- if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
138- t.Fatalf("good record missing: %q %v", got, ok)
139- }
140- if _, ok := s.Get([]byte("b")); ok {
141- t.Fatal("torn record was replayed")
142- }
143- info, err := os.Stat(path)
144- if err != nil {
145- t.Fatal(err)
146- }
147- if info.Size() != int64(len(first)) {
148- t.Fatalf("tail not truncated: size=%d want=%d", info.Size(), len(first))
149- }
136+func TestStoreCopiesValuesAndRangesInKeyOrder(t *testing.T) {
137+ t.Parallel()
138+ store, err := Open(privateStorePath(t))
139+ require.NoError(t, err)
140+ t.Cleanup(func() { _ = store.Close() })
141+ value := []byte("two")
142+ require.NoError(t, store.Set([]byte("b"), value))
143+ value[0] = 'x'
144+ require.NoError(t, store.Set([]byte("a"), []byte("one")))
145+ got, ok := store.Get([]byte("b"))
146+ require.True(t, ok)
147+ require.Equal(t, []byte("two"), got)
148+ got[0] = 'x'
149+ gotAgain, _ := store.Get([]byte("b"))
150+ require.Equal(t, []byte("two"), gotAgain)
151+
152+ var keys []string
153+ store.Range(func(k, v []byte) bool {
154+ keys = append(keys, string(k))
155+ return true
156+ })
157+ require.Equal(t, []string{"a", "b"}, keys)
150158 }
151159
152-func TestCRCcorruptionOfLastRecordDropsIt(t *testing.T) {
153- path := privatePath(t)
154- first, _ := encodeRecord(opSet, []byte("k"), []byte("old"))
155- second, _ := encodeRecord(opSet, []byte("k"), []byte("new"))
156- buf := append(append([]byte{}, first...), second...)
157- buf[len(first)+headerLen+payloadPrefixLen+1] ^= 0xff
158- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
159- t.Fatal(err)
160- }
161- if err := os.WriteFile(path, buf, 0o600); err != nil {
162- t.Fatal(err)
163- }
164-
165- s, err := Open(path)
166- if err != nil {
167- t.Fatal(err)
168- }
169- defer closeStore(t, s)
170- got, ok := s.Get([]byte("k"))
171- if !ok || string(got) != "old" {
172- t.Fatalf("corrupt last record not dropped: %q %v", got, ok)
173- }
174- info, err := os.Stat(path)
175- if err != nil {
176- t.Fatal(err)
177- }
178- if info.Size() != int64(len(first)) {
179- t.Fatalf("corrupt tail not truncated: size=%d want=%d", info.Size(), len(first))
180- }
160+func TestStoreLockAndClosedErrors(t *testing.T) {
161+ t.Parallel()
162+ path := privateStorePath(t)
163+ store, err := Open(path)
164+ require.NoError(t, err)
165+ _, err = Open(path)
166+ require.Error(t, err)
167+ require.NoError(t, store.Close())
168+ require.NoError(t, store.Close())
169+ require.ErrorIs(t, store.Set([]byte("k"), []byte("v")), os.ErrClosed)
170+ require.ErrorIs(t, store.Delete([]byte("k")), os.ErrClosed)
171+ require.ErrorIs(t, store.Sync(), os.ErrClosed)
181172 }
182173
183-func TestAbsentAndEmptyFile(t *testing.T) {
184- path := filepath.Join(t.TempDir(), "missing", "store.kv")
185- m, err := Replay(path)
186- if err != nil {
187- t.Fatal(err)
188- }
189- if len(m) != 0 {
190- t.Fatalf("Replay(absent) = %#v", m)
191- }
192- s, err := Open(path)
193- if err != nil {
194- t.Fatal(err)
195- }
196- if err := s.Close(); err != nil {
197- t.Fatal(err)
198- }
199- m, err = Replay(path)
200- if err != nil {
201- t.Fatal(err)
202- }
203- if len(m) != 0 {
204- t.Fatalf("Replay(empty) = %#v", m)
205- }
174+func privateStorePath(t *testing.T) string {
175+ t.Helper()
176+ dir := filepath.Join(t.TempDir(), "vev")
177+ require.NoError(t, os.Mkdir(dir, 0o700))
178+ return filepath.Join(dir, "sessions.kv")
206179 }
207180
208-func TestTombstoneSurvivesCompaction(t *testing.T) {
209- path := privatePath(t)
210- s, err := Open(path)
211- if err != nil {
212- t.Fatal(err)
213- }
214- if err := s.Set([]byte("dead"), bytes.Repeat([]byte("d"), 128)); err != nil {
215- t.Fatal(err)
216- }
217- if err := s.Set([]byte("keep"), bytes.Repeat([]byte("k"), 128)); err != nil {
218- t.Fatal(err)
219- }
220- if err := s.Delete([]byte("dead")); err != nil {
221- t.Fatal(err)
222- }
223- if err := s.Close(); err != nil {
224- t.Fatal(err)
225- }
181+func validEmptyFile() []byte {
182+ return encodeFile(map[string][]byte{})
183+}
226184
227- s, err = Open(path)
228- if err != nil {
229- t.Fatal(err)
230- }
231- defer closeStore(t, s)
232- if _, ok := s.Get([]byte("dead")); ok {
233- t.Fatal("deleted key resurrected after compaction/reopen")
234- }
235- if got, ok := s.Get([]byte("keep")); !ok || len(got) != 128 || got[0] != 'k' {
236- t.Fatalf("live key missing after compaction/reopen: len=%d ok=%v", len(got), ok)
237- }
185+func TestDecodeRejectsDuplicateKeysAndTrailingData(t *testing.T) {
186+ t.Parallel()
187+ payload := make([]byte, 0)
188+ payload = append(payload, fileMagic[:]...)
189+ payload = binary.BigEndian.AppendUint16(payload, fileVersion)
190+ payload = binary.BigEndian.AppendUint32(payload, 2)
191+ for range 2 {
192+ payload = binary.BigEndian.AppendUint32(payload, 1)
193+ payload = append(payload, 'k')
194+ payload = binary.BigEndian.AppendUint32(payload, 1)
195+ payload = append(payload, 'v')
196+ }
197+ raw := appendCRC(payload)
198+ _, err := decodeFile(raw)
199+ require.ErrorIs(t, err, ErrCorrupt)
200+
201+ payload = append([]byte(nil), validEmptyFile()[:filePrefixLen]...)
202+ payload = append(payload, 0)
203+ _, err = decodeFile(appendCRC(payload))
204+ require.True(t, errors.Is(err, ErrCorrupt))
238205 }
239206
240-func TestReplayStopsAtBadPayloadLengthWithoutAllocatingHugeBuffer(t *testing.T) {
241- path := privatePath(t)
242- first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
243- header := make([]byte, headerLen)
244- binary.BigEndian.PutUint32(header[0:4], 1<<31)
245- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
246- t.Fatal(err)
247- }
248- if err := os.WriteFile(path, append(first, header...), 0o600); err != nil {
249- t.Fatal(err)
250- }
251- s, err := Open(path)
252- if err != nil {
253- t.Fatal(err)
254- }
255- defer closeStore(t, s)
256- if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
257- t.Fatalf("good record missing before bad length: %q %v", got, ok)
258- }
207+func TestCloseWithoutSyncPreventsLaterCloseFromSyncing(t *testing.T) {
208+ t.Parallel()
209+ path := privateStorePath(t)
210+ store, err := Open(path)
211+ require.NoError(t, err)
212+ require.NoError(t, store.Set([]byte("rejected"), []byte("value")))
213+ require.NoError(t, store.CloseWithoutSync())
214+ require.NoError(t, store.Close(), "a later normal Close must remain a no-op")
215+
216+ reopened, err := Open(path)
217+ require.NoError(t, err)
218+ t.Cleanup(func() { _ = reopened.Close() })
219+ _, ok := reopened.Get([]byte("rejected"))
220+ require.False(t, ok)
259221 }

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