feat(persistence): persist named sessions
- commit
- 6ff07bde70e6d759783d1443252b3c26a0091eac
- author
- brice · · 2026-07-03 20:20 UTC
- committer
- brice · · 2026-07-03 20:32 UTC
- parent
- 7cfdd26
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
24 files · +2145 -66
M internal/app/logging.go +3 -15
| @@ -6,26 +6,14 @@ import ( | |||
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/platform" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | // logFileName is the fixed name of vev's shared log file, used by both the | |
| 12 | 14 | // daemon and the raw-attached client (which must never log to the console). | |
| 13 | 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 | 17 | // parseLogLevel maps VEV_LOG to a slog level, defaulting to info. | |
| 30 | 18 | func parseLogLevel(s string) slog.Level { | |
| 31 | 19 | switch strings.ToLower(strings.TrimSpace(s)) { | |
| @@ -48,7 +36,7 @@ func parseLogLevel(s string) slog.Level { | |||
| 48 | 36 | // | |
| 49 | 37 | // No log rotation is performed for the MVP (future work). | |
| 50 | 38 | func setupLogging() (*os.File, error) { | |
| 51 | - | dir := stateDir() | |
| 39 | + | dir := platform.StateDir() | |
| 52 | 40 | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 53 | 41 | return nil, fmt.Errorf("vev: creating state dir: %w", err) | |
| 54 | 42 | } | |
| @@ -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 the | 13 | // 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 +49 -7
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | _ "net/http/pprof" | |
| 15 | 15 | "os" | |
| 16 | 16 | "os/signal" | |
| 17 | + | "path/filepath" | |
| 17 | 18 | "syscall" | |
| 18 | 19 | "text/tabwriter" | |
| 19 | 20 | "time" | |
| @@ -24,10 +25,13 @@ import ( | |||
| 24 | 25 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 25 | 26 | "github.com/bnema/vev/internal/adapters/term" | |
| 26 | 27 | "github.com/bnema/vev/internal/domain" | |
| 28 | + | "github.com/bnema/vev/internal/persist" | |
| 29 | + | "github.com/bnema/vev/internal/platform" | |
| 27 | 30 | "github.com/bnema/vev/internal/ports" | |
| 28 | 31 | "github.com/bnema/vev/internal/usecase/client" | |
| 29 | 32 | "github.com/bnema/vev/internal/usecase/confirm" | |
| 30 | 33 | "github.com/bnema/vev/internal/usecase/daemon" | |
| 34 | + | "github.com/bnema/vev/pkg/kv" | |
| 31 | 35 | ) | |
| 32 | 36 | ||
| 33 | 37 | // cmdKind identifies which sub-command the CLI parsed. | |
| @@ -223,7 +227,13 @@ func runDaemon() error { | |||
| 223 | 227 | } | |
| 224 | 228 | ||
| 225 | 229 | slog.Info("daemon starting", "socket", ln.Addr()) | |
| 226 | - | d := daemon.New(pty.NewFactory(), clock.New(), slog.Default()) | |
| 230 | + | daemonOpts := []daemon.Option(nil) | |
| 231 | + | if store, err := kv.Open(filepath.Join(platform.StateDir(), "sessions.kv")); err != nil { | |
| 232 | + | slog.Warn("opening session store failed; persistence disabled", "err", err) | |
| 233 | + | } else { | |
| 234 | + | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | |
| 235 | + | } | |
| 236 | + | d := daemon.New(pty.NewFactory(), clock.New(), slog.Default(), daemonOpts...) | |
| 227 | 237 | if err := d.Serve(ctx, ln); err != nil { | |
| 228 | 238 | slog.Error("daemon exited", "err", err) | |
| 229 | 239 | return err | |
| @@ -482,12 +492,20 @@ func proxyTransports(ctx context.Context, a, b ports.Transport) error { | |||
| 482 | 492 | } | |
| 483 | 493 | } | |
| 484 | 494 | ||
| 485 | - | // runList prints the daemon's session listing. With no daemon running there | |
| 486 | - | // are, by definition, no sessions. | |
| 495 | + | // runList prints the daemon's session listing. With no daemon running, it | |
| 496 | + | // falls back to the persisted stopped-session records. | |
| 487 | 497 | func runList(_ context.Context) error { | |
| 488 | 498 | transport, err := realDial(ipc.SocketDir()) | |
| 489 | 499 | if err != nil { | |
| 490 | - | fmt.Println("no sessions") | |
| 500 | + | records, loadErr := persist.LoadReadOnly(platform.StateDir()) | |
| 501 | + | if loadErr != nil { | |
| 502 | + | return fmt.Errorf("vev: reading stored sessions: %w", loadErr) | |
| 503 | + | } | |
| 504 | + | infos := make([]ports.SessionInfo, 0, len(records)) | |
| 505 | + | for _, r := range records { | |
| 506 | + | infos = append(infos, ports.SessionInfo{Name: r.Name, Stopped: true}) | |
| 507 | + | } | |
| 508 | + | printSessions(os.Stdout, infos) | |
| 491 | 509 | return nil | |
| 492 | 510 | } | |
| 493 | 511 | defer func() { _ = transport.Close() }() | |
| @@ -521,13 +539,18 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||
| 521 | 539 | return | |
| 522 | 540 | } | |
| 523 | 541 | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | |
| 524 | - | _, _ = fmt.Fprintln(tw, "NAME\tTABS\tATTACHED") | |
| 542 | + | _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED") | |
| 525 | 543 | for _, s := range sessions { | |
| 544 | + | state := "running" | |
| 545 | + | tabs := fmt.Sprintf("%d", s.Tabs) | |
| 526 | 546 | attached := "no" | |
| 527 | - | if s.Attached { | |
| 547 | + | if s.Stopped { | |
| 548 | + | state = "stopped" | |
| 549 | + | tabs = "-" | |
| 550 | + | } else if s.Attached { | |
| 528 | 551 | attached = "yes" | |
| 529 | 552 | } | |
| 530 | - | _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Tabs, attached) | |
| 553 | + | _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached) | |
| 531 | 554 | } | |
| 532 | 555 | _ = tw.Flush() | |
| 533 | 556 | } | |
| @@ -536,6 +559,25 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||
| 536 | 559 | func runKill(_ context.Context, name string, all, daemon bool) error { | |
| 537 | 560 | transport, err := realDial(ipc.SocketDir()) | |
| 538 | 561 | if err != nil { | |
| 562 | + | if name != "" && !all && !daemon { | |
| 563 | + | store, openErr := kv.Open(filepath.Join(platform.StateDir(), "sessions.kv")) | |
| 564 | + | if openErr != nil { | |
| 565 | + | return fmt.Errorf("vev: opening stored sessions: %w", openErr) | |
| 566 | + | } | |
| 567 | + | if _, ok := store.Get([]byte(name)); !ok { | |
| 568 | + | _ = store.Close() | |
| 569 | + | return fmt.Errorf("vev: no such session: %s", name) | |
| 570 | + | } | |
| 571 | + | if deleteErr := store.Delete([]byte(name)); deleteErr != nil { | |
| 572 | + | _ = store.Close() | |
| 573 | + | return fmt.Errorf("vev: deleting stored session: %w", deleteErr) | |
| 574 | + | } | |
| 575 | + | if closeErr := store.Close(); closeErr != nil { | |
| 576 | + | return fmt.Errorf("vev: closing stored sessions: %w", closeErr) | |
| 577 | + | } | |
| 578 | + | printKillSuccess(name, all, daemon) | |
| 579 | + | return nil | |
| 580 | + | } | |
| 539 | 581 | return fmt.Errorf("vev: no daemon running") | |
| 540 | 582 | } | |
| 541 | 583 | defer func() { _ = transport.Close() }() | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | _ "net/http/pprof" | 14 | _ "net/http/pprof" | ||
| 15 | "os" | 15 | "os" | ||
| 16 | "os/signal" | 16 | "os/signal" | ||
| 17 | + | "path/filepath" | |||
| 17 | "syscall" | 18 | "syscall" | ||
| 18 | "text/tabwriter" | 19 | "text/tabwriter" | ||
| 19 | "time" | 20 | "time" | ||
| @@ -24,10 +25,13 @@ import ( | |||||
| 24 | "github.com/bnema/vev/internal/adapters/sshstdio" | 25 | "github.com/bnema/vev/internal/adapters/sshstdio" | ||
| 25 | "github.com/bnema/vev/internal/adapters/term" | 26 | "github.com/bnema/vev/internal/adapters/term" | ||
| 26 | "github.com/bnema/vev/internal/domain" | 27 | "github.com/bnema/vev/internal/domain" | ||
| 28 | + | "github.com/bnema/vev/internal/persist" | |||
| 29 | + | "github.com/bnema/vev/internal/platform" | |||
| 27 | "github.com/bnema/vev/internal/ports" | 30 | "github.com/bnema/vev/internal/ports" | ||
| 28 | "github.com/bnema/vev/internal/usecase/client" | 31 | "github.com/bnema/vev/internal/usecase/client" | ||
| 29 | "github.com/bnema/vev/internal/usecase/confirm" | 32 | "github.com/bnema/vev/internal/usecase/confirm" | ||
| 30 | "github.com/bnema/vev/internal/usecase/daemon" | 33 | "github.com/bnema/vev/internal/usecase/daemon" | ||
| 34 | + | "github.com/bnema/vev/pkg/kv" | |||
| 31 | ) | 35 | ) | ||
| 32 | 36 | ||||
| 33 | // cmdKind identifies which sub-command the CLI parsed. | 37 | // cmdKind identifies which sub-command the CLI parsed. | ||
| @@ -223,7 +227,13 @@ func runDaemon() error { | |||||
| 223 | } | 227 | } | ||
| 224 | 228 | ||||
| 225 | slog.Info("daemon starting", "socket", ln.Addr()) | 229 | slog.Info("daemon starting", "socket", ln.Addr()) | ||
| 226 | - | d := daemon.New(pty.NewFactory(), clock.New(), slog.Default()) | 230 | + | daemonOpts := []daemon.Option(nil) |
| 231 | + | if store, err := kv.Open(filepath.Join(platform.StateDir(), "sessions.kv")); err != nil { | |||
| 232 | + | slog.Warn("opening session store failed; persistence disabled", "err", err) | |||
| 233 | + | } else { | |||
| 234 | + | daemonOpts = append(daemonOpts, daemon.WithStore(store)) | |||
| 235 | + | } | |||
| 236 | + | d := daemon.New(pty.NewFactory(), clock.New(), slog.Default(), daemonOpts...) | |||
| 227 | if err := d.Serve(ctx, ln); err != nil { | 237 | if err := d.Serve(ctx, ln); err != nil { | ||
| 228 | slog.Error("daemon exited", "err", err) | 238 | slog.Error("daemon exited", "err", err) | ||
| 229 | return err | 239 | return err | ||
| @@ -482,12 +492,20 @@ func proxyTransports(ctx context.Context, a, b ports.Transport) error { | |||||
| 482 | } | 492 | } | ||
| 483 | } | 493 | } | ||
| 484 | 494 | ||||
| 485 | - | // runList prints the daemon's session listing. With no daemon running there | 495 | + | // runList prints the daemon's session listing. With no daemon running, it |
| 486 | - | // are, by definition, no sessions. | 496 | + | // falls back to the persisted stopped-session records. |
| 487 | func runList(_ context.Context) error { | 497 | func runList(_ context.Context) error { | ||
| 488 | transport, err := realDial(ipc.SocketDir()) | 498 | transport, err := realDial(ipc.SocketDir()) | ||
| 489 | if err != nil { | 499 | if err != nil { | ||
| 490 | - | fmt.Println("no sessions") | 500 | + | records, loadErr := persist.LoadReadOnly(platform.StateDir()) |
| 501 | + | if loadErr != nil { | |||
| 502 | + | return fmt.Errorf("vev: reading stored sessions: %w", loadErr) | |||
| 503 | + | } | |||
| 504 | + | infos := make([]ports.SessionInfo, 0, len(records)) | |||
| 505 | + | for _, r := range records { | |||
| 506 | + | infos = append(infos, ports.SessionInfo{Name: r.Name, Stopped: true}) | |||
| 507 | + | } | |||
| 508 | + | printSessions(os.Stdout, infos) | |||
| 491 | return nil | 509 | return nil | ||
| 492 | } | 510 | } | ||
| 493 | defer func() { _ = transport.Close() }() | 511 | defer func() { _ = transport.Close() }() | ||
| @@ -521,13 +539,18 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||||
| 521 | return | 539 | return | ||
| 522 | } | 540 | } | ||
| 523 | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | 541 | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | ||
| 524 | - | _, _ = fmt.Fprintln(tw, "NAME\tTABS\tATTACHED") | 542 | + | _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED") |
| 525 | for _, s := range sessions { | 543 | for _, s := range sessions { | ||
| 544 | + | state := "running" | |||
| 545 | + | tabs := fmt.Sprintf("%d", s.Tabs) | |||
| 526 | attached := "no" | 546 | attached := "no" | ||
| 527 | - | if s.Attached { | 547 | + | if s.Stopped { |
| 548 | + | state = "stopped" | |||
| 549 | + | tabs = "-" | |||
| 550 | + | } else if s.Attached { | |||
| 528 | attached = "yes" | 551 | attached = "yes" | ||
| 529 | } | 552 | } | ||
| 530 | - | _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Tabs, attached) | 553 | + | _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached) |
| 531 | } | 554 | } | ||
| 532 | _ = tw.Flush() | 555 | _ = tw.Flush() | ||
| 533 | } | 556 | } | ||
| @@ -536,6 +559,25 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||||
| 536 | func runKill(_ context.Context, name string, all, daemon bool) error { | 559 | func runKill(_ context.Context, name string, all, daemon bool) error { | ||
| 537 | transport, err := realDial(ipc.SocketDir()) | 560 | transport, err := realDial(ipc.SocketDir()) | ||
| 538 | if err != nil { | 561 | if err != nil { | ||
| 562 | + | if name != "" && !all && !daemon { | |||
| 563 | + | store, openErr := kv.Open(filepath.Join(platform.StateDir(), "sessions.kv")) | |||
| 564 | + | if openErr != nil { | |||
| 565 | + | return fmt.Errorf("vev: opening stored sessions: %w", openErr) | |||
| 566 | + | } | |||
| 567 | + | if _, ok := store.Get([]byte(name)); !ok { | |||
| 568 | + | _ = store.Close() | |||
| 569 | + | return fmt.Errorf("vev: no such session: %s", name) | |||
| 570 | + | } | |||
| 571 | + | if deleteErr := store.Delete([]byte(name)); deleteErr != nil { | |||
| 572 | + | _ = store.Close() | |||
| 573 | + | return fmt.Errorf("vev: deleting stored session: %w", deleteErr) | |||
| 574 | + | } | |||
| 575 | + | if closeErr := store.Close(); closeErr != nil { | |||
| 576 | + | return fmt.Errorf("vev: closing stored sessions: %w", closeErr) | |||
| 577 | + | } | |||
| 578 | + | printKillSuccess(name, all, daemon) | |||
| 579 | + | return nil | |||
| 580 | + | } | |||
| 539 | return fmt.Errorf("vev: no daemon running") | 581 | return fmt.Errorf("vev: no daemon running") | ||
| 540 | } | 582 | } | ||
| 541 | defer func() { _ = transport.Close() }() | 583 | defer func() { _ = transport.Close() }() | ||
M internal/app/run_test.go +109 -0
| @@ -4,9 +4,14 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 7 | 10 | "strings" | |
| 8 | 11 | "testing" | |
| 12 | + | "time" | |
| 9 | 13 | ||
| 14 | + | "github.com/bnema/vev/internal/persist" | |
| 10 | 15 | "github.com/bnema/vev/internal/ports" | |
| 11 | 16 | "github.com/bnema/vev/internal/usecase/client" | |
| 12 | 17 | "github.com/bnema/vev/internal/usecase/confirm" | |
| @@ -96,6 +101,110 @@ 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 TestRunKillDeletesStoppedSessionWithoutDaemon(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 | + | p, err := persist.Open(filepath.Join(stateRoot, "vev")) | |
| 153 | + | if err != nil { | |
| 154 | + | t.Fatalf("persist.Open error = %v", err) | |
| 155 | + | } | |
| 156 | + | now := time.Now().UnixNano() | |
| 157 | + | if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil { | |
| 158 | + | t.Fatalf("Save error = %v", err) | |
| 159 | + | } | |
| 160 | + | if err := p.Close(); err != nil { | |
| 161 | + | t.Fatalf("Close error = %v", err) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | got := captureStdout(t, func() { | |
| 165 | + | if err := runKill(context.Background(), "stored", false, false); err != nil { | |
| 166 | + | t.Fatalf("runKill error = %v", err) | |
| 167 | + | } | |
| 168 | + | }) | |
| 169 | + | if !strings.Contains(got, "killed stored") { | |
| 170 | + | t.Fatalf("runKill output = %q, want success", got) | |
| 171 | + | } | |
| 172 | + | records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev")) | |
| 173 | + | if err != nil { | |
| 174 | + | t.Fatalf("LoadReadOnly error = %v", err) | |
| 175 | + | } | |
| 176 | + | if len(records) != 0 { | |
| 177 | + | t.Fatalf("records after kill = %#v, want none", records) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func captureStdout(t *testing.T, fn func()) string { | |
| 182 | + | t.Helper() | |
| 183 | + | old := os.Stdout | |
| 184 | + | r, w, err := os.Pipe() | |
| 185 | + | if err != nil { | |
| 186 | + | t.Fatalf("os.Pipe error = %v", err) | |
| 187 | + | } | |
| 188 | + | os.Stdout = w | |
| 189 | + | defer func() { os.Stdout = old }() | |
| 190 | + | defer func() { _ = r.Close() }() | |
| 191 | + | closed := false | |
| 192 | + | defer func() { | |
| 193 | + | if !closed { | |
| 194 | + | _ = w.Close() | |
| 195 | + | } | |
| 196 | + | }() | |
| 197 | + | ||
| 198 | + | fn() | |
| 199 | + | _ = w.Close() | |
| 200 | + | closed = true | |
| 201 | + | out, err := io.ReadAll(r) | |
| 202 | + | if err != nil { | |
| 203 | + | t.Fatalf("ReadAll stdout error = %v", err) | |
| 204 | + | } | |
| 205 | + | return string(out) | |
| 206 | + | } | |
| 207 | + | ||
| 99 | 208 | func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) { | |
| 100 | 209 | var prompts bytes.Buffer | |
| 101 | 210 | 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,110 @@ 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 TestRunKillDeletesStoppedSessionWithoutDaemon(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 | + | p, err := persist.Open(filepath.Join(stateRoot, "vev")) | |||
| 153 | + | if err != nil { | |||
| 154 | + | t.Fatalf("persist.Open error = %v", err) | |||
| 155 | + | } | |||
| 156 | + | now := time.Now().UnixNano() | |||
| 157 | + | if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil { | |||
| 158 | + | t.Fatalf("Save error = %v", err) | |||
| 159 | + | } | |||
| 160 | + | if err := p.Close(); err != nil { | |||
| 161 | + | t.Fatalf("Close error = %v", err) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | got := captureStdout(t, func() { | |||
| 165 | + | if err := runKill(context.Background(), "stored", false, false); err != nil { | |||
| 166 | + | t.Fatalf("runKill error = %v", err) | |||
| 167 | + | } | |||
| 168 | + | }) | |||
| 169 | + | if !strings.Contains(got, "killed stored") { | |||
| 170 | + | t.Fatalf("runKill output = %q, want success", got) | |||
| 171 | + | } | |||
| 172 | + | records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev")) | |||
| 173 | + | if err != nil { | |||
| 174 | + | t.Fatalf("LoadReadOnly error = %v", err) | |||
| 175 | + | } | |||
| 176 | + | if len(records) != 0 { | |||
| 177 | + | t.Fatalf("records after kill = %#v, want none", records) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func captureStdout(t *testing.T, fn func()) string { | |||
| 182 | + | t.Helper() | |||
| 183 | + | old := os.Stdout | |||
| 184 | + | r, w, err := os.Pipe() | |||
| 185 | + | if err != nil { | |||
| 186 | + | t.Fatalf("os.Pipe error = %v", err) | |||
| 187 | + | } | |||
| 188 | + | os.Stdout = w | |||
| 189 | + | defer func() { os.Stdout = old }() | |||
| 190 | + | defer func() { _ = r.Close() }() | |||
| 191 | + | closed := false | |||
| 192 | + | defer func() { | |||
| 193 | + | if !closed { | |||
| 194 | + | _ = w.Close() | |||
| 195 | + | } | |||
| 196 | + | }() | |||
| 197 | + | ||||
| 198 | + | fn() | |||
| 199 | + | _ = w.Close() | |||
| 200 | + | closed = true | |||
| 201 | + | out, err := io.ReadAll(r) | |||
| 202 | + | if err != nil { | |||
| 203 | + | t.Fatalf("ReadAll stdout error = %v", err) | |||
| 204 | + | } | |||
| 205 | + | return string(out) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 99 | func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) { | 208 | func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) { | ||
| 100 | var prompts bytes.Buffer | 209 | var prompts bytes.Buffer | ||
| 101 | answers := strings.NewReader("y\n") | 210 | answers := strings.NewReader("y\n") | ||
A internal/persist/persist.go +131 -0
| @@ -0,0 +1,131 @@ | |||
| 1 | + | package persist | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "sort" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/pkg/kv" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const filename = "sessions.kv" | |
| 12 | + | ||
| 13 | + | // Persister stores named session metadata. A nil Store makes all mutating | |
| 14 | + | // operations no-ops, so callers can keep persistence optional. | |
| 15 | + | type Persister struct { | |
| 16 | + | store ports.Store | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // Open opens the session persister under dir. | |
| 20 | + | func Open(dir string) (*Persister, error) { | |
| 21 | + | store, err := kv.Open(filepath.Join(dir, filename)) | |
| 22 | + | if err != nil { | |
| 23 | + | return nil, err | |
| 24 | + | } | |
| 25 | + | return New(store), nil | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // New wraps store in a Persister. Passing nil creates a no-op persister. | |
| 29 | + | func New(store ports.Store) *Persister { | |
| 30 | + | return &Persister{store: store} | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // LoadReadOnly replays the session store under dir without mutating it. | |
| 34 | + | func LoadReadOnly(dir string) ([]Record, error) { | |
| 35 | + | data, err := kv.Replay(filepath.Join(dir, filename)) | |
| 36 | + | if err != nil { | |
| 37 | + | return nil, err | |
| 38 | + | } | |
| 39 | + | return decodeAll(data) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Save persists a structural session record and syncs it to disk. | |
| 43 | + | func (p *Persister) Save(r Record) error { | |
| 44 | + | if p == nil || p.store == nil { | |
| 45 | + | return nil | |
| 46 | + | } | |
| 47 | + | value, err := encodeRecordValue(r) | |
| 48 | + | if err != nil { | |
| 49 | + | return err | |
| 50 | + | } | |
| 51 | + | if err := p.store.Set([]byte(r.Name), value); err != nil { | |
| 52 | + | return err | |
| 53 | + | } | |
| 54 | + | return p.store.Sync() | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Touch updates hot-path session cwd metadata without forcing an fsync. | |
| 58 | + | func (p *Persister) Touch(name, cwd string, at int64) error { | |
| 59 | + | if p == nil || p.store == nil { | |
| 60 | + | return nil | |
| 61 | + | } | |
| 62 | + | if name == "" { | |
| 63 | + | return errEmptyName | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | createdAt := at | |
| 67 | + | p.store.Range(func(k, v []byte) bool { | |
| 68 | + | if string(k) != name { | |
| 69 | + | return true | |
| 70 | + | } | |
| 71 | + | r, err := decodeRecordValue(name, v) | |
| 72 | + | if err == nil { | |
| 73 | + | createdAt = r.CreatedAt | |
| 74 | + | } | |
| 75 | + | return false | |
| 76 | + | }) | |
| 77 | + | ||
| 78 | + | value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at}) | |
| 79 | + | if err != nil { | |
| 80 | + | return err | |
| 81 | + | } | |
| 82 | + | return p.store.Set([]byte(name), value) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Delete removes a persisted session record and syncs the structural change. | |
| 86 | + | func (p *Persister) Delete(name string) error { | |
| 87 | + | if p == nil || p.store == nil { | |
| 88 | + | return nil | |
| 89 | + | } | |
| 90 | + | if name == "" { | |
| 91 | + | return errEmptyName | |
| 92 | + | } | |
| 93 | + | if err := p.store.Delete([]byte(name)); err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 96 | + | return p.store.Sync() | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // LoadAll returns all persisted session records sorted by name. | |
| 100 | + | func (p *Persister) LoadAll() ([]Record, error) { | |
| 101 | + | if p == nil || p.store == nil { | |
| 102 | + | return nil, nil | |
| 103 | + | } | |
| 104 | + | data := make(map[string][]byte) | |
| 105 | + | p.store.Range(func(k, v []byte) bool { | |
| 106 | + | data[string(k)] = append([]byte(nil), v...) | |
| 107 | + | return true | |
| 108 | + | }) | |
| 109 | + | return decodeAll(data) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Close closes the underlying store. | |
| 113 | + | func (p *Persister) Close() error { | |
| 114 | + | if p == nil || p.store == nil { | |
| 115 | + | return nil | |
| 116 | + | } | |
| 117 | + | return p.store.Close() | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func decodeAll(data map[string][]byte) ([]Record, error) { | |
| 121 | + | records := make([]Record, 0, len(data)) | |
| 122 | + | for name, value := range data { | |
| 123 | + | r, err := decodeRecordValue(name, value) | |
| 124 | + | if err != nil { | |
| 125 | + | return nil, err | |
| 126 | + | } | |
| 127 | + | records = append(records, r) | |
| 128 | + | } | |
| 129 | + | sort.Slice(records, func(i, j int) bool { return records[i].Name < records[j].Name }) | |
| 130 | + | return records, nil | |
| 131 | + | } | |
| @@ -0,0 +1,131 @@ | |||||
| 1 | + | package persist | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "sort" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | "github.com/bnema/vev/pkg/kv" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | const filename = "sessions.kv" | |||
| 12 | + | ||||
| 13 | + | // Persister stores named session metadata. A nil Store makes all mutating | |||
| 14 | + | // operations no-ops, so callers can keep persistence optional. | |||
| 15 | + | type Persister struct { | |||
| 16 | + | store ports.Store | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // Open opens the session persister under dir. | |||
| 20 | + | func Open(dir string) (*Persister, error) { | |||
| 21 | + | store, err := kv.Open(filepath.Join(dir, filename)) | |||
| 22 | + | if err != nil { | |||
| 23 | + | return nil, err | |||
| 24 | + | } | |||
| 25 | + | return New(store), nil | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // New wraps store in a Persister. Passing nil creates a no-op persister. | |||
| 29 | + | func New(store ports.Store) *Persister { | |||
| 30 | + | return &Persister{store: store} | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // LoadReadOnly replays the session store under dir without mutating it. | |||
| 34 | + | func LoadReadOnly(dir string) ([]Record, error) { | |||
| 35 | + | data, err := kv.Replay(filepath.Join(dir, filename)) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return nil, err | |||
| 38 | + | } | |||
| 39 | + | return decodeAll(data) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Save persists a structural session record and syncs it to disk. | |||
| 43 | + | func (p *Persister) Save(r Record) error { | |||
| 44 | + | if p == nil || p.store == nil { | |||
| 45 | + | return nil | |||
| 46 | + | } | |||
| 47 | + | value, err := encodeRecordValue(r) | |||
| 48 | + | if err != nil { | |||
| 49 | + | return err | |||
| 50 | + | } | |||
| 51 | + | if err := p.store.Set([]byte(r.Name), value); err != nil { | |||
| 52 | + | return err | |||
| 53 | + | } | |||
| 54 | + | return p.store.Sync() | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // Touch updates hot-path session cwd metadata without forcing an fsync. | |||
| 58 | + | func (p *Persister) Touch(name, cwd string, at int64) error { | |||
| 59 | + | if p == nil || p.store == nil { | |||
| 60 | + | return nil | |||
| 61 | + | } | |||
| 62 | + | if name == "" { | |||
| 63 | + | return errEmptyName | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | createdAt := at | |||
| 67 | + | p.store.Range(func(k, v []byte) bool { | |||
| 68 | + | if string(k) != name { | |||
| 69 | + | return true | |||
| 70 | + | } | |||
| 71 | + | r, err := decodeRecordValue(name, v) | |||
| 72 | + | if err == nil { | |||
| 73 | + | createdAt = r.CreatedAt | |||
| 74 | + | } | |||
| 75 | + | return false | |||
| 76 | + | }) | |||
| 77 | + | ||||
| 78 | + | value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at}) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return err | |||
| 81 | + | } | |||
| 82 | + | return p.store.Set([]byte(name), value) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Delete removes a persisted session record and syncs the structural change. | |||
| 86 | + | func (p *Persister) Delete(name string) error { | |||
| 87 | + | if p == nil || p.store == nil { | |||
| 88 | + | return nil | |||
| 89 | + | } | |||
| 90 | + | if name == "" { | |||
| 91 | + | return errEmptyName | |||
| 92 | + | } | |||
| 93 | + | if err := p.store.Delete([]byte(name)); err != nil { | |||
| 94 | + | return err | |||
| 95 | + | } | |||
| 96 | + | return p.store.Sync() | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // LoadAll returns all persisted session records sorted by name. | |||
| 100 | + | func (p *Persister) LoadAll() ([]Record, error) { | |||
| 101 | + | if p == nil || p.store == nil { | |||
| 102 | + | return nil, nil | |||
| 103 | + | } | |||
| 104 | + | data := make(map[string][]byte) | |||
| 105 | + | p.store.Range(func(k, v []byte) bool { | |||
| 106 | + | data[string(k)] = append([]byte(nil), v...) | |||
| 107 | + | return true | |||
| 108 | + | }) | |||
| 109 | + | return decodeAll(data) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Close closes the underlying store. | |||
| 113 | + | func (p *Persister) Close() error { | |||
| 114 | + | if p == nil || p.store == nil { | |||
| 115 | + | return nil | |||
| 116 | + | } | |||
| 117 | + | return p.store.Close() | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func decodeAll(data map[string][]byte) ([]Record, error) { | |||
| 121 | + | records := make([]Record, 0, len(data)) | |||
| 122 | + | for name, value := range data { | |||
| 123 | + | r, err := decodeRecordValue(name, value) | |||
| 124 | + | if err != nil { | |||
| 125 | + | return nil, err | |||
| 126 | + | } | |||
| 127 | + | records = append(records, r) | |||
| 128 | + | } | |||
| 129 | + | sort.Slice(records, func(i, j int) bool { return records[i].Name < records[j].Name }) | |||
| 130 | + | return records, nil | |||
| 131 | + | } | |||
A internal/persist/persist_test.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | package persist | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestRecordCodecRoundTrip(t *testing.T) { | |
| 12 | + | r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22} | |
| 13 | + | value, err := encodeRecordValue(r) | |
| 14 | + | require.NoError(t, err) | |
| 15 | + | ||
| 16 | + | got, err := decodeRecordValue(r.Name, value) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | require.Equal(t, r, got) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestRecordCodecRejectsMalformedData(t *testing.T) { | |
| 22 | + | _, err := encodeRecordValue(Record{Cwd: "/tmp"}) | |
| 23 | + | require.ErrorIs(t, err, errEmptyName) | |
| 24 | + | ||
| 25 | + | badValues := [][]byte{ | |
| 26 | + | nil, | |
| 27 | + | {0, 0, 0}, | |
| 28 | + | {0, 0, 0, 5, 'a'}, | |
| 29 | + | {0, 0, 0, 1, 'a', 0}, | |
| 30 | + | {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, | |
| 31 | + | {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, | |
| 32 | + | } | |
| 33 | + | for _, value := range badValues { | |
| 34 | + | _, err := decodeRecordValue("name", value) | |
| 35 | + | require.ErrorIs(t, err, errMalformedRecord) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | _, err = decodeRecordValue("", make([]byte, 20)) | |
| 39 | + | require.ErrorIs(t, err, errEmptyName) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestNilPersisterNoOps(t *testing.T) { | |
| 43 | + | var p *Persister | |
| 44 | + | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1})) | |
| 45 | + | require.NoError(t, p.Touch("a", "/tmp/next", 2)) | |
| 46 | + | require.NoError(t, p.Delete("a")) | |
| 47 | + | records, err := p.LoadAll() | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | require.Empty(t, records) | |
| 50 | + | require.NoError(t, p.Close()) | |
| 51 | + | ||
| 52 | + | p = New(nil) | |
| 53 | + | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1})) | |
| 54 | + | require.NoError(t, p.Touch("a", "/tmp/next", 2)) | |
| 55 | + | require.NoError(t, p.Delete("a")) | |
| 56 | + | records, err = p.LoadAll() | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | require.Empty(t, records) | |
| 59 | + | require.NoError(t, p.Close()) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) { | |
| 63 | + | dir := t.TempDir() | |
| 64 | + | p, err := Open(dir) | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | ||
| 67 | + | require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10})) | |
| 68 | + | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20})) | |
| 69 | + | require.NoError(t, p.Touch("b", "/b/next", 30)) | |
| 70 | + | ||
| 71 | + | records, err := p.LoadAll() | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | require.Equal(t, []Record{ | |
| 74 | + | {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}, | |
| 75 | + | {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}, | |
| 76 | + | }, records) | |
| 77 | + | ||
| 78 | + | require.NoError(t, p.Delete("a")) | |
| 79 | + | records, err = p.LoadAll() | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}}, records) | |
| 82 | + | require.NoError(t, p.Close()) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestLoadReadOnlyUsesReplay(t *testing.T) { | |
| 86 | + | dir := t.TempDir() | |
| 87 | + | p, err := Open(dir) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2})) | |
| 90 | + | require.NoError(t, p.Close()) | |
| 91 | + | ||
| 92 | + | records, err := LoadReadOnly(dir) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}}, records) | |
| 95 | + | ||
| 96 | + | // Missing stores replay as empty rather than creating the WAL. | |
| 97 | + | missing := filepath.Join(t.TempDir(), "missing") | |
| 98 | + | records, err = LoadReadOnly(missing) | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | require.Empty(t, records) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestSyncBehavior(t *testing.T) { | |
| 104 | + | store := newFakeStore() | |
| 105 | + | p := New(store) | |
| 106 | + | ||
| 107 | + | require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1})) | |
| 108 | + | require.Equal(t, 1, store.sets) | |
| 109 | + | require.Equal(t, 1, store.syncs) | |
| 110 | + | ||
| 111 | + | require.NoError(t, p.Touch("work", "/work/next", 2)) | |
| 112 | + | require.Equal(t, 2, store.sets) | |
| 113 | + | require.Equal(t, 1, store.syncs) | |
| 114 | + | ||
| 115 | + | require.NoError(t, p.Delete("work")) | |
| 116 | + | require.Equal(t, 1, store.deletes) | |
| 117 | + | require.Equal(t, 2, store.syncs) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestSavePropagatesSetErrorWithoutSync(t *testing.T) { | |
| 121 | + | store := newFakeStore() | |
| 122 | + | store.errSet = errors.New("set failed") | |
| 123 | + | p := New(store) | |
| 124 | + | ||
| 125 | + | err := p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}) | |
| 126 | + | require.ErrorIs(t, err, store.errSet) | |
| 127 | + | require.Zero(t, store.syncs) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | type fakeStore struct { | |
| 131 | + | data map[string][]byte | |
| 132 | + | sets int | |
| 133 | + | deletes int | |
| 134 | + | syncs int | |
| 135 | + | closed bool | |
| 136 | + | errSet error | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func newFakeStore() *fakeStore { | |
| 140 | + | return &fakeStore{data: make(map[string][]byte)} | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (s *fakeStore) Set(key, val []byte) error { | |
| 144 | + | s.sets++ | |
| 145 | + | if s.errSet != nil { | |
| 146 | + | return s.errSet | |
| 147 | + | } | |
| 148 | + | s.data[string(key)] = append([]byte(nil), val...) | |
| 149 | + | return nil | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (s *fakeStore) Delete(key []byte) error { | |
| 153 | + | s.deletes++ | |
| 154 | + | delete(s.data, string(key)) | |
| 155 | + | return nil | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (s *fakeStore) Range(fn func(k, v []byte) bool) { | |
| 159 | + | for k, v := range s.data { | |
| 160 | + | if !fn([]byte(k), append([]byte(nil), v...)) { | |
| 161 | + | return | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (s *fakeStore) Sync() error { | |
| 167 | + | s.syncs++ | |
| 168 | + | return nil | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (s *fakeStore) Close() error { | |
| 172 | + | s.closed = true | |
| 173 | + | return nil | |
| 174 | + | } | |
| @@ -0,0 +1,174 @@ | |||||
| 1 | + | package persist | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestRecordCodecRoundTrip(t *testing.T) { | |||
| 12 | + | r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22} | |||
| 13 | + | value, err := encodeRecordValue(r) | |||
| 14 | + | require.NoError(t, err) | |||
| 15 | + | ||||
| 16 | + | got, err := decodeRecordValue(r.Name, value) | |||
| 17 | + | require.NoError(t, err) | |||
| 18 | + | require.Equal(t, r, got) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestRecordCodecRejectsMalformedData(t *testing.T) { | |||
| 22 | + | _, err := encodeRecordValue(Record{Cwd: "/tmp"}) | |||
| 23 | + | require.ErrorIs(t, err, errEmptyName) | |||
| 24 | + | ||||
| 25 | + | badValues := [][]byte{ | |||
| 26 | + | nil, | |||
| 27 | + | {0, 0, 0}, | |||
| 28 | + | {0, 0, 0, 5, 'a'}, | |||
| 29 | + | {0, 0, 0, 1, 'a', 0}, | |||
| 30 | + | {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, | |||
| 31 | + | {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, | |||
| 32 | + | } | |||
| 33 | + | for _, value := range badValues { | |||
| 34 | + | _, err := decodeRecordValue("name", value) | |||
| 35 | + | require.ErrorIs(t, err, errMalformedRecord) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | _, err = decodeRecordValue("", make([]byte, 20)) | |||
| 39 | + | require.ErrorIs(t, err, errEmptyName) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestNilPersisterNoOps(t *testing.T) { | |||
| 43 | + | var p *Persister | |||
| 44 | + | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1})) | |||
| 45 | + | require.NoError(t, p.Touch("a", "/tmp/next", 2)) | |||
| 46 | + | require.NoError(t, p.Delete("a")) | |||
| 47 | + | records, err := p.LoadAll() | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | require.Empty(t, records) | |||
| 50 | + | require.NoError(t, p.Close()) | |||
| 51 | + | ||||
| 52 | + | p = New(nil) | |||
| 53 | + | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1})) | |||
| 54 | + | require.NoError(t, p.Touch("a", "/tmp/next", 2)) | |||
| 55 | + | require.NoError(t, p.Delete("a")) | |||
| 56 | + | records, err = p.LoadAll() | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | require.Empty(t, records) | |||
| 59 | + | require.NoError(t, p.Close()) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) { | |||
| 63 | + | dir := t.TempDir() | |||
| 64 | + | p, err := Open(dir) | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | ||||
| 67 | + | require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10})) | |||
| 68 | + | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20})) | |||
| 69 | + | require.NoError(t, p.Touch("b", "/b/next", 30)) | |||
| 70 | + | ||||
| 71 | + | records, err := p.LoadAll() | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | require.Equal(t, []Record{ | |||
| 74 | + | {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}, | |||
| 75 | + | {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}, | |||
| 76 | + | }, records) | |||
| 77 | + | ||||
| 78 | + | require.NoError(t, p.Delete("a")) | |||
| 79 | + | records, err = p.LoadAll() | |||
| 80 | + | require.NoError(t, err) | |||
| 81 | + | require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}}, records) | |||
| 82 | + | require.NoError(t, p.Close()) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestLoadReadOnlyUsesReplay(t *testing.T) { | |||
| 86 | + | dir := t.TempDir() | |||
| 87 | + | p, err := Open(dir) | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2})) | |||
| 90 | + | require.NoError(t, p.Close()) | |||
| 91 | + | ||||
| 92 | + | records, err := LoadReadOnly(dir) | |||
| 93 | + | require.NoError(t, err) | |||
| 94 | + | require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}}, records) | |||
| 95 | + | ||||
| 96 | + | // Missing stores replay as empty rather than creating the WAL. | |||
| 97 | + | missing := filepath.Join(t.TempDir(), "missing") | |||
| 98 | + | records, err = LoadReadOnly(missing) | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | require.Empty(t, records) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestSyncBehavior(t *testing.T) { | |||
| 104 | + | store := newFakeStore() | |||
| 105 | + | p := New(store) | |||
| 106 | + | ||||
| 107 | + | require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1})) | |||
| 108 | + | require.Equal(t, 1, store.sets) | |||
| 109 | + | require.Equal(t, 1, store.syncs) | |||
| 110 | + | ||||
| 111 | + | require.NoError(t, p.Touch("work", "/work/next", 2)) | |||
| 112 | + | require.Equal(t, 2, store.sets) | |||
| 113 | + | require.Equal(t, 1, store.syncs) | |||
| 114 | + | ||||
| 115 | + | require.NoError(t, p.Delete("work")) | |||
| 116 | + | require.Equal(t, 1, store.deletes) | |||
| 117 | + | require.Equal(t, 2, store.syncs) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestSavePropagatesSetErrorWithoutSync(t *testing.T) { | |||
| 121 | + | store := newFakeStore() | |||
| 122 | + | store.errSet = errors.New("set failed") | |||
| 123 | + | p := New(store) | |||
| 124 | + | ||||
| 125 | + | err := p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}) | |||
| 126 | + | require.ErrorIs(t, err, store.errSet) | |||
| 127 | + | require.Zero(t, store.syncs) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | type fakeStore struct { | |||
| 131 | + | data map[string][]byte | |||
| 132 | + | sets int | |||
| 133 | + | deletes int | |||
| 134 | + | syncs int | |||
| 135 | + | closed bool | |||
| 136 | + | errSet error | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func newFakeStore() *fakeStore { | |||
| 140 | + | return &fakeStore{data: make(map[string][]byte)} | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (s *fakeStore) Set(key, val []byte) error { | |||
| 144 | + | s.sets++ | |||
| 145 | + | if s.errSet != nil { | |||
| 146 | + | return s.errSet | |||
| 147 | + | } | |||
| 148 | + | s.data[string(key)] = append([]byte(nil), val...) | |||
| 149 | + | return nil | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func (s *fakeStore) Delete(key []byte) error { | |||
| 153 | + | s.deletes++ | |||
| 154 | + | delete(s.data, string(key)) | |||
| 155 | + | return nil | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func (s *fakeStore) Range(fn func(k, v []byte) bool) { | |||
| 159 | + | for k, v := range s.data { | |||
| 160 | + | if !fn([]byte(k), append([]byte(nil), v...)) { | |||
| 161 | + | return | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func (s *fakeStore) Sync() error { | |||
| 167 | + | s.syncs++ | |||
| 168 | + | return nil | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (s *fakeStore) Close() error { | |||
| 172 | + | s.closed = true | |||
| 173 | + | return nil | |||
| 174 | + | } | |||
A internal/persist/record.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package persist | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Record is the persisted metadata for a named session. | |
| 10 | + | type Record struct { | |
| 11 | + | Name string | |
| 12 | + | Cwd string | |
| 13 | + | CreatedAt int64 | |
| 14 | + | UpdatedAt int64 | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | var ( | |
| 18 | + | errEmptyName = errors.New("persist: empty session name") | |
| 19 | + | errMalformedRecord = errors.New("persist: malformed record") | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | func encodeRecordValue(r Record) ([]byte, error) { | |
| 23 | + | if r.Name == "" { | |
| 24 | + | return nil, errEmptyName | |
| 25 | + | } | |
| 26 | + | if len(r.Cwd) > int(^uint32(0)) { | |
| 27 | + | return nil, fmt.Errorf("persist: cwd too large") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | buf := make([]byte, 4+len(r.Cwd)+8+8) | |
| 31 | + | binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd))) | |
| 32 | + | copy(buf[4:], r.Cwd) | |
| 33 | + | off := 4 + len(r.Cwd) | |
| 34 | + | binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt)) | |
| 35 | + | binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt)) | |
| 36 | + | return buf, nil | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func decodeRecordValue(name string, value []byte) (Record, error) { | |
| 40 | + | if name == "" { | |
| 41 | + | return Record{}, errEmptyName | |
| 42 | + | } | |
| 43 | + | if len(value) < 4 { | |
| 44 | + | return Record{}, errMalformedRecord | |
| 45 | + | } | |
| 46 | + | cwdLen := int(binary.BigEndian.Uint32(value[:4])) | |
| 47 | + | if cwdLen > len(value)-4 || len(value) != 4+cwdLen+8+8 { | |
| 48 | + | return Record{}, errMalformedRecord | |
| 49 | + | } | |
| 50 | + | off := 4 + cwdLen | |
| 51 | + | return Record{ | |
| 52 | + | Name: name, | |
| 53 | + | Cwd: string(value[4:off]), | |
| 54 | + | CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])), | |
| 55 | + | UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])), | |
| 56 | + | }, nil | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | package persist | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // Record is the persisted metadata for a named session. | |||
| 10 | + | type Record struct { | |||
| 11 | + | Name string | |||
| 12 | + | Cwd string | |||
| 13 | + | CreatedAt int64 | |||
| 14 | + | UpdatedAt int64 | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | var ( | |||
| 18 | + | errEmptyName = errors.New("persist: empty session name") | |||
| 19 | + | errMalformedRecord = errors.New("persist: malformed record") | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | func encodeRecordValue(r Record) ([]byte, error) { | |||
| 23 | + | if r.Name == "" { | |||
| 24 | + | return nil, errEmptyName | |||
| 25 | + | } | |||
| 26 | + | if len(r.Cwd) > int(^uint32(0)) { | |||
| 27 | + | return nil, fmt.Errorf("persist: cwd too large") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | buf := make([]byte, 4+len(r.Cwd)+8+8) | |||
| 31 | + | binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd))) | |||
| 32 | + | copy(buf[4:], r.Cwd) | |||
| 33 | + | off := 4 + len(r.Cwd) | |||
| 34 | + | binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt)) | |||
| 35 | + | binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt)) | |||
| 36 | + | return buf, nil | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func decodeRecordValue(name string, value []byte) (Record, error) { | |||
| 40 | + | if name == "" { | |||
| 41 | + | return Record{}, errEmptyName | |||
| 42 | + | } | |||
| 43 | + | if len(value) < 4 { | |||
| 44 | + | return Record{}, errMalformedRecord | |||
| 45 | + | } | |||
| 46 | + | cwdLen := int(binary.BigEndian.Uint32(value[:4])) | |||
| 47 | + | if cwdLen > len(value)-4 || len(value) != 4+cwdLen+8+8 { | |||
| 48 | + | return Record{}, errMalformedRecord | |||
| 49 | + | } | |||
| 50 | + | off := 4 + cwdLen | |||
| 51 | + | return Record{ | |||
| 52 | + | Name: name, | |||
| 53 | + | Cwd: string(value[4:off]), | |||
| 54 | + | CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])), | |||
| 55 | + | UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])), | |||
| 56 | + | }, nil | |||
| 57 | + | } | |||
M internal/platform/dir.go +19 -1
| @@ -1,6 +1,24 @@ | |||
| 1 | 1 | 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 | 23 | // DirOrHome returns dir when it names an existing directory. Otherwise it | |
| 6 | 24 | // falls back to the current user's home directory, then to / as a last resort. | |
| @@ -1,6 +1,24 @@ | |||||
| 1 | package platform | 1 | 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 it | 23 | // 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 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 3 | |
| 4 | + | const ProtocolVersion uint16 = 4 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | 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 = 3 | 4 | + | 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 uint8 | 7 | type MsgType uint8 | ||
M internal/ports/frame_test.go +6 -0
| @@ -2,6 +2,12 @@ package ports | |||
| 2 | 2 | ||
| 3 | 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 | 11 | func TestMsgTypeUnique(t *testing.T) { | |
| 6 | 12 | tests := []struct { | |
| 7 | 13 | 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 string | 13 | name string | ||
M internal/ports/mocks/mocks.go +263 -0
| @@ -1074,6 +1074,269 @@ func (_c *MockListener_Close_Call) RunAndReturn(run func() error) *MockListener_ | |||
| 1074 | 1074 | 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 | + | // Range provides a mock function for the type MockStore | |
| 1200 | + | func (_mock *MockStore) Range(fn func(k []byte, v []byte) bool) { | |
| 1201 | + | _mock.Called(fn) | |
| 1202 | + | return | |
| 1203 | + | } | |
| 1204 | + | ||
| 1205 | + | // MockStore_Range_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Range' | |
| 1206 | + | type MockStore_Range_Call struct { | |
| 1207 | + | *mock.Call | |
| 1208 | + | } | |
| 1209 | + | ||
| 1210 | + | // Range is a helper method to define mock.On call | |
| 1211 | + | // - fn func(k []byte, v []byte) bool | |
| 1212 | + | func (_e *MockStore_Expecter) Range(fn any) *MockStore_Range_Call { | |
| 1213 | + | return &MockStore_Range_Call{Call: _e.mock.On("Range", fn)} | |
| 1214 | + | } | |
| 1215 | + | ||
| 1216 | + | func (_c *MockStore_Range_Call) Run(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call { | |
| 1217 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1218 | + | var arg0 func(k []byte, v []byte) bool | |
| 1219 | + | if args[0] != nil { | |
| 1220 | + | arg0 = args[0].(func(k []byte, v []byte) bool) | |
| 1221 | + | } | |
| 1222 | + | run( | |
| 1223 | + | arg0, | |
| 1224 | + | ) | |
| 1225 | + | }) | |
| 1226 | + | return _c | |
| 1227 | + | } | |
| 1228 | + | ||
| 1229 | + | func (_c *MockStore_Range_Call) Return() *MockStore_Range_Call { | |
| 1230 | + | _c.Call.Return() | |
| 1231 | + | return _c | |
| 1232 | + | } | |
| 1233 | + | ||
| 1234 | + | func (_c *MockStore_Range_Call) RunAndReturn(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call { | |
| 1235 | + | _c.Run(run) | |
| 1236 | + | return _c | |
| 1237 | + | } | |
| 1238 | + | ||
| 1239 | + | // Set provides a mock function for the type MockStore | |
| 1240 | + | func (_mock *MockStore) Set(key []byte, val []byte) error { | |
| 1241 | + | ret := _mock.Called(key, val) | |
| 1242 | + | ||
| 1243 | + | if len(ret) == 0 { | |
| 1244 | + | panic("no return value specified for Set") | |
| 1245 | + | } | |
| 1246 | + | ||
| 1247 | + | var r0 error | |
| 1248 | + | if returnFunc, ok := ret.Get(0).(func([]byte, []byte) error); ok { | |
| 1249 | + | r0 = returnFunc(key, val) | |
| 1250 | + | } else { | |
| 1251 | + | r0 = ret.Error(0) | |
| 1252 | + | } | |
| 1253 | + | return r0 | |
| 1254 | + | } | |
| 1255 | + | ||
| 1256 | + | // MockStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set' | |
| 1257 | + | type MockStore_Set_Call struct { | |
| 1258 | + | *mock.Call | |
| 1259 | + | } | |
| 1260 | + | ||
| 1261 | + | // Set is a helper method to define mock.On call | |
| 1262 | + | // - key []byte | |
| 1263 | + | // - val []byte | |
| 1264 | + | func (_e *MockStore_Expecter) Set(key any, val any) *MockStore_Set_Call { | |
| 1265 | + | return &MockStore_Set_Call{Call: _e.mock.On("Set", key, val)} | |
| 1266 | + | } | |
| 1267 | + | ||
| 1268 | + | func (_c *MockStore_Set_Call) Run(run func(key []byte, val []byte)) *MockStore_Set_Call { | |
| 1269 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1270 | + | var arg0 []byte | |
| 1271 | + | if args[0] != nil { | |
| 1272 | + | arg0 = args[0].([]byte) | |
| 1273 | + | } | |
| 1274 | + | var arg1 []byte | |
| 1275 | + | if args[1] != nil { | |
| 1276 | + | arg1 = args[1].([]byte) | |
| 1277 | + | } | |
| 1278 | + | run( | |
| 1279 | + | arg0, | |
| 1280 | + | arg1, | |
| 1281 | + | ) | |
| 1282 | + | }) | |
| 1283 | + | return _c | |
| 1284 | + | } | |
| 1285 | + | ||
| 1286 | + | func (_c *MockStore_Set_Call) Return(err error) *MockStore_Set_Call { | |
| 1287 | + | _c.Call.Return(err) | |
| 1288 | + | return _c | |
| 1289 | + | } | |
| 1290 | + | ||
| 1291 | + | func (_c *MockStore_Set_Call) RunAndReturn(run func(key []byte, val []byte) error) *MockStore_Set_Call { | |
| 1292 | + | _c.Call.Return(run) | |
| 1293 | + | return _c | |
| 1294 | + | } | |
| 1295 | + | ||
| 1296 | + | // Sync provides a mock function for the type MockStore | |
| 1297 | + | func (_mock *MockStore) Sync() error { | |
| 1298 | + | ret := _mock.Called() | |
| 1299 | + | ||
| 1300 | + | if len(ret) == 0 { | |
| 1301 | + | panic("no return value specified for Sync") | |
| 1302 | + | } | |
| 1303 | + | ||
| 1304 | + | var r0 error | |
| 1305 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 1306 | + | r0 = returnFunc() | |
| 1307 | + | } else { | |
| 1308 | + | r0 = ret.Error(0) | |
| 1309 | + | } | |
| 1310 | + | return r0 | |
| 1311 | + | } | |
| 1312 | + | ||
| 1313 | + | // MockStore_Sync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sync' | |
| 1314 | + | type MockStore_Sync_Call struct { | |
| 1315 | + | *mock.Call | |
| 1316 | + | } | |
| 1317 | + | ||
| 1318 | + | // Sync is a helper method to define mock.On call | |
| 1319 | + | func (_e *MockStore_Expecter) Sync() *MockStore_Sync_Call { | |
| 1320 | + | return &MockStore_Sync_Call{Call: _e.mock.On("Sync")} | |
| 1321 | + | } | |
| 1322 | + | ||
| 1323 | + | func (_c *MockStore_Sync_Call) Run(run func()) *MockStore_Sync_Call { | |
| 1324 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1325 | + | run() | |
| 1326 | + | }) | |
| 1327 | + | return _c | |
| 1328 | + | } | |
| 1329 | + | ||
| 1330 | + | func (_c *MockStore_Sync_Call) Return(err error) *MockStore_Sync_Call { | |
| 1331 | + | _c.Call.Return(err) | |
| 1332 | + | return _c | |
| 1333 | + | } | |
| 1334 | + | ||
| 1335 | + | func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Call { | |
| 1336 | + | _c.Call.Return(run) | |
| 1337 | + | return _c | |
| 1338 | + | } | |
| 1339 | + | ||
| 1077 | 1340 | // 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 | 1341 | // The first argument is typically a *testing.T value. | |
| 1079 | 1342 | func NewMockClock(t interface { | |
| @@ -1074,6 +1074,269 @@ func (_c *MockListener_Close_Call) RunAndReturn(run func() error) *MockListener_ | |||||
| 1074 | return _c | 1074 | 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 | + | // Range provides a mock function for the type MockStore | |||
| 1200 | + | func (_mock *MockStore) Range(fn func(k []byte, v []byte) bool) { | |||
| 1201 | + | _mock.Called(fn) | |||
| 1202 | + | return | |||
| 1203 | + | } | |||
| 1204 | + | ||||
| 1205 | + | // MockStore_Range_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Range' | |||
| 1206 | + | type MockStore_Range_Call struct { | |||
| 1207 | + | *mock.Call | |||
| 1208 | + | } | |||
| 1209 | + | ||||
| 1210 | + | // Range is a helper method to define mock.On call | |||
| 1211 | + | // - fn func(k []byte, v []byte) bool | |||
| 1212 | + | func (_e *MockStore_Expecter) Range(fn any) *MockStore_Range_Call { | |||
| 1213 | + | return &MockStore_Range_Call{Call: _e.mock.On("Range", fn)} | |||
| 1214 | + | } | |||
| 1215 | + | ||||
| 1216 | + | func (_c *MockStore_Range_Call) Run(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call { | |||
| 1217 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1218 | + | var arg0 func(k []byte, v []byte) bool | |||
| 1219 | + | if args[0] != nil { | |||
| 1220 | + | arg0 = args[0].(func(k []byte, v []byte) bool) | |||
| 1221 | + | } | |||
| 1222 | + | run( | |||
| 1223 | + | arg0, | |||
| 1224 | + | ) | |||
| 1225 | + | }) | |||
| 1226 | + | return _c | |||
| 1227 | + | } | |||
| 1228 | + | ||||
| 1229 | + | func (_c *MockStore_Range_Call) Return() *MockStore_Range_Call { | |||
| 1230 | + | _c.Call.Return() | |||
| 1231 | + | return _c | |||
| 1232 | + | } | |||
| 1233 | + | ||||
| 1234 | + | func (_c *MockStore_Range_Call) RunAndReturn(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call { | |||
| 1235 | + | _c.Run(run) | |||
| 1236 | + | return _c | |||
| 1237 | + | } | |||
| 1238 | + | ||||
| 1239 | + | // Set provides a mock function for the type MockStore | |||
| 1240 | + | func (_mock *MockStore) Set(key []byte, val []byte) error { | |||
| 1241 | + | ret := _mock.Called(key, val) | |||
| 1242 | + | ||||
| 1243 | + | if len(ret) == 0 { | |||
| 1244 | + | panic("no return value specified for Set") | |||
| 1245 | + | } | |||
| 1246 | + | ||||
| 1247 | + | var r0 error | |||
| 1248 | + | if returnFunc, ok := ret.Get(0).(func([]byte, []byte) error); ok { | |||
| 1249 | + | r0 = returnFunc(key, val) | |||
| 1250 | + | } else { | |||
| 1251 | + | r0 = ret.Error(0) | |||
| 1252 | + | } | |||
| 1253 | + | return r0 | |||
| 1254 | + | } | |||
| 1255 | + | ||||
| 1256 | + | // MockStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set' | |||
| 1257 | + | type MockStore_Set_Call struct { | |||
| 1258 | + | *mock.Call | |||
| 1259 | + | } | |||
| 1260 | + | ||||
| 1261 | + | // Set is a helper method to define mock.On call | |||
| 1262 | + | // - key []byte | |||
| 1263 | + | // - val []byte | |||
| 1264 | + | func (_e *MockStore_Expecter) Set(key any, val any) *MockStore_Set_Call { | |||
| 1265 | + | return &MockStore_Set_Call{Call: _e.mock.On("Set", key, val)} | |||
| 1266 | + | } | |||
| 1267 | + | ||||
| 1268 | + | func (_c *MockStore_Set_Call) Run(run func(key []byte, val []byte)) *MockStore_Set_Call { | |||
| 1269 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1270 | + | var arg0 []byte | |||
| 1271 | + | if args[0] != nil { | |||
| 1272 | + | arg0 = args[0].([]byte) | |||
| 1273 | + | } | |||
| 1274 | + | var arg1 []byte | |||
| 1275 | + | if args[1] != nil { | |||
| 1276 | + | arg1 = args[1].([]byte) | |||
| 1277 | + | } | |||
| 1278 | + | run( | |||
| 1279 | + | arg0, | |||
| 1280 | + | arg1, | |||
| 1281 | + | ) | |||
| 1282 | + | }) | |||
| 1283 | + | return _c | |||
| 1284 | + | } | |||
| 1285 | + | ||||
| 1286 | + | func (_c *MockStore_Set_Call) Return(err error) *MockStore_Set_Call { | |||
| 1287 | + | _c.Call.Return(err) | |||
| 1288 | + | return _c | |||
| 1289 | + | } | |||
| 1290 | + | ||||
| 1291 | + | func (_c *MockStore_Set_Call) RunAndReturn(run func(key []byte, val []byte) error) *MockStore_Set_Call { | |||
| 1292 | + | _c.Call.Return(run) | |||
| 1293 | + | return _c | |||
| 1294 | + | } | |||
| 1295 | + | ||||
| 1296 | + | // Sync provides a mock function for the type MockStore | |||
| 1297 | + | func (_mock *MockStore) Sync() error { | |||
| 1298 | + | ret := _mock.Called() | |||
| 1299 | + | ||||
| 1300 | + | if len(ret) == 0 { | |||
| 1301 | + | panic("no return value specified for Sync") | |||
| 1302 | + | } | |||
| 1303 | + | ||||
| 1304 | + | var r0 error | |||
| 1305 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 1306 | + | r0 = returnFunc() | |||
| 1307 | + | } else { | |||
| 1308 | + | r0 = ret.Error(0) | |||
| 1309 | + | } | |||
| 1310 | + | return r0 | |||
| 1311 | + | } | |||
| 1312 | + | ||||
| 1313 | + | // MockStore_Sync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sync' | |||
| 1314 | + | type MockStore_Sync_Call struct { | |||
| 1315 | + | *mock.Call | |||
| 1316 | + | } | |||
| 1317 | + | ||||
| 1318 | + | // Sync is a helper method to define mock.On call | |||
| 1319 | + | func (_e *MockStore_Expecter) Sync() *MockStore_Sync_Call { | |||
| 1320 | + | return &MockStore_Sync_Call{Call: _e.mock.On("Sync")} | |||
| 1321 | + | } | |||
| 1322 | + | ||||
| 1323 | + | func (_c *MockStore_Sync_Call) Run(run func()) *MockStore_Sync_Call { | |||
| 1324 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1325 | + | run() | |||
| 1326 | + | }) | |||
| 1327 | + | return _c | |||
| 1328 | + | } | |||
| 1329 | + | ||||
| 1330 | + | func (_c *MockStore_Sync_Call) Return(err error) *MockStore_Sync_Call { | |||
| 1331 | + | _c.Call.Return(err) | |||
| 1332 | + | return _c | |||
| 1333 | + | } | |||
| 1334 | + | ||||
| 1335 | + | func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Call { | |||
| 1336 | + | _c.Call.Return(run) | |||
| 1337 | + | return _c | |||
| 1338 | + | } | |||
| 1339 | + | ||||
| 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. | 1340 | // 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. | 1341 | // The first argument is typically a *testing.T value. | ||
| 1079 | func NewMockClock(t interface { | 1342 | func NewMockClock(t interface { | ||
M internal/ports/ports.go +11 -0
| @@ -43,6 +43,17 @@ type Listener interface { | |||
| 43 | 43 | 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 | + | Set(key, val []byte) error | |
| 51 | + | Delete(key []byte) error | |
| 52 | + | Range(fn func(k, v []byte) bool) | |
| 53 | + | Sync() error | |
| 54 | + | Close() error | |
| 55 | + | } | |
| 56 | + | ||
| 46 | 57 | // Clock abstracts time so usecases can be tested without real delays. | |
| 47 | 58 | type Clock interface { | |
| 48 | 59 | Now() time.Time | |
| @@ -43,6 +43,17 @@ type Listener interface { | |||||
| 43 | Addr() string | 43 | 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 | + | Set(key, val []byte) error | |||
| 51 | + | Delete(key []byte) error | |||
| 52 | + | Range(fn func(k, v []byte) bool) | |||
| 53 | + | Sync() error | |||
| 54 | + | Close() error | |||
| 55 | + | } | |||
| 56 | + | ||||
| 46 | // Clock abstracts time so usecases can be tested without real delays. | 57 | // Clock abstracts time so usecases can be tested without real delays. | ||
| 47 | type Clock interface { | 58 | type Clock interface { | ||
| 48 | Now() time.Time | 59 | Now() time.Time | ||
M internal/ports/wire.go +11 -0
| @@ -126,6 +126,7 @@ type SessionInfo struct { | |||
| 126 | 126 | Ephemeral bool | |
| 127 | 127 | Tabs uint16 | |
| 128 | 128 | Attached bool | |
| 129 | + | Stopped bool | |
| 129 | 130 | } | |
| 130 | 131 | ||
| 131 | 132 | // Sessions is the daemon's reply to a List, enumerating live sessions. | |
| @@ -559,6 +560,11 @@ func MarshalSessions(m Sessions) []byte { | |||
| 559 | 560 | } else { | |
| 560 | 561 | w.putUint8(0) | |
| 561 | 562 | } | |
| 563 | + | if s.Stopped { | |
| 564 | + | w.putUint8(1) | |
| 565 | + | } else { | |
| 566 | + | w.putUint8(0) | |
| 567 | + | } | |
| 562 | 568 | } | |
| 563 | 569 | return w.b | |
| 564 | 570 | } | |
| @@ -592,6 +598,11 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||
| 592 | 598 | return Sessions{}, err | |
| 593 | 599 | } | |
| 594 | 600 | s.Attached = att != 0 | |
| 601 | + | stopped, err := r.getUint8() | |
| 602 | + | if err != nil { | |
| 603 | + | return Sessions{}, err | |
| 604 | + | } | |
| 605 | + | s.Stopped = stopped != 0 | |
| 595 | 606 | sessions = append(sessions, s) | |
| 596 | 607 | } | |
| 597 | 608 | if err := r.done(); err != nil { | |
| @@ -126,6 +126,7 @@ type SessionInfo struct { | |||||
| 126 | Ephemeral bool | 126 | Ephemeral bool | ||
| 127 | Tabs uint16 | 127 | Tabs uint16 | ||
| 128 | Attached bool | 128 | 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.b | 569 | return w.b | ||
| 564 | } | 570 | } | ||
| @@ -592,6 +598,11 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||||
| 592 | return Sessions{}, err | 598 | return Sessions{}, err | ||
| 593 | } | 599 | } | ||
| 594 | s.Attached = att != 0 | 600 | 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) { | |||
| 424 | 424 | name: "two", | |
| 425 | 425 | msg: Sessions{Sessions: []SessionInfo{ | |
| 426 | 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 | 429 | want: []byte{ | |
| 430 | 430 | 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, | |
| 433 | 433 | }, | |
| 434 | 434 | }, | |
| 435 | 435 | } | |
| @@ -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 +5 -2
| @@ -366,6 +366,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||
| 366 | 366 | return // already displaced by a newer client; nothing to do | |
| 367 | 367 | } | |
| 368 | 368 | d.unregisterPreview(ac) | |
| 369 | + | if !sess.ephemeral { | |
| 370 | + | d.refreshSessionCwd(sess) | |
| 371 | + | } | |
| 369 | 372 | d.resetScreenDefaultColors(sess) | |
| 370 | 373 | if explicit { | |
| 371 | 374 | // Synchronous so the ack is delivered before the transport closes | |
| @@ -376,7 +379,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||
| 376 | 379 | } | |
| 377 | 380 | _ = ac.tr.Close() | |
| 378 | 381 | if sess.ephemeral { | |
| 379 | - | d.killSession(sess, ports.ReasonSessionKilled) | |
| 382 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 380 | 383 | } | |
| 381 | 384 | } | |
| 382 | 385 | ||
| @@ -446,7 +449,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | |||
| 446 | 449 | _ = ac.tr.Close() | |
| 447 | 450 | d.log.Warn("detached client after send error", "session", sess.name) | |
| 448 | 451 | if sess.ephemeral { | |
| 449 | - | d.killSession(sess, ports.ReasonSessionKilled) | |
| 452 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 450 | 453 | } | |
| 451 | 454 | } | |
| 452 | 455 | } | |
| @@ -366,6 +366,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||||
| 366 | return // already displaced by a newer client; nothing to do | 366 | return // already displaced by a newer client; nothing to do | ||
| 367 | } | 367 | } | ||
| 368 | d.unregisterPreview(ac) | 368 | d.unregisterPreview(ac) | ||
| 369 | + | if !sess.ephemeral { | |||
| 370 | + | d.refreshSessionCwd(sess) | |||
| 371 | + | } | |||
| 369 | d.resetScreenDefaultColors(sess) | 372 | d.resetScreenDefaultColors(sess) | ||
| 370 | if explicit { | 373 | if explicit { | ||
| 371 | // Synchronous so the ack is delivered before the transport closes | 374 | // Synchronous so the ack is delivered before the transport closes | ||
| @@ -376,7 +379,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||||
| 376 | } | 379 | } | ||
| 377 | _ = ac.tr.Close() | 380 | _ = ac.tr.Close() | ||
| 378 | if sess.ephemeral { | 381 | if sess.ephemeral { | ||
| 379 | - | d.killSession(sess, ports.ReasonSessionKilled) | 382 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, false) |
| 380 | } | 383 | } | ||
| 381 | } | 384 | } | ||
| 382 | 385 | ||||
| @@ -446,7 +449,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | |||||
| 446 | _ = ac.tr.Close() | 449 | _ = ac.tr.Close() | ||
| 447 | d.log.Warn("detached client after send error", "session", sess.name) | 450 | d.log.Warn("detached client after send error", "session", sess.name) | ||
| 448 | if sess.ephemeral { | 451 | if sess.ephemeral { | ||
| 449 | - | d.killSession(sess, ports.ReasonSessionKilled) | 452 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, false) |
| 450 | } | 453 | } | ||
| 451 | } | 454 | } | ||
| 452 | } | 455 | } | ||
M internal/usecase/daemon/daemon.go +100 -12
| @@ -35,6 +35,8 @@ import ( | |||
| 35 | 35 | "time" | |
| 36 | 36 | ||
| 37 | 37 | "github.com/bnema/vev/internal/domain" | |
| 38 | + | "github.com/bnema/vev/internal/persist" | |
| 39 | + | "github.com/bnema/vev/internal/platform" | |
| 38 | 40 | "github.com/bnema/vev/internal/ports" | |
| 39 | 41 | ) | |
| 40 | 42 | ||
| @@ -67,6 +69,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24} | |||
| 67 | 69 | type Daemon struct { | |
| 68 | 70 | mu sync.Mutex | |
| 69 | 71 | sessions map[domain.SessionID]*session | |
| 72 | + | stopped map[string]stoppedSession | |
| 70 | 73 | nextID uint64 | |
| 71 | 74 | // closing marks that shutdown has irreversibly begun. It is set under mu, | |
| 72 | 75 | // atomically with the event that makes shutdown inevitable (the registry | |
| @@ -85,12 +88,15 @@ type Daemon struct { | |||
| 85 | 88 | animFrame int | |
| 86 | 89 | animWake chan struct{} | |
| 87 | 90 | ||
| 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) | |
| 94 | 100 | ||
| 95 | 101 | serveCtx context.Context | |
| 96 | 102 | serveCancel context.CancelFunc | |
| @@ -110,6 +116,12 @@ type Daemon struct { | |||
| 110 | 116 | ||
| 111 | 117 | // 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 | + | } | |
| 124 | + | ||
| 113 | 125 | type Option func(*Daemon) | |
| 114 | 126 | ||
| 115 | 127 | // WithShell overrides the command (and its args) each session spawns. The | |
| @@ -122,6 +134,24 @@ func WithShell(cmd string, args []string) Option { | |||
| 122 | 134 | } | |
| 123 | 135 | } | |
| 124 | 136 | ||
| 137 | + | // WithStore enables persisted named session metadata. A nil store keeps the | |
| 138 | + | // daemon in no-op persistence mode. | |
| 139 | + | func WithStore(store ports.Store) Option { | |
| 140 | + | return func(d *Daemon) { | |
| 141 | + | d.persist = persist.New(store) | |
| 142 | + | d.persistEnabled = store != nil | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // WithCwdReader overrides the process cwd reader used for persistence tests. | |
| 147 | + | func WithCwdReader(fn func(int) (string, error)) Option { | |
| 148 | + | return func(d *Daemon) { | |
| 149 | + | if fn != nil { | |
| 150 | + | d.procCwd = fn | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 125 | 155 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | |
| 126 | 156 | // render debounce, and log receives diagnostics (defaults to slog.Default). | |
| 127 | 157 | func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon { | |
| @@ -134,17 +164,33 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 134 | 164 | } | |
| 135 | 165 | d := &Daemon{ | |
| 136 | 166 | sessions: make(map[domain.SessionID]*session), | |
| 167 | + | stopped: make(map[string]stoppedSession), | |
| 137 | 168 | ptys: ptys, | |
| 138 | 169 | clock: clock, | |
| 139 | 170 | log: log, | |
| 140 | 171 | baseEnv: os.Environ(), | |
| 141 | 172 | shell: shell, | |
| 173 | + | persist: persist.New(nil), | |
| 174 | + | procCwd: platform.ProcessCwd, | |
| 142 | 175 | done: make(chan struct{}), | |
| 143 | 176 | animWake: make(chan struct{}, 1), | |
| 144 | 177 | } | |
| 145 | 178 | for _, o := range opts { | |
| 146 | 179 | o(d) | |
| 147 | 180 | } | |
| 181 | + | if d.persist == nil { | |
| 182 | + | d.persist = persist.New(nil) | |
| 183 | + | } | |
| 184 | + | if d.procCwd == nil { | |
| 185 | + | d.procCwd = platform.ProcessCwd | |
| 186 | + | } | |
| 187 | + | if records, err := d.persist.LoadAll(); err != nil { | |
| 188 | + | d.log.Warn("loading persisted sessions failed", "err", err) | |
| 189 | + | } else { | |
| 190 | + | for _, r := range records { | |
| 191 | + | d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt} | |
| 192 | + | } | |
| 193 | + | } | |
| 148 | 194 | return d | |
| 149 | 195 | } | |
| 150 | 196 | ||
| @@ -160,6 +206,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 160 | 206 | d.sessWg.Go(func() { | |
| 161 | 207 | d.attentionAnimator(d.serveCtx) | |
| 162 | 208 | }) | |
| 209 | + | d.sessWg.Go(func() { | |
| 210 | + | d.cwdSampler(d.serveCtx) | |
| 211 | + | }) | |
| 163 | 212 | ||
| 164 | 213 | // Break the accept loop when either the parent context is cancelled or the | |
| 165 | 214 | // registry drains to empty: both close the listener, which fails Accept. | |
| @@ -198,6 +247,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 198 | 247 | d.shutdownAll(ports.ReasonServerShutdown) | |
| 199 | 248 | d.sessWg.Wait() | |
| 200 | 249 | d.waitNotifies() | |
| 250 | + | if err := d.persist.Close(); err != nil { | |
| 251 | + | d.log.Warn("closing session persister failed", "err", err) | |
| 252 | + | } | |
| 201 | 253 | return nil | |
| 202 | 254 | } | |
| 203 | 255 | ||
| @@ -216,7 +268,7 @@ func (d *Daemon) shutdownAll(reason uint8) { | |||
| 216 | 268 | return | |
| 217 | 269 | } | |
| 218 | 270 | for _, s := range snapshot { | |
| 219 | - | d.killSession(s, reason) | |
| 271 | + | _ = d.killSession(s, reason, false) | |
| 220 | 272 | } | |
| 221 | 273 | } | |
| 222 | 274 | ||
| @@ -259,7 +311,8 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 259 | 311 | defer func() { _ = tr.Close() }() | |
| 260 | 312 | ||
| 261 | 313 | d.mu.Lock() | |
| 262 | - | infos := make([]ports.SessionInfo, 0, len(d.sessions)) | |
| 314 | + | infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped)) | |
| 315 | + | liveNames := make(map[string]struct{}, len(d.sessions)) | |
| 263 | 316 | for _, s := range d.sessions { | |
| 264 | 317 | s.mu.Lock() | |
| 265 | 318 | info := ports.SessionInfo{ | |
| @@ -269,9 +322,16 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 269 | 322 | Tabs: uint16(len(s.tabs)), | |
| 270 | 323 | Attached: s.client != nil, | |
| 271 | 324 | } | |
| 325 | + | liveNames[s.name] = struct{}{} | |
| 272 | 326 | s.mu.Unlock() | |
| 273 | 327 | infos = append(infos, info) | |
| 274 | 328 | } | |
| 329 | + | for name := range d.stopped { | |
| 330 | + | if _, live := liveNames[name]; live { | |
| 331 | + | continue | |
| 332 | + | } | |
| 333 | + | infos = append(infos, ports.SessionInfo{Name: name, Stopped: true}) | |
| 334 | + | } | |
| 275 | 335 | d.mu.Unlock() | |
| 276 | 336 | ||
| 277 | 337 | sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name }) | |
| @@ -296,13 +356,31 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||
| 296 | 356 | ||
| 297 | 357 | d.mu.Lock() | |
| 298 | 358 | target := d.findByNameLocked(k.Name) | |
| 359 | + | if target == nil { | |
| 360 | + | if stopped, ok := d.stopped[k.Name]; ok { | |
| 361 | + | d.mu.Unlock() | |
| 362 | + | if err := d.persist.Delete(k.Name); err != nil { | |
| 363 | + | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name) | |
| 364 | + | _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed")) | |
| 365 | + | return | |
| 366 | + | } | |
| 367 | + | d.mu.Lock() | |
| 368 | + | if cur, ok := d.stopped[k.Name]; ok && cur == stopped { | |
| 369 | + | delete(d.stopped, k.Name) | |
| 370 | + | } | |
| 371 | + | d.mu.Unlock() | |
| 372 | + | return | |
| 373 | + | } | |
| 374 | + | } | |
| 299 | 375 | d.mu.Unlock() | |
| 300 | 376 | ||
| 301 | 377 | if target == nil { | |
| 302 | 378 | _ = tr.Send(frameError(ports.ErrNoSuchSession, "no such session: "+k.Name)) | |
| 303 | 379 | return | |
| 304 | 380 | } | |
| 305 | - | d.killSession(target, ports.ReasonSessionKilled) | |
| 381 | + | if err := d.killSession(target, ports.ReasonSessionKilled, true); err != nil { | |
| 382 | + | _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted session failed")) | |
| 383 | + | } | |
| 306 | 384 | } | |
| 307 | 385 | ||
| 308 | 386 | // handleHello runs the attach handshake: version check, intent routing, | |
| @@ -387,7 +465,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 387 | 465 | d.mu.Unlock() | |
| 388 | 466 | return nil, nil, &protoErr{ports.ErrInternal, "empty session name"} | |
| 389 | 467 | } | |
| 390 | - | if d.nameTakenLocked(h.Name) { | |
| 468 | + | if d.nameLiveOrStoppedLocked(h.Name) { | |
| 391 | 469 | d.mu.Unlock() | |
| 392 | 470 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | |
| 393 | 471 | } | |
| @@ -404,8 +482,18 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 404 | 482 | case ports.IntentAttach: | |
| 405 | 483 | sess := d.findByNameLocked(h.Name) | |
| 406 | 484 | if sess == nil { | |
| 407 | - | d.mu.Unlock() | |
| 408 | - | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | |
| 485 | + | stopped, ok := d.stopped[h.Name] | |
| 486 | + | if !ok { | |
| 487 | + | d.mu.Unlock() | |
| 488 | + | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | |
| 489 | + | } | |
| 490 | + | cwd := platform.DirOrHome(stopped.cwd) | |
| 491 | + | var err error | |
| 492 | + | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | |
| 493 | + | if err != nil { | |
| 494 | + | d.mu.Unlock() | |
| 495 | + | return nil, nil, err | |
| 496 | + | } | |
| 409 | 497 | } | |
| 410 | 498 | ac, old := d.attachClient(sess, tr, sz) | |
| 411 | 499 | 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.Mutex | 70 | mu sync.Mutex | ||
| 69 | sessions map[domain.SessionID]*session | 71 | sessions map[domain.SessionID]*session | ||
| 72 | + | stopped map[string]stoppedSession | |||
| 70 | nextID uint64 | 73 | 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 registry | 75 | // atomically with the event that makes shutdown inevitable (the registry | ||
| @@ -85,12 +88,15 @@ type Daemon struct { | |||||
| 85 | animFrame int | 88 | animFrame int | ||
| 86 | animWake chan struct{} | 89 | animWake chan struct{} | ||
| 87 | 90 | ||||
| 88 | - | ptys ports.PTYFactory | 91 | + | ptys ports.PTYFactory |
| 89 | - | clock ports.Clock | 92 | + | clock ports.Clock |
| 90 | - | log *slog.Logger | 93 | + | log *slog.Logger |
| 91 | - | baseEnv []string | 94 | + | baseEnv []string |
| 92 | - | shell string | 95 | + | shell string |
| 93 | - | shellArgs []string | 96 | + | shellArgs []string |
| 97 | + | persist *persist.Persister | |||
| 98 | + | persistEnabled bool | |||
| 99 | + | procCwd func(int) (string, error) | |||
| 94 | 100 | ||||
| 95 | serveCtx context.Context | 101 | serveCtx context.Context | ||
| 96 | serveCancel context.CancelFunc | 102 | serveCancel context.CancelFunc | ||
| @@ -110,6 +116,12 @@ type Daemon struct { | |||||
| 110 | 116 | ||||
| 111 | // session is a single multiplexed session. It owns one or more full-screen | 117 | // 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 | + | } | |||
| 124 | + | ||||
| 113 | type Option func(*Daemon) | 125 | type Option func(*Daemon) | ||
| 114 | 126 | ||||
| 115 | // WithShell overrides the command (and its args) each session spawns. The | 127 | // WithShell overrides the command (and its args) each session spawns. The | ||
| @@ -122,6 +134,24 @@ func WithShell(cmd string, args []string) Option { | |||||
| 122 | } | 134 | } | ||
| 123 | } | 135 | } | ||
| 124 | 136 | ||||
| 137 | + | // WithStore enables persisted named session metadata. A nil store keeps the | |||
| 138 | + | // daemon in no-op persistence mode. | |||
| 139 | + | func WithStore(store ports.Store) Option { | |||
| 140 | + | return func(d *Daemon) { | |||
| 141 | + | d.persist = persist.New(store) | |||
| 142 | + | d.persistEnabled = store != nil | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // WithCwdReader overrides the process cwd reader used for persistence tests. | |||
| 147 | + | func WithCwdReader(fn func(int) (string, error)) Option { | |||
| 148 | + | return func(d *Daemon) { | |||
| 149 | + | if fn != nil { | |||
| 150 | + | d.procCwd = fn | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 125 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | 155 | // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the | ||
| 126 | // render debounce, and log receives diagnostics (defaults to slog.Default). | 156 | // 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 { | 157 | func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon { | ||
| @@ -134,17 +164,33 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 134 | } | 164 | } | ||
| 135 | d := &Daemon{ | 165 | d := &Daemon{ | ||
| 136 | sessions: make(map[domain.SessionID]*session), | 166 | sessions: make(map[domain.SessionID]*session), | ||
| 167 | + | stopped: make(map[string]stoppedSession), | |||
| 137 | ptys: ptys, | 168 | ptys: ptys, | ||
| 138 | clock: clock, | 169 | clock: clock, | ||
| 139 | log: log, | 170 | log: log, | ||
| 140 | baseEnv: os.Environ(), | 171 | baseEnv: os.Environ(), | ||
| 141 | shell: shell, | 172 | shell: shell, | ||
| 173 | + | persist: persist.New(nil), | |||
| 174 | + | procCwd: platform.ProcessCwd, | |||
| 142 | done: make(chan struct{}), | 175 | done: make(chan struct{}), | ||
| 143 | animWake: make(chan struct{}, 1), | 176 | animWake: make(chan struct{}, 1), | ||
| 144 | } | 177 | } | ||
| 145 | for _, o := range opts { | 178 | for _, o := range opts { | ||
| 146 | o(d) | 179 | o(d) | ||
| 147 | } | 180 | } | ||
| 181 | + | if d.persist == nil { | |||
| 182 | + | d.persist = persist.New(nil) | |||
| 183 | + | } | |||
| 184 | + | if d.procCwd == nil { | |||
| 185 | + | d.procCwd = platform.ProcessCwd | |||
| 186 | + | } | |||
| 187 | + | if records, err := d.persist.LoadAll(); err != nil { | |||
| 188 | + | d.log.Warn("loading persisted sessions failed", "err", err) | |||
| 189 | + | } else { | |||
| 190 | + | for _, r := range records { | |||
| 191 | + | d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt} | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 148 | return d | 194 | return d | ||
| 149 | } | 195 | } | ||
| 150 | 196 | ||||
| @@ -160,6 +206,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 160 | d.sessWg.Go(func() { | 206 | d.sessWg.Go(func() { | ||
| 161 | d.attentionAnimator(d.serveCtx) | 207 | d.attentionAnimator(d.serveCtx) | ||
| 162 | }) | 208 | }) | ||
| 209 | + | d.sessWg.Go(func() { | |||
| 210 | + | d.cwdSampler(d.serveCtx) | |||
| 211 | + | }) | |||
| 163 | 212 | ||||
| 164 | // Break the accept loop when either the parent context is cancelled or the | 213 | // 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. | 214 | // registry drains to empty: both close the listener, which fails Accept. | ||
| @@ -198,6 +247,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 198 | d.shutdownAll(ports.ReasonServerShutdown) | 247 | d.shutdownAll(ports.ReasonServerShutdown) | ||
| 199 | d.sessWg.Wait() | 248 | d.sessWg.Wait() | ||
| 200 | d.waitNotifies() | 249 | d.waitNotifies() | ||
| 250 | + | if err := d.persist.Close(); err != nil { | |||
| 251 | + | d.log.Warn("closing session persister failed", "err", err) | |||
| 252 | + | } | |||
| 201 | return nil | 253 | return nil | ||
| 202 | } | 254 | } | ||
| 203 | 255 | ||||
| @@ -216,7 +268,7 @@ func (d *Daemon) shutdownAll(reason uint8) { | |||||
| 216 | return | 268 | return | ||
| 217 | } | 269 | } | ||
| 218 | for _, s := range snapshot { | 270 | for _, s := range snapshot { | ||
| 219 | - | d.killSession(s, reason) | 271 | + | _ = d.killSession(s, reason, false) |
| 220 | } | 272 | } | ||
| 221 | } | 273 | } | ||
| 222 | 274 | ||||
| @@ -259,7 +311,8 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||||
| 259 | defer func() { _ = tr.Close() }() | 311 | defer func() { _ = tr.Close() }() | ||
| 260 | 312 | ||||
| 261 | d.mu.Lock() | 313 | d.mu.Lock() | ||
| 262 | - | infos := make([]ports.SessionInfo, 0, len(d.sessions)) | 314 | + | infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped)) |
| 315 | + | liveNames := make(map[string]struct{}, len(d.sessions)) | |||
| 263 | for _, s := range d.sessions { | 316 | for _, s := range d.sessions { | ||
| 264 | s.mu.Lock() | 317 | s.mu.Lock() | ||
| 265 | info := ports.SessionInfo{ | 318 | info := ports.SessionInfo{ | ||
| @@ -269,9 +322,16 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||||
| 269 | Tabs: uint16(len(s.tabs)), | 322 | Tabs: uint16(len(s.tabs)), | ||
| 270 | Attached: s.client != nil, | 323 | Attached: s.client != nil, | ||
| 271 | } | 324 | } | ||
| 325 | + | liveNames[s.name] = struct{}{} | |||
| 272 | s.mu.Unlock() | 326 | s.mu.Unlock() | ||
| 273 | infos = append(infos, info) | 327 | infos = append(infos, info) | ||
| 274 | } | 328 | } | ||
| 329 | + | for name := range d.stopped { | |||
| 330 | + | if _, live := liveNames[name]; live { | |||
| 331 | + | continue | |||
| 332 | + | } | |||
| 333 | + | infos = append(infos, ports.SessionInfo{Name: name, Stopped: true}) | |||
| 334 | + | } | |||
| 275 | d.mu.Unlock() | 335 | d.mu.Unlock() | ||
| 276 | 336 | ||||
| 277 | sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name }) | 337 | sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name }) | ||
| @@ -296,13 +356,31 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||||
| 296 | 356 | ||||
| 297 | d.mu.Lock() | 357 | d.mu.Lock() | ||
| 298 | target := d.findByNameLocked(k.Name) | 358 | target := d.findByNameLocked(k.Name) | ||
| 359 | + | if target == nil { | |||
| 360 | + | if stopped, ok := d.stopped[k.Name]; ok { | |||
| 361 | + | d.mu.Unlock() | |||
| 362 | + | if err := d.persist.Delete(k.Name); err != nil { | |||
| 363 | + | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name) | |||
| 364 | + | _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed")) | |||
| 365 | + | return | |||
| 366 | + | } | |||
| 367 | + | d.mu.Lock() | |||
| 368 | + | if cur, ok := d.stopped[k.Name]; ok && cur == stopped { | |||
| 369 | + | delete(d.stopped, k.Name) | |||
| 370 | + | } | |||
| 371 | + | d.mu.Unlock() | |||
| 372 | + | return | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 299 | d.mu.Unlock() | 375 | d.mu.Unlock() | ||
| 300 | 376 | ||||
| 301 | if target == nil { | 377 | if target == nil { | ||
| 302 | _ = tr.Send(frameError(ports.ErrNoSuchSession, "no such session: "+k.Name)) | 378 | _ = tr.Send(frameError(ports.ErrNoSuchSession, "no such session: "+k.Name)) | ||
| 303 | return | 379 | return | ||
| 304 | } | 380 | } | ||
| 305 | - | d.killSession(target, ports.ReasonSessionKilled) | 381 | + | if err := d.killSession(target, ports.ReasonSessionKilled, true); err != nil { |
| 382 | + | _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted session failed")) | |||
| 383 | + | } | |||
| 306 | } | 384 | } | ||
| 307 | 385 | ||||
| 308 | // handleHello runs the attach handshake: version check, intent routing, | 386 | // handleHello runs the attach handshake: version check, intent routing, | ||
| @@ -387,7 +465,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 387 | d.mu.Unlock() | 465 | d.mu.Unlock() | ||
| 388 | return nil, nil, &protoErr{ports.ErrInternal, "empty session name"} | 466 | return nil, nil, &protoErr{ports.ErrInternal, "empty session name"} | ||
| 389 | } | 467 | } | ||
| 390 | - | if d.nameTakenLocked(h.Name) { | 468 | + | if d.nameLiveOrStoppedLocked(h.Name) { |
| 391 | d.mu.Unlock() | 469 | d.mu.Unlock() | ||
| 392 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | 470 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | ||
| 393 | } | 471 | } | ||
| @@ -404,8 +482,18 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 404 | case ports.IntentAttach: | 482 | case ports.IntentAttach: | ||
| 405 | sess := d.findByNameLocked(h.Name) | 483 | sess := d.findByNameLocked(h.Name) | ||
| 406 | if sess == nil { | 484 | if sess == nil { | ||
| 407 | - | d.mu.Unlock() | 485 | + | stopped, ok := d.stopped[h.Name] |
| 408 | - | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | 486 | + | if !ok { |
| 487 | + | d.mu.Unlock() | |||
| 488 | + | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | |||
| 489 | + | } | |||
| 490 | + | cwd := platform.DirOrHome(stopped.cwd) | |||
| 491 | + | var err error | |||
| 492 | + | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | |||
| 493 | + | if err != nil { | |||
| 494 | + | d.mu.Unlock() | |||
| 495 | + | return nil, nil, err | |||
| 496 | + | } | |||
| 409 | } | 497 | } | ||
| 410 | ac, old := d.attachClient(sess, tr, sz) | 498 | ac, old := d.attachClient(sess, tr, sz) | ||
| 411 | d.mu.Unlock() | 499 | d.mu.Unlock() | ||
M internal/usecase/daemon/picker.go +102 -1
| @@ -30,6 +30,7 @@ import ( | |||
| 30 | 30 | "strconv" | |
| 31 | 31 | ||
| 32 | 32 | "github.com/bnema/vev/internal/domain" | |
| 33 | + | "github.com/bnema/vev/internal/platform" | |
| 33 | 34 | "github.com/bnema/vev/internal/ports" | |
| 34 | 35 | "github.com/bnema/vev/internal/usecase/keys" | |
| 35 | 36 | "github.com/bnema/vev/internal/usecase/picker" | |
| @@ -53,10 +54,15 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | |||
| 53 | 54 | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |
| 54 | 55 | d.mu.Lock() | |
| 55 | 56 | sessions := d.sessionsSnapshotLocked() | |
| 57 | + | stopped := make([]stoppedSession, 0, len(d.stopped)) | |
| 58 | + | for _, s := range d.stopped { | |
| 59 | + | stopped = append(stopped, s) | |
| 60 | + | } | |
| 56 | 61 | d.mu.Unlock() | |
| 57 | 62 | sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name }) | |
| 63 | + | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | |
| 58 | 64 | ||
| 59 | - | views := make([]picker.SessionView, 0, len(sessions)) | |
| 65 | + | views := make([]picker.SessionView, 0, len(sessions)+len(stopped)) | |
| 60 | 66 | curTab := 0 | |
| 61 | 67 | for _, s := range sessions { | |
| 62 | 68 | s.mu.Lock() | |
| @@ -79,6 +85,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 79 | 85 | s.mu.Unlock() | |
| 80 | 86 | views = append(views, view) | |
| 81 | 87 | } | |
| 88 | + | for _, s := range stopped { | |
| 89 | + | views = append(views, picker.SessionView{ | |
| 90 | + | ID: domain.SessionID("stopped:" + s.name), | |
| 91 | + | Name: s.name, | |
| 92 | + | Tabs: []string{"(stopped)"}, | |
| 93 | + | Stopped: true, | |
| 94 | + | }) | |
| 95 | + | } | |
| 82 | 96 | return views, curTab | |
| 83 | 97 | } | |
| 84 | 98 | ||
| @@ -111,6 +125,15 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||
| 111 | 125 | switchTarget := false | |
| 112 | 126 | for i := 0; i < len(data); i++ { | |
| 113 | 127 | switch data[i] { | |
| 128 | + | case 'x': | |
| 129 | + | target, ok := ac.picker.Selected() | |
| 130 | + | ac.pickerMu.Unlock() | |
| 131 | + | if ok { | |
| 132 | + | d.killPickerTarget(target) | |
| 133 | + | } | |
| 134 | + | d.refreshPicker(ac) | |
| 135 | + | d.paint(sess, ac, true) | |
| 136 | + | return | |
| 114 | 137 | case 'j': | |
| 115 | 138 | ac.picker.Down() | |
| 116 | 139 | changed = true | |
| @@ -325,6 +348,10 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 325 | 348 | } | |
| 326 | 349 | ||
| 327 | 350 | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) { | |
| 351 | + | if target.Stopped { | |
| 352 | + | d.resumeStoppedAndSwitch(sess, ac, target) | |
| 353 | + | return | |
| 354 | + | } | |
| 328 | 355 | targetSess := d.sessionByID(target.Session) | |
| 329 | 356 | if targetSess == nil { | |
| 330 | 357 | d.paint(sess, ac, true) | |
| @@ -376,6 +403,80 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 376 | 403 | return old | |
| 377 | 404 | } | |
| 378 | 405 | ||
| 406 | + | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) { | |
| 407 | + | d.mu.Lock() | |
| 408 | + | stopped, ok := d.stopped[target.Name] | |
| 409 | + | if !ok { | |
| 410 | + | d.mu.Unlock() | |
| 411 | + | d.paint(from, ac, true) | |
| 412 | + | return | |
| 413 | + | } | |
| 414 | + | from.mu.Lock() | |
| 415 | + | if from.client != ac { | |
| 416 | + | from.mu.Unlock() | |
| 417 | + | d.mu.Unlock() | |
| 418 | + | return | |
| 419 | + | } | |
| 420 | + | cwd := platform.DirOrHome(stopped.cwd) | |
| 421 | + | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size) | |
| 422 | + | if err != nil { | |
| 423 | + | from.mu.Unlock() | |
| 424 | + | d.mu.Unlock() | |
| 425 | + | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | |
| 426 | + | d.paint(from, ac, true) | |
| 427 | + | return | |
| 428 | + | } | |
| 429 | + | from.client = nil | |
| 430 | + | ac.setSession(nil) | |
| 431 | + | from.mu.Unlock() | |
| 432 | + | targetSess.mu.Lock() | |
| 433 | + | targetSess.client = ac | |
| 434 | + | targetSess.mu.Unlock() | |
| 435 | + | ac.setSession(targetSess) | |
| 436 | + | d.mu.Unlock() | |
| 437 | + | d.firstPaint(targetSess, ac, ac.size) | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | func (d *Daemon) killPickerTarget(target picker.Target) { | |
| 441 | + | if target.Stopped { | |
| 442 | + | d.mu.Lock() | |
| 443 | + | stopped, ok := d.stopped[target.Name] | |
| 444 | + | d.mu.Unlock() | |
| 445 | + | if ok { | |
| 446 | + | if err := d.persist.Delete(target.Name); err != nil { | |
| 447 | + | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | |
| 448 | + | return | |
| 449 | + | } | |
| 450 | + | d.mu.Lock() | |
| 451 | + | if cur, ok := d.stopped[target.Name]; ok && cur == stopped { | |
| 452 | + | delete(d.stopped, target.Name) | |
| 453 | + | } | |
| 454 | + | d.mu.Unlock() | |
| 455 | + | } | |
| 456 | + | return | |
| 457 | + | } | |
| 458 | + | d.mu.Lock() | |
| 459 | + | targetSess := d.sessions[target.Session] | |
| 460 | + | d.mu.Unlock() | |
| 461 | + | if targetSess != nil { | |
| 462 | + | _ = d.killSession(targetSess, ports.ReasonSessionKilled, true) | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | func (d *Daemon) refreshPicker(ac *attachedClient) { | |
| 467 | + | sess := ac.currentSession() | |
| 468 | + | if sess == nil { | |
| 469 | + | return | |
| 470 | + | } | |
| 471 | + | views, curTab := d.pickerViews(sess) | |
| 472 | + | ac.pickerMu.Lock() | |
| 473 | + | if ac.picker != nil { | |
| 474 | + | ac.picker = picker.New(views, sess.id, curTab) | |
| 475 | + | } | |
| 476 | + | ac.pickerMu.Unlock() | |
| 477 | + | d.registerPreviewForSelection(ac) | |
| 478 | + | } | |
| 479 | + | ||
| 379 | 480 | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | |
| 380 | 481 | styleSet := resolveThemeStyles(styles) | |
| 381 | 482 | 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,15 @@ 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 | + | stopped = append(stopped, s) | |||
| 60 | + | } | |||
| 56 | d.mu.Unlock() | 61 | d.mu.Unlock() | ||
| 57 | sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name }) | 62 | sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name }) | ||
| 63 | + | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | |||
| 58 | 64 | ||||
| 59 | - | views := make([]picker.SessionView, 0, len(sessions)) | 65 | + | views := make([]picker.SessionView, 0, len(sessions)+len(stopped)) |
| 60 | curTab := 0 | 66 | curTab := 0 | ||
| 61 | for _, s := range sessions { | 67 | for _, s := range sessions { | ||
| 62 | s.mu.Lock() | 68 | s.mu.Lock() | ||
| @@ -79,6 +85,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 79 | s.mu.Unlock() | 85 | s.mu.Unlock() | ||
| 80 | views = append(views, view) | 86 | views = append(views, view) | ||
| 81 | } | 87 | } | ||
| 88 | + | for _, s := range stopped { | |||
| 89 | + | views = append(views, picker.SessionView{ | |||
| 90 | + | ID: domain.SessionID("stopped:" + s.name), | |||
| 91 | + | Name: s.name, | |||
| 92 | + | Tabs: []string{"(stopped)"}, | |||
| 93 | + | Stopped: true, | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 82 | return views, curTab | 96 | return views, curTab | ||
| 83 | } | 97 | } | ||
| 84 | 98 | ||||
| @@ -111,6 +125,15 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||||
| 111 | switchTarget := false | 125 | switchTarget := false | ||
| 112 | for i := 0; i < len(data); i++ { | 126 | for i := 0; i < len(data); i++ { | ||
| 113 | switch data[i] { | 127 | switch data[i] { | ||
| 128 | + | case 'x': | |||
| 129 | + | target, ok := ac.picker.Selected() | |||
| 130 | + | ac.pickerMu.Unlock() | |||
| 131 | + | if ok { | |||
| 132 | + | d.killPickerTarget(target) | |||
| 133 | + | } | |||
| 134 | + | d.refreshPicker(ac) | |||
| 135 | + | d.paint(sess, ac, true) | |||
| 136 | + | return | |||
| 114 | case 'j': | 137 | case 'j': | ||
| 115 | ac.picker.Down() | 138 | ac.picker.Down() | ||
| 116 | changed = true | 139 | changed = true | ||
| @@ -325,6 +348,10 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||||
| 325 | } | 348 | } | ||
| 326 | 349 | ||||
| 327 | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) { | 350 | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) { | ||
| 351 | + | if target.Stopped { | |||
| 352 | + | d.resumeStoppedAndSwitch(sess, ac, target) | |||
| 353 | + | return | |||
| 354 | + | } | |||
| 328 | targetSess := d.sessionByID(target.Session) | 355 | targetSess := d.sessionByID(target.Session) | ||
| 329 | if targetSess == nil { | 356 | if targetSess == nil { | ||
| 330 | d.paint(sess, ac, true) | 357 | d.paint(sess, ac, true) | ||
| @@ -376,6 +403,80 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 376 | return old | 403 | return old | ||
| 377 | } | 404 | } | ||
| 378 | 405 | ||||
| 406 | + | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) { | |||
| 407 | + | d.mu.Lock() | |||
| 408 | + | stopped, ok := d.stopped[target.Name] | |||
| 409 | + | if !ok { | |||
| 410 | + | d.mu.Unlock() | |||
| 411 | + | d.paint(from, ac, true) | |||
| 412 | + | return | |||
| 413 | + | } | |||
| 414 | + | from.mu.Lock() | |||
| 415 | + | if from.client != ac { | |||
| 416 | + | from.mu.Unlock() | |||
| 417 | + | d.mu.Unlock() | |||
| 418 | + | return | |||
| 419 | + | } | |||
| 420 | + | cwd := platform.DirOrHome(stopped.cwd) | |||
| 421 | + | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size) | |||
| 422 | + | if err != nil { | |||
| 423 | + | from.mu.Unlock() | |||
| 424 | + | d.mu.Unlock() | |||
| 425 | + | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | |||
| 426 | + | d.paint(from, ac, true) | |||
| 427 | + | return | |||
| 428 | + | } | |||
| 429 | + | from.client = nil | |||
| 430 | + | ac.setSession(nil) | |||
| 431 | + | from.mu.Unlock() | |||
| 432 | + | targetSess.mu.Lock() | |||
| 433 | + | targetSess.client = ac | |||
| 434 | + | targetSess.mu.Unlock() | |||
| 435 | + | ac.setSession(targetSess) | |||
| 436 | + | d.mu.Unlock() | |||
| 437 | + | d.firstPaint(targetSess, ac, ac.size) | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | func (d *Daemon) killPickerTarget(target picker.Target) { | |||
| 441 | + | if target.Stopped { | |||
| 442 | + | d.mu.Lock() | |||
| 443 | + | stopped, ok := d.stopped[target.Name] | |||
| 444 | + | d.mu.Unlock() | |||
| 445 | + | if ok { | |||
| 446 | + | if err := d.persist.Delete(target.Name); err != nil { | |||
| 447 | + | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | |||
| 448 | + | return | |||
| 449 | + | } | |||
| 450 | + | d.mu.Lock() | |||
| 451 | + | if cur, ok := d.stopped[target.Name]; ok && cur == stopped { | |||
| 452 | + | delete(d.stopped, target.Name) | |||
| 453 | + | } | |||
| 454 | + | d.mu.Unlock() | |||
| 455 | + | } | |||
| 456 | + | return | |||
| 457 | + | } | |||
| 458 | + | d.mu.Lock() | |||
| 459 | + | targetSess := d.sessions[target.Session] | |||
| 460 | + | d.mu.Unlock() | |||
| 461 | + | if targetSess != nil { | |||
| 462 | + | _ = d.killSession(targetSess, ports.ReasonSessionKilled, true) | |||
| 463 | + | } | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | func (d *Daemon) refreshPicker(ac *attachedClient) { | |||
| 467 | + | sess := ac.currentSession() | |||
| 468 | + | if sess == nil { | |||
| 469 | + | return | |||
| 470 | + | } | |||
| 471 | + | views, curTab := d.pickerViews(sess) | |||
| 472 | + | ac.pickerMu.Lock() | |||
| 473 | + | if ac.picker != nil { | |||
| 474 | + | ac.picker = picker.New(views, sess.id, curTab) | |||
| 475 | + | } | |||
| 476 | + | ac.pickerMu.Unlock() | |||
| 477 | + | d.registerPreviewForSelection(ac) | |||
| 478 | + | } | |||
| 479 | + | ||||
| 379 | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | 480 | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) { | ||
| 380 | styleSet := resolveThemeStyles(styles) | 481 | styleSet := resolveThemeStyles(styles) | ||
| 381 | inner := pickerModal.Composite(base, styleSet.border) | 482 | inner := pickerModal.Composite(base, styleSet.border) | ||
M internal/usecase/daemon/session.go +150 -12
| @@ -34,6 +34,7 @@ import ( | |||
| 34 | 34 | "time" | |
| 35 | 35 | ||
| 36 | 36 | "github.com/bnema/vev/internal/domain" | |
| 37 | + | "github.com/bnema/vev/internal/persist" | |
| 37 | 38 | "github.com/bnema/vev/internal/ports" | |
| 38 | 39 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 39 | 40 | "github.com/bnema/vev/pkg/vt" | |
| @@ -47,11 +48,12 @@ type session struct { | |||
| 47 | 48 | ctx context.Context | |
| 48 | 49 | cancel context.CancelFunc | |
| 49 | 50 | ||
| 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 | |
| 55 | 57 | } | |
| 56 | 58 | ||
| 57 | 59 | // 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 | 93 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 92 | 94 | d.nextID++ | |
| 95 | + | createdAt := time.Now().UnixNano() | |
| 93 | 96 | ||
| 94 | 97 | tb := newTab(pty, tbSize) | |
| 95 | 98 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| @@ -102,6 +105,15 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 102 | 105 | cancel: cancel, | |
| 103 | 106 | tabs: []*tab{tb}, | |
| 104 | 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 | 118 | d.sessions[id] = sess | |
| 107 | 119 | d.startTabGoroutines(sess, tb) | |
| @@ -118,7 +130,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 118 | 130 | d.mu.Unlock() | |
| 119 | 131 | return errors.New("daemon is shutting down") | |
| 120 | 132 | } | |
| 121 | - | if d.nameTakenLocked(name) { | |
| 133 | + | if d.nameLiveOrStoppedLocked(name) { | |
| 122 | 134 | d.mu.Unlock() | |
| 123 | 135 | return errors.New("name already in use") | |
| 124 | 136 | } | |
| @@ -140,7 +152,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 140 | 152 | if from.client != ac { | |
| 141 | 153 | from.mu.Unlock() | |
| 142 | 154 | d.mu.Unlock() | |
| 143 | - | d.killSession(newSess, ports.ReasonSessionKilled) | |
| 155 | + | _ = d.killSession(newSess, ports.ReasonSessionKilled, true) | |
| 144 | 156 | return errors.New("client detached") | |
| 145 | 157 | } | |
| 146 | 158 | from.client = nil | |
| @@ -269,8 +281,30 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 269 | 281 | if taken := d.findByNameLocked(name); taken != nil && taken != sess { | |
| 270 | 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 | 287 | sess.mu.Lock() | |
| 273 | 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.Delete(oldName); err != nil { | |
| 298 | + | return err | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | if wasEphemeral || oldName != name { | |
| 302 | + | if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano()}); err != nil { | |
| 303 | + | return err | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | delete(d.stopped, oldName) | |
| 307 | + | delete(d.stopped, name) | |
| 274 | 308 | sess.name = name | |
| 275 | 309 | sess.ephemeral = false | |
| 276 | 310 | return nil | |
| @@ -291,7 +325,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 291 | 325 | } | |
| 292 | 326 | if len(sess.tabs) == 1 { | |
| 293 | 327 | sess.mu.Unlock() | |
| 294 | - | d.killSession(sess, ports.ReasonSessionKilled) | |
| 328 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 295 | 329 | return | |
| 296 | 330 | } | |
| 297 | 331 | ringing := tb.attention | |
| @@ -320,14 +354,33 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 320 | 354 | // ptyReader drains child output into the VT screen and pokes the dirty channel | |
| 321 | 355 | // (non-blocking: a full channel already means a render is pending). On any read | |
| 322 | 356 | ||
| 323 | - | func (d *Daemon) killSession(sess *session, reason uint8) { | |
| 357 | + | func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |
| 324 | 358 | ringing := sess.anyAttention() | |
| 359 | + | sess.mu.Lock() | |
| 360 | + | isEphemeral := sess.ephemeral | |
| 361 | + | sess.mu.Unlock() | |
| 362 | + | if !purge && !isEphemeral && d.persistEnabled { | |
| 363 | + | d.refreshSessionCwd(sess) | |
| 364 | + | } | |
| 325 | 365 | d.mu.Lock() | |
| 326 | 366 | if _, ok := d.sessions[sess.id]; !ok { | |
| 327 | 367 | d.mu.Unlock() | |
| 328 | - | return | |
| 368 | + | return nil | |
| 329 | 369 | } | |
| 330 | 370 | delete(d.sessions, sess.id) | |
| 371 | + | sess.mu.Lock() | |
| 372 | + | stoppedName := sess.name | |
| 373 | + | stoppedCwd := sess.cwd | |
| 374 | + | createdAt := sess.createdAt | |
| 375 | + | ephemeral := sess.ephemeral | |
| 376 | + | sess.mu.Unlock() | |
| 377 | + | if !ephemeral { | |
| 378 | + | if purge { | |
| 379 | + | delete(d.stopped, stoppedName) | |
| 380 | + | } else { | |
| 381 | + | d.stopped[stoppedName] = stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt} | |
| 382 | + | } | |
| 383 | + | } | |
| 331 | 384 | empty := len(d.sessions) == 0 | |
| 332 | 385 | if empty { | |
| 333 | 386 | // Shutdown is now inevitable and irreversible (doneOnce below): stop | |
| @@ -337,6 +390,19 @@ func (d *Daemon) killSession(sess *session, reason uint8) { | |||
| 337 | 390 | } | |
| 338 | 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 _, live := d.sessions[sess.id]; !live { | |
| 400 | + | d.stopped[stoppedName] = stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt} | |
| 401 | + | } | |
| 402 | + | d.mu.Unlock() | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | ||
| 340 | 406 | sess.mu.Lock() | |
| 341 | 407 | ac := sess.client | |
| 342 | 408 | sess.client = nil | |
| @@ -363,15 +429,19 @@ func (d *Daemon) killSession(sess *session, reason uint8) { | |||
| 363 | 429 | if ac != nil { | |
| 364 | 430 | d.notifyDetachedAsync(ac, reason) | |
| 365 | 431 | } | |
| 432 | + | return purgeErr | |
| 366 | 433 | } | |
| 367 | 434 | ||
| 368 | 435 | // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds | |
| 369 | 436 | // d.mu. | |
| 370 | 437 | func (d *Daemon) allocEphemeralNameLocked() string { | |
| 371 | - | used := make(map[string]struct{}, len(d.sessions)) | |
| 438 | + | used := make(map[string]struct{}, len(d.sessions)+len(d.stopped)) | |
| 372 | 439 | for _, s := range d.sessions { | |
| 373 | 440 | used[s.name] = struct{}{} | |
| 374 | 441 | } | |
| 442 | + | for name := range d.stopped { | |
| 443 | + | used[name] = struct{}{} | |
| 444 | + | } | |
| 375 | 445 | for i := 0; ; i++ { | |
| 376 | 446 | n := strconv.Itoa(i) | |
| 377 | 447 | if _, taken := used[n]; !taken { | |
| @@ -389,7 +459,75 @@ func (d *Daemon) findByNameLocked(name string) *session { | |||
| 389 | 459 | return nil | |
| 390 | 460 | } | |
| 391 | 461 | ||
| 392 | - | func (d *Daemon) nameTakenLocked(name string) bool { return d.findByNameLocked(name) != nil } | |
| 462 | + | func (d *Daemon) nameLiveOrStoppedLocked(name string) bool { | |
| 463 | + | if d.findByNameLocked(name) != nil { | |
| 464 | + | return true | |
| 465 | + | } | |
| 466 | + | _, ok := d.stopped[name] | |
| 467 | + | return ok | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | func (d *Daemon) cwdSampler(ctx context.Context) { | |
| 471 | + | t := time.NewTicker(5 * time.Second) | |
| 472 | + | defer t.Stop() | |
| 473 | + | for { | |
| 474 | + | select { | |
| 475 | + | case <-ctx.Done(): | |
| 476 | + | return | |
| 477 | + | case <-t.C: | |
| 478 | + | d.refreshNamedSessionCwds() | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | func (d *Daemon) refreshNamedSessionCwds() { | |
| 484 | + | d.mu.Lock() | |
| 485 | + | sessions := d.sessionsSnapshotLocked() | |
| 486 | + | d.mu.Unlock() | |
| 487 | + | for _, sess := range sessions { | |
| 488 | + | sess.mu.Lock() | |
| 489 | + | ephemeral := sess.ephemeral | |
| 490 | + | sess.mu.Unlock() | |
| 491 | + | if !ephemeral { | |
| 492 | + | d.refreshSessionCwd(sess) | |
| 493 | + | } | |
| 494 | + | } | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | func (d *Daemon) refreshSessionCwd(sess *session) { | |
| 498 | + | if d.procCwd == nil { | |
| 499 | + | return | |
| 500 | + | } | |
| 501 | + | tb := sess.activeTab() | |
| 502 | + | if tb == nil { | |
| 503 | + | return | |
| 504 | + | } | |
| 505 | + | cwd, err := d.procCwd(tb.pty.Pid()) | |
| 506 | + | if err != nil || cwd == "" { | |
| 507 | + | return | |
| 508 | + | } | |
| 509 | + | d.mu.Lock() | |
| 510 | + | if d.sessions[sess.id] != sess { | |
| 511 | + | d.mu.Unlock() | |
| 512 | + | return | |
| 513 | + | } | |
| 514 | + | sess.mu.Lock() | |
| 515 | + | if sess.cwd == cwd { | |
| 516 | + | sess.mu.Unlock() | |
| 517 | + | d.mu.Unlock() | |
| 518 | + | return | |
| 519 | + | } | |
| 520 | + | sess.cwd = cwd | |
| 521 | + | name := sess.name | |
| 522 | + | ephemeral := sess.ephemeral | |
| 523 | + | sess.mu.Unlock() | |
| 524 | + | if !ephemeral { | |
| 525 | + | if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil { | |
| 526 | + | d.log.Warn("touching persisted session cwd failed", "err", err, "session", name) | |
| 527 | + | } | |
| 528 | + | } | |
| 529 | + | d.mu.Unlock() | |
| 530 | + | } | |
| 393 | 531 | ||
| 394 | 532 | // childEnv builds the session child's environment: the daemon's own, with TERM | |
| 395 | 533 | // 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.Context | 48 | ctx context.Context | ||
| 48 | cancel context.CancelFunc | 49 | cancel context.CancelFunc | ||
| 49 | 50 | ||||
| 50 | - | mu sync.Mutex // guards tabs, active, and client | 51 | + | mu sync.Mutex // guards tabs, active, and client |
| 51 | - | tabs []*tab | 52 | + | tabs []*tab |
| 52 | - | active int | 53 | + | active int |
| 53 | - | client *attachedClient | 54 | + | client *attachedClient |
| 54 | - | cwd string | 55 | + | cwd string |
| 56 | + | createdAt int64 | |||
| 55 | } | 57 | } | ||
| 56 | 58 | ||||
| 57 | // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output | 59 | // 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] = sess | 118 | 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 = nil | 158 | from.client = nil | ||
| @@ -269,8 +281,30 @@ 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.Delete(oldName); err != nil { | |||
| 298 | + | return err | |||
| 299 | + | } | |||
| 300 | + | } | |||
| 301 | + | if wasEphemeral || oldName != name { | |||
| 302 | + | if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano()}); err != nil { | |||
| 303 | + | return err | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | delete(d.stopped, oldName) | |||
| 307 | + | delete(d.stopped, name) | |||
| 274 | sess.name = name | 308 | sess.name = name | ||
| 275 | sess.ephemeral = false | 309 | sess.ephemeral = false | ||
| 276 | return nil | 310 | return nil | ||
| @@ -291,7 +325,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 291 | } | 325 | } | ||
| 292 | if len(sess.tabs) == 1 { | 326 | if len(sess.tabs) == 1 { | ||
| 293 | sess.mu.Unlock() | 327 | sess.mu.Unlock() | ||
| 294 | - | d.killSession(sess, ports.ReasonSessionKilled) | 328 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, false) |
| 295 | return | 329 | return | ||
| 296 | } | 330 | } | ||
| 297 | ringing := tb.attention | 331 | ringing := tb.attention | ||
| @@ -320,14 +354,33 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 320 | // ptyReader drains child output into the VT screen and pokes the dirty channel | 354 | // 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 read | 355 | // (non-blocking: a full channel already means a render is pending). On any read | ||
| 322 | 356 | ||||
| 323 | - | func (d *Daemon) killSession(sess *session, reason uint8) { | 357 | + | func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { |
| 324 | ringing := sess.anyAttention() | 358 | ringing := sess.anyAttention() | ||
| 359 | + | sess.mu.Lock() | |||
| 360 | + | isEphemeral := sess.ephemeral | |||
| 361 | + | sess.mu.Unlock() | |||
| 362 | + | if !purge && !isEphemeral && d.persistEnabled { | |||
| 363 | + | d.refreshSessionCwd(sess) | |||
| 364 | + | } | |||
| 325 | d.mu.Lock() | 365 | d.mu.Lock() | ||
| 326 | if _, ok := d.sessions[sess.id]; !ok { | 366 | if _, ok := d.sessions[sess.id]; !ok { | ||
| 327 | d.mu.Unlock() | 367 | d.mu.Unlock() | ||
| 328 | - | return | 368 | + | return nil |
| 329 | } | 369 | } | ||
| 330 | delete(d.sessions, sess.id) | 370 | delete(d.sessions, sess.id) | ||
| 371 | + | sess.mu.Lock() | |||
| 372 | + | stoppedName := sess.name | |||
| 373 | + | stoppedCwd := sess.cwd | |||
| 374 | + | createdAt := sess.createdAt | |||
| 375 | + | ephemeral := sess.ephemeral | |||
| 376 | + | sess.mu.Unlock() | |||
| 377 | + | if !ephemeral { | |||
| 378 | + | if purge { | |||
| 379 | + | delete(d.stopped, stoppedName) | |||
| 380 | + | } else { | |||
| 381 | + | d.stopped[stoppedName] = stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt} | |||
| 382 | + | } | |||
| 383 | + | } | |||
| 331 | empty := len(d.sessions) == 0 | 384 | empty := len(d.sessions) == 0 | ||
| 332 | if empty { | 385 | if empty { | ||
| 333 | // Shutdown is now inevitable and irreversible (doneOnce below): stop | 386 | // Shutdown is now inevitable and irreversible (doneOnce below): stop | ||
| @@ -337,6 +390,19 @@ 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 _, live := d.sessions[sess.id]; !live { | |||
| 400 | + | d.stopped[stoppedName] = stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt} | |||
| 401 | + | } | |||
| 402 | + | d.mu.Unlock() | |||
| 403 | + | } | |||
| 404 | + | } | |||
| 405 | + | ||||
| 340 | sess.mu.Lock() | 406 | sess.mu.Lock() | ||
| 341 | ac := sess.client | 407 | ac := sess.client | ||
| 342 | sess.client = nil | 408 | sess.client = nil | ||
| @@ -363,15 +429,19 @@ func (d *Daemon) killSession(sess *session, reason uint8) { | |||||
| 363 | if ac != nil { | 429 | if ac != nil { | ||
| 364 | d.notifyDetachedAsync(ac, reason) | 430 | d.notifyDetachedAsync(ac, reason) | ||
| 365 | } | 431 | } | ||
| 432 | + | return purgeErr | |||
| 366 | } | 433 | } | ||
| 367 | 434 | ||||
| 368 | // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds | 435 | // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds | ||
| 369 | // d.mu. | 436 | // d.mu. | ||
| 370 | func (d *Daemon) allocEphemeralNameLocked() string { | 437 | func (d *Daemon) allocEphemeralNameLocked() string { | ||
| 371 | - | used := make(map[string]struct{}, len(d.sessions)) | 438 | + | used := make(map[string]struct{}, len(d.sessions)+len(d.stopped)) |
| 372 | for _, s := range d.sessions { | 439 | for _, s := range d.sessions { | ||
| 373 | used[s.name] = struct{}{} | 440 | used[s.name] = struct{}{} | ||
| 374 | } | 441 | } | ||
| 442 | + | for name := range d.stopped { | |||
| 443 | + | used[name] = struct{}{} | |||
| 444 | + | } | |||
| 375 | for i := 0; ; i++ { | 445 | for i := 0; ; i++ { | ||
| 376 | n := strconv.Itoa(i) | 446 | n := strconv.Itoa(i) | ||
| 377 | if _, taken := used[n]; !taken { | 447 | if _, taken := used[n]; !taken { | ||
| @@ -389,7 +459,75 @@ func (d *Daemon) findByNameLocked(name string) *session { | |||||
| 389 | return nil | 459 | return nil | ||
| 390 | } | 460 | } | ||
| 391 | 461 | ||||
| 392 | - | func (d *Daemon) nameTakenLocked(name string) bool { return d.findByNameLocked(name) != nil } | 462 | + | func (d *Daemon) nameLiveOrStoppedLocked(name string) bool { |
| 463 | + | if d.findByNameLocked(name) != nil { | |||
| 464 | + | return true | |||
| 465 | + | } | |||
| 466 | + | _, ok := d.stopped[name] | |||
| 467 | + | return ok | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | func (d *Daemon) cwdSampler(ctx context.Context) { | |||
| 471 | + | t := time.NewTicker(5 * time.Second) | |||
| 472 | + | defer t.Stop() | |||
| 473 | + | for { | |||
| 474 | + | select { | |||
| 475 | + | case <-ctx.Done(): | |||
| 476 | + | return | |||
| 477 | + | case <-t.C: | |||
| 478 | + | d.refreshNamedSessionCwds() | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | func (d *Daemon) refreshNamedSessionCwds() { | |||
| 484 | + | d.mu.Lock() | |||
| 485 | + | sessions := d.sessionsSnapshotLocked() | |||
| 486 | + | d.mu.Unlock() | |||
| 487 | + | for _, sess := range sessions { | |||
| 488 | + | sess.mu.Lock() | |||
| 489 | + | ephemeral := sess.ephemeral | |||
| 490 | + | sess.mu.Unlock() | |||
| 491 | + | if !ephemeral { | |||
| 492 | + | d.refreshSessionCwd(sess) | |||
| 493 | + | } | |||
| 494 | + | } | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | func (d *Daemon) refreshSessionCwd(sess *session) { | |||
| 498 | + | if d.procCwd == nil { | |||
| 499 | + | return | |||
| 500 | + | } | |||
| 501 | + | tb := sess.activeTab() | |||
| 502 | + | if tb == nil { | |||
| 503 | + | return | |||
| 504 | + | } | |||
| 505 | + | cwd, err := d.procCwd(tb.pty.Pid()) | |||
| 506 | + | if err != nil || cwd == "" { | |||
| 507 | + | return | |||
| 508 | + | } | |||
| 509 | + | d.mu.Lock() | |||
| 510 | + | if d.sessions[sess.id] != sess { | |||
| 511 | + | d.mu.Unlock() | |||
| 512 | + | return | |||
| 513 | + | } | |||
| 514 | + | sess.mu.Lock() | |||
| 515 | + | if sess.cwd == cwd { | |||
| 516 | + | sess.mu.Unlock() | |||
| 517 | + | d.mu.Unlock() | |||
| 518 | + | return | |||
| 519 | + | } | |||
| 520 | + | sess.cwd = cwd | |||
| 521 | + | name := sess.name | |||
| 522 | + | ephemeral := sess.ephemeral | |||
| 523 | + | sess.mu.Unlock() | |||
| 524 | + | if !ephemeral { | |||
| 525 | + | if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil { | |||
| 526 | + | d.log.Warn("touching persisted session cwd failed", "err", err, "session", name) | |||
| 527 | + | } | |||
| 528 | + | } | |||
| 529 | + | d.mu.Unlock() | |||
| 530 | + | } | |||
| 393 | 531 | ||||
| 394 | // childEnv builds the session child's environment: the daemon's own, with TERM | 532 | // childEnv builds the session child's environment: the daemon's own, with TERM | ||
| 395 | // and VEV forced to well-known values. | 533 | // and VEV forced to well-known values. | ||
M internal/usecase/daemon/session_test.go +231 -5
| @@ -12,8 +12,10 @@ import ( | |||
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/persist" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 16 | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| 19 | 21 | // --- test doubles ----------------------------------------------------------- | |
| @@ -100,7 +102,7 @@ func TestHandshakeNewHappy(t *testing.T) { | |||
| 100 | 102 | require.NotNil(t, sess) | |
| 101 | 103 | ||
| 102 | 104 | releaseConn() | |
| 103 | - | d.killSession(sess, ports.ReasonServerShutdown) | |
| 105 | + | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| 104 | 106 | releasePTY() | |
| 105 | 107 | hg.Wait() | |
| 106 | 108 | d.sessWg.Wait() | |
| @@ -214,7 +216,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||
| 214 | 216 | errCh := make(chan error, 1) | |
| 215 | 217 | go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }() | |
| 216 | 218 | <-opened | |
| 217 | - | d.killSession(sess, ports.ReasonSessionKilled) | |
| 219 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 218 | 220 | close(releaseOpen) | |
| 219 | 221 | ||
| 220 | 222 | require.Error(t, <-errCh) | |
| @@ -275,7 +277,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) { | |||
| 275 | 277 | ||
| 276 | 278 | releaseA() | |
| 277 | 279 | releaseB() | |
| 278 | - | d.killSession(sess, ports.ReasonServerShutdown) | |
| 280 | + | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| 279 | 281 | releasePTY() | |
| 280 | 282 | hg.Wait() | |
| 281 | 283 | d.sessWg.Wait() | |
| @@ -324,7 +326,7 @@ func TestDetachKeepsNamed(t *testing.T) { | |||
| 324 | 326 | require.Nil(t, sess.client, "named session is headless after detach") | |
| 325 | 327 | sess.mu.Unlock() | |
| 326 | 328 | ||
| 327 | - | d.killSession(sess, ports.ReasonServerShutdown) | |
| 329 | + | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| 328 | 330 | releasePTY() | |
| 329 | 331 | d.sessWg.Wait() | |
| 330 | 332 | d.waitNotifies() | |
| @@ -472,7 +474,7 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) { | |||
| 472 | 474 | require.NotNil(t, sess) | |
| 473 | 475 | ||
| 474 | 476 | // The last session dies: shutdown begins irreversibly. | |
| 475 | - | d.killSession(sess, ports.ReasonSessionKilled) | |
| 477 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 476 | 478 | select { | |
| 477 | 479 | case <-d.done: | |
| 478 | 480 | default: | |
| @@ -580,3 +582,227 @@ func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) { | |||
| 580 | 582 | ||
| 581 | 583 | // TestSendErrorKillsEphemeral: a failed output send detaches the client, and — | |
| 582 | 584 | // 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().Set(mock.Anything, mock.Anything).RunAndReturn(func(k, v []byte) error { | |
| 600 | + | state.mu.Lock() | |
| 601 | + | defer state.mu.Unlock() | |
| 602 | + | state.sets++ | |
| 603 | + | state.data[string(k)] = append([]byte(nil), v...) | |
| 604 | + | return nil | |
| 605 | + | }).Maybe() | |
| 606 | + | store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error { | |
| 607 | + | state.mu.Lock() | |
| 608 | + | defer state.mu.Unlock() | |
| 609 | + | state.dels = append(state.dels, string(k)) | |
| 610 | + | delete(state.data, string(k)) | |
| 611 | + | return nil | |
| 612 | + | }).Maybe() | |
| 613 | + | store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) { | |
| 614 | + | state.mu.Lock() | |
| 615 | + | defer state.mu.Unlock() | |
| 616 | + | for k, v := range state.data { | |
| 617 | + | if !fn([]byte(k), append([]byte(nil), v...)) { | |
| 618 | + | return | |
| 619 | + | } | |
| 620 | + | } | |
| 621 | + | }).Maybe() | |
| 622 | + | store.EXPECT().Sync().RunAndReturn(func() error { | |
| 623 | + | state.mu.Lock() | |
| 624 | + | defer state.mu.Unlock() | |
| 625 | + | state.syncs++ | |
| 626 | + | return nil | |
| 627 | + | }).Maybe() | |
| 628 | + | store.EXPECT().Close().RunAndReturn(func() error { | |
| 629 | + | state.mu.Lock() | |
| 630 | + | defer state.mu.Unlock() | |
| 631 | + | state.closed = true | |
| 632 | + | return nil | |
| 633 | + | }).Maybe() | |
| 634 | + | return store, state | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | func (s *mockStoreState) has(name string) bool { | |
| 638 | + | s.mu.Lock() | |
| 639 | + | defer s.mu.Unlock() | |
| 640 | + | _, ok := s.data[name] | |
| 641 | + | return ok | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) { | |
| 645 | + | store, _ := newMockStore(t) | |
| 646 | + | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store)) | |
| 647 | + | require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8})) | |
| 648 | + | ||
| 649 | + | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store)) | |
| 650 | + | d.mu.Lock() | |
| 651 | + | stopped := d.stopped["work"] | |
| 652 | + | d.mu.Unlock() | |
| 653 | + | require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped) | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |
| 657 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 658 | + | p, release := newBlockingPTY(t) | |
| 659 | + | defer release() | |
| 660 | + | store, state := newMockStore(t) | |
| 661 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 662 | + | WithStore(store)(d) | |
| 663 | + | ||
| 664 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | |
| 665 | + | require.NoError(t, err) | |
| 666 | + | require.True(t, state.has("work")) | |
| 667 | + | ||
| 668 | + | require.NoError(t, d.renameSession(sess, "renamed")) | |
| 669 | + | require.False(t, state.has("work")) | |
| 670 | + | require.True(t, state.has("renamed")) | |
| 671 | + | ||
| 672 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, true) | |
| 673 | + | require.False(t, state.has("renamed")) | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | |
| 677 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 678 | + | p, release := newBlockingPTY(t) | |
| 679 | + | defer release() | |
| 680 | + | store, state := newMockStore(t) | |
| 681 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 682 | + | WithStore(store)(d) | |
| 683 | + | d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1} | |
| 684 | + | ||
| 685 | + | sess, err := d.createSessionLocked("0", true, "/tmp/e", sz) | |
| 686 | + | require.NoError(t, err) | |
| 687 | + | require.False(t, state.has("0")) | |
| 688 | + | require.EqualError(t, d.renameSession(sess, "taken"), "name already in use") | |
| 689 | + | require.NoError(t, d.renameSession(sess, "named")) | |
| 690 | + | require.True(t, state.has("named")) | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) { | |
| 694 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 695 | + | p, release := newBlockingPTY(t) | |
| 696 | + | defer release() | |
| 697 | + | store, state := newMockStore(t) | |
| 698 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 699 | + | WithStore(store)(d) | |
| 700 | + | cwd := "/tmp/work" | |
| 701 | + | WithCwdReader(func(int) (string, error) { return cwd, nil })(d) | |
| 702 | + | ||
| 703 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | |
| 704 | + | require.NoError(t, err) | |
| 705 | + | state.mu.Lock() | |
| 706 | + | setsAfterCreate := state.sets | |
| 707 | + | state.mu.Unlock() | |
| 708 | + | ||
| 709 | + | d.refreshSessionCwd(sess) | |
| 710 | + | state.mu.Lock() | |
| 711 | + | require.Equal(t, setsAfterCreate, state.sets) | |
| 712 | + | state.mu.Unlock() | |
| 713 | + | ||
| 714 | + | cwd = "/tmp/next" | |
| 715 | + | d.refreshSessionCwd(sess) | |
| 716 | + | state.mu.Lock() | |
| 717 | + | require.Equal(t, setsAfterCreate+1, state.sets) | |
| 718 | + | state.mu.Unlock() | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) { | |
| 722 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 723 | + | p, release := newBlockingPTY(t) | |
| 724 | + | defer release() | |
| 725 | + | store, _ := newMockStore(t) | |
| 726 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 727 | + | WithStore(store)(d) | |
| 728 | + | cwd := t.TempDir() | |
| 729 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: cwd, createdAt: 1} | |
| 730 | + | tr := portsmocks.NewMockTransport(t) | |
| 731 | + | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 732 | + | tr.EXPECT().Close().Return(nil).Maybe() | |
| 733 | + | ||
| 734 | + | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr) | |
| 735 | + | require.NoError(t, err) | |
| 736 | + | require.NotNil(t, ac) | |
| 737 | + | require.Equal(t, "work", sess.name) | |
| 738 | + | require.Equal(t, cwd, sess.cwd) | |
| 739 | + | d.mu.Lock() | |
| 740 | + | _, stillStopped := d.stopped["work"] | |
| 741 | + | d.mu.Unlock() | |
| 742 | + | require.False(t, stillStopped) | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | func TestAttachStoppedMissingCwdFallsBackToHome(t *testing.T) { | |
| 746 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 747 | + | p, release := newBlockingPTY(t) | |
| 748 | + | defer release() | |
| 749 | + | store, _ := newMockStore(t) | |
| 750 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 751 | + | WithStore(store)(d) | |
| 752 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: "/definitely/missing/vev", createdAt: 1} | |
| 753 | + | tr := portsmocks.NewMockTransport(t) | |
| 754 | + | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 755 | + | tr.EXPECT().Close().Return(nil).Maybe() | |
| 756 | + | ||
| 757 | + | sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr) | |
| 758 | + | require.NoError(t, err) | |
| 759 | + | require.NotEqual(t, "/definitely/missing/vev", sess.cwd) | |
| 760 | + | } | |
| 761 | + | ||
| 762 | + | func TestPickerStoppedTargetKillPurges(t *testing.T) { | |
| 763 | + | store, state := newMockStore(t) | |
| 764 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 765 | + | WithStore(store)(d) | |
| 766 | + | require.NoError(t, d.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1})) | |
| 767 | + | d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1} | |
| 768 | + | d.killPickerTarget(picker.Target{Name: "old", Stopped: true}) | |
| 769 | + | require.False(t, state.has("old")) | |
| 770 | + | d.mu.Lock() | |
| 771 | + | _, ok := d.stopped["old"] | |
| 772 | + | d.mu.Unlock() | |
| 773 | + | require.False(t, ok) | |
| 774 | + | } | |
| 775 | + | ||
| 776 | + | func TestIntentNewStoppedNameRejected(t *testing.T) { | |
| 777 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 778 | + | d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1} | |
| 779 | + | tr := portsmocks.NewMockTransport(t) | |
| 780 | + | _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "taken", Size: domain.Size{Cols: 80, Rows: 24}}, tr) | |
| 781 | + | require.ErrorContains(t, err, "name already in use") | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | |
| 785 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 786 | + | p1, release1 := newBlockingPTY(t) | |
| 787 | + | defer release1() | |
| 788 | + | p2, release2 := newBlockingPTY(t) | |
| 789 | + | defer release2() | |
| 790 | + | store, state := newMockStore(t) | |
| 791 | + | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |
| 792 | + | WithStore(store)(d) | |
| 793 | + | WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d) | |
| 794 | + | ||
| 795 | + | natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz) | |
| 796 | + | require.NoError(t, err) | |
| 797 | + | other, err := d.createSessionLocked("other", false, "/tmp/other", sz) | |
| 798 | + | require.NoError(t, err) | |
| 799 | + | _ = d.killSession(natural, ports.ReasonSessionKilled, false) | |
| 800 | + | require.True(t, state.has("natural")) | |
| 801 | + | d.mu.Lock() | |
| 802 | + | stopped := d.stopped["natural"] | |
| 803 | + | d.mu.Unlock() | |
| 804 | + | require.Equal(t, "/tmp/latest", stopped.cwd) | |
| 805 | + | ||
| 806 | + | _ = d.killSession(other, ports.ReasonSessionKilled, true) | |
| 807 | + | require.False(t, state.has("other")) | |
| 808 | + | } | |
| @@ -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 | <-opened | 218 | <-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,227 @@ 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 than | 584 | // 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().Set(mock.Anything, mock.Anything).RunAndReturn(func(k, v []byte) error { | |||
| 600 | + | state.mu.Lock() | |||
| 601 | + | defer state.mu.Unlock() | |||
| 602 | + | state.sets++ | |||
| 603 | + | state.data[string(k)] = append([]byte(nil), v...) | |||
| 604 | + | return nil | |||
| 605 | + | }).Maybe() | |||
| 606 | + | store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error { | |||
| 607 | + | state.mu.Lock() | |||
| 608 | + | defer state.mu.Unlock() | |||
| 609 | + | state.dels = append(state.dels, string(k)) | |||
| 610 | + | delete(state.data, string(k)) | |||
| 611 | + | return nil | |||
| 612 | + | }).Maybe() | |||
| 613 | + | store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) { | |||
| 614 | + | state.mu.Lock() | |||
| 615 | + | defer state.mu.Unlock() | |||
| 616 | + | for k, v := range state.data { | |||
| 617 | + | if !fn([]byte(k), append([]byte(nil), v...)) { | |||
| 618 | + | return | |||
| 619 | + | } | |||
| 620 | + | } | |||
| 621 | + | }).Maybe() | |||
| 622 | + | store.EXPECT().Sync().RunAndReturn(func() error { | |||
| 623 | + | state.mu.Lock() | |||
| 624 | + | defer state.mu.Unlock() | |||
| 625 | + | state.syncs++ | |||
| 626 | + | return nil | |||
| 627 | + | }).Maybe() | |||
| 628 | + | store.EXPECT().Close().RunAndReturn(func() error { | |||
| 629 | + | state.mu.Lock() | |||
| 630 | + | defer state.mu.Unlock() | |||
| 631 | + | state.closed = true | |||
| 632 | + | return nil | |||
| 633 | + | }).Maybe() | |||
| 634 | + | return store, state | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | func (s *mockStoreState) has(name string) bool { | |||
| 638 | + | s.mu.Lock() | |||
| 639 | + | defer s.mu.Unlock() | |||
| 640 | + | _, ok := s.data[name] | |||
| 641 | + | return ok | |||
| 642 | + | } | |||
| 643 | + | ||||
| 644 | + | func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) { | |||
| 645 | + | store, _ := newMockStore(t) | |||
| 646 | + | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store)) | |||
| 647 | + | require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8})) | |||
| 648 | + | ||||
| 649 | + | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store)) | |||
| 650 | + | d.mu.Lock() | |||
| 651 | + | stopped := d.stopped["work"] | |||
| 652 | + | d.mu.Unlock() | |||
| 653 | + | require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped) | |||
| 654 | + | } | |||
| 655 | + | ||||
| 656 | + | func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |||
| 657 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 658 | + | p, release := newBlockingPTY(t) | |||
| 659 | + | defer release() | |||
| 660 | + | store, state := newMockStore(t) | |||
| 661 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 662 | + | WithStore(store)(d) | |||
| 663 | + | ||||
| 664 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | |||
| 665 | + | require.NoError(t, err) | |||
| 666 | + | require.True(t, state.has("work")) | |||
| 667 | + | ||||
| 668 | + | require.NoError(t, d.renameSession(sess, "renamed")) | |||
| 669 | + | require.False(t, state.has("work")) | |||
| 670 | + | require.True(t, state.has("renamed")) | |||
| 671 | + | ||||
| 672 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, true) | |||
| 673 | + | require.False(t, state.has("renamed")) | |||
| 674 | + | } | |||
| 675 | + | ||||
| 676 | + | func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | |||
| 677 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 678 | + | p, release := newBlockingPTY(t) | |||
| 679 | + | defer release() | |||
| 680 | + | store, state := newMockStore(t) | |||
| 681 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 682 | + | WithStore(store)(d) | |||
| 683 | + | d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1} | |||
| 684 | + | ||||
| 685 | + | sess, err := d.createSessionLocked("0", true, "/tmp/e", sz) | |||
| 686 | + | require.NoError(t, err) | |||
| 687 | + | require.False(t, state.has("0")) | |||
| 688 | + | require.EqualError(t, d.renameSession(sess, "taken"), "name already in use") | |||
| 689 | + | require.NoError(t, d.renameSession(sess, "named")) | |||
| 690 | + | require.True(t, state.has("named")) | |||
| 691 | + | } | |||
| 692 | + | ||||
| 693 | + | func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) { | |||
| 694 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 695 | + | p, release := newBlockingPTY(t) | |||
| 696 | + | defer release() | |||
| 697 | + | store, state := newMockStore(t) | |||
| 698 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 699 | + | WithStore(store)(d) | |||
| 700 | + | cwd := "/tmp/work" | |||
| 701 | + | WithCwdReader(func(int) (string, error) { return cwd, nil })(d) | |||
| 702 | + | ||||
| 703 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | |||
| 704 | + | require.NoError(t, err) | |||
| 705 | + | state.mu.Lock() | |||
| 706 | + | setsAfterCreate := state.sets | |||
| 707 | + | state.mu.Unlock() | |||
| 708 | + | ||||
| 709 | + | d.refreshSessionCwd(sess) | |||
| 710 | + | state.mu.Lock() | |||
| 711 | + | require.Equal(t, setsAfterCreate, state.sets) | |||
| 712 | + | state.mu.Unlock() | |||
| 713 | + | ||||
| 714 | + | cwd = "/tmp/next" | |||
| 715 | + | d.refreshSessionCwd(sess) | |||
| 716 | + | state.mu.Lock() | |||
| 717 | + | require.Equal(t, setsAfterCreate+1, state.sets) | |||
| 718 | + | state.mu.Unlock() | |||
| 719 | + | } | |||
| 720 | + | ||||
| 721 | + | func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) { | |||
| 722 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 723 | + | p, release := newBlockingPTY(t) | |||
| 724 | + | defer release() | |||
| 725 | + | store, _ := newMockStore(t) | |||
| 726 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 727 | + | WithStore(store)(d) | |||
| 728 | + | cwd := t.TempDir() | |||
| 729 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: cwd, createdAt: 1} | |||
| 730 | + | tr := portsmocks.NewMockTransport(t) | |||
| 731 | + | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |||
| 732 | + | tr.EXPECT().Close().Return(nil).Maybe() | |||
| 733 | + | ||||
| 734 | + | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr) | |||
| 735 | + | require.NoError(t, err) | |||
| 736 | + | require.NotNil(t, ac) | |||
| 737 | + | require.Equal(t, "work", sess.name) | |||
| 738 | + | require.Equal(t, cwd, sess.cwd) | |||
| 739 | + | d.mu.Lock() | |||
| 740 | + | _, stillStopped := d.stopped["work"] | |||
| 741 | + | d.mu.Unlock() | |||
| 742 | + | require.False(t, stillStopped) | |||
| 743 | + | } | |||
| 744 | + | ||||
| 745 | + | func TestAttachStoppedMissingCwdFallsBackToHome(t *testing.T) { | |||
| 746 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 747 | + | p, release := newBlockingPTY(t) | |||
| 748 | + | defer release() | |||
| 749 | + | store, _ := newMockStore(t) | |||
| 750 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 751 | + | WithStore(store)(d) | |||
| 752 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: "/definitely/missing/vev", createdAt: 1} | |||
| 753 | + | tr := portsmocks.NewMockTransport(t) | |||
| 754 | + | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |||
| 755 | + | tr.EXPECT().Close().Return(nil).Maybe() | |||
| 756 | + | ||||
| 757 | + | sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr) | |||
| 758 | + | require.NoError(t, err) | |||
| 759 | + | require.NotEqual(t, "/definitely/missing/vev", sess.cwd) | |||
| 760 | + | } | |||
| 761 | + | ||||
| 762 | + | func TestPickerStoppedTargetKillPurges(t *testing.T) { | |||
| 763 | + | store, state := newMockStore(t) | |||
| 764 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 765 | + | WithStore(store)(d) | |||
| 766 | + | require.NoError(t, d.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1})) | |||
| 767 | + | d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1} | |||
| 768 | + | d.killPickerTarget(picker.Target{Name: "old", Stopped: true}) | |||
| 769 | + | require.False(t, state.has("old")) | |||
| 770 | + | d.mu.Lock() | |||
| 771 | + | _, ok := d.stopped["old"] | |||
| 772 | + | d.mu.Unlock() | |||
| 773 | + | require.False(t, ok) | |||
| 774 | + | } | |||
| 775 | + | ||||
| 776 | + | func TestIntentNewStoppedNameRejected(t *testing.T) { | |||
| 777 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 778 | + | d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1} | |||
| 779 | + | tr := portsmocks.NewMockTransport(t) | |||
| 780 | + | _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "taken", Size: domain.Size{Cols: 80, Rows: 24}}, tr) | |||
| 781 | + | require.ErrorContains(t, err, "name already in use") | |||
| 782 | + | } | |||
| 783 | + | ||||
| 784 | + | func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | |||
| 785 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 786 | + | p1, release1 := newBlockingPTY(t) | |||
| 787 | + | defer release1() | |||
| 788 | + | p2, release2 := newBlockingPTY(t) | |||
| 789 | + | defer release2() | |||
| 790 | + | store, state := newMockStore(t) | |||
| 791 | + | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |||
| 792 | + | WithStore(store)(d) | |||
| 793 | + | WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d) | |||
| 794 | + | ||||
| 795 | + | natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz) | |||
| 796 | + | require.NoError(t, err) | |||
| 797 | + | other, err := d.createSessionLocked("other", false, "/tmp/other", sz) | |||
| 798 | + | require.NoError(t, err) | |||
| 799 | + | _ = d.killSession(natural, ports.ReasonSessionKilled, false) | |||
| 800 | + | require.True(t, state.has("natural")) | |||
| 801 | + | d.mu.Lock() | |||
| 802 | + | stopped := d.stopped["natural"] | |||
| 803 | + | d.mu.Unlock() | |||
| 804 | + | require.Equal(t, "/tmp/latest", stopped.cwd) | |||
| 805 | + | ||||
| 806 | + | _ = d.killSession(other, ports.ReasonSessionKilled, true) | |||
| 807 | + | require.False(t, state.has("other")) | |||
| 808 | + | } | |||
M internal/usecase/picker/model.go +19 -7
| @@ -15,15 +15,18 @@ const ( | |||
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | 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 | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | type Target struct { | |
| 25 | 26 | Session domain.SessionID | |
| 27 | + | Name string | |
| 26 | 28 | TabIndex int | |
| 29 | + | Stopped bool | |
| 27 | 30 | } | |
| 28 | 31 | ||
| 29 | 32 | type Preview struct { | |
| @@ -46,21 +49,27 @@ type row struct { | |||
| 46 | 49 | header bool | |
| 47 | 50 | label string | |
| 48 | 51 | session domain.SessionID | |
| 52 | + | name string | |
| 49 | 53 | tabIndex int | |
| 54 | + | stopped bool | |
| 50 | 55 | } | |
| 51 | 56 | ||
| 52 | 57 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | |
| 53 | 58 | m := &Model{selected: -1} | |
| 54 | 59 | activeSelection := -1 | |
| 55 | 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 | 66 | active := session.Active | |
| 58 | 67 | if active < 0 || active >= len(session.Tabs) { | |
| 59 | 68 | active = 0 | |
| 60 | 69 | } | |
| 61 | 70 | for i, tab := range session.Tabs { | |
| 62 | 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 | 73 | if session.ID == cur && i == curTab { | |
| 65 | 74 | m.selected = idx | |
| 66 | 75 | } | |
| @@ -122,7 +131,7 @@ func (m *Model) Selected() (Target, bool) { | |||
| 122 | 131 | if r.header { | |
| 123 | 132 | return Target{}, false | |
| 124 | 133 | } | |
| 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 | |
| 126 | 135 | } | |
| 127 | 136 | ||
| 128 | 137 | func (m *Model) Clone() *Model { | |
| @@ -189,6 +198,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection ren | |||
| 189 | 198 | style = selection | |
| 190 | 199 | } | |
| 191 | 200 | label := r.label | |
| 201 | + | if r.header && r.stopped { | |
| 202 | + | label += " (stopped)" | |
| 203 | + | } | |
| 192 | 204 | if !r.header { | |
| 193 | 205 | label = " " + label | |
| 194 | 206 | } | |
| @@ -15,15 +15,18 @@ const ( | |||||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | type SessionView struct { | 17 | type SessionView struct { | ||
| 18 | - | ID domain.SessionID | 18 | + | ID domain.SessionID |
| 19 | - | Name string | 19 | + | Name string |
| 20 | - | Tabs []string | 20 | + | Tabs []string |
| 21 | - | Active int | 21 | + | Active int |
| 22 | + | Stopped bool | |||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 24 | type Target struct { | 25 | type Target struct { | ||
| 25 | Session domain.SessionID | 26 | Session domain.SessionID | ||
| 27 | + | Name string | |||
| 26 | TabIndex int | 28 | 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 bool | 49 | header bool | ||
| 47 | label string | 50 | label string | ||
| 48 | session domain.SessionID | 51 | session domain.SessionID | ||
| 52 | + | name string | |||
| 49 | tabIndex int | 53 | 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 := -1 | 59 | 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.Active | 66 | active := session.Active | ||
| 58 | if active < 0 || active >= len(session.Tabs) { | 67 | if active < 0 || active >= len(session.Tabs) { | ||
| 59 | active = 0 | 68 | 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 = idx | 74 | 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{}, false | 132 | return Target{}, false | ||
| 124 | } | 133 | } | ||
| 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 |
| 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 = selection | 198 | style = selection | ||
| 190 | } | 199 | } | ||
| 191 | label := r.label | 200 | label := r.label | ||
| 201 | + | if r.header && r.stopped { | |||
| 202 | + | label += " (stopped)" | |||
| 203 | + | } | |||
| 192 | if !r.header { | 204 | if !r.header { | ||
| 193 | label = " " + label | 205 | label = " " + label | ||
| 194 | } | 206 | } | ||
M internal/usecase/picker/model_test.go +10 -0
| @@ -101,6 +101,16 @@ func TestSelectedMapping(t *testing.T) { | |||
| 101 | 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)"}, 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 | 114 | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |
| 105 | 115 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | |
| 106 | 116 | 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)"}, 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 +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package kv | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "errors" | |
| 6 | + | "hash/crc32" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | opSet byte = 0 | |
| 11 | + | opDel byte = 1 | |
| 12 | + | ||
| 13 | + | headerLen = 8 | |
| 14 | + | payloadPrefixLen = 3 | |
| 15 | + | compactThreshold = 64 | |
| 16 | + | compactWasteRatio = 0.5 | |
| 17 | + | maxKeyLen = 1<<16 - 1 | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var errBadRecord = errors.New("bad record") | |
| 21 | + | ||
| 22 | + | type record struct { | |
| 23 | + | op byte | |
| 24 | + | key []byte | |
| 25 | + | value []byte | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func encodeRecord(op byte, key, value []byte) ([]byte, error) { | |
| 29 | + | if len(key) > maxKeyLen { | |
| 30 | + | return nil, errors.New("key too large") | |
| 31 | + | } | |
| 32 | + | if op != opSet && op != opDel { | |
| 33 | + | return nil, errBadRecord | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | payloadLen := payloadPrefixLen + len(key) | |
| 37 | + | if op == opSet { | |
| 38 | + | payloadLen += len(value) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | buf := make([]byte, headerLen+payloadLen) | |
| 42 | + | payload := buf[headerLen:] | |
| 43 | + | payload[0] = op | |
| 44 | + | binary.BigEndian.PutUint16(payload[1:3], uint16(len(key))) | |
| 45 | + | copy(payload[3:], key) | |
| 46 | + | if op == opSet { | |
| 47 | + | copy(payload[3+len(key):], value) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | binary.BigEndian.PutUint32(buf[0:4], uint32(payloadLen)) | |
| 51 | + | binary.BigEndian.PutUint32(buf[4:8], crc32.ChecksumIEEE(payload)) | |
| 52 | + | return buf, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func decodePayload(payload []byte) (record, error) { | |
| 56 | + | if len(payload) < payloadPrefixLen { | |
| 57 | + | return record{}, errBadRecord | |
| 58 | + | } | |
| 59 | + | op := payload[0] | |
| 60 | + | if op != opSet && op != opDel { | |
| 61 | + | return record{}, errBadRecord | |
| 62 | + | } | |
| 63 | + | keyLen := int(binary.BigEndian.Uint16(payload[1:3])) | |
| 64 | + | if len(payload) < payloadPrefixLen+keyLen { | |
| 65 | + | return record{}, errBadRecord | |
| 66 | + | } | |
| 67 | + | if op == opDel && len(payload) != payloadPrefixLen+keyLen { | |
| 68 | + | return record{}, errBadRecord | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | key := append([]byte(nil), payload[payloadPrefixLen:payloadPrefixLen+keyLen]...) | |
| 72 | + | var val []byte | |
| 73 | + | if op == opSet { | |
| 74 | + | val = append([]byte(nil), payload[payloadPrefixLen+keyLen:]...) | |
| 75 | + | } | |
| 76 | + | return record{op: op, key: key, value: val}, nil | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package kv | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "errors" | |||
| 6 | + | "hash/crc32" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const ( | |||
| 10 | + | opSet byte = 0 | |||
| 11 | + | opDel byte = 1 | |||
| 12 | + | ||||
| 13 | + | headerLen = 8 | |||
| 14 | + | payloadPrefixLen = 3 | |||
| 15 | + | compactThreshold = 64 | |||
| 16 | + | compactWasteRatio = 0.5 | |||
| 17 | + | maxKeyLen = 1<<16 - 1 | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | var errBadRecord = errors.New("bad record") | |||
| 21 | + | ||||
| 22 | + | type record struct { | |||
| 23 | + | op byte | |||
| 24 | + | key []byte | |||
| 25 | + | value []byte | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func encodeRecord(op byte, key, value []byte) ([]byte, error) { | |||
| 29 | + | if len(key) > maxKeyLen { | |||
| 30 | + | return nil, errors.New("key too large") | |||
| 31 | + | } | |||
| 32 | + | if op != opSet && op != opDel { | |||
| 33 | + | return nil, errBadRecord | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | payloadLen := payloadPrefixLen + len(key) | |||
| 37 | + | if op == opSet { | |||
| 38 | + | payloadLen += len(value) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | buf := make([]byte, headerLen+payloadLen) | |||
| 42 | + | payload := buf[headerLen:] | |||
| 43 | + | payload[0] = op | |||
| 44 | + | binary.BigEndian.PutUint16(payload[1:3], uint16(len(key))) | |||
| 45 | + | copy(payload[3:], key) | |||
| 46 | + | if op == opSet { | |||
| 47 | + | copy(payload[3+len(key):], value) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | binary.BigEndian.PutUint32(buf[0:4], uint32(payloadLen)) | |||
| 51 | + | binary.BigEndian.PutUint32(buf[4:8], crc32.ChecksumIEEE(payload)) | |||
| 52 | + | return buf, nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func decodePayload(payload []byte) (record, error) { | |||
| 56 | + | if len(payload) < payloadPrefixLen { | |||
| 57 | + | return record{}, errBadRecord | |||
| 58 | + | } | |||
| 59 | + | op := payload[0] | |||
| 60 | + | if op != opSet && op != opDel { | |||
| 61 | + | return record{}, errBadRecord | |||
| 62 | + | } | |||
| 63 | + | keyLen := int(binary.BigEndian.Uint16(payload[1:3])) | |||
| 64 | + | if len(payload) < payloadPrefixLen+keyLen { | |||
| 65 | + | return record{}, errBadRecord | |||
| 66 | + | } | |||
| 67 | + | if op == opDel && len(payload) != payloadPrefixLen+keyLen { | |||
| 68 | + | return record{}, errBadRecord | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | key := append([]byte(nil), payload[payloadPrefixLen:payloadPrefixLen+keyLen]...) | |||
| 72 | + | var val []byte | |||
| 73 | + | if op == opSet { | |||
| 74 | + | val = append([]byte(nil), payload[payloadPrefixLen+keyLen:]...) | |||
| 75 | + | } | |||
| 76 | + | return record{op: op, key: key, value: val}, nil | |||
| 77 | + | } | |||
A pkg/kv/kv.go +358 -0
| @@ -0,0 +1,358 @@ | |||
| 1 | + | package kv | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "errors" | |
| 6 | + | "hash/crc32" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "sort" | |
| 11 | + | "sync" | |
| 12 | + | "syscall" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Store is an append-only WAL-backed key/value store. | |
| 16 | + | type Store struct { | |
| 17 | + | mu sync.Mutex | |
| 18 | + | path string | |
| 19 | + | file *os.File | |
| 20 | + | lockFile *os.File | |
| 21 | + | data map[string][]byte | |
| 22 | + | entrySize map[string]int64 | |
| 23 | + | total int64 | |
| 24 | + | live int64 | |
| 25 | + | closed bool | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Open opens or creates a store at path, replays valid records, truncates any | |
| 29 | + | // corrupt or torn tail, and compacts if the file contains enough obsolete data. | |
| 30 | + | func Open(path string) (*Store, error) { | |
| 31 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 32 | + | return nil, err | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | lockFile, err := acquireLock(path) | |
| 36 | + | if err != nil { | |
| 37 | + | return nil, err | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) | |
| 41 | + | if err != nil { | |
| 42 | + | _ = releaseLock(lockFile) | |
| 43 | + | return nil, err | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | data, sizes, total, live, lastGood, err := replayFile(f) | |
| 47 | + | if err != nil { | |
| 48 | + | _ = f.Close() | |
| 49 | + | _ = releaseLock(lockFile) | |
| 50 | + | return nil, err | |
| 51 | + | } | |
| 52 | + | if err := f.Truncate(lastGood); err != nil { | |
| 53 | + | _ = f.Close() | |
| 54 | + | _ = releaseLock(lockFile) | |
| 55 | + | return nil, err | |
| 56 | + | } | |
| 57 | + | if _, err := f.Seek(0, io.SeekEnd); err != nil { | |
| 58 | + | _ = f.Close() | |
| 59 | + | _ = releaseLock(lockFile) | |
| 60 | + | return nil, err | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | s := &Store{path: path, file: f, lockFile: lockFile, data: data, entrySize: sizes, total: total, live: live} | |
| 64 | + | if s.shouldCompact() { | |
| 65 | + | if err := s.compactLocked(); err != nil { | |
| 66 | + | _ = f.Close() | |
| 67 | + | _ = releaseLock(lockFile) | |
| 68 | + | return nil, err | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | return s, nil | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Replay reads path without mutating it and returns the state from valid records. | |
| 75 | + | func Replay(path string) (map[string][]byte, error) { | |
| 76 | + | f, err := os.Open(path) | |
| 77 | + | if errors.Is(err, os.ErrNotExist) { | |
| 78 | + | return map[string][]byte{}, nil | |
| 79 | + | } | |
| 80 | + | if err != nil { | |
| 81 | + | return nil, err | |
| 82 | + | } | |
| 83 | + | defer func() { _ = f.Close() }() | |
| 84 | + | ||
| 85 | + | data, _, _, _, _, err := replayFile(f) | |
| 86 | + | if err != nil { | |
| 87 | + | return nil, err | |
| 88 | + | } | |
| 89 | + | return data, nil | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Get returns a copy of the value for key. | |
| 93 | + | func (s *Store) Get(key []byte) ([]byte, bool) { | |
| 94 | + | s.mu.Lock() | |
| 95 | + | defer s.mu.Unlock() | |
| 96 | + | ||
| 97 | + | v, ok := s.data[string(key)] | |
| 98 | + | if !ok { | |
| 99 | + | return nil, false | |
| 100 | + | } | |
| 101 | + | return append([]byte(nil), v...), true | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Set appends a SET record. It does not fsync. | |
| 105 | + | func (s *Store) Set(key, val []byte) error { | |
| 106 | + | return s.appendRecord(opSet, key, val) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Delete appends a DEL tombstone. It does not fsync. | |
| 110 | + | func (s *Store) Delete(key []byte) error { | |
| 111 | + | return s.appendRecord(opDel, key, nil) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Range calls fn for each key/value pair, using copies. Iteration stops when fn returns false. | |
| 115 | + | func (s *Store) Range(fn func(k, v []byte) bool) { | |
| 116 | + | s.mu.Lock() | |
| 117 | + | keys := make([]string, 0, len(s.data)) | |
| 118 | + | for k := range s.data { | |
| 119 | + | keys = append(keys, k) | |
| 120 | + | } | |
| 121 | + | sort.Strings(keys) | |
| 122 | + | pairs := make([]struct{ k, v []byte }, 0, len(keys)) | |
| 123 | + | for _, k := range keys { | |
| 124 | + | pairs = append(pairs, struct{ k, v []byte }{[]byte(k), append([]byte(nil), s.data[k]...)}) | |
| 125 | + | } | |
| 126 | + | s.mu.Unlock() | |
| 127 | + | ||
| 128 | + | for _, p := range pairs { | |
| 129 | + | if !fn(p.k, p.v) { | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // Sync fsyncs the WAL file. | |
| 136 | + | func (s *Store) Sync() error { | |
| 137 | + | s.mu.Lock() | |
| 138 | + | defer s.mu.Unlock() | |
| 139 | + | if s.closed { | |
| 140 | + | return os.ErrClosed | |
| 141 | + | } | |
| 142 | + | return s.file.Sync() | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // Close fsyncs and closes the store. | |
| 146 | + | func (s *Store) Close() error { | |
| 147 | + | s.mu.Lock() | |
| 148 | + | defer s.mu.Unlock() | |
| 149 | + | if s.closed { | |
| 150 | + | return nil | |
| 151 | + | } | |
| 152 | + | errSync := s.file.Sync() | |
| 153 | + | errClose := s.file.Close() | |
| 154 | + | errUnlock := releaseLock(s.lockFile) | |
| 155 | + | s.closed = true | |
| 156 | + | if errSync != nil { | |
| 157 | + | return errSync | |
| 158 | + | } | |
| 159 | + | if errClose != nil { | |
| 160 | + | return errClose | |
| 161 | + | } | |
| 162 | + | return errUnlock | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func acquireLock(path string) (*os.File, error) { | |
| 166 | + | lf, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600) | |
| 167 | + | if err != nil { | |
| 168 | + | return nil, err | |
| 169 | + | } | |
| 170 | + | if err := syscall.Flock(int(lf.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { | |
| 171 | + | _ = lf.Close() | |
| 172 | + | return nil, err | |
| 173 | + | } | |
| 174 | + | return lf, nil | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func releaseLock(f *os.File) error { | |
| 178 | + | if f == nil { | |
| 179 | + | return nil | |
| 180 | + | } | |
| 181 | + | errUnlock := syscall.Flock(int(f.Fd()), syscall.LOCK_UN) | |
| 182 | + | errClose := f.Close() | |
| 183 | + | if errUnlock != nil { | |
| 184 | + | return errUnlock | |
| 185 | + | } | |
| 186 | + | return errClose | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (s *Store) appendRecord(op byte, key, val []byte) error { | |
| 190 | + | buf, err := encodeRecord(op, key, val) | |
| 191 | + | if err != nil { | |
| 192 | + | return err | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | s.mu.Lock() | |
| 196 | + | defer s.mu.Unlock() | |
| 197 | + | if s.closed { | |
| 198 | + | return os.ErrClosed | |
| 199 | + | } | |
| 200 | + | pos, err := s.file.Seek(0, io.SeekCurrent) | |
| 201 | + | if err != nil { | |
| 202 | + | return err | |
| 203 | + | } | |
| 204 | + | if _, err := s.file.Write(buf); err != nil { | |
| 205 | + | _ = s.file.Truncate(pos) | |
| 206 | + | _, _ = s.file.Seek(pos, io.SeekStart) | |
| 207 | + | return err | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | k := string(key) | |
| 211 | + | if old, ok := s.entrySize[k]; ok { | |
| 212 | + | s.live -= old | |
| 213 | + | } | |
| 214 | + | s.total += int64(len(buf)) | |
| 215 | + | if op == opSet { | |
| 216 | + | s.data[k] = append([]byte(nil), val...) | |
| 217 | + | s.entrySize[k] = int64(len(buf)) | |
| 218 | + | s.live += int64(len(buf)) | |
| 219 | + | } else { | |
| 220 | + | delete(s.data, k) | |
| 221 | + | delete(s.entrySize, k) | |
| 222 | + | } | |
| 223 | + | if s.shouldCompact() { | |
| 224 | + | return s.compactLocked() | |
| 225 | + | } | |
| 226 | + | return nil | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func (s *Store) shouldCompact() bool { | |
| 230 | + | return s.total > compactThreshold && float64(s.total-s.live)/float64(s.total) > compactWasteRatio | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func (s *Store) compactLocked() error { | |
| 234 | + | tmp := s.path + ".compact" | |
| 235 | + | tf, err := os.OpenFile(tmp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) | |
| 236 | + | if err != nil { | |
| 237 | + | return err | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | keys := make([]string, 0, len(s.data)) | |
| 241 | + | for k := range s.data { | |
| 242 | + | keys = append(keys, k) | |
| 243 | + | } | |
| 244 | + | sort.Strings(keys) | |
| 245 | + | ||
| 246 | + | newSizes := make(map[string]int64, len(keys)) | |
| 247 | + | var newTotal, newLive int64 | |
| 248 | + | for _, k := range keys { | |
| 249 | + | buf, err := encodeRecord(opSet, []byte(k), s.data[k]) | |
| 250 | + | if err != nil { | |
| 251 | + | _ = tf.Close() | |
| 252 | + | _ = os.Remove(tmp) | |
| 253 | + | return err | |
| 254 | + | } | |
| 255 | + | if _, err := tf.Write(buf); err != nil { | |
| 256 | + | _ = tf.Close() | |
| 257 | + | _ = os.Remove(tmp) | |
| 258 | + | return err | |
| 259 | + | } | |
| 260 | + | sz := int64(len(buf)) | |
| 261 | + | newSizes[k] = sz | |
| 262 | + | newTotal += sz | |
| 263 | + | newLive += sz | |
| 264 | + | } | |
| 265 | + | if err := tf.Sync(); err != nil { | |
| 266 | + | _ = tf.Close() | |
| 267 | + | _ = os.Remove(tmp) | |
| 268 | + | return err | |
| 269 | + | } | |
| 270 | + | if err := tf.Close(); err != nil { | |
| 271 | + | _ = os.Remove(tmp) | |
| 272 | + | return err | |
| 273 | + | } | |
| 274 | + | if err := s.file.Close(); err != nil { | |
| 275 | + | _ = os.Remove(tmp) | |
| 276 | + | return err | |
| 277 | + | } | |
| 278 | + | if err := os.Rename(tmp, s.path); err != nil { | |
| 279 | + | return err | |
| 280 | + | } | |
| 281 | + | f, err := os.OpenFile(s.path, os.O_RDWR, 0o600) | |
| 282 | + | if err != nil { | |
| 283 | + | return err | |
| 284 | + | } | |
| 285 | + | if _, err := f.Seek(0, io.SeekEnd); err != nil { | |
| 286 | + | _ = f.Close() | |
| 287 | + | return err | |
| 288 | + | } | |
| 289 | + | s.file = f | |
| 290 | + | s.entrySize = newSizes | |
| 291 | + | s.total = newTotal | |
| 292 | + | s.live = newLive | |
| 293 | + | return nil | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func replayFile(f *os.File) (map[string][]byte, map[string]int64, int64, int64, int64, error) { | |
| 297 | + | info, err := f.Stat() | |
| 298 | + | if err != nil { | |
| 299 | + | return nil, nil, 0, 0, 0, err | |
| 300 | + | } | |
| 301 | + | fileSize := info.Size() | |
| 302 | + | if _, err := f.Seek(0, io.SeekStart); err != nil { | |
| 303 | + | return nil, nil, 0, 0, 0, err | |
| 304 | + | } | |
| 305 | + | data := make(map[string][]byte) | |
| 306 | + | sizes := make(map[string]int64) | |
| 307 | + | var total, live, off int64 | |
| 308 | + | header := make([]byte, headerLen) | |
| 309 | + | for { | |
| 310 | + | n, err := io.ReadFull(f, header) | |
| 311 | + | if errors.Is(err, io.EOF) { | |
| 312 | + | break | |
| 313 | + | } | |
| 314 | + | if errors.Is(err, io.ErrUnexpectedEOF) { | |
| 315 | + | _ = n | |
| 316 | + | break | |
| 317 | + | } | |
| 318 | + | if err != nil { | |
| 319 | + | return nil, nil, 0, 0, 0, err | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | payloadLen := binary.BigEndian.Uint32(header[0:4]) | |
| 323 | + | if int64(payloadLen) > fileSize-off-headerLen { | |
| 324 | + | break | |
| 325 | + | } | |
| 326 | + | wantCRC := binary.BigEndian.Uint32(header[4:8]) | |
| 327 | + | payload := make([]byte, int(payloadLen)) | |
| 328 | + | if _, err := io.ReadFull(f, payload); errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) { | |
| 329 | + | break | |
| 330 | + | } else if err != nil { | |
| 331 | + | return nil, nil, 0, 0, 0, err | |
| 332 | + | } | |
| 333 | + | if crc32.ChecksumIEEE(payload) != wantCRC { | |
| 334 | + | break | |
| 335 | + | } | |
| 336 | + | rec, err := decodePayload(payload) | |
| 337 | + | if err != nil { | |
| 338 | + | break | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | sz := int64(headerLen + len(payload)) | |
| 342 | + | k := string(rec.key) | |
| 343 | + | if old, ok := sizes[k]; ok { | |
| 344 | + | live -= old | |
| 345 | + | } | |
| 346 | + | total += sz | |
| 347 | + | if rec.op == opSet { | |
| 348 | + | data[k] = rec.value | |
| 349 | + | sizes[k] = sz | |
| 350 | + | live += sz | |
| 351 | + | } else { | |
| 352 | + | delete(data, k) | |
| 353 | + | delete(sizes, k) | |
| 354 | + | } | |
| 355 | + | off += sz | |
| 356 | + | } | |
| 357 | + | return data, sizes, total, live, off, nil | |
| 358 | + | } | |
| @@ -0,0 +1,358 @@ | |||||
| 1 | + | package kv | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "errors" | |||
| 6 | + | "hash/crc32" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "sort" | |||
| 11 | + | "sync" | |||
| 12 | + | "syscall" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Store is an append-only WAL-backed key/value store. | |||
| 16 | + | type Store struct { | |||
| 17 | + | mu sync.Mutex | |||
| 18 | + | path string | |||
| 19 | + | file *os.File | |||
| 20 | + | lockFile *os.File | |||
| 21 | + | data map[string][]byte | |||
| 22 | + | entrySize map[string]int64 | |||
| 23 | + | total int64 | |||
| 24 | + | live int64 | |||
| 25 | + | closed bool | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // Open opens or creates a store at path, replays valid records, truncates any | |||
| 29 | + | // corrupt or torn tail, and compacts if the file contains enough obsolete data. | |||
| 30 | + | func Open(path string) (*Store, error) { | |||
| 31 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |||
| 32 | + | return nil, err | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | lockFile, err := acquireLock(path) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return nil, err | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) | |||
| 41 | + | if err != nil { | |||
| 42 | + | _ = releaseLock(lockFile) | |||
| 43 | + | return nil, err | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | data, sizes, total, live, lastGood, err := replayFile(f) | |||
| 47 | + | if err != nil { | |||
| 48 | + | _ = f.Close() | |||
| 49 | + | _ = releaseLock(lockFile) | |||
| 50 | + | return nil, err | |||
| 51 | + | } | |||
| 52 | + | if err := f.Truncate(lastGood); err != nil { | |||
| 53 | + | _ = f.Close() | |||
| 54 | + | _ = releaseLock(lockFile) | |||
| 55 | + | return nil, err | |||
| 56 | + | } | |||
| 57 | + | if _, err := f.Seek(0, io.SeekEnd); err != nil { | |||
| 58 | + | _ = f.Close() | |||
| 59 | + | _ = releaseLock(lockFile) | |||
| 60 | + | return nil, err | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | s := &Store{path: path, file: f, lockFile: lockFile, data: data, entrySize: sizes, total: total, live: live} | |||
| 64 | + | if s.shouldCompact() { | |||
| 65 | + | if err := s.compactLocked(); err != nil { | |||
| 66 | + | _ = f.Close() | |||
| 67 | + | _ = releaseLock(lockFile) | |||
| 68 | + | return nil, err | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | return s, nil | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Replay reads path without mutating it and returns the state from valid records. | |||
| 75 | + | func Replay(path string) (map[string][]byte, error) { | |||
| 76 | + | f, err := os.Open(path) | |||
| 77 | + | if errors.Is(err, os.ErrNotExist) { | |||
| 78 | + | return map[string][]byte{}, nil | |||
| 79 | + | } | |||
| 80 | + | if err != nil { | |||
| 81 | + | return nil, err | |||
| 82 | + | } | |||
| 83 | + | defer func() { _ = f.Close() }() | |||
| 84 | + | ||||
| 85 | + | data, _, _, _, _, err := replayFile(f) | |||
| 86 | + | if err != nil { | |||
| 87 | + | return nil, err | |||
| 88 | + | } | |||
| 89 | + | return data, nil | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // Get returns a copy of the value for key. | |||
| 93 | + | func (s *Store) Get(key []byte) ([]byte, bool) { | |||
| 94 | + | s.mu.Lock() | |||
| 95 | + | defer s.mu.Unlock() | |||
| 96 | + | ||||
| 97 | + | v, ok := s.data[string(key)] | |||
| 98 | + | if !ok { | |||
| 99 | + | return nil, false | |||
| 100 | + | } | |||
| 101 | + | return append([]byte(nil), v...), true | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Set appends a SET record. It does not fsync. | |||
| 105 | + | func (s *Store) Set(key, val []byte) error { | |||
| 106 | + | return s.appendRecord(opSet, key, val) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Delete appends a DEL tombstone. It does not fsync. | |||
| 110 | + | func (s *Store) Delete(key []byte) error { | |||
| 111 | + | return s.appendRecord(opDel, key, nil) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // Range calls fn for each key/value pair, using copies. Iteration stops when fn returns false. | |||
| 115 | + | func (s *Store) Range(fn func(k, v []byte) bool) { | |||
| 116 | + | s.mu.Lock() | |||
| 117 | + | keys := make([]string, 0, len(s.data)) | |||
| 118 | + | for k := range s.data { | |||
| 119 | + | keys = append(keys, k) | |||
| 120 | + | } | |||
| 121 | + | sort.Strings(keys) | |||
| 122 | + | pairs := make([]struct{ k, v []byte }, 0, len(keys)) | |||
| 123 | + | for _, k := range keys { | |||
| 124 | + | pairs = append(pairs, struct{ k, v []byte }{[]byte(k), append([]byte(nil), s.data[k]...)}) | |||
| 125 | + | } | |||
| 126 | + | s.mu.Unlock() | |||
| 127 | + | ||||
| 128 | + | for _, p := range pairs { | |||
| 129 | + | if !fn(p.k, p.v) { | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // Sync fsyncs the WAL file. | |||
| 136 | + | func (s *Store) Sync() error { | |||
| 137 | + | s.mu.Lock() | |||
| 138 | + | defer s.mu.Unlock() | |||
| 139 | + | if s.closed { | |||
| 140 | + | return os.ErrClosed | |||
| 141 | + | } | |||
| 142 | + | return s.file.Sync() | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // Close fsyncs and closes the store. | |||
| 146 | + | func (s *Store) Close() error { | |||
| 147 | + | s.mu.Lock() | |||
| 148 | + | defer s.mu.Unlock() | |||
| 149 | + | if s.closed { | |||
| 150 | + | return nil | |||
| 151 | + | } | |||
| 152 | + | errSync := s.file.Sync() | |||
| 153 | + | errClose := s.file.Close() | |||
| 154 | + | errUnlock := releaseLock(s.lockFile) | |||
| 155 | + | s.closed = true | |||
| 156 | + | if errSync != nil { | |||
| 157 | + | return errSync | |||
| 158 | + | } | |||
| 159 | + | if errClose != nil { | |||
| 160 | + | return errClose | |||
| 161 | + | } | |||
| 162 | + | return errUnlock | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func acquireLock(path string) (*os.File, error) { | |||
| 166 | + | lf, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600) | |||
| 167 | + | if err != nil { | |||
| 168 | + | return nil, err | |||
| 169 | + | } | |||
| 170 | + | if err := syscall.Flock(int(lf.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { | |||
| 171 | + | _ = lf.Close() | |||
| 172 | + | return nil, err | |||
| 173 | + | } | |||
| 174 | + | return lf, nil | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func releaseLock(f *os.File) error { | |||
| 178 | + | if f == nil { | |||
| 179 | + | return nil | |||
| 180 | + | } | |||
| 181 | + | errUnlock := syscall.Flock(int(f.Fd()), syscall.LOCK_UN) | |||
| 182 | + | errClose := f.Close() | |||
| 183 | + | if errUnlock != nil { | |||
| 184 | + | return errUnlock | |||
| 185 | + | } | |||
| 186 | + | return errClose | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (s *Store) appendRecord(op byte, key, val []byte) error { | |||
| 190 | + | buf, err := encodeRecord(op, key, val) | |||
| 191 | + | if err != nil { | |||
| 192 | + | return err | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | s.mu.Lock() | |||
| 196 | + | defer s.mu.Unlock() | |||
| 197 | + | if s.closed { | |||
| 198 | + | return os.ErrClosed | |||
| 199 | + | } | |||
| 200 | + | pos, err := s.file.Seek(0, io.SeekCurrent) | |||
| 201 | + | if err != nil { | |||
| 202 | + | return err | |||
| 203 | + | } | |||
| 204 | + | if _, err := s.file.Write(buf); err != nil { | |||
| 205 | + | _ = s.file.Truncate(pos) | |||
| 206 | + | _, _ = s.file.Seek(pos, io.SeekStart) | |||
| 207 | + | return err | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | k := string(key) | |||
| 211 | + | if old, ok := s.entrySize[k]; ok { | |||
| 212 | + | s.live -= old | |||
| 213 | + | } | |||
| 214 | + | s.total += int64(len(buf)) | |||
| 215 | + | if op == opSet { | |||
| 216 | + | s.data[k] = append([]byte(nil), val...) | |||
| 217 | + | s.entrySize[k] = int64(len(buf)) | |||
| 218 | + | s.live += int64(len(buf)) | |||
| 219 | + | } else { | |||
| 220 | + | delete(s.data, k) | |||
| 221 | + | delete(s.entrySize, k) | |||
| 222 | + | } | |||
| 223 | + | if s.shouldCompact() { | |||
| 224 | + | return s.compactLocked() | |||
| 225 | + | } | |||
| 226 | + | return nil | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func (s *Store) shouldCompact() bool { | |||
| 230 | + | return s.total > compactThreshold && float64(s.total-s.live)/float64(s.total) > compactWasteRatio | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func (s *Store) compactLocked() error { | |||
| 234 | + | tmp := s.path + ".compact" | |||
| 235 | + | tf, err := os.OpenFile(tmp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) | |||
| 236 | + | if err != nil { | |||
| 237 | + | return err | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | keys := make([]string, 0, len(s.data)) | |||
| 241 | + | for k := range s.data { | |||
| 242 | + | keys = append(keys, k) | |||
| 243 | + | } | |||
| 244 | + | sort.Strings(keys) | |||
| 245 | + | ||||
| 246 | + | newSizes := make(map[string]int64, len(keys)) | |||
| 247 | + | var newTotal, newLive int64 | |||
| 248 | + | for _, k := range keys { | |||
| 249 | + | buf, err := encodeRecord(opSet, []byte(k), s.data[k]) | |||
| 250 | + | if err != nil { | |||
| 251 | + | _ = tf.Close() | |||
| 252 | + | _ = os.Remove(tmp) | |||
| 253 | + | return err | |||
| 254 | + | } | |||
| 255 | + | if _, err := tf.Write(buf); err != nil { | |||
| 256 | + | _ = tf.Close() | |||
| 257 | + | _ = os.Remove(tmp) | |||
| 258 | + | return err | |||
| 259 | + | } | |||
| 260 | + | sz := int64(len(buf)) | |||
| 261 | + | newSizes[k] = sz | |||
| 262 | + | newTotal += sz | |||
| 263 | + | newLive += sz | |||
| 264 | + | } | |||
| 265 | + | if err := tf.Sync(); err != nil { | |||
| 266 | + | _ = tf.Close() | |||
| 267 | + | _ = os.Remove(tmp) | |||
| 268 | + | return err | |||
| 269 | + | } | |||
| 270 | + | if err := tf.Close(); err != nil { | |||
| 271 | + | _ = os.Remove(tmp) | |||
| 272 | + | return err | |||
| 273 | + | } | |||
| 274 | + | if err := s.file.Close(); err != nil { | |||
| 275 | + | _ = os.Remove(tmp) | |||
| 276 | + | return err | |||
| 277 | + | } | |||
| 278 | + | if err := os.Rename(tmp, s.path); err != nil { | |||
| 279 | + | return err | |||
| 280 | + | } | |||
| 281 | + | f, err := os.OpenFile(s.path, os.O_RDWR, 0o600) | |||
| 282 | + | if err != nil { | |||
| 283 | + | return err | |||
| 284 | + | } | |||
| 285 | + | if _, err := f.Seek(0, io.SeekEnd); err != nil { | |||
| 286 | + | _ = f.Close() | |||
| 287 | + | return err | |||
| 288 | + | } | |||
| 289 | + | s.file = f | |||
| 290 | + | s.entrySize = newSizes | |||
| 291 | + | s.total = newTotal | |||
| 292 | + | s.live = newLive | |||
| 293 | + | return nil | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func replayFile(f *os.File) (map[string][]byte, map[string]int64, int64, int64, int64, error) { | |||
| 297 | + | info, err := f.Stat() | |||
| 298 | + | if err != nil { | |||
| 299 | + | return nil, nil, 0, 0, 0, err | |||
| 300 | + | } | |||
| 301 | + | fileSize := info.Size() | |||
| 302 | + | if _, err := f.Seek(0, io.SeekStart); err != nil { | |||
| 303 | + | return nil, nil, 0, 0, 0, err | |||
| 304 | + | } | |||
| 305 | + | data := make(map[string][]byte) | |||
| 306 | + | sizes := make(map[string]int64) | |||
| 307 | + | var total, live, off int64 | |||
| 308 | + | header := make([]byte, headerLen) | |||
| 309 | + | for { | |||
| 310 | + | n, err := io.ReadFull(f, header) | |||
| 311 | + | if errors.Is(err, io.EOF) { | |||
| 312 | + | break | |||
| 313 | + | } | |||
| 314 | + | if errors.Is(err, io.ErrUnexpectedEOF) { | |||
| 315 | + | _ = n | |||
| 316 | + | break | |||
| 317 | + | } | |||
| 318 | + | if err != nil { | |||
| 319 | + | return nil, nil, 0, 0, 0, err | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | payloadLen := binary.BigEndian.Uint32(header[0:4]) | |||
| 323 | + | if int64(payloadLen) > fileSize-off-headerLen { | |||
| 324 | + | break | |||
| 325 | + | } | |||
| 326 | + | wantCRC := binary.BigEndian.Uint32(header[4:8]) | |||
| 327 | + | payload := make([]byte, int(payloadLen)) | |||
| 328 | + | if _, err := io.ReadFull(f, payload); errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) { | |||
| 329 | + | break | |||
| 330 | + | } else if err != nil { | |||
| 331 | + | return nil, nil, 0, 0, 0, err | |||
| 332 | + | } | |||
| 333 | + | if crc32.ChecksumIEEE(payload) != wantCRC { | |||
| 334 | + | break | |||
| 335 | + | } | |||
| 336 | + | rec, err := decodePayload(payload) | |||
| 337 | + | if err != nil { | |||
| 338 | + | break | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | sz := int64(headerLen + len(payload)) | |||
| 342 | + | k := string(rec.key) | |||
| 343 | + | if old, ok := sizes[k]; ok { | |||
| 344 | + | live -= old | |||
| 345 | + | } | |||
| 346 | + | total += sz | |||
| 347 | + | if rec.op == opSet { | |||
| 348 | + | data[k] = rec.value | |||
| 349 | + | sizes[k] = sz | |||
| 350 | + | live += sz | |||
| 351 | + | } else { | |||
| 352 | + | delete(data, k) | |||
| 353 | + | delete(sizes, k) | |||
| 354 | + | } | |||
| 355 | + | off += sz | |||
| 356 | + | } | |||
| 357 | + | return data, sizes, total, live, off, nil | |||
| 358 | + | } | |||
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 | + | } | |||