detached ebfcd26

Merge pull request #5 from bnema/feat/persist-sessions-kv-store

feat(persistence): persist named sessions
commit
ebfcd26e99dab0cc1884ae9fb1e984106863b432
author
bnema · · 2026-07-03 21:33 UTC
committer
GitHub · · 2026-07-03 21:33 UTC
merge of
7cfdd26 + a5ac8f2 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
24 files · +2317 -68
M internal/app/logging.go +3 -15
@@ -6,26 +6,14 @@ import (
66 "os"
77 "path/filepath"
88 "strings"
9+
10+ "github.com/bnema/vev/internal/platform"
911 )
1012
1113 // logFileName is the fixed name of vev's shared log file, used by both the
1214 // daemon and the raw-attached client (which must never log to the console).
1315 const logFileName = "vev.log"
1416
15-// stateDir returns the directory vev writes its log to: $XDG_STATE_HOME/vev
16-// if set, else ~/.local/state/vev.
17-func stateDir() string {
18- if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
19- return filepath.Join(xdg, "vev")
20- }
21- home, err := os.UserHomeDir()
22- if err != nil || home == "" {
23- // Last resort: a per-uid temp path so logging never aborts startup.
24- return filepath.Join(os.TempDir(), fmt.Sprintf("vev-state-%d", os.Getuid()))
25- }
26- return filepath.Join(home, ".local", "state", "vev")
27-}
28-
2917 // parseLogLevel maps VEV_LOG to a slog level, defaulting to info.
3018 func parseLogLevel(s string) slog.Level {
3119 switch strings.ToLower(strings.TrimSpace(s)) {
@@ -48,7 +36,7 @@ func parseLogLevel(s string) slog.Level {
4836 //
4937 // No log rotation is performed for the MVP (future work).
5038 func setupLogging() (*os.File, error) {
51- dir := stateDir()
39+ dir := platform.StateDir()
5240 if err := os.MkdirAll(dir, 0o700); err != nil {
5341 return nil, fmt.Errorf("vev: creating state dir: %w", err)
5442 }
@@ -6,26 +6,14 @@ import (
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8 "strings"8 "strings"
9+
10+ "github.com/bnema/vev/internal/platform"
9 )11 )
10 12
11 // logFileName is the fixed name of vev's shared log file, used by both the13 // logFileName is the fixed name of vev's shared log file, used by both the
12 // daemon and the raw-attached client (which must never log to the console).14 // daemon and the raw-attached client (which must never log to the console).
13 const logFileName = "vev.log"15 const logFileName = "vev.log"
14 16
15-// stateDir returns the directory vev writes its log to: $XDG_STATE_HOME/vev
16-// if set, else ~/.local/state/vev.
17-func stateDir() string {
18- if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
19- return filepath.Join(xdg, "vev")
20- }
21- home, err := os.UserHomeDir()
22- if err != nil || home == "" {
23- // Last resort: a per-uid temp path so logging never aborts startup.
24- return filepath.Join(os.TempDir(), fmt.Sprintf("vev-state-%d", os.Getuid()))
25- }
26- return filepath.Join(home, ".local", "state", "vev")
27-}
28-
29 // parseLogLevel maps VEV_LOG to a slog level, defaulting to info.17 // parseLogLevel maps VEV_LOG to a slog level, defaulting to info.
30 func parseLogLevel(s string) slog.Level {18 func parseLogLevel(s string) slog.Level {
31 switch strings.ToLower(strings.TrimSpace(s)) {19 switch strings.ToLower(strings.TrimSpace(s)) {
@@ -48,7 +36,7 @@ func parseLogLevel(s string) slog.Level {
48 //36 //
49 // No log rotation is performed for the MVP (future work).37 // No log rotation is performed for the MVP (future work).
50 func setupLogging() (*os.File, error) {38 func setupLogging() (*os.File, error) {
51- dir := stateDir()39+ dir := platform.StateDir()
52 if err := os.MkdirAll(dir, 0o700); err != nil {40 if err := os.MkdirAll(dir, 0o700); err != nil {
53 return nil, fmt.Errorf("vev: creating state dir: %w", err)41 return nil, fmt.Errorf("vev: creating state dir: %w", err)
54 }42 }
M internal/app/run.go +64 -7
@@ -24,10 +24,13 @@ import (
2424 "github.com/bnema/vev/internal/adapters/sshstdio"
2525 "github.com/bnema/vev/internal/adapters/term"
2626 "github.com/bnema/vev/internal/domain"
27+ "github.com/bnema/vev/internal/persist"
28+ "github.com/bnema/vev/internal/platform"
2729 "github.com/bnema/vev/internal/ports"
2830 "github.com/bnema/vev/internal/usecase/client"
2931 "github.com/bnema/vev/internal/usecase/confirm"
3032 "github.com/bnema/vev/internal/usecase/daemon"
33+ "github.com/bnema/vev/pkg/kv"
3134 )
3235
3336 // cmdKind identifies which sub-command the CLI parsed.
@@ -223,7 +226,13 @@ func runDaemon() error {
223226 }
224227
225228 slog.Info("daemon starting", "socket", ln.Addr())
226- d := daemon.New(pty.NewFactory(), clock.New(), slog.Default())
229+ daemonOpts := []daemon.Option(nil)
230+ if store, err := kv.Open(persist.StorePath(platform.StateDir())); err != nil {
231+ slog.Warn("opening session store failed; persistence disabled", "err", err)
232+ } else {
233+ daemonOpts = append(daemonOpts, daemon.WithStore(store))
234+ }
235+ d := daemon.New(pty.NewFactory(), clock.New(), slog.Default(), daemonOpts...)
227236 if err := d.Serve(ctx, ln); err != nil {
228237 slog.Error("daemon exited", "err", err)
229238 return err
@@ -482,12 +491,20 @@ func proxyTransports(ctx context.Context, a, b ports.Transport) error {
482491 }
483492 }
484493
485-// runList prints the daemon's session listing. With no daemon running there
486-// are, by definition, no sessions.
494+// runList prints the daemon's session listing. With no daemon running, it
495+// falls back to the persisted stopped-session records.
487496 func runList(_ context.Context) error {
488497 transport, err := realDial(ipc.SocketDir())
489498 if err != nil {
490- fmt.Println("no sessions")
499+ records, loadErr := persist.LoadReadOnly(platform.StateDir())
500+ if loadErr != nil {
501+ return fmt.Errorf("vev: reading stored sessions: %w", loadErr)
502+ }
503+ infos := make([]ports.SessionInfo, 0, len(records))
504+ for _, r := range records {
505+ infos = append(infos, ports.SessionInfo{Name: r.Name, Stopped: true})
506+ }
507+ printSessions(os.Stdout, infos)
491508 return nil
492509 }
493510 defer func() { _ = transport.Close() }()
@@ -521,13 +538,18 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
521538 return
522539 }
523540 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
524- _, _ = fmt.Fprintln(tw, "NAME\tTABS\tATTACHED")
541+ _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED")
525542 for _, s := range sessions {
543+ state := "running"
544+ tabs := fmt.Sprintf("%d", s.Tabs)
526545 attached := "no"
527- if s.Attached {
546+ if s.Stopped {
547+ state = "stopped"
548+ tabs = "-"
549+ } else if s.Attached {
528550 attached = "yes"
529551 }
530- _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Tabs, attached)
552+ _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached)
531553 }
532554 _ = tw.Flush()
533555 }
@@ -536,6 +558,41 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
536558 func runKill(_ context.Context, name string, all, daemon bool) error {
537559 transport, err := realDial(ipc.SocketDir())
538560 if err != nil {
561+ if name != "" && !all && !daemon {
562+ storePath := persist.StorePath(platform.StateDir())
563+ if _, statErr := os.Stat(storePath); errors.Is(statErr, os.ErrNotExist) {
564+ return fmt.Errorf("vev: no such session: %s", name)
565+ } else if statErr != nil {
566+ return fmt.Errorf("vev: reading stored sessions: %w", statErr)
567+ }
568+ records, loadErr := persist.LoadReadOnly(platform.StateDir())
569+ if loadErr != nil {
570+ return fmt.Errorf("vev: reading stored sessions: %w", loadErr)
571+ }
572+ found := false
573+ for _, record := range records {
574+ if record.Name == name {
575+ found = true
576+ break
577+ }
578+ }
579+ if !found {
580+ return fmt.Errorf("vev: no such session: %s", name)
581+ }
582+ p, openErr := persist.Open(platform.StateDir())
583+ if openErr != nil {
584+ return fmt.Errorf("vev: opening stored sessions: %w", openErr)
585+ }
586+ if deleteErr := p.Delete(name); deleteErr != nil {
587+ _ = p.Close()
588+ return fmt.Errorf("vev: deleting stored session: %w", deleteErr)
589+ }
590+ if closeErr := p.Close(); closeErr != nil {
591+ return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
592+ }
593+ printKillSuccess(name, all, daemon)
594+ return nil
595+ }
539596 return fmt.Errorf("vev: no daemon running")
540597 }
541598 defer func() { _ = transport.Close() }()
@@ -24,10 +24,13 @@ import (
24 "github.com/bnema/vev/internal/adapters/sshstdio"24 "github.com/bnema/vev/internal/adapters/sshstdio"
25 "github.com/bnema/vev/internal/adapters/term"25 "github.com/bnema/vev/internal/adapters/term"
26 "github.com/bnema/vev/internal/domain"26 "github.com/bnema/vev/internal/domain"
27+ "github.com/bnema/vev/internal/persist"
28+ "github.com/bnema/vev/internal/platform"
27 "github.com/bnema/vev/internal/ports"29 "github.com/bnema/vev/internal/ports"
28 "github.com/bnema/vev/internal/usecase/client"30 "github.com/bnema/vev/internal/usecase/client"
29 "github.com/bnema/vev/internal/usecase/confirm"31 "github.com/bnema/vev/internal/usecase/confirm"
30 "github.com/bnema/vev/internal/usecase/daemon"32 "github.com/bnema/vev/internal/usecase/daemon"
33+ "github.com/bnema/vev/pkg/kv"
31 )34 )
32 35
33 // cmdKind identifies which sub-command the CLI parsed.36 // cmdKind identifies which sub-command the CLI parsed.
@@ -223,7 +226,13 @@ func runDaemon() error {
223 }226 }
224 227
225 slog.Info("daemon starting", "socket", ln.Addr())228 slog.Info("daemon starting", "socket", ln.Addr())
226- d := daemon.New(pty.NewFactory(), clock.New(), slog.Default())229+ daemonOpts := []daemon.Option(nil)
230+ if store, err := kv.Open(persist.StorePath(platform.StateDir())); err != nil {
231+ slog.Warn("opening session store failed; persistence disabled", "err", err)
232+ } else {
233+ daemonOpts = append(daemonOpts, daemon.WithStore(store))
234+ }
235+ d := daemon.New(pty.NewFactory(), clock.New(), slog.Default(), daemonOpts...)
227 if err := d.Serve(ctx, ln); err != nil {236 if err := d.Serve(ctx, ln); err != nil {
228 slog.Error("daemon exited", "err", err)237 slog.Error("daemon exited", "err", err)
229 return err238 return err
@@ -482,12 +491,20 @@ func proxyTransports(ctx context.Context, a, b ports.Transport) error {
482 }491 }
483 }492 }
484 493
485-// runList prints the daemon's session listing. With no daemon running there494+// runList prints the daemon's session listing. With no daemon running, it
486-// are, by definition, no sessions.495+// falls back to the persisted stopped-session records.
487 func runList(_ context.Context) error {496 func runList(_ context.Context) error {
488 transport, err := realDial(ipc.SocketDir())497 transport, err := realDial(ipc.SocketDir())
489 if err != nil {498 if err != nil {
490- fmt.Println("no sessions")499+ records, loadErr := persist.LoadReadOnly(platform.StateDir())
500+ if loadErr != nil {
501+ return fmt.Errorf("vev: reading stored sessions: %w", loadErr)
502+ }
503+ infos := make([]ports.SessionInfo, 0, len(records))
504+ for _, r := range records {
505+ infos = append(infos, ports.SessionInfo{Name: r.Name, Stopped: true})
506+ }
507+ printSessions(os.Stdout, infos)
491 return nil508 return nil
492 }509 }
493 defer func() { _ = transport.Close() }()510 defer func() { _ = transport.Close() }()
@@ -521,13 +538,18 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
521 return538 return
522 }539 }
523 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)540 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
524- _, _ = fmt.Fprintln(tw, "NAME\tTABS\tATTACHED")541+ _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED")
525 for _, s := range sessions {542 for _, s := range sessions {
543+ state := "running"
544+ tabs := fmt.Sprintf("%d", s.Tabs)
526 attached := "no"545 attached := "no"
527- if s.Attached {546+ if s.Stopped {
547+ state = "stopped"
548+ tabs = "-"
549+ } else if s.Attached {
528 attached = "yes"550 attached = "yes"
529 }551 }
530- _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Tabs, attached)552+ _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached)
531 }553 }
532 _ = tw.Flush()554 _ = tw.Flush()
533 }555 }
@@ -536,6 +558,41 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
536 func runKill(_ context.Context, name string, all, daemon bool) error {558 func runKill(_ context.Context, name string, all, daemon bool) error {
537 transport, err := realDial(ipc.SocketDir())559 transport, err := realDial(ipc.SocketDir())
538 if err != nil {560 if err != nil {
561+ if name != "" && !all && !daemon {
562+ storePath := persist.StorePath(platform.StateDir())
563+ if _, statErr := os.Stat(storePath); errors.Is(statErr, os.ErrNotExist) {
564+ return fmt.Errorf("vev: no such session: %s", name)
565+ } else if statErr != nil {
566+ return fmt.Errorf("vev: reading stored sessions: %w", statErr)
567+ }
568+ records, loadErr := persist.LoadReadOnly(platform.StateDir())
569+ if loadErr != nil {
570+ return fmt.Errorf("vev: reading stored sessions: %w", loadErr)
571+ }
572+ found := false
573+ for _, record := range records {
574+ if record.Name == name {
575+ found = true
576+ break
577+ }
578+ }
579+ if !found {
580+ return fmt.Errorf("vev: no such session: %s", name)
581+ }
582+ p, openErr := persist.Open(platform.StateDir())
583+ if openErr != nil {
584+ return fmt.Errorf("vev: opening stored sessions: %w", openErr)
585+ }
586+ if deleteErr := p.Delete(name); deleteErr != nil {
587+ _ = p.Close()
588+ return fmt.Errorf("vev: deleting stored session: %w", deleteErr)
589+ }
590+ if closeErr := p.Close(); closeErr != nil {
591+ return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
592+ }
593+ printKillSuccess(name, all, daemon)
594+ return nil
595+ }
539 return fmt.Errorf("vev: no daemon running")596 return fmt.Errorf("vev: no daemon running")
540 }597 }
541 defer func() { _ = transport.Close() }()598 defer func() { _ = transport.Close() }()
M internal/app/run_test.go +123 -0
@@ -4,9 +4,14 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "io"
8+ "os"
9+ "path/filepath"
710 "strings"
811 "testing"
12+ "time"
913
14+ "github.com/bnema/vev/internal/persist"
1015 "github.com/bnema/vev/internal/ports"
1116 "github.com/bnema/vev/internal/usecase/client"
1217 "github.com/bnema/vev/internal/usecase/confirm"
@@ -96,6 +101,124 @@ func TestParseArgs(t *testing.T) {
96101 }
97102 }
98103
104+func TestPrintSessionsShowsStoppedState(t *testing.T) {
105+ var out bytes.Buffer
106+ printSessions(&out, []ports.SessionInfo{
107+ {Name: "main", Tabs: 2, Attached: true},
108+ {Name: "old", Stopped: true},
109+ })
110+ got := out.String()
111+ for _, want := range []string{"NAME", "STATE", "main", "running", "2", "yes", "old", "stopped", "-"} {
112+ if !strings.Contains(got, want) {
113+ t.Fatalf("printSessions output %q missing %q", got, want)
114+ }
115+ }
116+}
117+
118+func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
119+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
120+ t.Setenv("XDG_STATE_HOME", stateRoot)
121+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
122+
123+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
124+ if err != nil {
125+ t.Fatalf("persist.Open error = %v", err)
126+ }
127+ now := time.Now().UnixNano()
128+ if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
129+ t.Fatalf("Save error = %v", err)
130+ }
131+ if err := p.Close(); err != nil {
132+ t.Fatalf("Close error = %v", err)
133+ }
134+
135+ got := captureStdout(t, func() {
136+ if err := runList(context.Background()); err != nil {
137+ t.Fatalf("runList error = %v", err)
138+ }
139+ })
140+ for _, want := range []string{"stored", "stopped", "-"} {
141+ if !strings.Contains(got, want) {
142+ t.Fatalf("runList output %q missing %q", got, want)
143+ }
144+ }
145+}
146+
147+func TestRunKillMissingStoppedSessionDoesNotCreateStore(t *testing.T) {
148+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
149+ t.Setenv("XDG_STATE_HOME", stateRoot)
150+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
151+
152+ err := runKill(context.Background(), "missing", false, false)
153+ if err == nil || !strings.Contains(err.Error(), "no such session: missing") {
154+ t.Fatalf("runKill error = %v, want no such session", err)
155+ }
156+ if _, statErr := os.Stat(filepath.Join(stateRoot, "vev", "sessions.kv")); !errors.Is(statErr, os.ErrNotExist) {
157+ t.Fatalf("sessions.kv stat error = %v, want not exist", statErr)
158+ }
159+}
160+
161+func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
162+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
163+ t.Setenv("XDG_STATE_HOME", stateRoot)
164+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
165+
166+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
167+ if err != nil {
168+ t.Fatalf("persist.Open error = %v", err)
169+ }
170+ now := time.Now().UnixNano()
171+ if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
172+ t.Fatalf("Save error = %v", err)
173+ }
174+ if err := p.Close(); err != nil {
175+ t.Fatalf("Close error = %v", err)
176+ }
177+
178+ got := captureStdout(t, func() {
179+ if err := runKill(context.Background(), "stored", false, false); err != nil {
180+ t.Fatalf("runKill error = %v", err)
181+ }
182+ })
183+ if !strings.Contains(got, "killed stored") {
184+ t.Fatalf("runKill output = %q, want success", got)
185+ }
186+ records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
187+ if err != nil {
188+ t.Fatalf("LoadReadOnly error = %v", err)
189+ }
190+ if len(records) != 0 {
191+ t.Fatalf("records after kill = %#v, want none", records)
192+ }
193+}
194+
195+func captureStdout(t *testing.T, fn func()) string {
196+ t.Helper()
197+ old := os.Stdout
198+ r, w, err := os.Pipe()
199+ if err != nil {
200+ t.Fatalf("os.Pipe error = %v", err)
201+ }
202+ os.Stdout = w
203+ defer func() { os.Stdout = old }()
204+ defer func() { _ = r.Close() }()
205+ outCh := make(chan []byte, 1)
206+ errCh := make(chan error, 1)
207+ go func() {
208+ out, err := io.ReadAll(r)
209+ outCh <- out
210+ errCh <- err
211+ }()
212+
213+ fn()
214+ _ = w.Close()
215+ out := <-outCh
216+ if err := <-errCh; err != nil {
217+ t.Fatalf("ReadAll stdout error = %v", err)
218+ }
219+ return string(out)
220+}
221+
99222 func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) {
100223 var prompts bytes.Buffer
101224 answers := strings.NewReader("y\n")
@@ -4,9 +4,14 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7+ "io"
8+ "os"
9+ "path/filepath"
7 "strings"10 "strings"
8 "testing"11 "testing"
12+ "time"
9 13
14+ "github.com/bnema/vev/internal/persist"
10 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/client"16 "github.com/bnema/vev/internal/usecase/client"
12 "github.com/bnema/vev/internal/usecase/confirm"17 "github.com/bnema/vev/internal/usecase/confirm"
@@ -96,6 +101,124 @@ func TestParseArgs(t *testing.T) {
96 }101 }
97 }102 }
98 103
104+func TestPrintSessionsShowsStoppedState(t *testing.T) {
105+ var out bytes.Buffer
106+ printSessions(&out, []ports.SessionInfo{
107+ {Name: "main", Tabs: 2, Attached: true},
108+ {Name: "old", Stopped: true},
109+ })
110+ got := out.String()
111+ for _, want := range []string{"NAME", "STATE", "main", "running", "2", "yes", "old", "stopped", "-"} {
112+ if !strings.Contains(got, want) {
113+ t.Fatalf("printSessions output %q missing %q", got, want)
114+ }
115+ }
116+}
117+
118+func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
119+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
120+ t.Setenv("XDG_STATE_HOME", stateRoot)
121+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
122+
123+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
124+ if err != nil {
125+ t.Fatalf("persist.Open error = %v", err)
126+ }
127+ now := time.Now().UnixNano()
128+ if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
129+ t.Fatalf("Save error = %v", err)
130+ }
131+ if err := p.Close(); err != nil {
132+ t.Fatalf("Close error = %v", err)
133+ }
134+
135+ got := captureStdout(t, func() {
136+ if err := runList(context.Background()); err != nil {
137+ t.Fatalf("runList error = %v", err)
138+ }
139+ })
140+ for _, want := range []string{"stored", "stopped", "-"} {
141+ if !strings.Contains(got, want) {
142+ t.Fatalf("runList output %q missing %q", got, want)
143+ }
144+ }
145+}
146+
147+func TestRunKillMissingStoppedSessionDoesNotCreateStore(t *testing.T) {
148+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
149+ t.Setenv("XDG_STATE_HOME", stateRoot)
150+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
151+
152+ err := runKill(context.Background(), "missing", false, false)
153+ if err == nil || !strings.Contains(err.Error(), "no such session: missing") {
154+ t.Fatalf("runKill error = %v, want no such session", err)
155+ }
156+ if _, statErr := os.Stat(filepath.Join(stateRoot, "vev", "sessions.kv")); !errors.Is(statErr, os.ErrNotExist) {
157+ t.Fatalf("sessions.kv stat error = %v, want not exist", statErr)
158+ }
159+}
160+
161+func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
162+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
163+ t.Setenv("XDG_STATE_HOME", stateRoot)
164+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
165+
166+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
167+ if err != nil {
168+ t.Fatalf("persist.Open error = %v", err)
169+ }
170+ now := time.Now().UnixNano()
171+ if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
172+ t.Fatalf("Save error = %v", err)
173+ }
174+ if err := p.Close(); err != nil {
175+ t.Fatalf("Close error = %v", err)
176+ }
177+
178+ got := captureStdout(t, func() {
179+ if err := runKill(context.Background(), "stored", false, false); err != nil {
180+ t.Fatalf("runKill error = %v", err)
181+ }
182+ })
183+ if !strings.Contains(got, "killed stored") {
184+ t.Fatalf("runKill output = %q, want success", got)
185+ }
186+ records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
187+ if err != nil {
188+ t.Fatalf("LoadReadOnly error = %v", err)
189+ }
190+ if len(records) != 0 {
191+ t.Fatalf("records after kill = %#v, want none", records)
192+ }
193+}
194+
195+func captureStdout(t *testing.T, fn func()) string {
196+ t.Helper()
197+ old := os.Stdout
198+ r, w, err := os.Pipe()
199+ if err != nil {
200+ t.Fatalf("os.Pipe error = %v", err)
201+ }
202+ os.Stdout = w
203+ defer func() { os.Stdout = old }()
204+ defer func() { _ = r.Close() }()
205+ outCh := make(chan []byte, 1)
206+ errCh := make(chan error, 1)
207+ go func() {
208+ out, err := io.ReadAll(r)
209+ outCh <- out
210+ errCh <- err
211+ }()
212+
213+ fn()
214+ _ = w.Close()
215+ out := <-outCh
216+ if err := <-errCh; err != nil {
217+ t.Fatalf("ReadAll stdout error = %v", err)
218+ }
219+ return string(out)
220+}
221+
99 func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) {222 func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) {
100 var prompts bytes.Buffer223 var prompts bytes.Buffer
101 answers := strings.NewReader("y\n")224 answers := strings.NewReader("y\n")
A internal/persist/persist.go +133 -0
@@ -0,0 +1,133 @@
1+package persist
2+
3+import (
4+ "log/slog"
5+ "path/filepath"
6+ "sort"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/kv"
10+)
11+
12+const filename = "sessions.kv"
13+
14+// StorePath returns the canonical path for the persisted session metadata store.
15+func StorePath(dir string) string {
16+ return filepath.Join(dir, filename)
17+}
18+
19+// Persister stores named session metadata. A nil Store makes all mutating
20+// operations no-ops, so callers can keep persistence optional.
21+type Persister struct {
22+ store ports.Store
23+}
24+
25+// Open opens the session persister under dir.
26+func Open(dir string) (*Persister, error) {
27+ store, err := kv.Open(StorePath(dir))
28+ if err != nil {
29+ return nil, err
30+ }
31+ return New(store), nil
32+}
33+
34+// New wraps store in a Persister. Passing nil creates a no-op persister.
35+func New(store ports.Store) *Persister {
36+ return &Persister{store: store}
37+}
38+
39+// LoadReadOnly replays the session store under dir without mutating it.
40+func LoadReadOnly(dir string) ([]Record, error) {
41+ data, err := kv.Replay(StorePath(dir))
42+ if err != nil {
43+ return nil, err
44+ }
45+ return decodeAll(data)
46+}
47+
48+// Save persists a structural session record and syncs it to disk.
49+func (p *Persister) Save(r Record) error {
50+ if p == nil || p.store == nil {
51+ return nil
52+ }
53+ value, err := encodeRecordValue(r)
54+ if err != nil {
55+ return err
56+ }
57+ if err := p.store.Set([]byte(r.Name), value); err != nil {
58+ return err
59+ }
60+ return p.store.Sync()
61+}
62+
63+// Touch updates hot-path session cwd metadata without forcing an fsync.
64+func (p *Persister) Touch(name, cwd string, at int64) error {
65+ if p == nil || p.store == nil {
66+ return nil
67+ }
68+ if name == "" {
69+ return errEmptyName
70+ }
71+
72+ createdAt := at
73+ if v, ok := p.store.Get([]byte(name)); ok {
74+ if r, err := decodeRecordValue(name, v); err == nil {
75+ createdAt = r.CreatedAt
76+ }
77+ }
78+
79+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at})
80+ if err != nil {
81+ return err
82+ }
83+ return p.store.Set([]byte(name), value)
84+}
85+
86+// Delete removes a persisted session record and syncs the structural change.
87+func (p *Persister) Delete(name string) error {
88+ if p == nil || p.store == nil {
89+ return nil
90+ }
91+ if name == "" {
92+ return errEmptyName
93+ }
94+ if err := p.store.Delete([]byte(name)); err != nil {
95+ return err
96+ }
97+ return p.store.Sync()
98+}
99+
100+// LoadAll returns all persisted session records sorted by name.
101+func (p *Persister) LoadAll() ([]Record, error) {
102+ if p == nil || p.store == nil {
103+ return nil, nil
104+ }
105+ data := make(map[string][]byte)
106+ p.store.Range(func(k, v []byte) bool {
107+ data[string(k)] = append([]byte(nil), v...)
108+ return true
109+ })
110+ return decodeAll(data)
111+}
112+
113+// Close closes the underlying store.
114+func (p *Persister) Close() error {
115+ if p == nil || p.store == nil {
116+ return nil
117+ }
118+ return p.store.Close()
119+}
120+
121+func decodeAll(data map[string][]byte) ([]Record, error) {
122+ records := make([]Record, 0, len(data))
123+ for name, value := range data {
124+ r, err := decodeRecordValue(name, value)
125+ if err != nil {
126+ slog.Warn("skipping malformed persisted session", "session", name, "err", err)
127+ continue
128+ }
129+ records = append(records, r)
130+ }
131+ sort.Slice(records, func(i, j int) bool { return records[i].Name < records[j].Name })
132+ return records, nil
133+}
@@ -0,0 +1,133 @@
1+package persist
2+
3+import (
4+ "log/slog"
5+ "path/filepath"
6+ "sort"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/kv"
10+)
11+
12+const filename = "sessions.kv"
13+
14+// StorePath returns the canonical path for the persisted session metadata store.
15+func StorePath(dir string) string {
16+ return filepath.Join(dir, filename)
17+}
18+
19+// Persister stores named session metadata. A nil Store makes all mutating
20+// operations no-ops, so callers can keep persistence optional.
21+type Persister struct {
22+ store ports.Store
23+}
24+
25+// Open opens the session persister under dir.
26+func Open(dir string) (*Persister, error) {
27+ store, err := kv.Open(StorePath(dir))
28+ if err != nil {
29+ return nil, err
30+ }
31+ return New(store), nil
32+}
33+
34+// New wraps store in a Persister. Passing nil creates a no-op persister.
35+func New(store ports.Store) *Persister {
36+ return &Persister{store: store}
37+}
38+
39+// LoadReadOnly replays the session store under dir without mutating it.
40+func LoadReadOnly(dir string) ([]Record, error) {
41+ data, err := kv.Replay(StorePath(dir))
42+ if err != nil {
43+ return nil, err
44+ }
45+ return decodeAll(data)
46+}
47+
48+// Save persists a structural session record and syncs it to disk.
49+func (p *Persister) Save(r Record) error {
50+ if p == nil || p.store == nil {
51+ return nil
52+ }
53+ value, err := encodeRecordValue(r)
54+ if err != nil {
55+ return err
56+ }
57+ if err := p.store.Set([]byte(r.Name), value); err != nil {
58+ return err
59+ }
60+ return p.store.Sync()
61+}
62+
63+// Touch updates hot-path session cwd metadata without forcing an fsync.
64+func (p *Persister) Touch(name, cwd string, at int64) error {
65+ if p == nil || p.store == nil {
66+ return nil
67+ }
68+ if name == "" {
69+ return errEmptyName
70+ }
71+
72+ createdAt := at
73+ if v, ok := p.store.Get([]byte(name)); ok {
74+ if r, err := decodeRecordValue(name, v); err == nil {
75+ createdAt = r.CreatedAt
76+ }
77+ }
78+
79+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at})
80+ if err != nil {
81+ return err
82+ }
83+ return p.store.Set([]byte(name), value)
84+}
85+
86+// Delete removes a persisted session record and syncs the structural change.
87+func (p *Persister) Delete(name string) error {
88+ if p == nil || p.store == nil {
89+ return nil
90+ }
91+ if name == "" {
92+ return errEmptyName
93+ }
94+ if err := p.store.Delete([]byte(name)); err != nil {
95+ return err
96+ }
97+ return p.store.Sync()
98+}
99+
100+// LoadAll returns all persisted session records sorted by name.
101+func (p *Persister) LoadAll() ([]Record, error) {
102+ if p == nil || p.store == nil {
103+ return nil, nil
104+ }
105+ data := make(map[string][]byte)
106+ p.store.Range(func(k, v []byte) bool {
107+ data[string(k)] = append([]byte(nil), v...)
108+ return true
109+ })
110+ return decodeAll(data)
111+}
112+
113+// Close closes the underlying store.
114+func (p *Persister) Close() error {
115+ if p == nil || p.store == nil {
116+ return nil
117+ }
118+ return p.store.Close()
119+}
120+
121+func decodeAll(data map[string][]byte) ([]Record, error) {
122+ records := make([]Record, 0, len(data))
123+ for name, value := range data {
124+ r, err := decodeRecordValue(name, value)
125+ if err != nil {
126+ slog.Warn("skipping malformed persisted session", "session", name, "err", err)
127+ continue
128+ }
129+ records = append(records, r)
130+ }
131+ sort.Slice(records, func(i, j int) bool { return records[i].Name < records[j].Name })
132+ return records, nil
133+}
A internal/persist/persist_test.go +191 -0
@@ -0,0 +1,191 @@
1+package persist
2+
3+import (
4+ "errors"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+)
13+
14+func TestRecordCodecRoundTrip(t *testing.T) {
15+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22}
16+ value, err := encodeRecordValue(r)
17+ require.NoError(t, err)
18+
19+ got, err := decodeRecordValue(r.Name, value)
20+ require.NoError(t, err)
21+ require.Equal(t, r, got)
22+}
23+
24+func TestRecordCodecRejectsMalformedData(t *testing.T) {
25+ _, err := encodeRecordValue(Record{Cwd: "/tmp"})
26+ require.ErrorIs(t, err, errEmptyName)
27+
28+ badValues := [][]byte{
29+ nil,
30+ {0, 0, 0},
31+ {0, 0, 0, 5, 'a'},
32+ {0, 0, 0, 1, 'a', 0},
33+ {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
34+ {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
35+ }
36+ for _, value := range badValues {
37+ _, err := decodeRecordValue("name", value)
38+ require.ErrorIs(t, err, errMalformedRecord)
39+ }
40+
41+ _, err = decodeRecordValue("", make([]byte, 20))
42+ require.ErrorIs(t, err, errEmptyName)
43+}
44+
45+func TestNilPersisterNoOps(t *testing.T) {
46+ var p *Persister
47+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
48+ require.NoError(t, p.Touch("a", "/tmp/next", 2))
49+ require.NoError(t, p.Delete("a"))
50+ records, err := p.LoadAll()
51+ require.NoError(t, err)
52+ require.Empty(t, records)
53+ require.NoError(t, p.Close())
54+
55+ p = New(nil)
56+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
57+ require.NoError(t, p.Touch("a", "/tmp/next", 2))
58+ require.NoError(t, p.Delete("a"))
59+ records, err = p.LoadAll()
60+ require.NoError(t, err)
61+ require.Empty(t, records)
62+ require.NoError(t, p.Close())
63+}
64+
65+func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
66+ dir := t.TempDir()
67+ p, err := Open(dir)
68+ require.NoError(t, err)
69+
70+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10}))
71+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}))
72+ require.NoError(t, p.Touch("b", "/b/next", 30))
73+
74+ records, err := p.LoadAll()
75+ require.NoError(t, err)
76+ require.Equal(t, []Record{
77+ {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20},
78+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30},
79+ }, records)
80+
81+ require.NoError(t, p.Delete("a"))
82+ records, err = p.LoadAll()
83+ require.NoError(t, err)
84+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}}, records)
85+ require.NoError(t, p.Close())
86+}
87+
88+func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
89+ good, err := encodeRecordValue(Record{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2})
90+ require.NoError(t, err)
91+
92+ records, err := decodeAll(map[string][]byte{
93+ "bad": []byte{0, 0, 0, 5, 'x'},
94+ "good": good,
95+ })
96+ require.NoError(t, err)
97+ require.Equal(t, []Record{{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2}}, records)
98+}
99+
100+func TestLoadReadOnlyUsesReplay(t *testing.T) {
101+ dir := t.TempDir()
102+ p, err := Open(dir)
103+ require.NoError(t, err)
104+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}))
105+ require.NoError(t, p.Close())
106+
107+ records, err := LoadReadOnly(dir)
108+ require.NoError(t, err)
109+ require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}}, records)
110+
111+ // Missing stores replay as empty rather than creating the WAL.
112+ missing := filepath.Join(t.TempDir(), "missing")
113+ records, err = LoadReadOnly(missing)
114+ require.NoError(t, err)
115+ require.Empty(t, records)
116+}
117+
118+func TestSyncBehavior(t *testing.T) {
119+ store, state := newMockStore(t)
120+ p := New(store)
121+
122+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
123+ require.Equal(t, 1, state.sets)
124+ require.Equal(t, 1, state.syncs)
125+
126+ require.NoError(t, p.Touch("work", "/work/next", 2))
127+ require.Equal(t, 2, state.sets)
128+ require.Equal(t, 1, state.syncs)
129+
130+ require.NoError(t, p.Delete("work"))
131+ require.Equal(t, 1, state.deletes)
132+ require.Equal(t, 2, state.syncs)
133+}
134+
135+func TestSavePropagatesSetErrorWithoutSync(t *testing.T) {
136+ store, state := newMockStore(t)
137+ state.errSet = errors.New("set failed")
138+ p := New(store)
139+
140+ err := p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1})
141+ require.ErrorIs(t, err, state.errSet)
142+ require.Zero(t, state.syncs)
143+}
144+
145+type mockStoreState struct {
146+ data map[string][]byte
147+ sets int
148+ deletes int
149+ syncs int
150+ closed bool
151+ errSet error
152+}
153+
154+func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
155+ t.Helper()
156+ state := &mockStoreState{data: make(map[string][]byte)}
157+ store := portsmocks.NewMockStore(t)
158+ store.EXPECT().Get(mock.Anything).RunAndReturn(func(key []byte) ([]byte, bool) {
159+ v, ok := state.data[string(key)]
160+ return append([]byte(nil), v...), ok
161+ }).Maybe()
162+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, val []byte) error {
163+ state.sets++
164+ if state.errSet != nil {
165+ return state.errSet
166+ }
167+ state.data[string(key)] = append([]byte(nil), val...)
168+ return nil
169+ }).Maybe()
170+ store.EXPECT().Delete(mock.Anything).RunAndReturn(func(key []byte) error {
171+ state.deletes++
172+ delete(state.data, string(key))
173+ return nil
174+ }).Maybe()
175+ store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) {
176+ for k, v := range state.data {
177+ if !fn([]byte(k), append([]byte(nil), v...)) {
178+ return
179+ }
180+ }
181+ }).Maybe()
182+ store.EXPECT().Sync().RunAndReturn(func() error {
183+ state.syncs++
184+ return nil
185+ }).Maybe()
186+ store.EXPECT().Close().RunAndReturn(func() error {
187+ state.closed = true
188+ return nil
189+ }).Maybe()
190+ return store, state
191+}
@@ -0,0 +1,191 @@
1+package persist
2+
3+import (
4+ "errors"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+)
13+
14+func TestRecordCodecRoundTrip(t *testing.T) {
15+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22}
16+ value, err := encodeRecordValue(r)
17+ require.NoError(t, err)
18+
19+ got, err := decodeRecordValue(r.Name, value)
20+ require.NoError(t, err)
21+ require.Equal(t, r, got)
22+}
23+
24+func TestRecordCodecRejectsMalformedData(t *testing.T) {
25+ _, err := encodeRecordValue(Record{Cwd: "/tmp"})
26+ require.ErrorIs(t, err, errEmptyName)
27+
28+ badValues := [][]byte{
29+ nil,
30+ {0, 0, 0},
31+ {0, 0, 0, 5, 'a'},
32+ {0, 0, 0, 1, 'a', 0},
33+ {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
34+ {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
35+ }
36+ for _, value := range badValues {
37+ _, err := decodeRecordValue("name", value)
38+ require.ErrorIs(t, err, errMalformedRecord)
39+ }
40+
41+ _, err = decodeRecordValue("", make([]byte, 20))
42+ require.ErrorIs(t, err, errEmptyName)
43+}
44+
45+func TestNilPersisterNoOps(t *testing.T) {
46+ var p *Persister
47+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
48+ require.NoError(t, p.Touch("a", "/tmp/next", 2))
49+ require.NoError(t, p.Delete("a"))
50+ records, err := p.LoadAll()
51+ require.NoError(t, err)
52+ require.Empty(t, records)
53+ require.NoError(t, p.Close())
54+
55+ p = New(nil)
56+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
57+ require.NoError(t, p.Touch("a", "/tmp/next", 2))
58+ require.NoError(t, p.Delete("a"))
59+ records, err = p.LoadAll()
60+ require.NoError(t, err)
61+ require.Empty(t, records)
62+ require.NoError(t, p.Close())
63+}
64+
65+func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
66+ dir := t.TempDir()
67+ p, err := Open(dir)
68+ require.NoError(t, err)
69+
70+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10}))
71+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}))
72+ require.NoError(t, p.Touch("b", "/b/next", 30))
73+
74+ records, err := p.LoadAll()
75+ require.NoError(t, err)
76+ require.Equal(t, []Record{
77+ {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20},
78+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30},
79+ }, records)
80+
81+ require.NoError(t, p.Delete("a"))
82+ records, err = p.LoadAll()
83+ require.NoError(t, err)
84+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}}, records)
85+ require.NoError(t, p.Close())
86+}
87+
88+func TestDecodeAllSkipsMalformedRecords(t *testing.T) {
89+ good, err := encodeRecordValue(Record{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2})
90+ require.NoError(t, err)
91+
92+ records, err := decodeAll(map[string][]byte{
93+ "bad": []byte{0, 0, 0, 5, 'x'},
94+ "good": good,
95+ })
96+ require.NoError(t, err)
97+ require.Equal(t, []Record{{Name: "good", Cwd: "/ok", CreatedAt: 1, UpdatedAt: 2}}, records)
98+}
99+
100+func TestLoadReadOnlyUsesReplay(t *testing.T) {
101+ dir := t.TempDir()
102+ p, err := Open(dir)
103+ require.NoError(t, err)
104+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}))
105+ require.NoError(t, p.Close())
106+
107+ records, err := LoadReadOnly(dir)
108+ require.NoError(t, err)
109+ require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}}, records)
110+
111+ // Missing stores replay as empty rather than creating the WAL.
112+ missing := filepath.Join(t.TempDir(), "missing")
113+ records, err = LoadReadOnly(missing)
114+ require.NoError(t, err)
115+ require.Empty(t, records)
116+}
117+
118+func TestSyncBehavior(t *testing.T) {
119+ store, state := newMockStore(t)
120+ p := New(store)
121+
122+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
123+ require.Equal(t, 1, state.sets)
124+ require.Equal(t, 1, state.syncs)
125+
126+ require.NoError(t, p.Touch("work", "/work/next", 2))
127+ require.Equal(t, 2, state.sets)
128+ require.Equal(t, 1, state.syncs)
129+
130+ require.NoError(t, p.Delete("work"))
131+ require.Equal(t, 1, state.deletes)
132+ require.Equal(t, 2, state.syncs)
133+}
134+
135+func TestSavePropagatesSetErrorWithoutSync(t *testing.T) {
136+ store, state := newMockStore(t)
137+ state.errSet = errors.New("set failed")
138+ p := New(store)
139+
140+ err := p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1})
141+ require.ErrorIs(t, err, state.errSet)
142+ require.Zero(t, state.syncs)
143+}
144+
145+type mockStoreState struct {
146+ data map[string][]byte
147+ sets int
148+ deletes int
149+ syncs int
150+ closed bool
151+ errSet error
152+}
153+
154+func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
155+ t.Helper()
156+ state := &mockStoreState{data: make(map[string][]byte)}
157+ store := portsmocks.NewMockStore(t)
158+ store.EXPECT().Get(mock.Anything).RunAndReturn(func(key []byte) ([]byte, bool) {
159+ v, ok := state.data[string(key)]
160+ return append([]byte(nil), v...), ok
161+ }).Maybe()
162+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, val []byte) error {
163+ state.sets++
164+ if state.errSet != nil {
165+ return state.errSet
166+ }
167+ state.data[string(key)] = append([]byte(nil), val...)
168+ return nil
169+ }).Maybe()
170+ store.EXPECT().Delete(mock.Anything).RunAndReturn(func(key []byte) error {
171+ state.deletes++
172+ delete(state.data, string(key))
173+ return nil
174+ }).Maybe()
175+ store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) {
176+ for k, v := range state.data {
177+ if !fn([]byte(k), append([]byte(nil), v...)) {
178+ return
179+ }
180+ }
181+ }).Maybe()
182+ store.EXPECT().Sync().RunAndReturn(func() error {
183+ state.syncs++
184+ return nil
185+ }).Maybe()
186+ store.EXPECT().Close().RunAndReturn(func() error {
187+ state.closed = true
188+ return nil
189+ }).Maybe()
190+ return store, state
191+}
A internal/persist/record.go +63 -0
@@ -0,0 +1,63 @@
1+package persist
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "fmt"
7+ "math"
8+)
9+
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+}
17+
18+var (
19+ errEmptyName = errors.New("persist: empty session name")
20+ errMalformedRecord = errors.New("persist: malformed record")
21+)
22+
23+func encodeRecordValue(r Record) ([]byte, error) {
24+ if r.Name == "" {
25+ return nil, errEmptyName
26+ }
27+ if uint64(len(r.Cwd)) > math.MaxUint32 {
28+ return nil, fmt.Errorf("persist: cwd too large")
29+ }
30+
31+ buf := make([]byte, 4+len(r.Cwd)+8+8)
32+ binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
33+ copy(buf[4:], r.Cwd)
34+ off := 4 + len(r.Cwd)
35+ binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
36+ binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
37+ return buf, nil
38+}
39+
40+func decodeRecordValue(name string, value []byte) (Record, error) {
41+ if name == "" {
42+ return Record{}, errEmptyName
43+ }
44+ if len(value) < 4 {
45+ return Record{}, errMalformedRecord
46+ }
47+ cwdLen32 := binary.BigEndian.Uint32(value[:4])
48+ remaining := len(value) - 4
49+ if uint64(cwdLen32) > uint64(remaining) {
50+ return Record{}, errMalformedRecord
51+ }
52+ cwdLen := int(cwdLen32)
53+ if len(value) != 4+cwdLen+8+8 {
54+ return Record{}, errMalformedRecord
55+ }
56+ off := 4 + cwdLen
57+ return Record{
58+ Name: name,
59+ Cwd: string(value[4:off]),
60+ CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
61+ UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
62+ }, nil
63+}
@@ -0,0 +1,63 @@
1+package persist
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "fmt"
7+ "math"
8+)
9+
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+}
17+
18+var (
19+ errEmptyName = errors.New("persist: empty session name")
20+ errMalformedRecord = errors.New("persist: malformed record")
21+)
22+
23+func encodeRecordValue(r Record) ([]byte, error) {
24+ if r.Name == "" {
25+ return nil, errEmptyName
26+ }
27+ if uint64(len(r.Cwd)) > math.MaxUint32 {
28+ return nil, fmt.Errorf("persist: cwd too large")
29+ }
30+
31+ buf := make([]byte, 4+len(r.Cwd)+8+8)
32+ binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
33+ copy(buf[4:], r.Cwd)
34+ off := 4 + len(r.Cwd)
35+ binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
36+ binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
37+ return buf, nil
38+}
39+
40+func decodeRecordValue(name string, value []byte) (Record, error) {
41+ if name == "" {
42+ return Record{}, errEmptyName
43+ }
44+ if len(value) < 4 {
45+ return Record{}, errMalformedRecord
46+ }
47+ cwdLen32 := binary.BigEndian.Uint32(value[:4])
48+ remaining := len(value) - 4
49+ if uint64(cwdLen32) > uint64(remaining) {
50+ return Record{}, errMalformedRecord
51+ }
52+ cwdLen := int(cwdLen32)
53+ if len(value) != 4+cwdLen+8+8 {
54+ return Record{}, errMalformedRecord
55+ }
56+ off := 4 + cwdLen
57+ return Record{
58+ Name: name,
59+ Cwd: string(value[4:off]),
60+ CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
61+ UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
62+ }, nil
63+}
M internal/platform/dir.go +19 -1
@@ -1,6 +1,24 @@
11 package platform
22
3-import "os"
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+)
8+
9+// StateDir returns the directory vev writes state to: $XDG_STATE_HOME/vev if
10+// set, else ~/.local/state/vev.
11+func StateDir() string {
12+ if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
13+ return filepath.Join(xdg, "vev")
14+ }
15+ home, err := os.UserHomeDir()
16+ if err != nil || home == "" {
17+ // Last resort: a per-uid temp path so state setup never aborts startup.
18+ return filepath.Join(os.TempDir(), fmt.Sprintf("vev-state-%d", os.Getuid()))
19+ }
20+ return filepath.Join(home, ".local", "state", "vev")
21+}
422
523 // DirOrHome returns dir when it names an existing directory. Otherwise it
624 // falls back to the current user's home directory, then to / as a last resort.
@@ -1,6 +1,24 @@
1 package platform1 package platform
2 2
3-import "os"3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+)
8+
9+// StateDir returns the directory vev writes state to: $XDG_STATE_HOME/vev if
10+// set, else ~/.local/state/vev.
11+func StateDir() string {
12+ if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
13+ return filepath.Join(xdg, "vev")
14+ }
15+ home, err := os.UserHomeDir()
16+ if err != nil || home == "" {
17+ // Last resort: a per-uid temp path so state setup never aborts startup.
18+ return filepath.Join(os.TempDir(), fmt.Sprintf("vev-state-%d", os.Getuid()))
19+ }
20+ return filepath.Join(home, ".local", "state", "vev")
21+}
4 22
5 // DirOrHome returns dir when it names an existing directory. Otherwise it23 // DirOrHome returns dir when it names an existing directory. Otherwise it
6 // falls back to the current user's home directory, then to / as a last resort.24 // falls back to the current user's home directory, then to / as a last resort.
A internal/platform/proc_linux.go +11 -0
@@ -0,0 +1,11 @@
1+package platform
2+
3+import (
4+ "fmt"
5+ "os"
6+)
7+
8+// ProcessCwd returns the current working directory for a Linux process.
9+func ProcessCwd(pid int) (string, error) {
10+ return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
11+}
@@ -0,0 +1,11 @@
1+package platform
2+
3+import (
4+ "fmt"
5+ "os"
6+)
7+
8+// ProcessCwd returns the current working directory for a Linux process.
9+func ProcessCwd(pid int) (string, error) {
10+ return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
11+}
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 = 3
4+const ProtocolVersion uint16 = 4
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 34+const ProtocolVersion uint16 = 4
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
M internal/ports/frame_test.go +6 -0
@@ -2,6 +2,12 @@ package ports
22
33 import "testing"
44
5+func TestProtocolVersion(t *testing.T) {
6+ if ProtocolVersion != 4 {
7+ t.Fatalf("ProtocolVersion = %d, want 4", ProtocolVersion)
8+ }
9+}
10+
511 func TestMsgTypeUnique(t *testing.T) {
612 tests := []struct {
713 name string
@@ -2,6 +2,12 @@ package ports
2 2
3 import "testing"3 import "testing"
4 4
5+func TestProtocolVersion(t *testing.T) {
6+ if ProtocolVersion != 4 {
7+ t.Fatalf("ProtocolVersion = %d, want 4", ProtocolVersion)
8+ }
9+}
10+
5 func TestMsgTypeUnique(t *testing.T) {11 func TestMsgTypeUnique(t *testing.T) {
6 tests := []struct {12 tests := []struct {
7 name string13 name string
M internal/ports/mocks/mocks.go +325 -0
@@ -1074,6 +1074,331 @@ func (_c *MockListener_Close_Call) RunAndReturn(run func() error) *MockListener_
10741074 return _c
10751075 }
10761076
1077+// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1078+// The first argument is typically a *testing.T value.
1079+func NewMockStore(t interface {
1080+ mock.TestingT
1081+ Cleanup(func())
1082+}) *MockStore {
1083+ mock := &MockStore{}
1084+ mock.Mock.Test(t)
1085+
1086+ t.Cleanup(func() { mock.AssertExpectations(t) })
1087+
1088+ return mock
1089+}
1090+
1091+// MockStore is an autogenerated mock type for the Store type
1092+type MockStore struct {
1093+ mock.Mock
1094+}
1095+
1096+type MockStore_Expecter struct {
1097+ mock *mock.Mock
1098+}
1099+
1100+func (_m *MockStore) EXPECT() *MockStore_Expecter {
1101+ return &MockStore_Expecter{mock: &_m.Mock}
1102+}
1103+
1104+// Close provides a mock function for the type MockStore
1105+func (_mock *MockStore) Close() error {
1106+ ret := _mock.Called()
1107+
1108+ if len(ret) == 0 {
1109+ panic("no return value specified for Close")
1110+ }
1111+
1112+ var r0 error
1113+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1114+ r0 = returnFunc()
1115+ } else {
1116+ r0 = ret.Error(0)
1117+ }
1118+ return r0
1119+}
1120+
1121+// MockStore_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
1122+type MockStore_Close_Call struct {
1123+ *mock.Call
1124+}
1125+
1126+// Close is a helper method to define mock.On call
1127+func (_e *MockStore_Expecter) Close() *MockStore_Close_Call {
1128+ return &MockStore_Close_Call{Call: _e.mock.On("Close")}
1129+}
1130+
1131+func (_c *MockStore_Close_Call) Run(run func()) *MockStore_Close_Call {
1132+ _c.Call.Run(func(args mock.Arguments) {
1133+ run()
1134+ })
1135+ return _c
1136+}
1137+
1138+func (_c *MockStore_Close_Call) Return(err error) *MockStore_Close_Call {
1139+ _c.Call.Return(err)
1140+ return _c
1141+}
1142+
1143+func (_c *MockStore_Close_Call) RunAndReturn(run func() error) *MockStore_Close_Call {
1144+ _c.Call.Return(run)
1145+ return _c
1146+}
1147+
1148+// Delete provides a mock function for the type MockStore
1149+func (_mock *MockStore) Delete(key []byte) error {
1150+ ret := _mock.Called(key)
1151+
1152+ if len(ret) == 0 {
1153+ panic("no return value specified for Delete")
1154+ }
1155+
1156+ var r0 error
1157+ if returnFunc, ok := ret.Get(0).(func([]byte) error); ok {
1158+ r0 = returnFunc(key)
1159+ } else {
1160+ r0 = ret.Error(0)
1161+ }
1162+ return r0
1163+}
1164+
1165+// MockStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
1166+type MockStore_Delete_Call struct {
1167+ *mock.Call
1168+}
1169+
1170+// Delete is a helper method to define mock.On call
1171+// - key []byte
1172+func (_e *MockStore_Expecter) Delete(key any) *MockStore_Delete_Call {
1173+ return &MockStore_Delete_Call{Call: _e.mock.On("Delete", key)}
1174+}
1175+
1176+func (_c *MockStore_Delete_Call) Run(run func(key []byte)) *MockStore_Delete_Call {
1177+ _c.Call.Run(func(args mock.Arguments) {
1178+ var arg0 []byte
1179+ if args[0] != nil {
1180+ arg0 = args[0].([]byte)
1181+ }
1182+ run(
1183+ arg0,
1184+ )
1185+ })
1186+ return _c
1187+}
1188+
1189+func (_c *MockStore_Delete_Call) Return(err error) *MockStore_Delete_Call {
1190+ _c.Call.Return(err)
1191+ return _c
1192+}
1193+
1194+func (_c *MockStore_Delete_Call) RunAndReturn(run func(key []byte) error) *MockStore_Delete_Call {
1195+ _c.Call.Return(run)
1196+ return _c
1197+}
1198+
1199+// Get provides a mock function for the type MockStore
1200+func (_mock *MockStore) Get(key []byte) ([]byte, bool) {
1201+ ret := _mock.Called(key)
1202+
1203+ if len(ret) == 0 {
1204+ panic("no return value specified for Get")
1205+ }
1206+
1207+ var r0 []byte
1208+ var r1 bool
1209+ if returnFunc, ok := ret.Get(0).(func([]byte) ([]byte, bool)); ok {
1210+ return returnFunc(key)
1211+ }
1212+ if returnFunc, ok := ret.Get(0).(func([]byte) []byte); ok {
1213+ r0 = returnFunc(key)
1214+ } else {
1215+ if ret.Get(0) != nil {
1216+ r0 = ret.Get(0).([]byte)
1217+ }
1218+ }
1219+ if returnFunc, ok := ret.Get(1).(func([]byte) bool); ok {
1220+ r1 = returnFunc(key)
1221+ } else {
1222+ r1 = ret.Get(1).(bool)
1223+ }
1224+ return r0, r1
1225+}
1226+
1227+// MockStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
1228+type MockStore_Get_Call struct {
1229+ *mock.Call
1230+}
1231+
1232+// Get is a helper method to define mock.On call
1233+// - key []byte
1234+func (_e *MockStore_Expecter) Get(key any) *MockStore_Get_Call {
1235+ return &MockStore_Get_Call{Call: _e.mock.On("Get", key)}
1236+}
1237+
1238+func (_c *MockStore_Get_Call) Run(run func(key []byte)) *MockStore_Get_Call {
1239+ _c.Call.Run(func(args mock.Arguments) {
1240+ var arg0 []byte
1241+ if args[0] != nil {
1242+ arg0 = args[0].([]byte)
1243+ }
1244+ run(
1245+ arg0,
1246+ )
1247+ })
1248+ return _c
1249+}
1250+
1251+func (_c *MockStore_Get_Call) Return(bytes []byte, b bool) *MockStore_Get_Call {
1252+ _c.Call.Return(bytes, b)
1253+ return _c
1254+}
1255+
1256+func (_c *MockStore_Get_Call) RunAndReturn(run func(key []byte) ([]byte, bool)) *MockStore_Get_Call {
1257+ _c.Call.Return(run)
1258+ return _c
1259+}
1260+
1261+// Range provides a mock function for the type MockStore
1262+func (_mock *MockStore) Range(fn func(k []byte, v []byte) bool) {
1263+ _mock.Called(fn)
1264+ return
1265+}
1266+
1267+// MockStore_Range_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Range'
1268+type MockStore_Range_Call struct {
1269+ *mock.Call
1270+}
1271+
1272+// Range is a helper method to define mock.On call
1273+// - fn func(k []byte, v []byte) bool
1274+func (_e *MockStore_Expecter) Range(fn any) *MockStore_Range_Call {
1275+ return &MockStore_Range_Call{Call: _e.mock.On("Range", fn)}
1276+}
1277+
1278+func (_c *MockStore_Range_Call) Run(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call {
1279+ _c.Call.Run(func(args mock.Arguments) {
1280+ var arg0 func(k []byte, v []byte) bool
1281+ if args[0] != nil {
1282+ arg0 = args[0].(func(k []byte, v []byte) bool)
1283+ }
1284+ run(
1285+ arg0,
1286+ )
1287+ })
1288+ return _c
1289+}
1290+
1291+func (_c *MockStore_Range_Call) Return() *MockStore_Range_Call {
1292+ _c.Call.Return()
1293+ return _c
1294+}
1295+
1296+func (_c *MockStore_Range_Call) RunAndReturn(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call {
1297+ _c.Run(run)
1298+ return _c
1299+}
1300+
1301+// Set provides a mock function for the type MockStore
1302+func (_mock *MockStore) Set(key []byte, val []byte) error {
1303+ ret := _mock.Called(key, val)
1304+
1305+ if len(ret) == 0 {
1306+ panic("no return value specified for Set")
1307+ }
1308+
1309+ var r0 error
1310+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte) error); ok {
1311+ r0 = returnFunc(key, val)
1312+ } else {
1313+ r0 = ret.Error(0)
1314+ }
1315+ return r0
1316+}
1317+
1318+// MockStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set'
1319+type MockStore_Set_Call struct {
1320+ *mock.Call
1321+}
1322+
1323+// Set is a helper method to define mock.On call
1324+// - key []byte
1325+// - val []byte
1326+func (_e *MockStore_Expecter) Set(key any, val any) *MockStore_Set_Call {
1327+ return &MockStore_Set_Call{Call: _e.mock.On("Set", key, val)}
1328+}
1329+
1330+func (_c *MockStore_Set_Call) Run(run func(key []byte, val []byte)) *MockStore_Set_Call {
1331+ _c.Call.Run(func(args mock.Arguments) {
1332+ var arg0 []byte
1333+ if args[0] != nil {
1334+ arg0 = args[0].([]byte)
1335+ }
1336+ var arg1 []byte
1337+ if args[1] != nil {
1338+ arg1 = args[1].([]byte)
1339+ }
1340+ run(
1341+ arg0,
1342+ arg1,
1343+ )
1344+ })
1345+ return _c
1346+}
1347+
1348+func (_c *MockStore_Set_Call) Return(err error) *MockStore_Set_Call {
1349+ _c.Call.Return(err)
1350+ return _c
1351+}
1352+
1353+func (_c *MockStore_Set_Call) RunAndReturn(run func(key []byte, val []byte) error) *MockStore_Set_Call {
1354+ _c.Call.Return(run)
1355+ return _c
1356+}
1357+
1358+// Sync provides a mock function for the type MockStore
1359+func (_mock *MockStore) Sync() error {
1360+ ret := _mock.Called()
1361+
1362+ if len(ret) == 0 {
1363+ panic("no return value specified for Sync")
1364+ }
1365+
1366+ var r0 error
1367+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1368+ r0 = returnFunc()
1369+ } else {
1370+ r0 = ret.Error(0)
1371+ }
1372+ return r0
1373+}
1374+
1375+// MockStore_Sync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sync'
1376+type MockStore_Sync_Call struct {
1377+ *mock.Call
1378+}
1379+
1380+// Sync is a helper method to define mock.On call
1381+func (_e *MockStore_Expecter) Sync() *MockStore_Sync_Call {
1382+ return &MockStore_Sync_Call{Call: _e.mock.On("Sync")}
1383+}
1384+
1385+func (_c *MockStore_Sync_Call) Run(run func()) *MockStore_Sync_Call {
1386+ _c.Call.Run(func(args mock.Arguments) {
1387+ run()
1388+ })
1389+ return _c
1390+}
1391+
1392+func (_c *MockStore_Sync_Call) Return(err error) *MockStore_Sync_Call {
1393+ _c.Call.Return(err)
1394+ return _c
1395+}
1396+
1397+func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Call {
1398+ _c.Call.Return(run)
1399+ return _c
1400+}
1401+
10771402 // NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
10781403 // The first argument is typically a *testing.T value.
10791404 func NewMockClock(t interface {
@@ -1074,6 +1074,331 @@ func (_c *MockListener_Close_Call) RunAndReturn(run func() error) *MockListener_
1074 return _c1074 return _c
1075 }1075 }
1076 1076
1077+// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1078+// The first argument is typically a *testing.T value.
1079+func NewMockStore(t interface {
1080+ mock.TestingT
1081+ Cleanup(func())
1082+}) *MockStore {
1083+ mock := &MockStore{}
1084+ mock.Mock.Test(t)
1085+
1086+ t.Cleanup(func() { mock.AssertExpectations(t) })
1087+
1088+ return mock
1089+}
1090+
1091+// MockStore is an autogenerated mock type for the Store type
1092+type MockStore struct {
1093+ mock.Mock
1094+}
1095+
1096+type MockStore_Expecter struct {
1097+ mock *mock.Mock
1098+}
1099+
1100+func (_m *MockStore) EXPECT() *MockStore_Expecter {
1101+ return &MockStore_Expecter{mock: &_m.Mock}
1102+}
1103+
1104+// Close provides a mock function for the type MockStore
1105+func (_mock *MockStore) Close() error {
1106+ ret := _mock.Called()
1107+
1108+ if len(ret) == 0 {
1109+ panic("no return value specified for Close")
1110+ }
1111+
1112+ var r0 error
1113+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1114+ r0 = returnFunc()
1115+ } else {
1116+ r0 = ret.Error(0)
1117+ }
1118+ return r0
1119+}
1120+
1121+// MockStore_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
1122+type MockStore_Close_Call struct {
1123+ *mock.Call
1124+}
1125+
1126+// Close is a helper method to define mock.On call
1127+func (_e *MockStore_Expecter) Close() *MockStore_Close_Call {
1128+ return &MockStore_Close_Call{Call: _e.mock.On("Close")}
1129+}
1130+
1131+func (_c *MockStore_Close_Call) Run(run func()) *MockStore_Close_Call {
1132+ _c.Call.Run(func(args mock.Arguments) {
1133+ run()
1134+ })
1135+ return _c
1136+}
1137+
1138+func (_c *MockStore_Close_Call) Return(err error) *MockStore_Close_Call {
1139+ _c.Call.Return(err)
1140+ return _c
1141+}
1142+
1143+func (_c *MockStore_Close_Call) RunAndReturn(run func() error) *MockStore_Close_Call {
1144+ _c.Call.Return(run)
1145+ return _c
1146+}
1147+
1148+// Delete provides a mock function for the type MockStore
1149+func (_mock *MockStore) Delete(key []byte) error {
1150+ ret := _mock.Called(key)
1151+
1152+ if len(ret) == 0 {
1153+ panic("no return value specified for Delete")
1154+ }
1155+
1156+ var r0 error
1157+ if returnFunc, ok := ret.Get(0).(func([]byte) error); ok {
1158+ r0 = returnFunc(key)
1159+ } else {
1160+ r0 = ret.Error(0)
1161+ }
1162+ return r0
1163+}
1164+
1165+// MockStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
1166+type MockStore_Delete_Call struct {
1167+ *mock.Call
1168+}
1169+
1170+// Delete is a helper method to define mock.On call
1171+// - key []byte
1172+func (_e *MockStore_Expecter) Delete(key any) *MockStore_Delete_Call {
1173+ return &MockStore_Delete_Call{Call: _e.mock.On("Delete", key)}
1174+}
1175+
1176+func (_c *MockStore_Delete_Call) Run(run func(key []byte)) *MockStore_Delete_Call {
1177+ _c.Call.Run(func(args mock.Arguments) {
1178+ var arg0 []byte
1179+ if args[0] != nil {
1180+ arg0 = args[0].([]byte)
1181+ }
1182+ run(
1183+ arg0,
1184+ )
1185+ })
1186+ return _c
1187+}
1188+
1189+func (_c *MockStore_Delete_Call) Return(err error) *MockStore_Delete_Call {
1190+ _c.Call.Return(err)
1191+ return _c
1192+}
1193+
1194+func (_c *MockStore_Delete_Call) RunAndReturn(run func(key []byte) error) *MockStore_Delete_Call {
1195+ _c.Call.Return(run)
1196+ return _c
1197+}
1198+
1199+// Get provides a mock function for the type MockStore
1200+func (_mock *MockStore) Get(key []byte) ([]byte, bool) {
1201+ ret := _mock.Called(key)
1202+
1203+ if len(ret) == 0 {
1204+ panic("no return value specified for Get")
1205+ }
1206+
1207+ var r0 []byte
1208+ var r1 bool
1209+ if returnFunc, ok := ret.Get(0).(func([]byte) ([]byte, bool)); ok {
1210+ return returnFunc(key)
1211+ }
1212+ if returnFunc, ok := ret.Get(0).(func([]byte) []byte); ok {
1213+ r0 = returnFunc(key)
1214+ } else {
1215+ if ret.Get(0) != nil {
1216+ r0 = ret.Get(0).([]byte)
1217+ }
1218+ }
1219+ if returnFunc, ok := ret.Get(1).(func([]byte) bool); ok {
1220+ r1 = returnFunc(key)
1221+ } else {
1222+ r1 = ret.Get(1).(bool)
1223+ }
1224+ return r0, r1
1225+}
1226+
1227+// MockStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
1228+type MockStore_Get_Call struct {
1229+ *mock.Call
1230+}
1231+
1232+// Get is a helper method to define mock.On call
1233+// - key []byte
1234+func (_e *MockStore_Expecter) Get(key any) *MockStore_Get_Call {
1235+ return &MockStore_Get_Call{Call: _e.mock.On("Get", key)}
1236+}
1237+
1238+func (_c *MockStore_Get_Call) Run(run func(key []byte)) *MockStore_Get_Call {
1239+ _c.Call.Run(func(args mock.Arguments) {
1240+ var arg0 []byte
1241+ if args[0] != nil {
1242+ arg0 = args[0].([]byte)
1243+ }
1244+ run(
1245+ arg0,
1246+ )
1247+ })
1248+ return _c
1249+}
1250+
1251+func (_c *MockStore_Get_Call) Return(bytes []byte, b bool) *MockStore_Get_Call {
1252+ _c.Call.Return(bytes, b)
1253+ return _c
1254+}
1255+
1256+func (_c *MockStore_Get_Call) RunAndReturn(run func(key []byte) ([]byte, bool)) *MockStore_Get_Call {
1257+ _c.Call.Return(run)
1258+ return _c
1259+}
1260+
1261+// Range provides a mock function for the type MockStore
1262+func (_mock *MockStore) Range(fn func(k []byte, v []byte) bool) {
1263+ _mock.Called(fn)
1264+ return
1265+}
1266+
1267+// MockStore_Range_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Range'
1268+type MockStore_Range_Call struct {
1269+ *mock.Call
1270+}
1271+
1272+// Range is a helper method to define mock.On call
1273+// - fn func(k []byte, v []byte) bool
1274+func (_e *MockStore_Expecter) Range(fn any) *MockStore_Range_Call {
1275+ return &MockStore_Range_Call{Call: _e.mock.On("Range", fn)}
1276+}
1277+
1278+func (_c *MockStore_Range_Call) Run(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call {
1279+ _c.Call.Run(func(args mock.Arguments) {
1280+ var arg0 func(k []byte, v []byte) bool
1281+ if args[0] != nil {
1282+ arg0 = args[0].(func(k []byte, v []byte) bool)
1283+ }
1284+ run(
1285+ arg0,
1286+ )
1287+ })
1288+ return _c
1289+}
1290+
1291+func (_c *MockStore_Range_Call) Return() *MockStore_Range_Call {
1292+ _c.Call.Return()
1293+ return _c
1294+}
1295+
1296+func (_c *MockStore_Range_Call) RunAndReturn(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call {
1297+ _c.Run(run)
1298+ return _c
1299+}
1300+
1301+// Set provides a mock function for the type MockStore
1302+func (_mock *MockStore) Set(key []byte, val []byte) error {
1303+ ret := _mock.Called(key, val)
1304+
1305+ if len(ret) == 0 {
1306+ panic("no return value specified for Set")
1307+ }
1308+
1309+ var r0 error
1310+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte) error); ok {
1311+ r0 = returnFunc(key, val)
1312+ } else {
1313+ r0 = ret.Error(0)
1314+ }
1315+ return r0
1316+}
1317+
1318+// MockStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set'
1319+type MockStore_Set_Call struct {
1320+ *mock.Call
1321+}
1322+
1323+// Set is a helper method to define mock.On call
1324+// - key []byte
1325+// - val []byte
1326+func (_e *MockStore_Expecter) Set(key any, val any) *MockStore_Set_Call {
1327+ return &MockStore_Set_Call{Call: _e.mock.On("Set", key, val)}
1328+}
1329+
1330+func (_c *MockStore_Set_Call) Run(run func(key []byte, val []byte)) *MockStore_Set_Call {
1331+ _c.Call.Run(func(args mock.Arguments) {
1332+ var arg0 []byte
1333+ if args[0] != nil {
1334+ arg0 = args[0].([]byte)
1335+ }
1336+ var arg1 []byte
1337+ if args[1] != nil {
1338+ arg1 = args[1].([]byte)
1339+ }
1340+ run(
1341+ arg0,
1342+ arg1,
1343+ )
1344+ })
1345+ return _c
1346+}
1347+
1348+func (_c *MockStore_Set_Call) Return(err error) *MockStore_Set_Call {
1349+ _c.Call.Return(err)
1350+ return _c
1351+}
1352+
1353+func (_c *MockStore_Set_Call) RunAndReturn(run func(key []byte, val []byte) error) *MockStore_Set_Call {
1354+ _c.Call.Return(run)
1355+ return _c
1356+}
1357+
1358+// Sync provides a mock function for the type MockStore
1359+func (_mock *MockStore) Sync() error {
1360+ ret := _mock.Called()
1361+
1362+ if len(ret) == 0 {
1363+ panic("no return value specified for Sync")
1364+ }
1365+
1366+ var r0 error
1367+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1368+ r0 = returnFunc()
1369+ } else {
1370+ r0 = ret.Error(0)
1371+ }
1372+ return r0
1373+}
1374+
1375+// MockStore_Sync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sync'
1376+type MockStore_Sync_Call struct {
1377+ *mock.Call
1378+}
1379+
1380+// Sync is a helper method to define mock.On call
1381+func (_e *MockStore_Expecter) Sync() *MockStore_Sync_Call {
1382+ return &MockStore_Sync_Call{Call: _e.mock.On("Sync")}
1383+}
1384+
1385+func (_c *MockStore_Sync_Call) Run(run func()) *MockStore_Sync_Call {
1386+ _c.Call.Run(func(args mock.Arguments) {
1387+ run()
1388+ })
1389+ return _c
1390+}
1391+
1392+func (_c *MockStore_Sync_Call) Return(err error) *MockStore_Sync_Call {
1393+ _c.Call.Return(err)
1394+ return _c
1395+}
1396+
1397+func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Call {
1398+ _c.Call.Return(run)
1399+ return _c
1400+}
1401+
1077 // NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1402 // NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1078 // The first argument is typically a *testing.T value.1403 // The first argument is typically a *testing.T value.
1079 func NewMockClock(t interface {1404 func NewMockClock(t interface {
M internal/ports/ports.go +13 -0
@@ -43,6 +43,19 @@ type Listener interface {
4343 Addr() string
4444 }
4545
46+// Store is a small byte-key/value persistence port.
47+//
48+// Implementations may buffer writes; Sync is the durability barrier.
49+type Store interface {
50+ Get(key []byte) ([]byte, bool)
51+ Set(key, val []byte) error
52+ Delete(key []byte) error
53+ // Range iterates key/value pairs; fn returning false stops iteration early.
54+ Range(fn func(k, v []byte) bool)
55+ Sync() error
56+ Close() error
57+}
58+
4659 // Clock abstracts time so usecases can be tested without real delays.
4760 type Clock interface {
4861 Now() time.Time
@@ -43,6 +43,19 @@ type Listener interface {
43 Addr() string43 Addr() string
44 }44 }
45 45
46+// Store is a small byte-key/value persistence port.
47+//
48+// Implementations may buffer writes; Sync is the durability barrier.
49+type Store interface {
50+ Get(key []byte) ([]byte, bool)
51+ Set(key, val []byte) error
52+ Delete(key []byte) error
53+ // Range iterates key/value pairs; fn returning false stops iteration early.
54+ Range(fn func(k, v []byte) bool)
55+ Sync() error
56+ Close() error
57+}
58+
46 // Clock abstracts time so usecases can be tested without real delays.59 // Clock abstracts time so usecases can be tested without real delays.
47 type Clock interface {60 type Clock interface {
48 Now() time.Time61 Now() time.Time
M internal/ports/wire.go +11 -0
@@ -126,6 +126,7 @@ type SessionInfo struct {
126126 Ephemeral bool
127127 Tabs uint16
128128 Attached bool
129+ Stopped bool
129130 }
130131
131132 // Sessions is the daemon's reply to a List, enumerating live sessions.
@@ -559,6 +560,11 @@ func MarshalSessions(m Sessions) []byte {
559560 } else {
560561 w.putUint8(0)
561562 }
563+ if s.Stopped {
564+ w.putUint8(1)
565+ } else {
566+ w.putUint8(0)
567+ }
562568 }
563569 return w.b
564570 }
@@ -592,6 +598,11 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
592598 return Sessions{}, err
593599 }
594600 s.Attached = att != 0
601+ stopped, err := r.getUint8()
602+ if err != nil {
603+ return Sessions{}, err
604+ }
605+ s.Stopped = stopped != 0
595606 sessions = append(sessions, s)
596607 }
597608 if err := r.done(); err != nil {
@@ -126,6 +126,7 @@ type SessionInfo struct {
126 Ephemeral bool126 Ephemeral bool
127 Tabs uint16127 Tabs uint16
128 Attached bool128 Attached bool
129+ Stopped bool
129 }130 }
130 131
131 // Sessions is the daemon's reply to a List, enumerating live sessions.132 // Sessions is the daemon's reply to a List, enumerating live sessions.
@@ -559,6 +560,11 @@ func MarshalSessions(m Sessions) []byte {
559 } else {560 } else {
560 w.putUint8(0)561 w.putUint8(0)
561 }562 }
563+ if s.Stopped {
564+ w.putUint8(1)
565+ } else {
566+ w.putUint8(0)
567+ }
562 }568 }
563 return w.b569 return w.b
564 }570 }
@@ -592,6 +598,11 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
592 return Sessions{}, err598 return Sessions{}, err
593 }599 }
594 s.Attached = att != 0600 s.Attached = att != 0
601+ stopped, err := r.getUint8()
602+ if err != nil {
603+ return Sessions{}, err
604+ }
605+ s.Stopped = stopped != 0
595 sessions = append(sessions, s)606 sessions = append(sessions, s)
596 }607 }
597 if err := r.done(); err != nil {608 if err := r.done(); err != nil {
M internal/ports/wire_test.go +3 -3
@@ -424,12 +424,12 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
424424 name: "two",
425425 msg: Sessions{Sessions: []SessionInfo{
426426 {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
427- {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true},
427+ {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true, Stopped: true},
428428 }},
429429 want: []byte{
430430 0x00, 0x02,
431- 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00,
432- 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01,
431+ 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x00,
432+ 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x01,
433433 },
434434 },
435435 }
@@ -424,12 +424,12 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
424 name: "two",424 name: "two",
425 msg: Sessions{Sessions: []SessionInfo{425 msg: Sessions{Sessions: []SessionInfo{
426 {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false},426 {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
427- {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true},427+ {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true, Stopped: true},
428 }},428 }},
429 want: []byte{429 want: []byte{
430 0x00, 0x02,430 0x00, 0x02,
431- 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00,431+ 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x00,
432- 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01,432+ 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x01,
433 },433 },
434 },434 },
435 }435 }
M internal/usecase/daemon/client.go +13 -4
@@ -366,6 +366,12 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
366366 return // already displaced by a newer client; nothing to do
367367 }
368368 d.unregisterPreview(ac)
369+ sess.mu.Lock()
370+ ephemeral := sess.ephemeral
371+ sess.mu.Unlock()
372+ if !ephemeral {
373+ d.refreshSessionCwd(sess)
374+ }
369375 d.resetScreenDefaultColors(sess)
370376 if explicit {
371377 // Synchronous so the ack is delivered before the transport closes
@@ -375,8 +381,8 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
375381 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
376382 }
377383 _ = ac.tr.Close()
378- if sess.ephemeral {
379- d.killSession(sess, ports.ReasonSessionKilled)
384+ if ephemeral {
385+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
380386 }
381387 }
382388
@@ -445,8 +451,11 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
445451 d.resetScreenDefaultColors(sess)
446452 _ = ac.tr.Close()
447453 d.log.Warn("detached client after send error", "session", sess.name)
448- if sess.ephemeral {
449- d.killSession(sess, ports.ReasonSessionKilled)
454+ sess.mu.Lock()
455+ ephemeral := sess.ephemeral
456+ sess.mu.Unlock()
457+ if ephemeral {
458+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
450459 }
451460 }
452461 }
@@ -366,6 +366,12 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
366 return // already displaced by a newer client; nothing to do366 return // already displaced by a newer client; nothing to do
367 }367 }
368 d.unregisterPreview(ac)368 d.unregisterPreview(ac)
369+ sess.mu.Lock()
370+ ephemeral := sess.ephemeral
371+ sess.mu.Unlock()
372+ if !ephemeral {
373+ d.refreshSessionCwd(sess)
374+ }
369 d.resetScreenDefaultColors(sess)375 d.resetScreenDefaultColors(sess)
370 if explicit {376 if explicit {
371 // Synchronous so the ack is delivered before the transport closes377 // Synchronous so the ack is delivered before the transport closes
@@ -375,8 +381,8 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
375 d.boundedSend(ac, frameDetached(ports.ReasonDetach))381 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
376 }382 }
377 _ = ac.tr.Close()383 _ = ac.tr.Close()
378- if sess.ephemeral {384+ if ephemeral {
379- d.killSession(sess, ports.ReasonSessionKilled)385+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
380 }386 }
381 }387 }
382 388
@@ -445,8 +451,11 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
445 d.resetScreenDefaultColors(sess)451 d.resetScreenDefaultColors(sess)
446 _ = ac.tr.Close()452 _ = ac.tr.Close()
447 d.log.Warn("detached client after send error", "session", sess.name)453 d.log.Warn("detached client after send error", "session", sess.name)
448- if sess.ephemeral {454+ sess.mu.Lock()
449- d.killSession(sess, ports.ReasonSessionKilled)455+ ephemeral := sess.ephemeral
456+ sess.mu.Unlock()
457+ if ephemeral {
458+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
450 }459 }
451 }460 }
452 }461 }
M internal/usecase/daemon/daemon.go +105 -12
@@ -35,6 +35,8 @@ import (
3535 "time"
3636
3737 "github.com/bnema/vev/internal/domain"
38+ "github.com/bnema/vev/internal/persist"
39+ "github.com/bnema/vev/internal/platform"
3840 "github.com/bnema/vev/internal/ports"
3941 )
4042
@@ -67,6 +69,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24}
6769 type Daemon struct {
6870 mu sync.Mutex
6971 sessions map[domain.SessionID]*session
72+ stopped map[string]stoppedSession
7073 nextID uint64
7174 // closing marks that shutdown has irreversibly begun. It is set under mu,
7275 // atomically with the event that makes shutdown inevitable (the registry
@@ -85,12 +88,15 @@ type Daemon struct {
8588 animFrame int
8689 animWake chan struct{}
8790
88- ptys ports.PTYFactory
89- clock ports.Clock
90- log *slog.Logger
91- baseEnv []string
92- shell string
93- shellArgs []string
91+ ptys ports.PTYFactory
92+ clock ports.Clock
93+ log *slog.Logger
94+ baseEnv []string
95+ shell string
96+ shellArgs []string
97+ persist *persist.Persister
98+ persistEnabled bool
99+ procCwd func(int) (string, error)
94100
95101 serveCtx context.Context
96102 serveCancel context.CancelFunc
@@ -110,6 +116,13 @@ type Daemon struct {
110116
111117 // session is a single multiplexed session. It owns one or more full-screen
112118
119+type stoppedSession struct {
120+ name string
121+ cwd string
122+ createdAt int64
123+ purging bool
124+}
125+
113126 type Option func(*Daemon)
114127
115128 // WithShell overrides the command (and its args) each session spawns. The
@@ -122,6 +135,24 @@ func WithShell(cmd string, args []string) Option {
122135 }
123136 }
124137
138+// WithStore enables persisted named session metadata. A nil store keeps the
139+// daemon in no-op persistence mode.
140+func WithStore(store ports.Store) Option {
141+ return func(d *Daemon) {
142+ d.persist = persist.New(store)
143+ d.persistEnabled = store != nil
144+ }
145+}
146+
147+// WithCwdReader overrides the process cwd reader used for persistence tests.
148+func WithCwdReader(fn func(int) (string, error)) Option {
149+ return func(d *Daemon) {
150+ if fn != nil {
151+ d.procCwd = fn
152+ }
153+ }
154+}
155+
125156 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
126157 // render debounce, and log receives diagnostics (defaults to slog.Default).
127158 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -134,17 +165,33 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
134165 }
135166 d := &Daemon{
136167 sessions: make(map[domain.SessionID]*session),
168+ stopped: make(map[string]stoppedSession),
137169 ptys: ptys,
138170 clock: clock,
139171 log: log,
140172 baseEnv: os.Environ(),
141173 shell: shell,
174+ persist: persist.New(nil),
175+ procCwd: platform.ProcessCwd,
142176 done: make(chan struct{}),
143177 animWake: make(chan struct{}, 1),
144178 }
145179 for _, o := range opts {
146180 o(d)
147181 }
182+ if d.persist == nil {
183+ d.persist = persist.New(nil)
184+ }
185+ if d.procCwd == nil {
186+ d.procCwd = platform.ProcessCwd
187+ }
188+ if records, err := d.persist.LoadAll(); err != nil {
189+ d.log.Warn("loading persisted sessions failed", "err", err)
190+ } else {
191+ for _, r := range records {
192+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt}
193+ }
194+ }
148195 return d
149196 }
150197
@@ -160,6 +207,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
160207 d.sessWg.Go(func() {
161208 d.attentionAnimator(d.serveCtx)
162209 })
210+ if d.persistEnabled && d.procCwd != nil {
211+ d.sessWg.Go(func() {
212+ d.cwdSampler(d.serveCtx)
213+ })
214+ }
163215
164216 // Break the accept loop when either the parent context is cancelled or the
165217 // registry drains to empty: both close the listener, which fails Accept.
@@ -198,6 +250,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
198250 d.shutdownAll(ports.ReasonServerShutdown)
199251 d.sessWg.Wait()
200252 d.waitNotifies()
253+ if err := d.persist.Close(); err != nil {
254+ d.log.Warn("closing session persister failed", "err", err)
255+ }
201256 return nil
202257 }
203258
@@ -216,7 +271,7 @@ func (d *Daemon) shutdownAll(reason uint8) {
216271 return
217272 }
218273 for _, s := range snapshot {
219- d.killSession(s, reason)
274+ _ = d.killSession(s, reason, false)
220275 }
221276 }
222277
@@ -259,7 +314,8 @@ func (d *Daemon) handleList(tr ports.Transport) {
259314 defer func() { _ = tr.Close() }()
260315
261316 d.mu.Lock()
262- infos := make([]ports.SessionInfo, 0, len(d.sessions))
317+ infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))
318+ liveNames := make(map[string]struct{}, len(d.sessions))
263319 for _, s := range d.sessions {
264320 s.mu.Lock()
265321 info := ports.SessionInfo{
@@ -269,9 +325,19 @@ func (d *Daemon) handleList(tr ports.Transport) {
269325 Tabs: uint16(len(s.tabs)),
270326 Attached: s.client != nil,
271327 }
328+ liveNames[s.name] = struct{}{}
272329 s.mu.Unlock()
273330 infos = append(infos, info)
274331 }
332+ for name, stopped := range d.stopped {
333+ if stopped.purging {
334+ continue
335+ }
336+ if _, live := liveNames[name]; live {
337+ continue
338+ }
339+ infos = append(infos, ports.SessionInfo{Name: name, Stopped: true})
340+ }
275341 d.mu.Unlock()
276342
277343 sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })
@@ -296,13 +362,30 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
296362
297363 d.mu.Lock()
298364 target := d.findByNameLocked(k.Name)
365+ if target == nil {
366+ if stopped, ok := d.stopped[k.Name]; ok {
367+ if err := d.persist.Delete(k.Name); err != nil {
368+ d.mu.Unlock()
369+ d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)
370+ _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
371+ return
372+ }
373+ if cur, ok := d.stopped[k.Name]; ok && cur == stopped {
374+ delete(d.stopped, k.Name)
375+ }
376+ d.mu.Unlock()
377+ return
378+ }
379+ }
299380 d.mu.Unlock()
300381
301382 if target == nil {
302383 _ = tr.Send(frameError(ports.ErrNoSuchSession, "no such session: "+k.Name))
303384 return
304385 }
305- d.killSession(target, ports.ReasonSessionKilled)
386+ if err := d.killSession(target, ports.ReasonSessionKilled, true); err != nil {
387+ _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted session failed"))
388+ }
306389 }
307390
308391 // handleHello runs the attach handshake: version check, intent routing,
@@ -387,7 +470,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
387470 d.mu.Unlock()
388471 return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}
389472 }
390- if d.nameTakenLocked(h.Name) {
473+ if d.nameLiveOrStoppedLocked(h.Name) {
391474 d.mu.Unlock()
392475 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
393476 }
@@ -404,8 +487,18 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
404487 case ports.IntentAttach:
405488 sess := d.findByNameLocked(h.Name)
406489 if sess == nil {
407- d.mu.Unlock()
408- return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
490+ stopped, ok := d.stopped[h.Name]
491+ if !ok || stopped.purging {
492+ d.mu.Unlock()
493+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
494+ }
495+ cwd := platform.DirOrHome(stopped.cwd)
496+ var err error
497+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
498+ if err != nil {
499+ d.mu.Unlock()
500+ return nil, nil, err
501+ }
409502 }
410503 ac, old := d.attachClient(sess, tr, sz)
411504 d.mu.Unlock()
@@ -35,6 +35,8 @@ import (
35 "time"35 "time"
36 36
37 "github.com/bnema/vev/internal/domain"37 "github.com/bnema/vev/internal/domain"
38+ "github.com/bnema/vev/internal/persist"
39+ "github.com/bnema/vev/internal/platform"
38 "github.com/bnema/vev/internal/ports"40 "github.com/bnema/vev/internal/ports"
39 )41 )
40 42
@@ -67,6 +69,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24}
67 type Daemon struct {69 type Daemon struct {
68 mu sync.Mutex70 mu sync.Mutex
69 sessions map[domain.SessionID]*session71 sessions map[domain.SessionID]*session
72+ stopped map[string]stoppedSession
70 nextID uint6473 nextID uint64
71 // closing marks that shutdown has irreversibly begun. It is set under mu,74 // closing marks that shutdown has irreversibly begun. It is set under mu,
72 // atomically with the event that makes shutdown inevitable (the registry75 // atomically with the event that makes shutdown inevitable (the registry
@@ -85,12 +88,15 @@ type Daemon struct {
85 animFrame int88 animFrame int
86 animWake chan struct{}89 animWake chan struct{}
87 90
88- ptys ports.PTYFactory91+ ptys ports.PTYFactory
89- clock ports.Clock92+ clock ports.Clock
90- log *slog.Logger93+ log *slog.Logger
91- baseEnv []string94+ baseEnv []string
92- shell string95+ shell string
93- shellArgs []string96+ shellArgs []string
97+ persist *persist.Persister
98+ persistEnabled bool
99+ procCwd func(int) (string, error)
94 100
95 serveCtx context.Context101 serveCtx context.Context
96 serveCancel context.CancelFunc102 serveCancel context.CancelFunc
@@ -110,6 +116,13 @@ type Daemon struct {
110 116
111 // session is a single multiplexed session. It owns one or more full-screen117 // session is a single multiplexed session. It owns one or more full-screen
112 118
119+type stoppedSession struct {
120+ name string
121+ cwd string
122+ createdAt int64
123+ purging bool
124+}
125+
113 type Option func(*Daemon)126 type Option func(*Daemon)
114 127
115 // WithShell overrides the command (and its args) each session spawns. The128 // WithShell overrides the command (and its args) each session spawns. The
@@ -122,6 +135,24 @@ func WithShell(cmd string, args []string) Option {
122 }135 }
123 }136 }
124 137
138+// WithStore enables persisted named session metadata. A nil store keeps the
139+// daemon in no-op persistence mode.
140+func WithStore(store ports.Store) Option {
141+ return func(d *Daemon) {
142+ d.persist = persist.New(store)
143+ d.persistEnabled = store != nil
144+ }
145+}
146+
147+// WithCwdReader overrides the process cwd reader used for persistence tests.
148+func WithCwdReader(fn func(int) (string, error)) Option {
149+ return func(d *Daemon) {
150+ if fn != nil {
151+ d.procCwd = fn
152+ }
153+ }
154+}
155+
125 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the156 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
126 // render debounce, and log receives diagnostics (defaults to slog.Default).157 // render debounce, and log receives diagnostics (defaults to slog.Default).
127 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {158 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -134,17 +165,33 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
134 }165 }
135 d := &Daemon{166 d := &Daemon{
136 sessions: make(map[domain.SessionID]*session),167 sessions: make(map[domain.SessionID]*session),
168+ stopped: make(map[string]stoppedSession),
137 ptys: ptys,169 ptys: ptys,
138 clock: clock,170 clock: clock,
139 log: log,171 log: log,
140 baseEnv: os.Environ(),172 baseEnv: os.Environ(),
141 shell: shell,173 shell: shell,
174+ persist: persist.New(nil),
175+ procCwd: platform.ProcessCwd,
142 done: make(chan struct{}),176 done: make(chan struct{}),
143 animWake: make(chan struct{}, 1),177 animWake: make(chan struct{}, 1),
144 }178 }
145 for _, o := range opts {179 for _, o := range opts {
146 o(d)180 o(d)
147 }181 }
182+ if d.persist == nil {
183+ d.persist = persist.New(nil)
184+ }
185+ if d.procCwd == nil {
186+ d.procCwd = platform.ProcessCwd
187+ }
188+ if records, err := d.persist.LoadAll(); err != nil {
189+ d.log.Warn("loading persisted sessions failed", "err", err)
190+ } else {
191+ for _, r := range records {
192+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt}
193+ }
194+ }
148 return d195 return d
149 }196 }
150 197
@@ -160,6 +207,11 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
160 d.sessWg.Go(func() {207 d.sessWg.Go(func() {
161 d.attentionAnimator(d.serveCtx)208 d.attentionAnimator(d.serveCtx)
162 })209 })
210+ if d.persistEnabled && d.procCwd != nil {
211+ d.sessWg.Go(func() {
212+ d.cwdSampler(d.serveCtx)
213+ })
214+ }
163 215
164 // Break the accept loop when either the parent context is cancelled or the216 // Break the accept loop when either the parent context is cancelled or the
165 // registry drains to empty: both close the listener, which fails Accept.217 // registry drains to empty: both close the listener, which fails Accept.
@@ -198,6 +250,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
198 d.shutdownAll(ports.ReasonServerShutdown)250 d.shutdownAll(ports.ReasonServerShutdown)
199 d.sessWg.Wait()251 d.sessWg.Wait()
200 d.waitNotifies()252 d.waitNotifies()
253+ if err := d.persist.Close(); err != nil {
254+ d.log.Warn("closing session persister failed", "err", err)
255+ }
201 return nil256 return nil
202 }257 }
203 258
@@ -216,7 +271,7 @@ func (d *Daemon) shutdownAll(reason uint8) {
216 return271 return
217 }272 }
218 for _, s := range snapshot {273 for _, s := range snapshot {
219- d.killSession(s, reason)274+ _ = d.killSession(s, reason, false)
220 }275 }
221 }276 }
222 277
@@ -259,7 +314,8 @@ func (d *Daemon) handleList(tr ports.Transport) {
259 defer func() { _ = tr.Close() }()314 defer func() { _ = tr.Close() }()
260 315
261 d.mu.Lock()316 d.mu.Lock()
262- infos := make([]ports.SessionInfo, 0, len(d.sessions))317+ infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))
318+ liveNames := make(map[string]struct{}, len(d.sessions))
263 for _, s := range d.sessions {319 for _, s := range d.sessions {
264 s.mu.Lock()320 s.mu.Lock()
265 info := ports.SessionInfo{321 info := ports.SessionInfo{
@@ -269,9 +325,19 @@ func (d *Daemon) handleList(tr ports.Transport) {
269 Tabs: uint16(len(s.tabs)),325 Tabs: uint16(len(s.tabs)),
270 Attached: s.client != nil,326 Attached: s.client != nil,
271 }327 }
328+ liveNames[s.name] = struct{}{}
272 s.mu.Unlock()329 s.mu.Unlock()
273 infos = append(infos, info)330 infos = append(infos, info)
274 }331 }
332+ for name, stopped := range d.stopped {
333+ if stopped.purging {
334+ continue
335+ }
336+ if _, live := liveNames[name]; live {
337+ continue
338+ }
339+ infos = append(infos, ports.SessionInfo{Name: name, Stopped: true})
340+ }
275 d.mu.Unlock()341 d.mu.Unlock()
276 342
277 sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })343 sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })
@@ -296,13 +362,30 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
296 362
297 d.mu.Lock()363 d.mu.Lock()
298 target := d.findByNameLocked(k.Name)364 target := d.findByNameLocked(k.Name)
365+ if target == nil {
366+ if stopped, ok := d.stopped[k.Name]; ok {
367+ if err := d.persist.Delete(k.Name); err != nil {
368+ d.mu.Unlock()
369+ d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)
370+ _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
371+ return
372+ }
373+ if cur, ok := d.stopped[k.Name]; ok && cur == stopped {
374+ delete(d.stopped, k.Name)
375+ }
376+ d.mu.Unlock()
377+ return
378+ }
379+ }
299 d.mu.Unlock()380 d.mu.Unlock()
300 381
301 if target == nil {382 if target == nil {
302 _ = tr.Send(frameError(ports.ErrNoSuchSession, "no such session: "+k.Name))383 _ = tr.Send(frameError(ports.ErrNoSuchSession, "no such session: "+k.Name))
303 return384 return
304 }385 }
305- d.killSession(target, ports.ReasonSessionKilled)386+ if err := d.killSession(target, ports.ReasonSessionKilled, true); err != nil {
387+ _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted session failed"))
388+ }
306 }389 }
307 390
308 // handleHello runs the attach handshake: version check, intent routing,391 // handleHello runs the attach handshake: version check, intent routing,
@@ -387,7 +470,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
387 d.mu.Unlock()470 d.mu.Unlock()
388 return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}471 return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}
389 }472 }
390- if d.nameTakenLocked(h.Name) {473+ if d.nameLiveOrStoppedLocked(h.Name) {
391 d.mu.Unlock()474 d.mu.Unlock()
392 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}475 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
393 }476 }
@@ -404,8 +487,18 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
404 case ports.IntentAttach:487 case ports.IntentAttach:
405 sess := d.findByNameLocked(h.Name)488 sess := d.findByNameLocked(h.Name)
406 if sess == nil {489 if sess == nil {
407- d.mu.Unlock()490+ stopped, ok := d.stopped[h.Name]
408- return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}491+ if !ok || stopped.purging {
492+ d.mu.Unlock()
493+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
494+ }
495+ cwd := platform.DirOrHome(stopped.cwd)
496+ var err error
497+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
498+ if err != nil {
499+ d.mu.Unlock()
500+ return nil, nil, err
501+ }
409 }502 }
410 ac, old := d.attachClient(sess, tr, sz)503 ac, old := d.attachClient(sess, tr, sz)
411 d.mu.Unlock()504 d.mu.Unlock()
M internal/usecase/daemon/picker.go +103 -1
@@ -30,6 +30,7 @@ import (
3030 "strconv"
3131
3232 "github.com/bnema/vev/internal/domain"
33+ "github.com/bnema/vev/internal/platform"
3334 "github.com/bnema/vev/internal/ports"
3435 "github.com/bnema/vev/internal/usecase/keys"
3536 "github.com/bnema/vev/internal/usecase/picker"
@@ -53,10 +54,17 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
5354 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
5455 d.mu.Lock()
5556 sessions := d.sessionsSnapshotLocked()
57+ stopped := make([]stoppedSession, 0, len(d.stopped))
58+ for _, s := range d.stopped {
59+ if !s.purging {
60+ stopped = append(stopped, s)
61+ }
62+ }
5663 d.mu.Unlock()
5764 sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
65+ sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
5866
59- views := make([]picker.SessionView, 0, len(sessions))
67+ views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
6068 curTab := 0
6169 for _, s := range sessions {
6270 s.mu.Lock()
@@ -79,6 +87,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
7987 s.mu.Unlock()
8088 views = append(views, view)
8189 }
90+ for _, s := range stopped {
91+ views = append(views, picker.SessionView{
92+ ID: domain.SessionID("stopped:" + s.name),
93+ Name: s.name,
94+ Tabs: []string{""},
95+ Stopped: true,
96+ })
97+ }
8298 return views, curTab
8399 }
84100
@@ -111,6 +127,15 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
111127 switchTarget := false
112128 for i := 0; i < len(data); i++ {
113129 switch data[i] {
130+ case 'x':
131+ target, ok := ac.picker.Selected()
132+ ac.pickerMu.Unlock()
133+ if ok {
134+ d.killPickerTarget(target)
135+ }
136+ d.refreshPicker(ac)
137+ d.paint(sess, ac, true)
138+ return
114139 case 'j':
115140 ac.picker.Down()
116141 changed = true
@@ -325,6 +350,10 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
325350 }
326351
327352 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
353+ if target.Stopped {
354+ d.resumeStoppedAndSwitch(sess, ac, target)
355+ return
356+ }
328357 targetSess := d.sessionByID(target.Session)
329358 if targetSess == nil {
330359 d.paint(sess, ac, true)
@@ -376,6 +405,79 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
376405 return old
377406 }
378407
408+func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) {
409+ d.mu.Lock()
410+ stopped, ok := d.stopped[target.Name]
411+ if !ok || stopped.purging {
412+ d.mu.Unlock()
413+ d.paint(from, ac, true)
414+ return
415+ }
416+ from.mu.Lock()
417+ if from.client != ac {
418+ from.mu.Unlock()
419+ d.mu.Unlock()
420+ return
421+ }
422+ cwd := platform.DirOrHome(stopped.cwd)
423+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size)
424+ if err != nil {
425+ from.mu.Unlock()
426+ d.mu.Unlock()
427+ d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
428+ d.paint(from, ac, true)
429+ return
430+ }
431+ from.client = nil
432+ ac.setSession(nil)
433+ from.mu.Unlock()
434+ targetSess.mu.Lock()
435+ targetSess.client = ac
436+ targetSess.mu.Unlock()
437+ ac.setSession(targetSess)
438+ d.mu.Unlock()
439+ d.firstPaint(targetSess, ac, ac.size)
440+}
441+
442+func (d *Daemon) killPickerTarget(target picker.Target) {
443+ if target.Stopped {
444+ d.mu.Lock()
445+ stopped, ok := d.stopped[target.Name]
446+ if ok && !stopped.purging {
447+ if err := d.persist.Delete(target.Name); err != nil {
448+ d.mu.Unlock()
449+ d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
450+ return
451+ }
452+ if cur, ok := d.stopped[target.Name]; ok && cur == stopped {
453+ delete(d.stopped, target.Name)
454+ }
455+ }
456+ d.mu.Unlock()
457+ return
458+ }
459+ d.mu.Lock()
460+ targetSess := d.sessions[target.Session]
461+ d.mu.Unlock()
462+ if targetSess != nil {
463+ _ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
464+ }
465+}
466+
467+func (d *Daemon) refreshPicker(ac *attachedClient) {
468+ sess := ac.currentSession()
469+ if sess == nil {
470+ return
471+ }
472+ views, curTab := d.pickerViews(sess)
473+ ac.pickerMu.Lock()
474+ if ac.picker != nil {
475+ ac.picker = picker.New(views, sess.id, curTab)
476+ }
477+ ac.pickerMu.Unlock()
478+ d.registerPreviewForSelection(ac)
479+}
480+
379481 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
380482 styleSet := resolveThemeStyles(styles)
381483 inner := pickerModal.Composite(base, styleSet.border)
@@ -30,6 +30,7 @@ import (
30 "strconv"30 "strconv"
31 31
32 "github.com/bnema/vev/internal/domain"32 "github.com/bnema/vev/internal/domain"
33+ "github.com/bnema/vev/internal/platform"
33 "github.com/bnema/vev/internal/ports"34 "github.com/bnema/vev/internal/ports"
34 "github.com/bnema/vev/internal/usecase/keys"35 "github.com/bnema/vev/internal/usecase/keys"
35 "github.com/bnema/vev/internal/usecase/picker"36 "github.com/bnema/vev/internal/usecase/picker"
@@ -53,10 +54,17 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
53 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {54 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
54 d.mu.Lock()55 d.mu.Lock()
55 sessions := d.sessionsSnapshotLocked()56 sessions := d.sessionsSnapshotLocked()
57+ stopped := make([]stoppedSession, 0, len(d.stopped))
58+ for _, s := range d.stopped {
59+ if !s.purging {
60+ stopped = append(stopped, s)
61+ }
62+ }
56 d.mu.Unlock()63 d.mu.Unlock()
57 sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })64 sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
65+ sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
58 66
59- views := make([]picker.SessionView, 0, len(sessions))67+ views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
60 curTab := 068 curTab := 0
61 for _, s := range sessions {69 for _, s := range sessions {
62 s.mu.Lock()70 s.mu.Lock()
@@ -79,6 +87,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
79 s.mu.Unlock()87 s.mu.Unlock()
80 views = append(views, view)88 views = append(views, view)
81 }89 }
90+ for _, s := range stopped {
91+ views = append(views, picker.SessionView{
92+ ID: domain.SessionID("stopped:" + s.name),
93+ Name: s.name,
94+ Tabs: []string{""},
95+ Stopped: true,
96+ })
97+ }
82 return views, curTab98 return views, curTab
83 }99 }
84 100
@@ -111,6 +127,15 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
111 switchTarget := false127 switchTarget := false
112 for i := 0; i < len(data); i++ {128 for i := 0; i < len(data); i++ {
113 switch data[i] {129 switch data[i] {
130+ case 'x':
131+ target, ok := ac.picker.Selected()
132+ ac.pickerMu.Unlock()
133+ if ok {
134+ d.killPickerTarget(target)
135+ }
136+ d.refreshPicker(ac)
137+ d.paint(sess, ac, true)
138+ return
114 case 'j':139 case 'j':
115 ac.picker.Down()140 ac.picker.Down()
116 changed = true141 changed = true
@@ -325,6 +350,10 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
325 }350 }
326 351
327 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {352 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
353+ if target.Stopped {
354+ d.resumeStoppedAndSwitch(sess, ac, target)
355+ return
356+ }
328 targetSess := d.sessionByID(target.Session)357 targetSess := d.sessionByID(target.Session)
329 if targetSess == nil {358 if targetSess == nil {
330 d.paint(sess, ac, true)359 d.paint(sess, ac, true)
@@ -376,6 +405,79 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
376 return old405 return old
377 }406 }
378 407
408+func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) {
409+ d.mu.Lock()
410+ stopped, ok := d.stopped[target.Name]
411+ if !ok || stopped.purging {
412+ d.mu.Unlock()
413+ d.paint(from, ac, true)
414+ return
415+ }
416+ from.mu.Lock()
417+ if from.client != ac {
418+ from.mu.Unlock()
419+ d.mu.Unlock()
420+ return
421+ }
422+ cwd := platform.DirOrHome(stopped.cwd)
423+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size)
424+ if err != nil {
425+ from.mu.Unlock()
426+ d.mu.Unlock()
427+ d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
428+ d.paint(from, ac, true)
429+ return
430+ }
431+ from.client = nil
432+ ac.setSession(nil)
433+ from.mu.Unlock()
434+ targetSess.mu.Lock()
435+ targetSess.client = ac
436+ targetSess.mu.Unlock()
437+ ac.setSession(targetSess)
438+ d.mu.Unlock()
439+ d.firstPaint(targetSess, ac, ac.size)
440+}
441+
442+func (d *Daemon) killPickerTarget(target picker.Target) {
443+ if target.Stopped {
444+ d.mu.Lock()
445+ stopped, ok := d.stopped[target.Name]
446+ if ok && !stopped.purging {
447+ if err := d.persist.Delete(target.Name); err != nil {
448+ d.mu.Unlock()
449+ d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
450+ return
451+ }
452+ if cur, ok := d.stopped[target.Name]; ok && cur == stopped {
453+ delete(d.stopped, target.Name)
454+ }
455+ }
456+ d.mu.Unlock()
457+ return
458+ }
459+ d.mu.Lock()
460+ targetSess := d.sessions[target.Session]
461+ d.mu.Unlock()
462+ if targetSess != nil {
463+ _ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
464+ }
465+}
466+
467+func (d *Daemon) refreshPicker(ac *attachedClient) {
468+ sess := ac.currentSession()
469+ if sess == nil {
470+ return
471+ }
472+ views, curTab := d.pickerViews(sess)
473+ ac.pickerMu.Lock()
474+ if ac.picker != nil {
475+ ac.picker = picker.New(views, sess.id, curTab)
476+ }
477+ ac.pickerMu.Unlock()
478+ d.registerPreviewForSelection(ac)
479+}
480+
379 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {481 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
380 styleSet := resolveThemeStyles(styles)482 styleSet := resolveThemeStyles(styles)
381 inner := pickerModal.Composite(base, styleSet.border)483 inner := pickerModal.Composite(base, styleSet.border)
M internal/usecase/daemon/session.go +157 -12
@@ -34,6 +34,7 @@ import (
3434 "time"
3535
3636 "github.com/bnema/vev/internal/domain"
37+ "github.com/bnema/vev/internal/persist"
3738 "github.com/bnema/vev/internal/ports"
3839 scopy "github.com/bnema/vev/internal/usecase/copy"
3940 "github.com/bnema/vev/pkg/vt"
@@ -47,11 +48,12 @@ type session struct {
4748 ctx context.Context
4849 cancel context.CancelFunc
4950
50- mu sync.Mutex // guards tabs, active, and client
51- tabs []*tab
52- active int
53- client *attachedClient
54- cwd string
51+ mu sync.Mutex // guards tabs, active, and client
52+ tabs []*tab
53+ active int
54+ client *attachedClient
55+ cwd string
56+ createdAt int64
5557 }
5658
5759 // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
@@ -90,6 +92,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
9092
9193 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
9294 d.nextID++
95+ createdAt := time.Now().UnixNano()
9396
9497 tb := newTab(pty, tbSize)
9598 sctx, cancel := context.WithCancel(d.serveCtx)
@@ -102,6 +105,15 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
102105 cancel: cancel,
103106 tabs: []*tab{tb},
104107 cwd: cwd,
108+ createdAt: createdAt,
109+ }
110+ if !ephemeral {
111+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt}); err != nil {
112+ _ = pty.Close()
113+ cancel()
114+ return nil, err
115+ }
116+ delete(d.stopped, name)
105117 }
106118 d.sessions[id] = sess
107119 d.startTabGoroutines(sess, tb)
@@ -118,7 +130,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
118130 d.mu.Unlock()
119131 return errors.New("daemon is shutting down")
120132 }
121- if d.nameTakenLocked(name) {
133+ if d.nameLiveOrStoppedLocked(name) {
122134 d.mu.Unlock()
123135 return errors.New("name already in use")
124136 }
@@ -140,7 +152,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
140152 if from.client != ac {
141153 from.mu.Unlock()
142154 d.mu.Unlock()
143- d.killSession(newSess, ports.ReasonSessionKilled)
155+ _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
144156 return errors.New("client detached")
145157 }
146158 from.client = nil
@@ -269,8 +281,33 @@ func (d *Daemon) renameSession(sess *session, name string) error {
269281 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
270282 return errors.New("name already in use")
271283 }
284+ if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {
285+ return errors.New("name already in use")
286+ }
272287 sess.mu.Lock()
273288 defer sess.mu.Unlock()
289+ oldName := sess.name
290+ wasEphemeral := sess.ephemeral
291+ createdAt := sess.createdAt
292+ if createdAt == 0 {
293+ createdAt = time.Now().UnixNano()
294+ sess.createdAt = createdAt
295+ }
296+ if wasEphemeral || oldName != name {
297+ if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano()}); err != nil {
298+ return err
299+ }
300+ }
301+ if !wasEphemeral && oldName != name {
302+ if err := d.persist.Delete(oldName); err != nil {
303+ if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
304+ d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
305+ }
306+ return err
307+ }
308+ }
309+ delete(d.stopped, oldName)
310+ delete(d.stopped, name)
274311 sess.name = name
275312 sess.ephemeral = false
276313 return nil
@@ -291,7 +328,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
291328 }
292329 if len(sess.tabs) == 1 {
293330 sess.mu.Unlock()
294- d.killSession(sess, ports.ReasonSessionKilled)
331+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
295332 return
296333 }
297334 ringing := tb.attention
@@ -320,14 +357,30 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
320357 // ptyReader drains child output into the VT screen and pokes the dirty channel
321358 // (non-blocking: a full channel already means a render is pending). On any read
322359
323-func (d *Daemon) killSession(sess *session, reason uint8) {
360+func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
324361 ringing := sess.anyAttention()
362+ sess.mu.Lock()
363+ isEphemeral := sess.ephemeral
364+ sess.mu.Unlock()
365+ if !purge && !isEphemeral && d.persistEnabled {
366+ d.refreshSessionCwd(sess)
367+ }
325368 d.mu.Lock()
326369 if _, ok := d.sessions[sess.id]; !ok {
327370 d.mu.Unlock()
328- return
371+ return nil
329372 }
330373 delete(d.sessions, sess.id)
374+ sess.mu.Lock()
375+ stoppedName := sess.name
376+ stoppedCwd := sess.cwd
377+ createdAt := sess.createdAt
378+ ephemeral := sess.ephemeral
379+ sess.mu.Unlock()
380+ if !ephemeral {
381+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, purging: purge}
382+ d.stopped[stoppedName] = stopped
383+ }
331384 empty := len(d.sessions) == 0
332385 if empty {
333386 // Shutdown is now inevitable and irreversible (doneOnce below): stop
@@ -337,6 +390,26 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
337390 }
338391 d.mu.Unlock()
339392
393+ var purgeErr error
394+ if !ephemeral && purge {
395+ if err := d.persist.Delete(stoppedName); err != nil {
396+ purgeErr = err
397+ d.log.Warn("deleting persisted session failed", "err", err, "session", stoppedName)
398+ d.mu.Lock()
399+ if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging {
400+ stopped.purging = false
401+ d.stopped[stoppedName] = stopped
402+ }
403+ d.mu.Unlock()
404+ } else {
405+ d.mu.Lock()
406+ if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging {
407+ delete(d.stopped, stoppedName)
408+ }
409+ d.mu.Unlock()
410+ }
411+ }
412+
340413 sess.mu.Lock()
341414 ac := sess.client
342415 sess.client = nil
@@ -363,15 +436,19 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
363436 if ac != nil {
364437 d.notifyDetachedAsync(ac, reason)
365438 }
439+ return purgeErr
366440 }
367441
368442 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
369443 // d.mu.
370444 func (d *Daemon) allocEphemeralNameLocked() string {
371- used := make(map[string]struct{}, len(d.sessions))
445+ used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
372446 for _, s := range d.sessions {
373447 used[s.name] = struct{}{}
374448 }
449+ for name := range d.stopped {
450+ used[name] = struct{}{}
451+ }
375452 for i := 0; ; i++ {
376453 n := strconv.Itoa(i)
377454 if _, taken := used[n]; !taken {
@@ -389,7 +466,75 @@ func (d *Daemon) findByNameLocked(name string) *session {
389466 return nil
390467 }
391468
392-func (d *Daemon) nameTakenLocked(name string) bool { return d.findByNameLocked(name) != nil }
469+func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
470+ if d.findByNameLocked(name) != nil {
471+ return true
472+ }
473+ _, ok := d.stopped[name]
474+ return ok
475+}
476+
477+func (d *Daemon) cwdSampler(ctx context.Context) {
478+ t := time.NewTicker(5 * time.Second)
479+ defer t.Stop()
480+ for {
481+ select {
482+ case <-ctx.Done():
483+ return
484+ case <-t.C:
485+ d.refreshNamedSessionCwds()
486+ }
487+ }
488+}
489+
490+func (d *Daemon) refreshNamedSessionCwds() {
491+ d.mu.Lock()
492+ sessions := d.sessionsSnapshotLocked()
493+ d.mu.Unlock()
494+ for _, sess := range sessions {
495+ sess.mu.Lock()
496+ ephemeral := sess.ephemeral
497+ sess.mu.Unlock()
498+ if !ephemeral {
499+ d.refreshSessionCwd(sess)
500+ }
501+ }
502+}
503+
504+func (d *Daemon) refreshSessionCwd(sess *session) {
505+ if d.procCwd == nil {
506+ return
507+ }
508+ tb := sess.activeTab()
509+ if tb == nil {
510+ return
511+ }
512+ cwd, err := d.procCwd(tb.pty.Pid())
513+ if err != nil || cwd == "" {
514+ return
515+ }
516+ d.mu.Lock()
517+ if d.sessions[sess.id] != sess {
518+ d.mu.Unlock()
519+ return
520+ }
521+ sess.mu.Lock()
522+ if sess.cwd == cwd {
523+ sess.mu.Unlock()
524+ d.mu.Unlock()
525+ return
526+ }
527+ sess.cwd = cwd
528+ name := sess.name
529+ ephemeral := sess.ephemeral
530+ sess.mu.Unlock()
531+ if !ephemeral {
532+ if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil {
533+ d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)
534+ }
535+ }
536+ d.mu.Unlock()
537+}
393538
394539 // childEnv builds the session child's environment: the daemon's own, with TERM
395540 // and VEV forced to well-known values.
@@ -34,6 +34,7 @@ import (
34 "time"34 "time"
35 35
36 "github.com/bnema/vev/internal/domain"36 "github.com/bnema/vev/internal/domain"
37+ "github.com/bnema/vev/internal/persist"
37 "github.com/bnema/vev/internal/ports"38 "github.com/bnema/vev/internal/ports"
38 scopy "github.com/bnema/vev/internal/usecase/copy"39 scopy "github.com/bnema/vev/internal/usecase/copy"
39 "github.com/bnema/vev/pkg/vt"40 "github.com/bnema/vev/pkg/vt"
@@ -47,11 +48,12 @@ type session struct {
47 ctx context.Context48 ctx context.Context
48 cancel context.CancelFunc49 cancel context.CancelFunc
49 50
50- mu sync.Mutex // guards tabs, active, and client51+ mu sync.Mutex // guards tabs, active, and client
51- tabs []*tab52+ tabs []*tab
52- active int53+ active int
53- client *attachedClient54+ client *attachedClient
54- cwd string55+ cwd string
56+ createdAt int64
55 }57 }
56 58
57 // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output59 // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
@@ -90,6 +92,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
90 92
91 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))93 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
92 d.nextID++94 d.nextID++
95+ createdAt := time.Now().UnixNano()
93 96
94 tb := newTab(pty, tbSize)97 tb := newTab(pty, tbSize)
95 sctx, cancel := context.WithCancel(d.serveCtx)98 sctx, cancel := context.WithCancel(d.serveCtx)
@@ -102,6 +105,15 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
102 cancel: cancel,105 cancel: cancel,
103 tabs: []*tab{tb},106 tabs: []*tab{tb},
104 cwd: cwd,107 cwd: cwd,
108+ createdAt: createdAt,
109+ }
110+ if !ephemeral {
111+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt}); err != nil {
112+ _ = pty.Close()
113+ cancel()
114+ return nil, err
115+ }
116+ delete(d.stopped, name)
105 }117 }
106 d.sessions[id] = sess118 d.sessions[id] = sess
107 d.startTabGoroutines(sess, tb)119 d.startTabGoroutines(sess, tb)
@@ -118,7 +130,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
118 d.mu.Unlock()130 d.mu.Unlock()
119 return errors.New("daemon is shutting down")131 return errors.New("daemon is shutting down")
120 }132 }
121- if d.nameTakenLocked(name) {133+ if d.nameLiveOrStoppedLocked(name) {
122 d.mu.Unlock()134 d.mu.Unlock()
123 return errors.New("name already in use")135 return errors.New("name already in use")
124 }136 }
@@ -140,7 +152,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
140 if from.client != ac {152 if from.client != ac {
141 from.mu.Unlock()153 from.mu.Unlock()
142 d.mu.Unlock()154 d.mu.Unlock()
143- d.killSession(newSess, ports.ReasonSessionKilled)155+ _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
144 return errors.New("client detached")156 return errors.New("client detached")
145 }157 }
146 from.client = nil158 from.client = nil
@@ -269,8 +281,33 @@ func (d *Daemon) renameSession(sess *session, name string) error {
269 if taken := d.findByNameLocked(name); taken != nil && taken != sess {281 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
270 return errors.New("name already in use")282 return errors.New("name already in use")
271 }283 }
284+ if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {
285+ return errors.New("name already in use")
286+ }
272 sess.mu.Lock()287 sess.mu.Lock()
273 defer sess.mu.Unlock()288 defer sess.mu.Unlock()
289+ oldName := sess.name
290+ wasEphemeral := sess.ephemeral
291+ createdAt := sess.createdAt
292+ if createdAt == 0 {
293+ createdAt = time.Now().UnixNano()
294+ sess.createdAt = createdAt
295+ }
296+ if wasEphemeral || oldName != name {
297+ if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano()}); err != nil {
298+ return err
299+ }
300+ }
301+ if !wasEphemeral && oldName != name {
302+ if err := d.persist.Delete(oldName); err != nil {
303+ if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
304+ d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
305+ }
306+ return err
307+ }
308+ }
309+ delete(d.stopped, oldName)
310+ delete(d.stopped, name)
274 sess.name = name311 sess.name = name
275 sess.ephemeral = false312 sess.ephemeral = false
276 return nil313 return nil
@@ -291,7 +328,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
291 }328 }
292 if len(sess.tabs) == 1 {329 if len(sess.tabs) == 1 {
293 sess.mu.Unlock()330 sess.mu.Unlock()
294- d.killSession(sess, ports.ReasonSessionKilled)331+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
295 return332 return
296 }333 }
297 ringing := tb.attention334 ringing := tb.attention
@@ -320,14 +357,30 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
320 // ptyReader drains child output into the VT screen and pokes the dirty channel357 // ptyReader drains child output into the VT screen and pokes the dirty channel
321 // (non-blocking: a full channel already means a render is pending). On any read358 // (non-blocking: a full channel already means a render is pending). On any read
322 359
323-func (d *Daemon) killSession(sess *session, reason uint8) {360+func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
324 ringing := sess.anyAttention()361 ringing := sess.anyAttention()
362+ sess.mu.Lock()
363+ isEphemeral := sess.ephemeral
364+ sess.mu.Unlock()
365+ if !purge && !isEphemeral && d.persistEnabled {
366+ d.refreshSessionCwd(sess)
367+ }
325 d.mu.Lock()368 d.mu.Lock()
326 if _, ok := d.sessions[sess.id]; !ok {369 if _, ok := d.sessions[sess.id]; !ok {
327 d.mu.Unlock()370 d.mu.Unlock()
328- return371+ return nil
329 }372 }
330 delete(d.sessions, sess.id)373 delete(d.sessions, sess.id)
374+ sess.mu.Lock()
375+ stoppedName := sess.name
376+ stoppedCwd := sess.cwd
377+ createdAt := sess.createdAt
378+ ephemeral := sess.ephemeral
379+ sess.mu.Unlock()
380+ if !ephemeral {
381+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, purging: purge}
382+ d.stopped[stoppedName] = stopped
383+ }
331 empty := len(d.sessions) == 0384 empty := len(d.sessions) == 0
332 if empty {385 if empty {
333 // Shutdown is now inevitable and irreversible (doneOnce below): stop386 // Shutdown is now inevitable and irreversible (doneOnce below): stop
@@ -337,6 +390,26 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
337 }390 }
338 d.mu.Unlock()391 d.mu.Unlock()
339 392
393+ var purgeErr error
394+ if !ephemeral && purge {
395+ if err := d.persist.Delete(stoppedName); err != nil {
396+ purgeErr = err
397+ d.log.Warn("deleting persisted session failed", "err", err, "session", stoppedName)
398+ d.mu.Lock()
399+ if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging {
400+ stopped.purging = false
401+ d.stopped[stoppedName] = stopped
402+ }
403+ d.mu.Unlock()
404+ } else {
405+ d.mu.Lock()
406+ if stopped, ok := d.stopped[stoppedName]; ok && stopped.purging {
407+ delete(d.stopped, stoppedName)
408+ }
409+ d.mu.Unlock()
410+ }
411+ }
412+
340 sess.mu.Lock()413 sess.mu.Lock()
341 ac := sess.client414 ac := sess.client
342 sess.client = nil415 sess.client = nil
@@ -363,15 +436,19 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
363 if ac != nil {436 if ac != nil {
364 d.notifyDetachedAsync(ac, reason)437 d.notifyDetachedAsync(ac, reason)
365 }438 }
439+ return purgeErr
366 }440 }
367 441
368 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds442 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
369 // d.mu.443 // d.mu.
370 func (d *Daemon) allocEphemeralNameLocked() string {444 func (d *Daemon) allocEphemeralNameLocked() string {
371- used := make(map[string]struct{}, len(d.sessions))445+ used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
372 for _, s := range d.sessions {446 for _, s := range d.sessions {
373 used[s.name] = struct{}{}447 used[s.name] = struct{}{}
374 }448 }
449+ for name := range d.stopped {
450+ used[name] = struct{}{}
451+ }
375 for i := 0; ; i++ {452 for i := 0; ; i++ {
376 n := strconv.Itoa(i)453 n := strconv.Itoa(i)
377 if _, taken := used[n]; !taken {454 if _, taken := used[n]; !taken {
@@ -389,7 +466,75 @@ func (d *Daemon) findByNameLocked(name string) *session {
389 return nil466 return nil
390 }467 }
391 468
392-func (d *Daemon) nameTakenLocked(name string) bool { return d.findByNameLocked(name) != nil }469+func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
470+ if d.findByNameLocked(name) != nil {
471+ return true
472+ }
473+ _, ok := d.stopped[name]
474+ return ok
475+}
476+
477+func (d *Daemon) cwdSampler(ctx context.Context) {
478+ t := time.NewTicker(5 * time.Second)
479+ defer t.Stop()
480+ for {
481+ select {
482+ case <-ctx.Done():
483+ return
484+ case <-t.C:
485+ d.refreshNamedSessionCwds()
486+ }
487+ }
488+}
489+
490+func (d *Daemon) refreshNamedSessionCwds() {
491+ d.mu.Lock()
492+ sessions := d.sessionsSnapshotLocked()
493+ d.mu.Unlock()
494+ for _, sess := range sessions {
495+ sess.mu.Lock()
496+ ephemeral := sess.ephemeral
497+ sess.mu.Unlock()
498+ if !ephemeral {
499+ d.refreshSessionCwd(sess)
500+ }
501+ }
502+}
503+
504+func (d *Daemon) refreshSessionCwd(sess *session) {
505+ if d.procCwd == nil {
506+ return
507+ }
508+ tb := sess.activeTab()
509+ if tb == nil {
510+ return
511+ }
512+ cwd, err := d.procCwd(tb.pty.Pid())
513+ if err != nil || cwd == "" {
514+ return
515+ }
516+ d.mu.Lock()
517+ if d.sessions[sess.id] != sess {
518+ d.mu.Unlock()
519+ return
520+ }
521+ sess.mu.Lock()
522+ if sess.cwd == cwd {
523+ sess.mu.Unlock()
524+ d.mu.Unlock()
525+ return
526+ }
527+ sess.cwd = cwd
528+ name := sess.name
529+ ephemeral := sess.ephemeral
530+ sess.mu.Unlock()
531+ if !ephemeral {
532+ if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil {
533+ d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)
534+ }
535+ }
536+ d.mu.Unlock()
537+}
393 538
394 // childEnv builds the session child's environment: the daemon's own, with TERM539 // childEnv builds the session child's environment: the daemon's own, with TERM
395 // and VEV forced to well-known values.540 // and VEV forced to well-known values.
M internal/usecase/daemon/session_test.go +237 -5
@@ -12,8 +12,10 @@ import (
1212 "github.com/stretchr/testify/require"
1313
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/persist"
1516 "github.com/bnema/vev/internal/ports"
1617 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/picker"
1719 )
1820
1921 // --- test doubles -----------------------------------------------------------
@@ -100,7 +102,7 @@ func TestHandshakeNewHappy(t *testing.T) {
100102 require.NotNil(t, sess)
101103
102104 releaseConn()
103- d.killSession(sess, ports.ReasonServerShutdown)
105+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
104106 releasePTY()
105107 hg.Wait()
106108 d.sessWg.Wait()
@@ -214,7 +216,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
214216 errCh := make(chan error, 1)
215217 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()
216218 <-opened
217- d.killSession(sess, ports.ReasonSessionKilled)
219+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
218220 close(releaseOpen)
219221
220222 require.Error(t, <-errCh)
@@ -275,7 +277,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
275277
276278 releaseA()
277279 releaseB()
278- d.killSession(sess, ports.ReasonServerShutdown)
280+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
279281 releasePTY()
280282 hg.Wait()
281283 d.sessWg.Wait()
@@ -324,7 +326,7 @@ func TestDetachKeepsNamed(t *testing.T) {
324326 require.Nil(t, sess.client, "named session is headless after detach")
325327 sess.mu.Unlock()
326328
327- d.killSession(sess, ports.ReasonServerShutdown)
329+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
328330 releasePTY()
329331 d.sessWg.Wait()
330332 d.waitNotifies()
@@ -472,7 +474,7 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
472474 require.NotNil(t, sess)
473475
474476 // The last session dies: shutdown begins irreversibly.
475- d.killSession(sess, ports.ReasonSessionKilled)
477+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
476478 select {
477479 case <-d.done:
478480 default:
@@ -580,3 +582,233 @@ func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
580582
581583 // TestSendErrorKillsEphemeral: a failed output send detaches the client, and —
582584 // like every other detach path — that kills an ephemeral session rather than
585+
586+type mockStoreState struct {
587+ mu sync.Mutex
588+ data map[string][]byte
589+ sets int
590+ dels []string
591+ syncs int
592+ closed bool
593+}
594+
595+func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
596+ t.Helper()
597+ state := &mockStoreState{data: make(map[string][]byte)}
598+ store := portsmocks.NewMockStore(t)
599+ store.EXPECT().Get(mock.Anything).RunAndReturn(func(k []byte) ([]byte, bool) {
600+ state.mu.Lock()
601+ defer state.mu.Unlock()
602+ v, ok := state.data[string(k)]
603+ return append([]byte(nil), v...), ok
604+ }).Maybe()
605+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(k, v []byte) error {
606+ state.mu.Lock()
607+ defer state.mu.Unlock()
608+ state.sets++
609+ state.data[string(k)] = append([]byte(nil), v...)
610+ return nil
611+ }).Maybe()
612+ store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
613+ state.mu.Lock()
614+ defer state.mu.Unlock()
615+ state.dels = append(state.dels, string(k))
616+ delete(state.data, string(k))
617+ return nil
618+ }).Maybe()
619+ store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) {
620+ state.mu.Lock()
621+ defer state.mu.Unlock()
622+ for k, v := range state.data {
623+ if !fn([]byte(k), append([]byte(nil), v...)) {
624+ return
625+ }
626+ }
627+ }).Maybe()
628+ store.EXPECT().Sync().RunAndReturn(func() error {
629+ state.mu.Lock()
630+ defer state.mu.Unlock()
631+ state.syncs++
632+ return nil
633+ }).Maybe()
634+ store.EXPECT().Close().RunAndReturn(func() error {
635+ state.mu.Lock()
636+ defer state.mu.Unlock()
637+ state.closed = true
638+ return nil
639+ }).Maybe()
640+ return store, state
641+}
642+
643+func (s *mockStoreState) has(name string) bool {
644+ s.mu.Lock()
645+ defer s.mu.Unlock()
646+ _, ok := s.data[name]
647+ return ok
648+}
649+
650+func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
651+ store, _ := newMockStore(t)
652+ seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
653+ require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8}))
654+
655+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
656+ d.mu.Lock()
657+ stopped := d.stopped["work"]
658+ d.mu.Unlock()
659+ require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped)
660+}
661+
662+func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
663+ sz := domain.Size{Cols: 80, Rows: 24}
664+ p, release := newBlockingPTY(t)
665+ defer release()
666+ store, state := newMockStore(t)
667+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
668+ WithStore(store)(d)
669+
670+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
671+ require.NoError(t, err)
672+ require.True(t, state.has("work"))
673+
674+ require.NoError(t, d.renameSession(sess, "renamed"))
675+ require.False(t, state.has("work"))
676+ require.True(t, state.has("renamed"))
677+
678+ _ = d.killSession(sess, ports.ReasonSessionKilled, true)
679+ require.False(t, state.has("renamed"))
680+}
681+
682+func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
683+ sz := domain.Size{Cols: 80, Rows: 24}
684+ p, release := newBlockingPTY(t)
685+ defer release()
686+ store, state := newMockStore(t)
687+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
688+ WithStore(store)(d)
689+ d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
690+
691+ sess, err := d.createSessionLocked("0", true, "/tmp/e", sz)
692+ require.NoError(t, err)
693+ require.False(t, state.has("0"))
694+ require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")
695+ require.NoError(t, d.renameSession(sess, "named"))
696+ require.True(t, state.has("named"))
697+}
698+
699+func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
700+ sz := domain.Size{Cols: 80, Rows: 24}
701+ p, release := newBlockingPTY(t)
702+ defer release()
703+ store, state := newMockStore(t)
704+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
705+ WithStore(store)(d)
706+ cwd := "/tmp/work"
707+ WithCwdReader(func(int) (string, error) { return cwd, nil })(d)
708+
709+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
710+ require.NoError(t, err)
711+ state.mu.Lock()
712+ setsAfterCreate := state.sets
713+ state.mu.Unlock()
714+
715+ d.refreshSessionCwd(sess)
716+ state.mu.Lock()
717+ require.Equal(t, setsAfterCreate, state.sets)
718+ state.mu.Unlock()
719+
720+ cwd = "/tmp/next"
721+ d.refreshSessionCwd(sess)
722+ state.mu.Lock()
723+ require.Equal(t, setsAfterCreate+1, state.sets)
724+ state.mu.Unlock()
725+}
726+
727+func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) {
728+ sz := domain.Size{Cols: 80, Rows: 24}
729+ p, release := newBlockingPTY(t)
730+ defer release()
731+ store, _ := newMockStore(t)
732+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
733+ WithStore(store)(d)
734+ cwd := t.TempDir()
735+ d.stopped["work"] = stoppedSession{name: "work", cwd: cwd, createdAt: 1}
736+ tr := portsmocks.NewMockTransport(t)
737+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
738+ tr.EXPECT().Close().Return(nil).Maybe()
739+
740+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
741+ require.NoError(t, err)
742+ require.NotNil(t, ac)
743+ require.Equal(t, "work", sess.name)
744+ require.Equal(t, cwd, sess.cwd)
745+ d.mu.Lock()
746+ _, stillStopped := d.stopped["work"]
747+ d.mu.Unlock()
748+ require.False(t, stillStopped)
749+}
750+
751+func TestAttachStoppedMissingCwdFallsBackToHome(t *testing.T) {
752+ sz := domain.Size{Cols: 80, Rows: 24}
753+ p, release := newBlockingPTY(t)
754+ defer release()
755+ store, _ := newMockStore(t)
756+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
757+ WithStore(store)(d)
758+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/definitely/missing/vev", createdAt: 1}
759+ tr := portsmocks.NewMockTransport(t)
760+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
761+ tr.EXPECT().Close().Return(nil).Maybe()
762+
763+ sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
764+ require.NoError(t, err)
765+ require.NotEqual(t, "/definitely/missing/vev", sess.cwd)
766+}
767+
768+func TestPickerStoppedTargetKillPurges(t *testing.T) {
769+ store, state := newMockStore(t)
770+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
771+ WithStore(store)(d)
772+ require.NoError(t, d.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
773+ d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}
774+ d.killPickerTarget(picker.Target{Name: "old", Stopped: true})
775+ require.False(t, state.has("old"))
776+ d.mu.Lock()
777+ _, ok := d.stopped["old"]
778+ d.mu.Unlock()
779+ require.False(t, ok)
780+}
781+
782+func TestIntentNewStoppedNameRejected(t *testing.T) {
783+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
784+ d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
785+ tr := portsmocks.NewMockTransport(t)
786+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "taken", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
787+ require.ErrorContains(t, err, "name already in use")
788+}
789+
790+func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
791+ sz := domain.Size{Cols: 80, Rows: 24}
792+ p1, release1 := newBlockingPTY(t)
793+ defer release1()
794+ p2, release2 := newBlockingPTY(t)
795+ defer release2()
796+ store, state := newMockStore(t)
797+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
798+ WithStore(store)(d)
799+ WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)
800+
801+ natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz)
802+ require.NoError(t, err)
803+ other, err := d.createSessionLocked("other", false, "/tmp/other", sz)
804+ require.NoError(t, err)
805+ _ = d.killSession(natural, ports.ReasonSessionKilled, false)
806+ require.True(t, state.has("natural"))
807+ d.mu.Lock()
808+ stopped := d.stopped["natural"]
809+ d.mu.Unlock()
810+ require.Equal(t, "/tmp/latest", stopped.cwd)
811+
812+ _ = d.killSession(other, ports.ReasonSessionKilled, true)
813+ require.False(t, state.has("other"))
814+}
@@ -12,8 +12,10 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/persist"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/picker"
17 )19 )
18 20
19 // --- test doubles -----------------------------------------------------------21 // --- test doubles -----------------------------------------------------------
@@ -100,7 +102,7 @@ func TestHandshakeNewHappy(t *testing.T) {
100 require.NotNil(t, sess)102 require.NotNil(t, sess)
101 103
102 releaseConn()104 releaseConn()
103- d.killSession(sess, ports.ReasonServerShutdown)105+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
104 releasePTY()106 releasePTY()
105 hg.Wait()107 hg.Wait()
106 d.sessWg.Wait()108 d.sessWg.Wait()
@@ -214,7 +216,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
214 errCh := make(chan error, 1)216 errCh := make(chan error, 1)
215 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()217 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()
216 <-opened218 <-opened
217- d.killSession(sess, ports.ReasonSessionKilled)219+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
218 close(releaseOpen)220 close(releaseOpen)
219 221
220 require.Error(t, <-errCh)222 require.Error(t, <-errCh)
@@ -275,7 +277,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
275 277
276 releaseA()278 releaseA()
277 releaseB()279 releaseB()
278- d.killSession(sess, ports.ReasonServerShutdown)280+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
279 releasePTY()281 releasePTY()
280 hg.Wait()282 hg.Wait()
281 d.sessWg.Wait()283 d.sessWg.Wait()
@@ -324,7 +326,7 @@ func TestDetachKeepsNamed(t *testing.T) {
324 require.Nil(t, sess.client, "named session is headless after detach")326 require.Nil(t, sess.client, "named session is headless after detach")
325 sess.mu.Unlock()327 sess.mu.Unlock()
326 328
327- d.killSession(sess, ports.ReasonServerShutdown)329+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
328 releasePTY()330 releasePTY()
329 d.sessWg.Wait()331 d.sessWg.Wait()
330 d.waitNotifies()332 d.waitNotifies()
@@ -472,7 +474,7 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
472 require.NotNil(t, sess)474 require.NotNil(t, sess)
473 475
474 // The last session dies: shutdown begins irreversibly.476 // The last session dies: shutdown begins irreversibly.
475- d.killSession(sess, ports.ReasonSessionKilled)477+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
476 select {478 select {
477 case <-d.done:479 case <-d.done:
478 default:480 default:
@@ -580,3 +582,233 @@ func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
580 582
581 // TestSendErrorKillsEphemeral: a failed output send detaches the client, and —583 // TestSendErrorKillsEphemeral: a failed output send detaches the client, and —
582 // like every other detach path — that kills an ephemeral session rather than584 // like every other detach path — that kills an ephemeral session rather than
585+
586+type mockStoreState struct {
587+ mu sync.Mutex
588+ data map[string][]byte
589+ sets int
590+ dels []string
591+ syncs int
592+ closed bool
593+}
594+
595+func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
596+ t.Helper()
597+ state := &mockStoreState{data: make(map[string][]byte)}
598+ store := portsmocks.NewMockStore(t)
599+ store.EXPECT().Get(mock.Anything).RunAndReturn(func(k []byte) ([]byte, bool) {
600+ state.mu.Lock()
601+ defer state.mu.Unlock()
602+ v, ok := state.data[string(k)]
603+ return append([]byte(nil), v...), ok
604+ }).Maybe()
605+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(k, v []byte) error {
606+ state.mu.Lock()
607+ defer state.mu.Unlock()
608+ state.sets++
609+ state.data[string(k)] = append([]byte(nil), v...)
610+ return nil
611+ }).Maybe()
612+ store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
613+ state.mu.Lock()
614+ defer state.mu.Unlock()
615+ state.dels = append(state.dels, string(k))
616+ delete(state.data, string(k))
617+ return nil
618+ }).Maybe()
619+ store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) {
620+ state.mu.Lock()
621+ defer state.mu.Unlock()
622+ for k, v := range state.data {
623+ if !fn([]byte(k), append([]byte(nil), v...)) {
624+ return
625+ }
626+ }
627+ }).Maybe()
628+ store.EXPECT().Sync().RunAndReturn(func() error {
629+ state.mu.Lock()
630+ defer state.mu.Unlock()
631+ state.syncs++
632+ return nil
633+ }).Maybe()
634+ store.EXPECT().Close().RunAndReturn(func() error {
635+ state.mu.Lock()
636+ defer state.mu.Unlock()
637+ state.closed = true
638+ return nil
639+ }).Maybe()
640+ return store, state
641+}
642+
643+func (s *mockStoreState) has(name string) bool {
644+ s.mu.Lock()
645+ defer s.mu.Unlock()
646+ _, ok := s.data[name]
647+ return ok
648+}
649+
650+func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
651+ store, _ := newMockStore(t)
652+ seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
653+ require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8}))
654+
655+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
656+ d.mu.Lock()
657+ stopped := d.stopped["work"]
658+ d.mu.Unlock()
659+ require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped)
660+}
661+
662+func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
663+ sz := domain.Size{Cols: 80, Rows: 24}
664+ p, release := newBlockingPTY(t)
665+ defer release()
666+ store, state := newMockStore(t)
667+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
668+ WithStore(store)(d)
669+
670+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
671+ require.NoError(t, err)
672+ require.True(t, state.has("work"))
673+
674+ require.NoError(t, d.renameSession(sess, "renamed"))
675+ require.False(t, state.has("work"))
676+ require.True(t, state.has("renamed"))
677+
678+ _ = d.killSession(sess, ports.ReasonSessionKilled, true)
679+ require.False(t, state.has("renamed"))
680+}
681+
682+func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
683+ sz := domain.Size{Cols: 80, Rows: 24}
684+ p, release := newBlockingPTY(t)
685+ defer release()
686+ store, state := newMockStore(t)
687+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
688+ WithStore(store)(d)
689+ d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
690+
691+ sess, err := d.createSessionLocked("0", true, "/tmp/e", sz)
692+ require.NoError(t, err)
693+ require.False(t, state.has("0"))
694+ require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")
695+ require.NoError(t, d.renameSession(sess, "named"))
696+ require.True(t, state.has("named"))
697+}
698+
699+func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
700+ sz := domain.Size{Cols: 80, Rows: 24}
701+ p, release := newBlockingPTY(t)
702+ defer release()
703+ store, state := newMockStore(t)
704+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
705+ WithStore(store)(d)
706+ cwd := "/tmp/work"
707+ WithCwdReader(func(int) (string, error) { return cwd, nil })(d)
708+
709+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
710+ require.NoError(t, err)
711+ state.mu.Lock()
712+ setsAfterCreate := state.sets
713+ state.mu.Unlock()
714+
715+ d.refreshSessionCwd(sess)
716+ state.mu.Lock()
717+ require.Equal(t, setsAfterCreate, state.sets)
718+ state.mu.Unlock()
719+
720+ cwd = "/tmp/next"
721+ d.refreshSessionCwd(sess)
722+ state.mu.Lock()
723+ require.Equal(t, setsAfterCreate+1, state.sets)
724+ state.mu.Unlock()
725+}
726+
727+func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) {
728+ sz := domain.Size{Cols: 80, Rows: 24}
729+ p, release := newBlockingPTY(t)
730+ defer release()
731+ store, _ := newMockStore(t)
732+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
733+ WithStore(store)(d)
734+ cwd := t.TempDir()
735+ d.stopped["work"] = stoppedSession{name: "work", cwd: cwd, createdAt: 1}
736+ tr := portsmocks.NewMockTransport(t)
737+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
738+ tr.EXPECT().Close().Return(nil).Maybe()
739+
740+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
741+ require.NoError(t, err)
742+ require.NotNil(t, ac)
743+ require.Equal(t, "work", sess.name)
744+ require.Equal(t, cwd, sess.cwd)
745+ d.mu.Lock()
746+ _, stillStopped := d.stopped["work"]
747+ d.mu.Unlock()
748+ require.False(t, stillStopped)
749+}
750+
751+func TestAttachStoppedMissingCwdFallsBackToHome(t *testing.T) {
752+ sz := domain.Size{Cols: 80, Rows: 24}
753+ p, release := newBlockingPTY(t)
754+ defer release()
755+ store, _ := newMockStore(t)
756+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
757+ WithStore(store)(d)
758+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/definitely/missing/vev", createdAt: 1}
759+ tr := portsmocks.NewMockTransport(t)
760+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
761+ tr.EXPECT().Close().Return(nil).Maybe()
762+
763+ sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
764+ require.NoError(t, err)
765+ require.NotEqual(t, "/definitely/missing/vev", sess.cwd)
766+}
767+
768+func TestPickerStoppedTargetKillPurges(t *testing.T) {
769+ store, state := newMockStore(t)
770+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
771+ WithStore(store)(d)
772+ require.NoError(t, d.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
773+ d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}
774+ d.killPickerTarget(picker.Target{Name: "old", Stopped: true})
775+ require.False(t, state.has("old"))
776+ d.mu.Lock()
777+ _, ok := d.stopped["old"]
778+ d.mu.Unlock()
779+ require.False(t, ok)
780+}
781+
782+func TestIntentNewStoppedNameRejected(t *testing.T) {
783+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
784+ d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
785+ tr := portsmocks.NewMockTransport(t)
786+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "taken", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
787+ require.ErrorContains(t, err, "name already in use")
788+}
789+
790+func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
791+ sz := domain.Size{Cols: 80, Rows: 24}
792+ p1, release1 := newBlockingPTY(t)
793+ defer release1()
794+ p2, release2 := newBlockingPTY(t)
795+ defer release2()
796+ store, state := newMockStore(t)
797+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
798+ WithStore(store)(d)
799+ WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)
800+
801+ natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz)
802+ require.NoError(t, err)
803+ other, err := d.createSessionLocked("other", false, "/tmp/other", sz)
804+ require.NoError(t, err)
805+ _ = d.killSession(natural, ports.ReasonSessionKilled, false)
806+ require.True(t, state.has("natural"))
807+ d.mu.Lock()
808+ stopped := d.stopped["natural"]
809+ d.mu.Unlock()
810+ require.Equal(t, "/tmp/latest", stopped.cwd)
811+
812+ _ = d.killSession(other, ports.ReasonSessionKilled, true)
813+ require.False(t, state.has("other"))
814+}
M internal/usecase/picker/model.go +19 -7
@@ -15,15 +15,18 @@ const (
1515 )
1616
1717 type SessionView struct {
18- ID domain.SessionID
19- Name string
20- Tabs []string
21- Active int
18+ ID domain.SessionID
19+ Name string
20+ Tabs []string
21+ Active int
22+ Stopped bool
2223 }
2324
2425 type Target struct {
2526 Session domain.SessionID
27+ Name string
2628 TabIndex int
29+ Stopped bool
2730 }
2831
2932 type Preview struct {
@@ -46,21 +49,27 @@ type row struct {
4649 header bool
4750 label string
4851 session domain.SessionID
52+ name string
4953 tabIndex int
54+ stopped bool
5055 }
5156
5257 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
5358 m := &Model{selected: -1}
5459 activeSelection := -1
5560 for _, session := range sessions {
56- m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, tabIndex: -1})
61+ name := ""
62+ if session.Stopped {
63+ name = session.Name
64+ }
65+ m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, name: name, tabIndex: -1, stopped: session.Stopped})
5766 active := session.Active
5867 if active < 0 || active >= len(session.Tabs) {
5968 active = 0
6069 }
6170 for i, tab := range session.Tabs {
6271 idx := len(m.rows)
63- m.rows = append(m.rows, row{label: tab, session: session.ID, tabIndex: i})
72+ m.rows = append(m.rows, row{label: tab, session: session.ID, name: name, tabIndex: i, stopped: session.Stopped})
6473 if session.ID == cur && i == curTab {
6574 m.selected = idx
6675 }
@@ -122,7 +131,7 @@ func (m *Model) Selected() (Target, bool) {
122131 if r.header {
123132 return Target{}, false
124133 }
125- return Target{Session: r.session, TabIndex: r.tabIndex}, true
134+ return Target{Session: r.session, Name: r.name, TabIndex: r.tabIndex, Stopped: r.stopped}, true
126135 }
127136
128137 func (m *Model) Clone() *Model {
@@ -189,6 +198,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection ren
189198 style = selection
190199 }
191200 label := r.label
201+ if r.header && r.stopped {
202+ label += " (stopped)"
203+ }
192204 if !r.header {
193205 label = " " + label
194206 }
@@ -15,15 +15,18 @@ const (
15 )15 )
16 16
17 type SessionView struct {17 type SessionView struct {
18- ID domain.SessionID18+ ID domain.SessionID
19- Name string19+ Name string
20- Tabs []string20+ Tabs []string
21- Active int21+ Active int
22+ Stopped bool
22 }23 }
23 24
24 type Target struct {25 type Target struct {
25 Session domain.SessionID26 Session domain.SessionID
27+ Name string
26 TabIndex int28 TabIndex int
29+ Stopped bool
27 }30 }
28 31
29 type Preview struct {32 type Preview struct {
@@ -46,21 +49,27 @@ type row struct {
46 header bool49 header bool
47 label string50 label string
48 session domain.SessionID51 session domain.SessionID
52+ name string
49 tabIndex int53 tabIndex int
54+ stopped bool
50 }55 }
51 56
52 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {57 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
53 m := &Model{selected: -1}58 m := &Model{selected: -1}
54 activeSelection := -159 activeSelection := -1
55 for _, session := range sessions {60 for _, session := range sessions {
56- m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, tabIndex: -1})61+ name := ""
62+ if session.Stopped {
63+ name = session.Name
64+ }
65+ m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, name: name, tabIndex: -1, stopped: session.Stopped})
57 active := session.Active66 active := session.Active
58 if active < 0 || active >= len(session.Tabs) {67 if active < 0 || active >= len(session.Tabs) {
59 active = 068 active = 0
60 }69 }
61 for i, tab := range session.Tabs {70 for i, tab := range session.Tabs {
62 idx := len(m.rows)71 idx := len(m.rows)
63- m.rows = append(m.rows, row{label: tab, session: session.ID, tabIndex: i})72+ m.rows = append(m.rows, row{label: tab, session: session.ID, name: name, tabIndex: i, stopped: session.Stopped})
64 if session.ID == cur && i == curTab {73 if session.ID == cur && i == curTab {
65 m.selected = idx74 m.selected = idx
66 }75 }
@@ -122,7 +131,7 @@ func (m *Model) Selected() (Target, bool) {
122 if r.header {131 if r.header {
123 return Target{}, false132 return Target{}, false
124 }133 }
125- return Target{Session: r.session, TabIndex: r.tabIndex}, true134+ return Target{Session: r.session, Name: r.name, TabIndex: r.tabIndex, Stopped: r.stopped}, true
126 }135 }
127 136
128 func (m *Model) Clone() *Model {137 func (m *Model) Clone() *Model {
@@ -189,6 +198,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection ren
189 style = selection198 style = selection
190 }199 }
191 label := r.label200 label := r.label
201+ if r.header && r.stopped {
202+ label += " (stopped)"
203+ }
192 if !r.header {204 if !r.header {
193 label = " " + label205 label = " " + label
194 }206 }
M internal/usecase/picker/model_test.go +10 -0
@@ -101,6 +101,16 @@ func TestSelectedMapping(t *testing.T) {
101101 require.Equal(t, Target{Session: "beta", TabIndex: 1}, got)
102102 }
103103
104+func TestStoppedSessionSelectableAndRendered(t *testing.T) {
105+ m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []string{""}, Stopped: true}}, "", 0)
106+ got, ok := m.Selected()
107+ require.True(t, ok)
108+ require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)
109+ frame := m.Render(domain.Size{Cols: 24, Rows: 4}, Preview{})
110+ require.Equal(t, 'w', frame.At(0, 0).Rune)
111+ require.Equal(t, '(', frame.At(5, 0).Rune)
112+}
113+
104114 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
105115 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
106116 preview := Preview{
@@ -101,6 +101,16 @@ func TestSelectedMapping(t *testing.T) {
101 require.Equal(t, Target{Session: "beta", TabIndex: 1}, got)101 require.Equal(t, Target{Session: "beta", TabIndex: 1}, got)
102 }102 }
103 103
104+func TestStoppedSessionSelectableAndRendered(t *testing.T) {
105+ m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []string{""}, Stopped: true}}, "", 0)
106+ got, ok := m.Selected()
107+ require.True(t, ok)
108+ require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)
109+ frame := m.Render(domain.Size{Cols: 24, Rows: 4}, Preview{})
110+ require.Equal(t, 'w', frame.At(0, 0).Rune)
111+ require.Equal(t, '(', frame.At(5, 0).Rune)
112+}
113+
104 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {114 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
105 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)115 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
106 preview := Preview{116 preview := Preview{
A pkg/kv/codec.go +81 -0
@@ -0,0 +1,81 @@
1+package kv
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "hash/crc32"
7+ "math"
8+)
9+
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+)
20+
21+var errBadRecord = errors.New("bad record")
22+
23+type record struct {
24+ op byte
25+ key []byte
26+ value []byte
27+}
28+
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
35+ }
36+
37+ payloadLen := payloadPrefixLen + len(key)
38+ if op == opSet {
39+ payloadLen += len(value)
40+ }
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)
52+ }
53+
54+ binary.BigEndian.PutUint32(buf[0:4], uint32(payloadLen))
55+ binary.BigEndian.PutUint32(buf[4:8], crc32.ChecksumIEEE(payload))
56+ return buf, nil
57+}
58+
59+func decodePayload(payload []byte) (record, error) {
60+ if len(payload) < payloadPrefixLen {
61+ return record{}, errBadRecord
62+ }
63+ op := payload[0]
64+ if op != opSet && op != opDel {
65+ return record{}, errBadRecord
66+ }
67+ keyLen := int(binary.BigEndian.Uint16(payload[1:3]))
68+ if len(payload) < payloadPrefixLen+keyLen {
69+ return record{}, errBadRecord
70+ }
71+ if op == opDel && len(payload) != payloadPrefixLen+keyLen {
72+ return record{}, errBadRecord
73+ }
74+
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:]...)
79+ }
80+ return record{op: op, key: key, value: val}, nil
81+}
@@ -0,0 +1,81 @@
1+package kv
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "hash/crc32"
7+ "math"
8+)
9+
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+)
20+
21+var errBadRecord = errors.New("bad record")
22+
23+type record struct {
24+ op byte
25+ key []byte
26+ value []byte
27+}
28+
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
35+ }
36+
37+ payloadLen := payloadPrefixLen + len(key)
38+ if op == opSet {
39+ payloadLen += len(value)
40+ }
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)
52+ }
53+
54+ binary.BigEndian.PutUint32(buf[0:4], uint32(payloadLen))
55+ binary.BigEndian.PutUint32(buf[4:8], crc32.ChecksumIEEE(payload))
56+ return buf, nil
57+}
58+
59+func decodePayload(payload []byte) (record, error) {
60+ if len(payload) < payloadPrefixLen {
61+ return record{}, errBadRecord
62+ }
63+ op := payload[0]
64+ if op != opSet && op != opDel {
65+ return record{}, errBadRecord
66+ }
67+ keyLen := int(binary.BigEndian.Uint16(payload[1:3]))
68+ if len(payload) < payloadPrefixLen+keyLen {
69+ return record{}, errBadRecord
70+ }
71+ if op == opDel && len(payload) != payloadPrefixLen+keyLen {
72+ return record{}, errBadRecord
73+ }
74+
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:]...)
79+ }
80+ return record{op: op, key: key, value: val}, nil
81+}
A pkg/kv/kv.go +381 -0
@@ -0,0 +1,381 @@
1+package kv
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "fmt"
7+ "hash/crc32"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "sort"
12+ "sync"
13+ "syscall"
14+)
15+
16+// Store is an append-only WAL-backed key/value store.
17+type Store struct {
18+ mu sync.Mutex
19+ path string
20+ file *os.File
21+ lockFile *os.File
22+ data map[string][]byte
23+ entrySize map[string]int64
24+ total int64
25+ live int64
26+ closed bool
27+}
28+
29+// Open opens or creates a store at path, replays valid records, truncates any
30+// corrupt or torn tail, and compacts if the file contains enough obsolete data.
31+func Open(path string) (*Store, error) {
32+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
33+ return nil, err
34+ }
35+
36+ lockFile, err := acquireLock(path)
37+ if err != nil {
38+ return nil, err
39+ }
40+
41+ f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
42+ if err != nil {
43+ _ = releaseLock(lockFile)
44+ return nil, err
45+ }
46+
47+ data, sizes, total, live, lastGood, err := replayFile(f)
48+ if err != nil {
49+ _ = f.Close()
50+ _ = releaseLock(lockFile)
51+ return nil, err
52+ }
53+ if err := f.Truncate(lastGood); err != nil {
54+ _ = f.Close()
55+ _ = releaseLock(lockFile)
56+ return nil, err
57+ }
58+ if _, err := f.Seek(0, io.SeekEnd); err != nil {
59+ _ = f.Close()
60+ _ = releaseLock(lockFile)
61+ return nil, err
62+ }
63+
64+ s := &Store{path: path, file: f, lockFile: lockFile, data: data, entrySize: sizes, total: total, live: live}
65+ if s.shouldCompact() {
66+ if err := s.compactLocked(); err != nil {
67+ _ = f.Close()
68+ _ = releaseLock(lockFile)
69+ return nil, err
70+ }
71+ }
72+ return s, nil
73+}
74+
75+// Replay reads path without mutating it and returns the state from valid records.
76+func Replay(path string) (map[string][]byte, error) {
77+ f, err := os.Open(path)
78+ if errors.Is(err, os.ErrNotExist) {
79+ return map[string][]byte{}, nil
80+ }
81+ if err != nil {
82+ return nil, err
83+ }
84+ defer func() { _ = f.Close() }()
85+
86+ data, _, _, _, _, err := replayFile(f)
87+ if err != nil {
88+ return nil, err
89+ }
90+ return data, nil
91+}
92+
93+// Get returns a copy of the value for key.
94+func (s *Store) Get(key []byte) ([]byte, bool) {
95+ s.mu.Lock()
96+ defer s.mu.Unlock()
97+
98+ v, ok := s.data[string(key)]
99+ if !ok {
100+ return nil, false
101+ }
102+ return append([]byte(nil), v...), true
103+}
104+
105+// Set appends a SET record. It does not fsync.
106+func (s *Store) Set(key, val []byte) error {
107+ return s.appendRecord(opSet, key, val)
108+}
109+
110+// Delete appends a DEL tombstone. It does not fsync.
111+func (s *Store) Delete(key []byte) error {
112+ return s.appendRecord(opDel, key, nil)
113+}
114+
115+// Range calls fn for each key/value pair, using copies. Iteration stops when fn returns false.
116+func (s *Store) Range(fn func(k, v []byte) bool) {
117+ s.mu.Lock()
118+ keys := make([]string, 0, len(s.data))
119+ for k := range s.data {
120+ keys = append(keys, k)
121+ }
122+ sort.Strings(keys)
123+ pairs := make([]struct{ k, v []byte }, 0, len(keys))
124+ for _, k := range keys {
125+ pairs = append(pairs, struct{ k, v []byte }{[]byte(k), append([]byte(nil), s.data[k]...)})
126+ }
127+ s.mu.Unlock()
128+
129+ for _, p := range pairs {
130+ if !fn(p.k, p.v) {
131+ return
132+ }
133+ }
134+}
135+
136+// Sync fsyncs the WAL file.
137+func (s *Store) Sync() error {
138+ s.mu.Lock()
139+ defer s.mu.Unlock()
140+ if s.closed {
141+ return os.ErrClosed
142+ }
143+ return s.file.Sync()
144+}
145+
146+// Close fsyncs and closes the store.
147+func (s *Store) Close() error {
148+ s.mu.Lock()
149+ defer s.mu.Unlock()
150+ if s.closed {
151+ return nil
152+ }
153+ errSync := s.file.Sync()
154+ errClose := s.file.Close()
155+ errUnlock := releaseLock(s.lockFile)
156+ s.closed = true
157+ if errSync != nil {
158+ return errSync
159+ }
160+ if errClose != nil {
161+ return errClose
162+ }
163+ return errUnlock
164+}
165+
166+func acquireLock(path string) (*os.File, error) {
167+ lf, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
168+ if err != nil {
169+ return nil, err
170+ }
171+ if err := syscall.Flock(int(lf.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
172+ _ = lf.Close()
173+ return nil, err
174+ }
175+ return lf, nil
176+}
177+
178+func releaseLock(f *os.File) error {
179+ if f == nil {
180+ return nil
181+ }
182+ errUnlock := syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
183+ errClose := f.Close()
184+ if errUnlock != nil {
185+ return errUnlock
186+ }
187+ return errClose
188+}
189+
190+func (s *Store) appendRecord(op byte, key, val []byte) error {
191+ buf, err := encodeRecord(op, key, val)
192+ if err != nil {
193+ return err
194+ }
195+
196+ s.mu.Lock()
197+ defer s.mu.Unlock()
198+ if s.closed {
199+ return os.ErrClosed
200+ }
201+ pos, err := s.file.Seek(0, io.SeekCurrent)
202+ if err != nil {
203+ return err
204+ }
205+ n, err := s.file.Write(buf)
206+ if err != nil || n != len(buf) {
207+ writeErr := err
208+ if writeErr == nil {
209+ writeErr = io.ErrShortWrite
210+ }
211+ if tErr := s.file.Truncate(pos); tErr != nil {
212+ s.closed = true
213+ return fmt.Errorf("write failed (%w) and recovery truncate failed: %w", writeErr, tErr)
214+ }
215+ if _, sErr := s.file.Seek(pos, io.SeekStart); sErr != nil {
216+ s.closed = true
217+ return fmt.Errorf("write failed (%w) and recovery seek failed: %w", writeErr, sErr)
218+ }
219+ return writeErr
220+ }
221+
222+ k := string(key)
223+ if old, ok := s.entrySize[k]; ok {
224+ s.live -= old
225+ }
226+ s.total += int64(len(buf))
227+ if op == opSet {
228+ s.data[k] = append([]byte(nil), val...)
229+ s.entrySize[k] = int64(len(buf))
230+ s.live += int64(len(buf))
231+ } else {
232+ delete(s.data, k)
233+ delete(s.entrySize, k)
234+ }
235+ if s.shouldCompact() {
236+ return s.compactLocked()
237+ }
238+ return nil
239+}
240+
241+func (s *Store) shouldCompact() bool {
242+ return s.total > compactThreshold && float64(s.total-s.live)/float64(s.total) > compactWasteRatio
243+}
244+
245+func (s *Store) compactLocked() error {
246+ tmp := s.path + ".compact"
247+ tf, err := os.OpenFile(tmp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
248+ if err != nil {
249+ return err
250+ }
251+
252+ keys := make([]string, 0, len(s.data))
253+ for k := range s.data {
254+ keys = append(keys, k)
255+ }
256+ sort.Strings(keys)
257+
258+ newSizes := make(map[string]int64, len(keys))
259+ var newTotal, newLive int64
260+ for _, k := range keys {
261+ buf, err := encodeRecord(opSet, []byte(k), s.data[k])
262+ if err != nil {
263+ _ = tf.Close()
264+ _ = os.Remove(tmp)
265+ return err
266+ }
267+ if _, err := tf.Write(buf); err != nil {
268+ _ = tf.Close()
269+ _ = os.Remove(tmp)
270+ return err
271+ }
272+ sz := int64(len(buf))
273+ newSizes[k] = sz
274+ newTotal += sz
275+ newLive += sz
276+ }
277+ if err := tf.Sync(); err != nil {
278+ _ = tf.Close()
279+ _ = os.Remove(tmp)
280+ return err
281+ }
282+ if err := tf.Close(); err != nil {
283+ _ = os.Remove(tmp)
284+ return err
285+ }
286+ bak := s.path + ".bak"
287+ _ = os.Remove(bak)
288+ if err := os.Rename(s.path, bak); err != nil {
289+ _ = os.Remove(tmp)
290+ return err
291+ }
292+ if err := os.Rename(tmp, s.path); err != nil {
293+ _ = os.Rename(bak, s.path)
294+ _ = os.Remove(tmp)
295+ return err
296+ }
297+ f, err := os.OpenFile(s.path, os.O_RDWR, 0o600)
298+ if err != nil {
299+ _ = os.Remove(s.path)
300+ _ = os.Rename(bak, s.path)
301+ return err
302+ }
303+ if _, err := f.Seek(0, io.SeekEnd); err != nil {
304+ _ = f.Close()
305+ _ = os.Remove(s.path)
306+ _ = os.Rename(bak, s.path)
307+ return err
308+ }
309+ old := s.file
310+ s.file = f
311+ s.entrySize = newSizes
312+ s.total = newTotal
313+ s.live = newLive
314+ _ = old.Close()
315+ _ = os.Remove(bak)
316+ return nil
317+}
318+
319+func replayFile(f *os.File) (map[string][]byte, map[string]int64, int64, int64, int64, error) {
320+ info, err := f.Stat()
321+ if err != nil {
322+ return nil, nil, 0, 0, 0, err
323+ }
324+ fileSize := info.Size()
325+ if _, err := f.Seek(0, io.SeekStart); err != nil {
326+ return nil, nil, 0, 0, 0, err
327+ }
328+ data := make(map[string][]byte)
329+ sizes := make(map[string]int64)
330+ var total, live, off int64
331+ header := make([]byte, headerLen)
332+ for {
333+ n, err := io.ReadFull(f, header)
334+ if errors.Is(err, io.EOF) {
335+ break
336+ }
337+ if errors.Is(err, io.ErrUnexpectedEOF) {
338+ _ = n
339+ break
340+ }
341+ if err != nil {
342+ return nil, nil, 0, 0, 0, err
343+ }
344+
345+ payloadLen := binary.BigEndian.Uint32(header[0:4])
346+ if int64(payloadLen) > fileSize-off-headerLen {
347+ break
348+ }
349+ wantCRC := binary.BigEndian.Uint32(header[4:8])
350+ payload := make([]byte, int(payloadLen))
351+ if _, err := io.ReadFull(f, payload); errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
352+ break
353+ } else if err != nil {
354+ return nil, nil, 0, 0, 0, err
355+ }
356+ if crc32.ChecksumIEEE(payload) != wantCRC {
357+ break
358+ }
359+ rec, err := decodePayload(payload)
360+ if err != nil {
361+ break
362+ }
363+
364+ sz := int64(headerLen + len(payload))
365+ k := string(rec.key)
366+ if old, ok := sizes[k]; ok {
367+ live -= old
368+ }
369+ total += sz
370+ if rec.op == opSet {
371+ data[k] = rec.value
372+ sizes[k] = sz
373+ live += sz
374+ } else {
375+ delete(data, k)
376+ delete(sizes, k)
377+ }
378+ off += sz
379+ }
380+ return data, sizes, total, live, off, nil
381+}
@@ -0,0 +1,381 @@
1+package kv
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "fmt"
7+ "hash/crc32"
8+ "io"
9+ "os"
10+ "path/filepath"
11+ "sort"
12+ "sync"
13+ "syscall"
14+)
15+
16+// Store is an append-only WAL-backed key/value store.
17+type Store struct {
18+ mu sync.Mutex
19+ path string
20+ file *os.File
21+ lockFile *os.File
22+ data map[string][]byte
23+ entrySize map[string]int64
24+ total int64
25+ live int64
26+ closed bool
27+}
28+
29+// Open opens or creates a store at path, replays valid records, truncates any
30+// corrupt or torn tail, and compacts if the file contains enough obsolete data.
31+func Open(path string) (*Store, error) {
32+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
33+ return nil, err
34+ }
35+
36+ lockFile, err := acquireLock(path)
37+ if err != nil {
38+ return nil, err
39+ }
40+
41+ f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
42+ if err != nil {
43+ _ = releaseLock(lockFile)
44+ return nil, err
45+ }
46+
47+ data, sizes, total, live, lastGood, err := replayFile(f)
48+ if err != nil {
49+ _ = f.Close()
50+ _ = releaseLock(lockFile)
51+ return nil, err
52+ }
53+ if err := f.Truncate(lastGood); err != nil {
54+ _ = f.Close()
55+ _ = releaseLock(lockFile)
56+ return nil, err
57+ }
58+ if _, err := f.Seek(0, io.SeekEnd); err != nil {
59+ _ = f.Close()
60+ _ = releaseLock(lockFile)
61+ return nil, err
62+ }
63+
64+ s := &Store{path: path, file: f, lockFile: lockFile, data: data, entrySize: sizes, total: total, live: live}
65+ if s.shouldCompact() {
66+ if err := s.compactLocked(); err != nil {
67+ _ = f.Close()
68+ _ = releaseLock(lockFile)
69+ return nil, err
70+ }
71+ }
72+ return s, nil
73+}
74+
75+// Replay reads path without mutating it and returns the state from valid records.
76+func Replay(path string) (map[string][]byte, error) {
77+ f, err := os.Open(path)
78+ if errors.Is(err, os.ErrNotExist) {
79+ return map[string][]byte{}, nil
80+ }
81+ if err != nil {
82+ return nil, err
83+ }
84+ defer func() { _ = f.Close() }()
85+
86+ data, _, _, _, _, err := replayFile(f)
87+ if err != nil {
88+ return nil, err
89+ }
90+ return data, nil
91+}
92+
93+// Get returns a copy of the value for key.
94+func (s *Store) Get(key []byte) ([]byte, bool) {
95+ s.mu.Lock()
96+ defer s.mu.Unlock()
97+
98+ v, ok := s.data[string(key)]
99+ if !ok {
100+ return nil, false
101+ }
102+ return append([]byte(nil), v...), true
103+}
104+
105+// Set appends a SET record. It does not fsync.
106+func (s *Store) Set(key, val []byte) error {
107+ return s.appendRecord(opSet, key, val)
108+}
109+
110+// Delete appends a DEL tombstone. It does not fsync.
111+func (s *Store) Delete(key []byte) error {
112+ return s.appendRecord(opDel, key, nil)
113+}
114+
115+// Range calls fn for each key/value pair, using copies. Iteration stops when fn returns false.
116+func (s *Store) Range(fn func(k, v []byte) bool) {
117+ s.mu.Lock()
118+ keys := make([]string, 0, len(s.data))
119+ for k := range s.data {
120+ keys = append(keys, k)
121+ }
122+ sort.Strings(keys)
123+ pairs := make([]struct{ k, v []byte }, 0, len(keys))
124+ for _, k := range keys {
125+ pairs = append(pairs, struct{ k, v []byte }{[]byte(k), append([]byte(nil), s.data[k]...)})
126+ }
127+ s.mu.Unlock()
128+
129+ for _, p := range pairs {
130+ if !fn(p.k, p.v) {
131+ return
132+ }
133+ }
134+}
135+
136+// Sync fsyncs the WAL file.
137+func (s *Store) Sync() error {
138+ s.mu.Lock()
139+ defer s.mu.Unlock()
140+ if s.closed {
141+ return os.ErrClosed
142+ }
143+ return s.file.Sync()
144+}
145+
146+// Close fsyncs and closes the store.
147+func (s *Store) Close() error {
148+ s.mu.Lock()
149+ defer s.mu.Unlock()
150+ if s.closed {
151+ return nil
152+ }
153+ errSync := s.file.Sync()
154+ errClose := s.file.Close()
155+ errUnlock := releaseLock(s.lockFile)
156+ s.closed = true
157+ if errSync != nil {
158+ return errSync
159+ }
160+ if errClose != nil {
161+ return errClose
162+ }
163+ return errUnlock
164+}
165+
166+func acquireLock(path string) (*os.File, error) {
167+ lf, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
168+ if err != nil {
169+ return nil, err
170+ }
171+ if err := syscall.Flock(int(lf.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
172+ _ = lf.Close()
173+ return nil, err
174+ }
175+ return lf, nil
176+}
177+
178+func releaseLock(f *os.File) error {
179+ if f == nil {
180+ return nil
181+ }
182+ errUnlock := syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
183+ errClose := f.Close()
184+ if errUnlock != nil {
185+ return errUnlock
186+ }
187+ return errClose
188+}
189+
190+func (s *Store) appendRecord(op byte, key, val []byte) error {
191+ buf, err := encodeRecord(op, key, val)
192+ if err != nil {
193+ return err
194+ }
195+
196+ s.mu.Lock()
197+ defer s.mu.Unlock()
198+ if s.closed {
199+ return os.ErrClosed
200+ }
201+ pos, err := s.file.Seek(0, io.SeekCurrent)
202+ if err != nil {
203+ return err
204+ }
205+ n, err := s.file.Write(buf)
206+ if err != nil || n != len(buf) {
207+ writeErr := err
208+ if writeErr == nil {
209+ writeErr = io.ErrShortWrite
210+ }
211+ if tErr := s.file.Truncate(pos); tErr != nil {
212+ s.closed = true
213+ return fmt.Errorf("write failed (%w) and recovery truncate failed: %w", writeErr, tErr)
214+ }
215+ if _, sErr := s.file.Seek(pos, io.SeekStart); sErr != nil {
216+ s.closed = true
217+ return fmt.Errorf("write failed (%w) and recovery seek failed: %w", writeErr, sErr)
218+ }
219+ return writeErr
220+ }
221+
222+ k := string(key)
223+ if old, ok := s.entrySize[k]; ok {
224+ s.live -= old
225+ }
226+ s.total += int64(len(buf))
227+ if op == opSet {
228+ s.data[k] = append([]byte(nil), val...)
229+ s.entrySize[k] = int64(len(buf))
230+ s.live += int64(len(buf))
231+ } else {
232+ delete(s.data, k)
233+ delete(s.entrySize, k)
234+ }
235+ if s.shouldCompact() {
236+ return s.compactLocked()
237+ }
238+ return nil
239+}
240+
241+func (s *Store) shouldCompact() bool {
242+ return s.total > compactThreshold && float64(s.total-s.live)/float64(s.total) > compactWasteRatio
243+}
244+
245+func (s *Store) compactLocked() error {
246+ tmp := s.path + ".compact"
247+ tf, err := os.OpenFile(tmp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
248+ if err != nil {
249+ return err
250+ }
251+
252+ keys := make([]string, 0, len(s.data))
253+ for k := range s.data {
254+ keys = append(keys, k)
255+ }
256+ sort.Strings(keys)
257+
258+ newSizes := make(map[string]int64, len(keys))
259+ var newTotal, newLive int64
260+ for _, k := range keys {
261+ buf, err := encodeRecord(opSet, []byte(k), s.data[k])
262+ if err != nil {
263+ _ = tf.Close()
264+ _ = os.Remove(tmp)
265+ return err
266+ }
267+ if _, err := tf.Write(buf); err != nil {
268+ _ = tf.Close()
269+ _ = os.Remove(tmp)
270+ return err
271+ }
272+ sz := int64(len(buf))
273+ newSizes[k] = sz
274+ newTotal += sz
275+ newLive += sz
276+ }
277+ if err := tf.Sync(); err != nil {
278+ _ = tf.Close()
279+ _ = os.Remove(tmp)
280+ return err
281+ }
282+ if err := tf.Close(); err != nil {
283+ _ = os.Remove(tmp)
284+ return err
285+ }
286+ bak := s.path + ".bak"
287+ _ = os.Remove(bak)
288+ if err := os.Rename(s.path, bak); err != nil {
289+ _ = os.Remove(tmp)
290+ return err
291+ }
292+ if err := os.Rename(tmp, s.path); err != nil {
293+ _ = os.Rename(bak, s.path)
294+ _ = os.Remove(tmp)
295+ return err
296+ }
297+ f, err := os.OpenFile(s.path, os.O_RDWR, 0o600)
298+ if err != nil {
299+ _ = os.Remove(s.path)
300+ _ = os.Rename(bak, s.path)
301+ return err
302+ }
303+ if _, err := f.Seek(0, io.SeekEnd); err != nil {
304+ _ = f.Close()
305+ _ = os.Remove(s.path)
306+ _ = os.Rename(bak, s.path)
307+ return err
308+ }
309+ old := s.file
310+ s.file = f
311+ s.entrySize = newSizes
312+ s.total = newTotal
313+ s.live = newLive
314+ _ = old.Close()
315+ _ = os.Remove(bak)
316+ return nil
317+}
318+
319+func replayFile(f *os.File) (map[string][]byte, map[string]int64, int64, int64, int64, error) {
320+ info, err := f.Stat()
321+ if err != nil {
322+ return nil, nil, 0, 0, 0, err
323+ }
324+ fileSize := info.Size()
325+ if _, err := f.Seek(0, io.SeekStart); err != nil {
326+ return nil, nil, 0, 0, 0, err
327+ }
328+ data := make(map[string][]byte)
329+ sizes := make(map[string]int64)
330+ var total, live, off int64
331+ header := make([]byte, headerLen)
332+ for {
333+ n, err := io.ReadFull(f, header)
334+ if errors.Is(err, io.EOF) {
335+ break
336+ }
337+ if errors.Is(err, io.ErrUnexpectedEOF) {
338+ _ = n
339+ break
340+ }
341+ if err != nil {
342+ return nil, nil, 0, 0, 0, err
343+ }
344+
345+ payloadLen := binary.BigEndian.Uint32(header[0:4])
346+ if int64(payloadLen) > fileSize-off-headerLen {
347+ break
348+ }
349+ wantCRC := binary.BigEndian.Uint32(header[4:8])
350+ payload := make([]byte, int(payloadLen))
351+ if _, err := io.ReadFull(f, payload); errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
352+ break
353+ } else if err != nil {
354+ return nil, nil, 0, 0, 0, err
355+ }
356+ if crc32.ChecksumIEEE(payload) != wantCRC {
357+ break
358+ }
359+ rec, err := decodePayload(payload)
360+ if err != nil {
361+ break
362+ }
363+
364+ sz := int64(headerLen + len(payload))
365+ k := string(rec.key)
366+ if old, ok := sizes[k]; ok {
367+ live -= old
368+ }
369+ total += sz
370+ if rec.op == opSet {
371+ data[k] = rec.value
372+ sizes[k] = sz
373+ live += sz
374+ } else {
375+ delete(data, k)
376+ delete(sizes, k)
377+ }
378+ off += sz
379+ }
380+ return data, sizes, total, live, off, nil
381+}
A pkg/kv/kv_test.go +245 -0
@@ -0,0 +1,245 @@
1+package kv
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+)
10+
11+func closeStore(t *testing.T, s *Store) {
12+ t.Helper()
13+ if err := s.Close(); err != nil {
14+ t.Fatal(err)
15+ }
16+}
17+
18+func TestSetGetDeleteRange(t *testing.T) {
19+ path := filepath.Join(t.TempDir(), "store.kv")
20+ s, err := Open(path)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer closeStore(t, s)
25+
26+ if err := s.Set([]byte("b"), []byte("two")); err != nil {
27+ t.Fatal(err)
28+ }
29+ if err := s.Set([]byte("a"), []byte("one")); err != nil {
30+ t.Fatal(err)
31+ }
32+ got, ok := s.Get([]byte("a"))
33+ if !ok || string(got) != "one" {
34+ t.Fatalf("Get(a) = %q, %v", got, ok)
35+ }
36+ got[0] = 'X'
37+ got, _ = s.Get([]byte("a"))
38+ if string(got) != "one" {
39+ t.Fatalf("Get returned mutable value: %q", got)
40+ }
41+ if err := s.Delete([]byte("b")); err != nil {
42+ t.Fatal(err)
43+ }
44+ if _, ok := s.Get([]byte("b")); ok {
45+ t.Fatal("deleted key still present")
46+ }
47+
48+ seen := map[string]string{}
49+ s.Range(func(k, v []byte) bool {
50+ seen[string(k)] = string(v)
51+ return true
52+ })
53+ if len(seen) != 1 || seen["a"] != "one" {
54+ t.Fatalf("Range saw %#v", seen)
55+ }
56+}
57+
58+func TestReopenDurability(t *testing.T) {
59+ path := filepath.Join(t.TempDir(), "store.kv")
60+ s, err := Open(path)
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ if err := s.Set([]byte("k"), []byte("v")); err != nil {
65+ t.Fatal(err)
66+ }
67+ if err := s.Close(); err != nil {
68+ t.Fatal(err)
69+ }
70+
71+ s, err = Open(path)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ defer closeStore(t, s)
76+ got, ok := s.Get([]byte("k"))
77+ if !ok || string(got) != "v" {
78+ t.Fatalf("after reopen Get(k) = %q, %v", got, ok)
79+ }
80+}
81+
82+func TestCompactionShrinksWithDataIntact(t *testing.T) {
83+ path := filepath.Join(t.TempDir(), "store.kv")
84+ s, err := Open(path)
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ if err := s.Set([]byte("keep"), bytes.Repeat([]byte("x"), 128)); err != nil {
89+ t.Fatal(err)
90+ }
91+ info, err := os.Stat(path)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ before := info.Size()
96+ if err := s.Set([]byte("keep"), []byte("y")); err != nil {
97+ t.Fatal(err)
98+ }
99+ info, err = os.Stat(path)
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ after := info.Size()
104+ if after >= before {
105+ t.Fatalf("expected compaction to shrink file: before=%d after=%d", before, after)
106+ }
107+ got, ok := s.Get([]byte("keep"))
108+ if !ok || string(got) != "y" {
109+ t.Fatalf("compacted value = %q, %v", got, ok)
110+ }
111+ if err := s.Close(); err != nil {
112+ t.Fatal(err)
113+ }
114+}
115+
116+func TestOpenTruncatesMidRecordTail(t *testing.T) {
117+ path := filepath.Join(t.TempDir(), "store.kv")
118+ first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
119+ second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
120+ if err := os.WriteFile(path, append(first, second[:len(second)/2]...), 0o600); err != nil {
121+ t.Fatal(err)
122+ }
123+
124+ s, err := Open(path)
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ defer closeStore(t, s)
129+ if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
130+ t.Fatalf("good record missing: %q %v", got, ok)
131+ }
132+ if _, ok := s.Get([]byte("b")); ok {
133+ t.Fatal("torn record was replayed")
134+ }
135+ info, err := os.Stat(path)
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ if info.Size() != int64(len(first)) {
140+ t.Fatalf("tail not truncated: size=%d want=%d", info.Size(), len(first))
141+ }
142+}
143+
144+func TestCRCcorruptionOfLastRecordDropsIt(t *testing.T) {
145+ path := filepath.Join(t.TempDir(), "store.kv")
146+ first, _ := encodeRecord(opSet, []byte("k"), []byte("old"))
147+ second, _ := encodeRecord(opSet, []byte("k"), []byte("new"))
148+ buf := append(append([]byte{}, first...), second...)
149+ buf[len(first)+headerLen+payloadPrefixLen+1] ^= 0xff
150+ if err := os.WriteFile(path, buf, 0o600); err != nil {
151+ t.Fatal(err)
152+ }
153+
154+ s, err := Open(path)
155+ if err != nil {
156+ t.Fatal(err)
157+ }
158+ defer closeStore(t, s)
159+ got, ok := s.Get([]byte("k"))
160+ if !ok || string(got) != "old" {
161+ t.Fatalf("corrupt last record not dropped: %q %v", got, ok)
162+ }
163+ info, err := os.Stat(path)
164+ if err != nil {
165+ t.Fatal(err)
166+ }
167+ if info.Size() != int64(len(first)) {
168+ t.Fatalf("corrupt tail not truncated: size=%d want=%d", info.Size(), len(first))
169+ }
170+}
171+
172+func TestAbsentAndEmptyFile(t *testing.T) {
173+ path := filepath.Join(t.TempDir(), "missing", "store.kv")
174+ m, err := Replay(path)
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ if len(m) != 0 {
179+ t.Fatalf("Replay(absent) = %#v", m)
180+ }
181+ s, err := Open(path)
182+ if err != nil {
183+ t.Fatal(err)
184+ }
185+ if err := s.Close(); err != nil {
186+ t.Fatal(err)
187+ }
188+ m, err = Replay(path)
189+ if err != nil {
190+ t.Fatal(err)
191+ }
192+ if len(m) != 0 {
193+ t.Fatalf("Replay(empty) = %#v", m)
194+ }
195+}
196+
197+func TestTombstoneSurvivesCompaction(t *testing.T) {
198+ path := filepath.Join(t.TempDir(), "store.kv")
199+ s, err := Open(path)
200+ if err != nil {
201+ t.Fatal(err)
202+ }
203+ if err := s.Set([]byte("dead"), bytes.Repeat([]byte("d"), 128)); err != nil {
204+ t.Fatal(err)
205+ }
206+ if err := s.Set([]byte("keep"), bytes.Repeat([]byte("k"), 128)); err != nil {
207+ t.Fatal(err)
208+ }
209+ if err := s.Delete([]byte("dead")); err != nil {
210+ t.Fatal(err)
211+ }
212+ if err := s.Close(); err != nil {
213+ t.Fatal(err)
214+ }
215+
216+ s, err = Open(path)
217+ if err != nil {
218+ t.Fatal(err)
219+ }
220+ defer closeStore(t, s)
221+ if _, ok := s.Get([]byte("dead")); ok {
222+ t.Fatal("deleted key resurrected after compaction/reopen")
223+ }
224+ if got, ok := s.Get([]byte("keep")); !ok || len(got) != 128 || got[0] != 'k' {
225+ t.Fatalf("live key missing after compaction/reopen: len=%d ok=%v", len(got), ok)
226+ }
227+}
228+
229+func TestReplayStopsAtBadPayloadLengthWithoutAllocatingHugeBuffer(t *testing.T) {
230+ path := filepath.Join(t.TempDir(), "store.kv")
231+ first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
232+ header := make([]byte, headerLen)
233+ binary.BigEndian.PutUint32(header[0:4], 1<<31)
234+ if err := os.WriteFile(path, append(first, header...), 0o600); err != nil {
235+ t.Fatal(err)
236+ }
237+ s, err := Open(path)
238+ if err != nil {
239+ t.Fatal(err)
240+ }
241+ defer closeStore(t, s)
242+ if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
243+ t.Fatalf("good record missing before bad length: %q %v", got, ok)
244+ }
245+}
@@ -0,0 +1,245 @@
1+package kv
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+)
10+
11+func closeStore(t *testing.T, s *Store) {
12+ t.Helper()
13+ if err := s.Close(); err != nil {
14+ t.Fatal(err)
15+ }
16+}
17+
18+func TestSetGetDeleteRange(t *testing.T) {
19+ path := filepath.Join(t.TempDir(), "store.kv")
20+ s, err := Open(path)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer closeStore(t, s)
25+
26+ if err := s.Set([]byte("b"), []byte("two")); err != nil {
27+ t.Fatal(err)
28+ }
29+ if err := s.Set([]byte("a"), []byte("one")); err != nil {
30+ t.Fatal(err)
31+ }
32+ got, ok := s.Get([]byte("a"))
33+ if !ok || string(got) != "one" {
34+ t.Fatalf("Get(a) = %q, %v", got, ok)
35+ }
36+ got[0] = 'X'
37+ got, _ = s.Get([]byte("a"))
38+ if string(got) != "one" {
39+ t.Fatalf("Get returned mutable value: %q", got)
40+ }
41+ if err := s.Delete([]byte("b")); err != nil {
42+ t.Fatal(err)
43+ }
44+ if _, ok := s.Get([]byte("b")); ok {
45+ t.Fatal("deleted key still present")
46+ }
47+
48+ seen := map[string]string{}
49+ s.Range(func(k, v []byte) bool {
50+ seen[string(k)] = string(v)
51+ return true
52+ })
53+ if len(seen) != 1 || seen["a"] != "one" {
54+ t.Fatalf("Range saw %#v", seen)
55+ }
56+}
57+
58+func TestReopenDurability(t *testing.T) {
59+ path := filepath.Join(t.TempDir(), "store.kv")
60+ s, err := Open(path)
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ if err := s.Set([]byte("k"), []byte("v")); err != nil {
65+ t.Fatal(err)
66+ }
67+ if err := s.Close(); err != nil {
68+ t.Fatal(err)
69+ }
70+
71+ s, err = Open(path)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ defer closeStore(t, s)
76+ got, ok := s.Get([]byte("k"))
77+ if !ok || string(got) != "v" {
78+ t.Fatalf("after reopen Get(k) = %q, %v", got, ok)
79+ }
80+}
81+
82+func TestCompactionShrinksWithDataIntact(t *testing.T) {
83+ path := filepath.Join(t.TempDir(), "store.kv")
84+ s, err := Open(path)
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ if err := s.Set([]byte("keep"), bytes.Repeat([]byte("x"), 128)); err != nil {
89+ t.Fatal(err)
90+ }
91+ info, err := os.Stat(path)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ before := info.Size()
96+ if err := s.Set([]byte("keep"), []byte("y")); err != nil {
97+ t.Fatal(err)
98+ }
99+ info, err = os.Stat(path)
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ after := info.Size()
104+ if after >= before {
105+ t.Fatalf("expected compaction to shrink file: before=%d after=%d", before, after)
106+ }
107+ got, ok := s.Get([]byte("keep"))
108+ if !ok || string(got) != "y" {
109+ t.Fatalf("compacted value = %q, %v", got, ok)
110+ }
111+ if err := s.Close(); err != nil {
112+ t.Fatal(err)
113+ }
114+}
115+
116+func TestOpenTruncatesMidRecordTail(t *testing.T) {
117+ path := filepath.Join(t.TempDir(), "store.kv")
118+ first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
119+ second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
120+ if err := os.WriteFile(path, append(first, second[:len(second)/2]...), 0o600); err != nil {
121+ t.Fatal(err)
122+ }
123+
124+ s, err := Open(path)
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ defer closeStore(t, s)
129+ if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
130+ t.Fatalf("good record missing: %q %v", got, ok)
131+ }
132+ if _, ok := s.Get([]byte("b")); ok {
133+ t.Fatal("torn record was replayed")
134+ }
135+ info, err := os.Stat(path)
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ if info.Size() != int64(len(first)) {
140+ t.Fatalf("tail not truncated: size=%d want=%d", info.Size(), len(first))
141+ }
142+}
143+
144+func TestCRCcorruptionOfLastRecordDropsIt(t *testing.T) {
145+ path := filepath.Join(t.TempDir(), "store.kv")
146+ first, _ := encodeRecord(opSet, []byte("k"), []byte("old"))
147+ second, _ := encodeRecord(opSet, []byte("k"), []byte("new"))
148+ buf := append(append([]byte{}, first...), second...)
149+ buf[len(first)+headerLen+payloadPrefixLen+1] ^= 0xff
150+ if err := os.WriteFile(path, buf, 0o600); err != nil {
151+ t.Fatal(err)
152+ }
153+
154+ s, err := Open(path)
155+ if err != nil {
156+ t.Fatal(err)
157+ }
158+ defer closeStore(t, s)
159+ got, ok := s.Get([]byte("k"))
160+ if !ok || string(got) != "old" {
161+ t.Fatalf("corrupt last record not dropped: %q %v", got, ok)
162+ }
163+ info, err := os.Stat(path)
164+ if err != nil {
165+ t.Fatal(err)
166+ }
167+ if info.Size() != int64(len(first)) {
168+ t.Fatalf("corrupt tail not truncated: size=%d want=%d", info.Size(), len(first))
169+ }
170+}
171+
172+func TestAbsentAndEmptyFile(t *testing.T) {
173+ path := filepath.Join(t.TempDir(), "missing", "store.kv")
174+ m, err := Replay(path)
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ if len(m) != 0 {
179+ t.Fatalf("Replay(absent) = %#v", m)
180+ }
181+ s, err := Open(path)
182+ if err != nil {
183+ t.Fatal(err)
184+ }
185+ if err := s.Close(); err != nil {
186+ t.Fatal(err)
187+ }
188+ m, err = Replay(path)
189+ if err != nil {
190+ t.Fatal(err)
191+ }
192+ if len(m) != 0 {
193+ t.Fatalf("Replay(empty) = %#v", m)
194+ }
195+}
196+
197+func TestTombstoneSurvivesCompaction(t *testing.T) {
198+ path := filepath.Join(t.TempDir(), "store.kv")
199+ s, err := Open(path)
200+ if err != nil {
201+ t.Fatal(err)
202+ }
203+ if err := s.Set([]byte("dead"), bytes.Repeat([]byte("d"), 128)); err != nil {
204+ t.Fatal(err)
205+ }
206+ if err := s.Set([]byte("keep"), bytes.Repeat([]byte("k"), 128)); err != nil {
207+ t.Fatal(err)
208+ }
209+ if err := s.Delete([]byte("dead")); err != nil {
210+ t.Fatal(err)
211+ }
212+ if err := s.Close(); err != nil {
213+ t.Fatal(err)
214+ }
215+
216+ s, err = Open(path)
217+ if err != nil {
218+ t.Fatal(err)
219+ }
220+ defer closeStore(t, s)
221+ if _, ok := s.Get([]byte("dead")); ok {
222+ t.Fatal("deleted key resurrected after compaction/reopen")
223+ }
224+ if got, ok := s.Get([]byte("keep")); !ok || len(got) != 128 || got[0] != 'k' {
225+ t.Fatalf("live key missing after compaction/reopen: len=%d ok=%v", len(got), ok)
226+ }
227+}
228+
229+func TestReplayStopsAtBadPayloadLengthWithoutAllocatingHugeBuffer(t *testing.T) {
230+ path := filepath.Join(t.TempDir(), "store.kv")
231+ first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
232+ header := make([]byte, headerLen)
233+ binary.BigEndian.PutUint32(header[0:4], 1<<31)
234+ if err := os.WriteFile(path, append(first, header...), 0o600); err != nil {
235+ t.Fatal(err)
236+ }
237+ s, err := Open(path)
238+ if err != nil {
239+ t.Fatal(err)
240+ }
241+ defer closeStore(t, s)
242+ if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
243+ t.Fatalf("good record missing before bad length: %q %v", got, ok)
244+ }
245+}

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