feat(app,client): attach flow, CLI dispatch, daemon auto-spawn with mkdir lock
- commit
- 2795b89f1ad8d43b2ac0b2c9301c94949f62ea6a
- author
- brice · · 2026-07-02 11:29 UTC
- parent
- 36aa808
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +1337 -3
A internal/adapters/ipc/dial.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | package ipc | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "net" | |
| 6 | + | "path/filepath" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Dial connects to the daemon's socket in dir (dir/daemon.sock) and returns | |
| 12 | + | // a Transport speaking vev's framed protocol. A dial failure (no daemon | |
| 13 | + | // listening, or a stale/absent socket) is reported as a wrapped error so | |
| 14 | + | // callers can decide whether to spawn a daemon and retry. | |
| 15 | + | func Dial(dir string) (ports.Transport, error) { | |
| 16 | + | sockPath := filepath.Join(dir, socketFileName) | |
| 17 | + | conn, err := net.Dial("unix", sockPath) | |
| 18 | + | if err != nil { | |
| 19 | + | return nil, fmt.Errorf("ipc: dial %s: %w", sockPath, err) | |
| 20 | + | } | |
| 21 | + | return NewTransport(conn), nil | |
| 22 | + | } | |
| @@ -0,0 +1,22 @@ | |||||
| 1 | + | package ipc | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "net" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Dial connects to the daemon's socket in dir (dir/daemon.sock) and returns | |||
| 12 | + | // a Transport speaking vev's framed protocol. A dial failure (no daemon | |||
| 13 | + | // listening, or a stale/absent socket) is reported as a wrapped error so | |||
| 14 | + | // callers can decide whether to spawn a daemon and retry. | |||
| 15 | + | func Dial(dir string) (ports.Transport, error) { | |||
| 16 | + | sockPath := filepath.Join(dir, socketFileName) | |||
| 17 | + | conn, err := net.Dial("unix", sockPath) | |||
| 18 | + | if err != nil { | |||
| 19 | + | return nil, fmt.Errorf("ipc: dial %s: %w", sockPath, err) | |||
| 20 | + | } | |||
| 21 | + | return NewTransport(conn), nil | |||
| 22 | + | } | |||
A internal/app/logging.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "log/slog" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // 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). | |
| 13 | + | const logFileName = "vev.log" | |
| 14 | + | ||
| 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. | |
| 30 | + | func parseLogLevel(s string) slog.Level { | |
| 31 | + | switch strings.ToLower(strings.TrimSpace(s)) { | |
| 32 | + | case "debug": | |
| 33 | + | return slog.LevelDebug | |
| 34 | + | case "warn", "warning": | |
| 35 | + | return slog.LevelWarn | |
| 36 | + | case "error": | |
| 37 | + | return slog.LevelError | |
| 38 | + | default: | |
| 39 | + | return slog.LevelInfo | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // setupLogging installs a JSON slog handler writing to the shared log file | |
| 44 | + | // and returns the open file so the caller can close it on exit. The level | |
| 45 | + | // comes from VEV_LOG (default info). Both the daemon and the client route | |
| 46 | + | // diagnostics here — the client because console output while the terminal is | |
| 47 | + | // raw would corrupt the display. | |
| 48 | + | // | |
| 49 | + | // No log rotation is performed for the MVP (future work). | |
| 50 | + | func setupLogging() (*os.File, error) { | |
| 51 | + | dir := stateDir() | |
| 52 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 53 | + | return nil, fmt.Errorf("vev: creating state dir: %w", err) | |
| 54 | + | } | |
| 55 | + | f, err := os.OpenFile(filepath.Join(dir, logFileName), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) | |
| 56 | + | if err != nil { | |
| 57 | + | return nil, fmt.Errorf("vev: opening log file: %w", err) | |
| 58 | + | } | |
| 59 | + | handler := slog.NewJSONHandler(f, &slog.HandlerOptions{Level: parseLogLevel(os.Getenv("VEV_LOG"))}) | |
| 60 | + | slog.SetDefault(slog.New(handler)) | |
| 61 | + | return f, nil | |
| 62 | + | } | |
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "log/slog" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // 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). | |||
| 13 | + | const logFileName = "vev.log" | |||
| 14 | + | ||||
| 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. | |||
| 30 | + | func parseLogLevel(s string) slog.Level { | |||
| 31 | + | switch strings.ToLower(strings.TrimSpace(s)) { | |||
| 32 | + | case "debug": | |||
| 33 | + | return slog.LevelDebug | |||
| 34 | + | case "warn", "warning": | |||
| 35 | + | return slog.LevelWarn | |||
| 36 | + | case "error": | |||
| 37 | + | return slog.LevelError | |||
| 38 | + | default: | |||
| 39 | + | return slog.LevelInfo | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // setupLogging installs a JSON slog handler writing to the shared log file | |||
| 44 | + | // and returns the open file so the caller can close it on exit. The level | |||
| 45 | + | // comes from VEV_LOG (default info). Both the daemon and the client route | |||
| 46 | + | // diagnostics here — the client because console output while the terminal is | |||
| 47 | + | // raw would corrupt the display. | |||
| 48 | + | // | |||
| 49 | + | // No log rotation is performed for the MVP (future work). | |||
| 50 | + | func setupLogging() (*os.File, error) { | |||
| 51 | + | dir := stateDir() | |||
| 52 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 53 | + | return nil, fmt.Errorf("vev: creating state dir: %w", err) | |||
| 54 | + | } | |||
| 55 | + | f, err := os.OpenFile(filepath.Join(dir, logFileName), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) | |||
| 56 | + | if err != nil { | |||
| 57 | + | return nil, fmt.Errorf("vev: opening log file: %w", err) | |||
| 58 | + | } | |||
| 59 | + | handler := slog.NewJSONHandler(f, &slog.HandlerOptions{Level: parseLogLevel(os.Getenv("VEV_LOG"))}) | |||
| 60 | + | slog.SetDefault(slog.New(handler)) | |||
| 61 | + | return f, nil | |||
| 62 | + | } | |||
M internal/app/run.go +249 -3
| @@ -1,8 +1,254 @@ | |||
| 1 | - | // Package app wires vev's runtime together via dependency injection. | |
| 1 | + | // Package app wires vev's runtime together via dependency injection. It is | |
| 2 | + | // the single place where usecases (client, daemon) are connected to concrete | |
| 3 | + | // adapters (ipc transport/listener, terminal); usecases themselves never | |
| 4 | + | // import adapters. | |
| 2 | 5 | package app | |
| 3 | 6 | ||
| 4 | - | // Run is the entry point invoked by main. It parses no flags yet and | |
| 5 | - | // performs no work; later milestones will wire the application here. | |
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "log/slog" | |
| 13 | + | "os" | |
| 14 | + | "text/tabwriter" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 17 | + | "github.com/bnema/vev/internal/adapters/term" | |
| 18 | + | "github.com/bnema/vev/internal/ports" | |
| 19 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 20 | + | "github.com/bnema/vev/internal/usecase/daemon" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // cmdKind identifies which sub-command the CLI parsed. | |
| 24 | + | type cmdKind int | |
| 25 | + | ||
| 26 | + | const ( | |
| 27 | + | kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent | |
| 28 | + | kindList | |
| 29 | + | kindKill | |
| 30 | + | kindDaemon | |
| 31 | + | kindHelp | |
| 32 | + | kindVersion | |
| 33 | + | ) | |
| 34 | + | ||
| 35 | + | // command is the parsed CLI invocation: what to do, plus the attach intent | |
| 36 | + | // and session name where relevant. | |
| 37 | + | type command struct { | |
| 38 | + | kind cmdKind | |
| 39 | + | intent uint8 | |
| 40 | + | name string | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // usageError is a user-facing argument error; the app prints it (with usage) | |
| 44 | + | // rather than a stack of wrapped internals. | |
| 45 | + | type usageError struct{ msg string } | |
| 46 | + | ||
| 47 | + | func (e *usageError) Error() string { return "vev: " + e.msg } | |
| 48 | + | ||
| 49 | + | func usagef(format string, args ...any) error { | |
| 50 | + | return &usageError{msg: fmt.Sprintf(format, args...)} | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | const usageText = `vev — a terminal multiplexer | |
| 54 | + | ||
| 55 | + | usage: | |
| 56 | + | vev attach to (or create) an ephemeral session | |
| 57 | + | vev new <name> create and attach to a named session | |
| 58 | + | vev attach <name> attach to an existing named session (alias: a) | |
| 59 | + | vev ls list sessions | |
| 60 | + | vev kill <name> kill a named session | |
| 61 | + | vev --help show this help | |
| 62 | + | vev --version show version` | |
| 63 | + | ||
| 64 | + | // version is vev's reported build version. Wired here for the MVP; a build | |
| 65 | + | // stamp can replace it later. | |
| 66 | + | const version = "0.1.0-dev" | |
| 67 | + | ||
| 68 | + | // Run is the entry point invoked by main. It parses args into a command and | |
| 69 | + | // dispatches it. | |
| 6 | 70 | func Run(args []string) error { | |
| 71 | + | cmd, err := parseArgs(args) | |
| 72 | + | if err != nil { | |
| 73 | + | return err | |
| 74 | + | } | |
| 75 | + | return dispatch(context.Background(), cmd) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // parseArgs turns the raw argv tail into a command. It is deliberately a | |
| 79 | + | // pure function so the full dispatch table can be unit-tested without any | |
| 80 | + | // I/O. | |
| 81 | + | func parseArgs(args []string) (command, error) { | |
| 82 | + | if len(args) == 0 { | |
| 83 | + | return command{kind: kindAttach, intent: ipc.IntentEphemeral}, nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | switch args[0] { | |
| 87 | + | case "--daemon": | |
| 88 | + | return command{kind: kindDaemon}, nil | |
| 89 | + | case "new": | |
| 90 | + | if len(args) < 2 || args[1] == "" { | |
| 91 | + | return command{}, usagef("`new` requires a session name") | |
| 92 | + | } | |
| 93 | + | return command{kind: kindAttach, intent: ipc.IntentNew, name: args[1]}, nil | |
| 94 | + | case "attach", "a": | |
| 95 | + | if len(args) < 2 || args[1] == "" { | |
| 96 | + | return command{}, usagef("`attach` requires a session name") | |
| 97 | + | } | |
| 98 | + | return command{kind: kindAttach, intent: ipc.IntentAttach, name: args[1]}, nil | |
| 99 | + | case "ls", "list": | |
| 100 | + | return command{kind: kindList}, nil | |
| 101 | + | case "kill": | |
| 102 | + | if len(args) < 2 || args[1] == "" { | |
| 103 | + | return command{}, usagef("`kill` requires a session name") | |
| 104 | + | } | |
| 105 | + | return command{kind: kindKill, name: args[1]}, nil | |
| 106 | + | case "-h", "--help", "help": | |
| 107 | + | return command{kind: kindHelp}, nil | |
| 108 | + | case "--version", "version": | |
| 109 | + | return command{kind: kindVersion}, nil | |
| 110 | + | default: | |
| 111 | + | return command{}, usagef("unknown command %q", args[0]) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // dispatch runs the parsed command. | |
| 116 | + | func dispatch(ctx context.Context, cmd command) error { | |
| 117 | + | switch cmd.kind { | |
| 118 | + | case kindHelp: | |
| 119 | + | fmt.Println(usageText) | |
| 120 | + | return nil | |
| 121 | + | case kindVersion: | |
| 122 | + | fmt.Println("vev", version) | |
| 123 | + | return nil | |
| 124 | + | case kindDaemon: | |
| 125 | + | return runDaemon() | |
| 126 | + | case kindList: | |
| 127 | + | return runList(ctx) | |
| 128 | + | case kindKill: | |
| 129 | + | return runKill(ctx, cmd.name) | |
| 130 | + | case kindAttach: | |
| 131 | + | return runAttach(ctx, cmd.intent, cmd.name) | |
| 132 | + | default: | |
| 133 | + | return usagef("unhandled command") | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | |
| 138 | + | // entered by an auto-spawned child). The daemon core lands in Task 11; here | |
| 139 | + | // we set up logging, bind the socket, and hand off to daemon.Serve. | |
| 140 | + | func runDaemon() error { | |
| 141 | + | logFile, err := setupLogging() | |
| 142 | + | if err != nil { | |
| 143 | + | return err | |
| 144 | + | } | |
| 145 | + | defer logFile.Close() | |
| 146 | + | ||
| 147 | + | ln, err := ipc.Listen(ipc.SocketDir()) | |
| 148 | + | if err != nil { | |
| 149 | + | return fmt.Errorf("vev: daemon listen: %w", err) | |
| 150 | + | } | |
| 151 | + | defer ln.Close() | |
| 152 | + | ||
| 153 | + | slog.Info("daemon starting", "socket", ln.Addr()) | |
| 154 | + | err = daemon.Serve(ln) | |
| 155 | + | slog.Error("daemon exited", "err", err) | |
| 156 | + | return err | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // runAttach dials (auto-spawning the daemon if needed) and runs the client | |
| 160 | + | // attach loop. Logging goes to the shared file: the client must never write | |
| 161 | + | // to the console while the terminal is raw. | |
| 162 | + | func runAttach(ctx context.Context, intent uint8, name string) error { | |
| 163 | + | logFile, err := setupLogging() | |
| 164 | + | if err != nil { | |
| 165 | + | return err | |
| 166 | + | } | |
| 167 | + | defer logFile.Close() | |
| 168 | + | ||
| 169 | + | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 170 | + | if err != nil { | |
| 171 | + | return err | |
| 172 | + | } | |
| 173 | + | // client.Attach owns and closes transport. | |
| 174 | + | return client.Attach(ctx, transport, term.New(), intent, name) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // runList prints the daemon's session listing. With no daemon running there | |
| 178 | + | // are, by definition, no sessions. | |
| 179 | + | func runList(_ context.Context) error { | |
| 180 | + | transport, err := realDial(ipc.SocketDir()) | |
| 181 | + | if err != nil { | |
| 182 | + | fmt.Println("no sessions") | |
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | defer transport.Close() | |
| 186 | + | ||
| 187 | + | if err := transport.Send(ports.Frame{Type: ports.MsgList, Payload: ipc.MarshalList(ipc.List{})}); err != nil { | |
| 188 | + | return fmt.Errorf("vev: requesting session list: %w", err) | |
| 189 | + | } | |
| 190 | + | reply, err := transport.Recv() | |
| 191 | + | if err != nil { | |
| 192 | + | return fmt.Errorf("vev: reading session list: %w", err) | |
| 193 | + | } | |
| 194 | + | if reply.Type == ports.MsgError { | |
| 195 | + | em, _ := ipc.UnmarshalErrorMsg(reply.Payload) | |
| 196 | + | return fmt.Errorf("vev: %s", em.Text) | |
| 197 | + | } | |
| 198 | + | if reply.Type != ports.MsgSessions { | |
| 199 | + | return fmt.Errorf("vev: unexpected reply type %d to list", reply.Type) | |
| 200 | + | } | |
| 201 | + | sessions, err := ipc.UnmarshalSessions(reply.Payload) | |
| 202 | + | if err != nil { | |
| 203 | + | return fmt.Errorf("vev: decoding session list: %w", err) | |
| 204 | + | } | |
| 205 | + | printSessions(os.Stdout, sessions.Sessions) | |
| 206 | + | return nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // printSessions renders a session table (or a friendly note when empty). | |
| 210 | + | func printSessions(w io.Writer, sessions []ipc.SessionInfo) { | |
| 211 | + | if len(sessions) == 0 { | |
| 212 | + | fmt.Fprintln(w, "no sessions") | |
| 213 | + | return | |
| 214 | + | } | |
| 215 | + | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | |
| 216 | + | fmt.Fprintln(tw, "NAME\tWINDOWS\tATTACHED") | |
| 217 | + | for _, s := range sessions { | |
| 218 | + | attached := "no" | |
| 219 | + | if s.Attached { | |
| 220 | + | attached = "yes" | |
| 221 | + | } | |
| 222 | + | fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Windows, attached) | |
| 223 | + | } | |
| 224 | + | _ = tw.Flush() | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // runKill asks the daemon to terminate a named session. | |
| 228 | + | func runKill(_ context.Context, name string) error { | |
| 229 | + | transport, err := realDial(ipc.SocketDir()) | |
| 230 | + | if err != nil { | |
| 231 | + | return fmt.Errorf("vev: no daemon running") | |
| 232 | + | } | |
| 233 | + | defer transport.Close() | |
| 234 | + | ||
| 235 | + | if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ipc.MarshalKill(ipc.Kill{Name: name})}); err != nil { | |
| 236 | + | return fmt.Errorf("vev: requesting kill: %w", err) | |
| 237 | + | } | |
| 238 | + | reply, err := transport.Recv() | |
| 239 | + | if err != nil { | |
| 240 | + | // The daemon may close the connection after killing; treat a clean | |
| 241 | + | // EOF as success. | |
| 242 | + | if errors.Is(err, io.EOF) { | |
| 243 | + | fmt.Printf("killed %s\n", name) | |
| 244 | + | return nil | |
| 245 | + | } | |
| 246 | + | return fmt.Errorf("vev: reading kill reply: %w", err) | |
| 247 | + | } | |
| 248 | + | if reply.Type == ports.MsgError { | |
| 249 | + | em, _ := ipc.UnmarshalErrorMsg(reply.Payload) | |
| 250 | + | return fmt.Errorf("vev: %s", em.Text) | |
| 251 | + | } | |
| 252 | + | fmt.Printf("killed %s\n", name) | |
| 7 | 253 | return nil | |
| 8 | 254 | } | |
| @@ -1,8 +1,254 @@ | |||||
| 1 | - | // Package app wires vev's runtime together via dependency injection. | 1 | + | // Package app wires vev's runtime together via dependency injection. It is |
| 2 | + | // the single place where usecases (client, daemon) are connected to concrete | |||
| 3 | + | // adapters (ipc transport/listener, terminal); usecases themselves never | |||
| 4 | + | // import adapters. | |||
| 2 | package app | 5 | package app | ||
| 3 | 6 | ||||
| 4 | - | // Run is the entry point invoked by main. It parses no flags yet and | 7 | + | import ( |
| 5 | - | // performs no work; later milestones will wire the application here. | 8 | + | "context" |
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "io" | |||
| 12 | + | "log/slog" | |||
| 13 | + | "os" | |||
| 14 | + | "text/tabwriter" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 17 | + | "github.com/bnema/vev/internal/adapters/term" | |||
| 18 | + | "github.com/bnema/vev/internal/ports" | |||
| 19 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 20 | + | "github.com/bnema/vev/internal/usecase/daemon" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // cmdKind identifies which sub-command the CLI parsed. | |||
| 24 | + | type cmdKind int | |||
| 25 | + | ||||
| 26 | + | const ( | |||
| 27 | + | kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent | |||
| 28 | + | kindList | |||
| 29 | + | kindKill | |||
| 30 | + | kindDaemon | |||
| 31 | + | kindHelp | |||
| 32 | + | kindVersion | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | // command is the parsed CLI invocation: what to do, plus the attach intent | |||
| 36 | + | // and session name where relevant. | |||
| 37 | + | type command struct { | |||
| 38 | + | kind cmdKind | |||
| 39 | + | intent uint8 | |||
| 40 | + | name string | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // usageError is a user-facing argument error; the app prints it (with usage) | |||
| 44 | + | // rather than a stack of wrapped internals. | |||
| 45 | + | type usageError struct{ msg string } | |||
| 46 | + | ||||
| 47 | + | func (e *usageError) Error() string { return "vev: " + e.msg } | |||
| 48 | + | ||||
| 49 | + | func usagef(format string, args ...any) error { | |||
| 50 | + | return &usageError{msg: fmt.Sprintf(format, args...)} | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | const usageText = `vev — a terminal multiplexer | |||
| 54 | + | ||||
| 55 | + | usage: | |||
| 56 | + | vev attach to (or create) an ephemeral session | |||
| 57 | + | vev new <name> create and attach to a named session | |||
| 58 | + | vev attach <name> attach to an existing named session (alias: a) | |||
| 59 | + | vev ls list sessions | |||
| 60 | + | vev kill <name> kill a named session | |||
| 61 | + | vev --help show this help | |||
| 62 | + | vev --version show version` | |||
| 63 | + | ||||
| 64 | + | // version is vev's reported build version. Wired here for the MVP; a build | |||
| 65 | + | // stamp can replace it later. | |||
| 66 | + | const version = "0.1.0-dev" | |||
| 67 | + | ||||
| 68 | + | // Run is the entry point invoked by main. It parses args into a command and | |||
| 69 | + | // dispatches it. | |||
| 6 | func Run(args []string) error { | 70 | func Run(args []string) error { | ||
| 71 | + | cmd, err := parseArgs(args) | |||
| 72 | + | if err != nil { | |||
| 73 | + | return err | |||
| 74 | + | } | |||
| 75 | + | return dispatch(context.Background(), cmd) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // parseArgs turns the raw argv tail into a command. It is deliberately a | |||
| 79 | + | // pure function so the full dispatch table can be unit-tested without any | |||
| 80 | + | // I/O. | |||
| 81 | + | func parseArgs(args []string) (command, error) { | |||
| 82 | + | if len(args) == 0 { | |||
| 83 | + | return command{kind: kindAttach, intent: ipc.IntentEphemeral}, nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | switch args[0] { | |||
| 87 | + | case "--daemon": | |||
| 88 | + | return command{kind: kindDaemon}, nil | |||
| 89 | + | case "new": | |||
| 90 | + | if len(args) < 2 || args[1] == "" { | |||
| 91 | + | return command{}, usagef("`new` requires a session name") | |||
| 92 | + | } | |||
| 93 | + | return command{kind: kindAttach, intent: ipc.IntentNew, name: args[1]}, nil | |||
| 94 | + | case "attach", "a": | |||
| 95 | + | if len(args) < 2 || args[1] == "" { | |||
| 96 | + | return command{}, usagef("`attach` requires a session name") | |||
| 97 | + | } | |||
| 98 | + | return command{kind: kindAttach, intent: ipc.IntentAttach, name: args[1]}, nil | |||
| 99 | + | case "ls", "list": | |||
| 100 | + | return command{kind: kindList}, nil | |||
| 101 | + | case "kill": | |||
| 102 | + | if len(args) < 2 || args[1] == "" { | |||
| 103 | + | return command{}, usagef("`kill` requires a session name") | |||
| 104 | + | } | |||
| 105 | + | return command{kind: kindKill, name: args[1]}, nil | |||
| 106 | + | case "-h", "--help", "help": | |||
| 107 | + | return command{kind: kindHelp}, nil | |||
| 108 | + | case "--version", "version": | |||
| 109 | + | return command{kind: kindVersion}, nil | |||
| 110 | + | default: | |||
| 111 | + | return command{}, usagef("unknown command %q", args[0]) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // dispatch runs the parsed command. | |||
| 116 | + | func dispatch(ctx context.Context, cmd command) error { | |||
| 117 | + | switch cmd.kind { | |||
| 118 | + | case kindHelp: | |||
| 119 | + | fmt.Println(usageText) | |||
| 120 | + | return nil | |||
| 121 | + | case kindVersion: | |||
| 122 | + | fmt.Println("vev", version) | |||
| 123 | + | return nil | |||
| 124 | + | case kindDaemon: | |||
| 125 | + | return runDaemon() | |||
| 126 | + | case kindList: | |||
| 127 | + | return runList(ctx) | |||
| 128 | + | case kindKill: | |||
| 129 | + | return runKill(ctx, cmd.name) | |||
| 130 | + | case kindAttach: | |||
| 131 | + | return runAttach(ctx, cmd.intent, cmd.name) | |||
| 132 | + | default: | |||
| 133 | + | return usagef("unhandled command") | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | |||
| 138 | + | // entered by an auto-spawned child). The daemon core lands in Task 11; here | |||
| 139 | + | // we set up logging, bind the socket, and hand off to daemon.Serve. | |||
| 140 | + | func runDaemon() error { | |||
| 141 | + | logFile, err := setupLogging() | |||
| 142 | + | if err != nil { | |||
| 143 | + | return err | |||
| 144 | + | } | |||
| 145 | + | defer logFile.Close() | |||
| 146 | + | ||||
| 147 | + | ln, err := ipc.Listen(ipc.SocketDir()) | |||
| 148 | + | if err != nil { | |||
| 149 | + | return fmt.Errorf("vev: daemon listen: %w", err) | |||
| 150 | + | } | |||
| 151 | + | defer ln.Close() | |||
| 152 | + | ||||
| 153 | + | slog.Info("daemon starting", "socket", ln.Addr()) | |||
| 154 | + | err = daemon.Serve(ln) | |||
| 155 | + | slog.Error("daemon exited", "err", err) | |||
| 156 | + | return err | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // runAttach dials (auto-spawning the daemon if needed) and runs the client | |||
| 160 | + | // attach loop. Logging goes to the shared file: the client must never write | |||
| 161 | + | // to the console while the terminal is raw. | |||
| 162 | + | func runAttach(ctx context.Context, intent uint8, name string) error { | |||
| 163 | + | logFile, err := setupLogging() | |||
| 164 | + | if err != nil { | |||
| 165 | + | return err | |||
| 166 | + | } | |||
| 167 | + | defer logFile.Close() | |||
| 168 | + | ||||
| 169 | + | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |||
| 170 | + | if err != nil { | |||
| 171 | + | return err | |||
| 172 | + | } | |||
| 173 | + | // client.Attach owns and closes transport. | |||
| 174 | + | return client.Attach(ctx, transport, term.New(), intent, name) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // runList prints the daemon's session listing. With no daemon running there | |||
| 178 | + | // are, by definition, no sessions. | |||
| 179 | + | func runList(_ context.Context) error { | |||
| 180 | + | transport, err := realDial(ipc.SocketDir()) | |||
| 181 | + | if err != nil { | |||
| 182 | + | fmt.Println("no sessions") | |||
| 183 | + | return nil | |||
| 184 | + | } | |||
| 185 | + | defer transport.Close() | |||
| 186 | + | ||||
| 187 | + | if err := transport.Send(ports.Frame{Type: ports.MsgList, Payload: ipc.MarshalList(ipc.List{})}); err != nil { | |||
| 188 | + | return fmt.Errorf("vev: requesting session list: %w", err) | |||
| 189 | + | } | |||
| 190 | + | reply, err := transport.Recv() | |||
| 191 | + | if err != nil { | |||
| 192 | + | return fmt.Errorf("vev: reading session list: %w", err) | |||
| 193 | + | } | |||
| 194 | + | if reply.Type == ports.MsgError { | |||
| 195 | + | em, _ := ipc.UnmarshalErrorMsg(reply.Payload) | |||
| 196 | + | return fmt.Errorf("vev: %s", em.Text) | |||
| 197 | + | } | |||
| 198 | + | if reply.Type != ports.MsgSessions { | |||
| 199 | + | return fmt.Errorf("vev: unexpected reply type %d to list", reply.Type) | |||
| 200 | + | } | |||
| 201 | + | sessions, err := ipc.UnmarshalSessions(reply.Payload) | |||
| 202 | + | if err != nil { | |||
| 203 | + | return fmt.Errorf("vev: decoding session list: %w", err) | |||
| 204 | + | } | |||
| 205 | + | printSessions(os.Stdout, sessions.Sessions) | |||
| 206 | + | return nil | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // printSessions renders a session table (or a friendly note when empty). | |||
| 210 | + | func printSessions(w io.Writer, sessions []ipc.SessionInfo) { | |||
| 211 | + | if len(sessions) == 0 { | |||
| 212 | + | fmt.Fprintln(w, "no sessions") | |||
| 213 | + | return | |||
| 214 | + | } | |||
| 215 | + | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | |||
| 216 | + | fmt.Fprintln(tw, "NAME\tWINDOWS\tATTACHED") | |||
| 217 | + | for _, s := range sessions { | |||
| 218 | + | attached := "no" | |||
| 219 | + | if s.Attached { | |||
| 220 | + | attached = "yes" | |||
| 221 | + | } | |||
| 222 | + | fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Windows, attached) | |||
| 223 | + | } | |||
| 224 | + | _ = tw.Flush() | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // runKill asks the daemon to terminate a named session. | |||
| 228 | + | func runKill(_ context.Context, name string) error { | |||
| 229 | + | transport, err := realDial(ipc.SocketDir()) | |||
| 230 | + | if err != nil { | |||
| 231 | + | return fmt.Errorf("vev: no daemon running") | |||
| 232 | + | } | |||
| 233 | + | defer transport.Close() | |||
| 234 | + | ||||
| 235 | + | if err := transport.Send(ports.Frame{Type: ports.MsgKill, Payload: ipc.MarshalKill(ipc.Kill{Name: name})}); err != nil { | |||
| 236 | + | return fmt.Errorf("vev: requesting kill: %w", err) | |||
| 237 | + | } | |||
| 238 | + | reply, err := transport.Recv() | |||
| 239 | + | if err != nil { | |||
| 240 | + | // The daemon may close the connection after killing; treat a clean | |||
| 241 | + | // EOF as success. | |||
| 242 | + | if errors.Is(err, io.EOF) { | |||
| 243 | + | fmt.Printf("killed %s\n", name) | |||
| 244 | + | return nil | |||
| 245 | + | } | |||
| 246 | + | return fmt.Errorf("vev: reading kill reply: %w", err) | |||
| 247 | + | } | |||
| 248 | + | if reply.Type == ports.MsgError { | |||
| 249 | + | em, _ := ipc.UnmarshalErrorMsg(reply.Payload) | |||
| 250 | + | return fmt.Errorf("vev: %s", em.Text) | |||
| 251 | + | } | |||
| 252 | + | fmt.Printf("killed %s\n", name) | |||
| 7 | return nil | 253 | return nil | ||
| 8 | } | 254 | } | ||
A internal/app/run_test.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestParseArgs(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | args []string | |
| 14 | + | wantKind cmdKind | |
| 15 | + | wantIntent uint8 | |
| 16 | + | wantName string | |
| 17 | + | wantErr bool | |
| 18 | + | }{ | |
| 19 | + | {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ipc.IntentEphemeral}, | |
| 20 | + | {name: "empty slice -> ephemeral attach", args: []string{}, wantKind: kindAttach, wantIntent: ipc.IntentEphemeral}, | |
| 21 | + | {name: "new named", args: []string{"new", "work"}, wantKind: kindAttach, wantIntent: ipc.IntentNew, wantName: "work"}, | |
| 22 | + | {name: "new without name", args: []string{"new"}, wantErr: true}, | |
| 23 | + | {name: "new empty name", args: []string{"new", ""}, wantErr: true}, | |
| 24 | + | {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ipc.IntentAttach, wantName: "work"}, | |
| 25 | + | {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ipc.IntentAttach, wantName: "work"}, | |
| 26 | + | {name: "attach without name", args: []string{"attach"}, wantErr: true}, | |
| 27 | + | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | |
| 28 | + | {name: "list", args: []string{"list"}, wantKind: kindList}, | |
| 29 | + | {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"}, | |
| 30 | + | {name: "kill without name", args: []string{"kill"}, wantErr: true}, | |
| 31 | + | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | |
| 32 | + | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | |
| 33 | + | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | |
| 34 | + | {name: "version", args: []string{"--version"}, wantKind: kindVersion}, | |
| 35 | + | {name: "unknown", args: []string{"frobnicate"}, wantErr: true}, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | for _, tt := range tests { | |
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |
| 40 | + | got, err := parseArgs(tt.args) | |
| 41 | + | if tt.wantErr { | |
| 42 | + | if err == nil { | |
| 43 | + | t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got) | |
| 44 | + | } | |
| 45 | + | var ue *usageError | |
| 46 | + | if !errors.As(err, &ue) { | |
| 47 | + | t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err) | |
| 48 | + | } | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | if err != nil { | |
| 52 | + | t.Fatalf("parseArgs(%q) unexpected error: %v", tt.args, err) | |
| 53 | + | } | |
| 54 | + | if got.kind != tt.wantKind { | |
| 55 | + | t.Errorf("kind = %v, want %v", got.kind, tt.wantKind) | |
| 56 | + | } | |
| 57 | + | if got.kind == kindAttach && got.intent != tt.wantIntent { | |
| 58 | + | t.Errorf("intent = %d, want %d", got.intent, tt.wantIntent) | |
| 59 | + | } | |
| 60 | + | if got.name != tt.wantName { | |
| 61 | + | t.Errorf("name = %q, want %q", got.name, tt.wantName) | |
| 62 | + | } | |
| 63 | + | }) | |
| 64 | + | } | |
| 65 | + | } | |
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestParseArgs(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | args []string | |||
| 14 | + | wantKind cmdKind | |||
| 15 | + | wantIntent uint8 | |||
| 16 | + | wantName string | |||
| 17 | + | wantErr bool | |||
| 18 | + | }{ | |||
| 19 | + | {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ipc.IntentEphemeral}, | |||
| 20 | + | {name: "empty slice -> ephemeral attach", args: []string{}, wantKind: kindAttach, wantIntent: ipc.IntentEphemeral}, | |||
| 21 | + | {name: "new named", args: []string{"new", "work"}, wantKind: kindAttach, wantIntent: ipc.IntentNew, wantName: "work"}, | |||
| 22 | + | {name: "new without name", args: []string{"new"}, wantErr: true}, | |||
| 23 | + | {name: "new empty name", args: []string{"new", ""}, wantErr: true}, | |||
| 24 | + | {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ipc.IntentAttach, wantName: "work"}, | |||
| 25 | + | {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ipc.IntentAttach, wantName: "work"}, | |||
| 26 | + | {name: "attach without name", args: []string{"attach"}, wantErr: true}, | |||
| 27 | + | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | |||
| 28 | + | {name: "list", args: []string{"list"}, wantKind: kindList}, | |||
| 29 | + | {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"}, | |||
| 30 | + | {name: "kill without name", args: []string{"kill"}, wantErr: true}, | |||
| 31 | + | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | |||
| 32 | + | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | |||
| 33 | + | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | |||
| 34 | + | {name: "version", args: []string{"--version"}, wantKind: kindVersion}, | |||
| 35 | + | {name: "unknown", args: []string{"frobnicate"}, wantErr: true}, | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | for _, tt := range tests { | |||
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 40 | + | got, err := parseArgs(tt.args) | |||
| 41 | + | if tt.wantErr { | |||
| 42 | + | if err == nil { | |||
| 43 | + | t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got) | |||
| 44 | + | } | |||
| 45 | + | var ue *usageError | |||
| 46 | + | if !errors.As(err, &ue) { | |||
| 47 | + | t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err) | |||
| 48 | + | } | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | if err != nil { | |||
| 52 | + | t.Fatalf("parseArgs(%q) unexpected error: %v", tt.args, err) | |||
| 53 | + | } | |||
| 54 | + | if got.kind != tt.wantKind { | |||
| 55 | + | t.Errorf("kind = %v, want %v", got.kind, tt.wantKind) | |||
| 56 | + | } | |||
| 57 | + | if got.kind == kindAttach && got.intent != tt.wantIntent { | |||
| 58 | + | t.Errorf("intent = %d, want %d", got.intent, tt.wantIntent) | |||
| 59 | + | } | |||
| 60 | + | if got.name != tt.wantName { | |||
| 61 | + | t.Errorf("name = %q, want %q", got.name, tt.wantName) | |||
| 62 | + | } | |||
| 63 | + | }) | |||
| 64 | + | } | |||
| 65 | + | } | |||
A internal/app/spawn.go +156 -0
| @@ -0,0 +1,156 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "path/filepath" | |
| 10 | + | "syscall" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // ErrDaemonUnreachable is returned when the daemon socket never becomes | |
| 18 | + | // dialable within the retry budget, despite a spawn attempt. | |
| 19 | + | var ErrDaemonUnreachable = errors.New("vev: daemon did not become reachable") | |
| 20 | + | ||
| 21 | + | // spawnLockName is the mkdir-based lock directory guarding daemon spawn, so | |
| 22 | + | // concurrent first-clients elect a single spawner instead of racing to | |
| 23 | + | // re-exec multiple daemons. | |
| 24 | + | const spawnLockName = "spawn.lock" | |
| 25 | + | ||
| 26 | + | // staleLockAge bounds how long a spawn lock may persist before it is treated | |
| 27 | + | // as abandoned (spawner crashed mid-flight) and forcibly taken over. | |
| 28 | + | const staleLockAge = 10 * time.Second | |
| 29 | + | ||
| 30 | + | // dialFunc dials the daemon socket in dir. Injected so tests can drive the | |
| 31 | + | // spawn/backoff logic without a real socket. | |
| 32 | + | type dialFunc func(dir string) (ports.Transport, error) | |
| 33 | + | ||
| 34 | + | // spawnFunc launches a detached daemon process. Injected so tests never | |
| 35 | + | // re-exec a real binary. | |
| 36 | + | type spawnFunc func() error | |
| 37 | + | ||
| 38 | + | // backoffConfig parameterises retry-dial timing so tests can shrink it. | |
| 39 | + | type backoffConfig struct { | |
| 40 | + | initial time.Duration | |
| 41 | + | max time.Duration | |
| 42 | + | total time.Duration | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // defaultBackoff is the production retry schedule: 10ms, 20ms, 40ms... capped | |
| 46 | + | // at 500ms, for roughly 5s total. | |
| 47 | + | var defaultBackoff = backoffConfig{ | |
| 48 | + | initial: 10 * time.Millisecond, | |
| 49 | + | max: 500 * time.Millisecond, | |
| 50 | + | total: 5 * time.Second, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // ensureDaemon returns a transport to a running daemon, spawning one if | |
| 54 | + | // necessary. It first tries a plain dial; on failure it elects a single | |
| 55 | + | // spawner via an mkdir lock (losers simply wait), then retry-dials with | |
| 56 | + | // backoff until the socket is live or the budget expires. | |
| 57 | + | func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) { | |
| 58 | + | if t, err := dial(dir); err == nil { | |
| 59 | + | return t, nil | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | release, acquired, err := acquireSpawnLock(dir) | |
| 63 | + | if err != nil { | |
| 64 | + | return nil, fmt.Errorf("vev: acquiring spawn lock: %w", err) | |
| 65 | + | } | |
| 66 | + | if acquired { | |
| 67 | + | // We won the election: spawn, and hold the lock until the socket is | |
| 68 | + | // dialable (or spawn fails) so late-arriving clients keep waiting | |
| 69 | + | // rather than spawning a second daemon. | |
| 70 | + | defer release() | |
| 71 | + | if err := spawn(); err != nil { | |
| 72 | + | return nil, fmt.Errorf("vev: spawning daemon: %w", err) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | return retryDial(ctx, dir, dial, cfg) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // acquireSpawnLock attempts to create the spawn-lock directory. It returns | |
| 80 | + | // acquired=true with a release closure when it wins, acquired=false (with a | |
| 81 | + | // no-op release) when another process holds a fresh lock, and takes over a | |
| 82 | + | // lock older than staleLockAge (crash resilience). | |
| 83 | + | func acquireSpawnLock(dir string) (release func(), acquired bool, err error) { | |
| 84 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 85 | + | return nil, false, err | |
| 86 | + | } | |
| 87 | + | path := filepath.Join(dir, spawnLockName) | |
| 88 | + | ||
| 89 | + | if mkErr := os.Mkdir(path, 0o700); mkErr == nil { | |
| 90 | + | return func() { _ = os.Remove(path) }, true, nil | |
| 91 | + | } else if !errors.Is(mkErr, os.ErrExist) { | |
| 92 | + | return nil, false, mkErr | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Lock exists: take it over only if it looks abandoned. | |
| 96 | + | if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > staleLockAge { | |
| 97 | + | _ = os.Remove(path) | |
| 98 | + | if mkErr := os.Mkdir(path, 0o700); mkErr == nil { | |
| 99 | + | return func() { _ = os.Remove(path) }, true, nil | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // A live peer is spawning; wait for it via retry-dial. | |
| 104 | + | return func() {}, false, nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // retryDial dials repeatedly with exponential backoff until the daemon | |
| 108 | + | // answers, the context is cancelled, or the total budget is exhausted. | |
| 109 | + | func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, error) { | |
| 110 | + | deadline := time.Now().Add(cfg.total) | |
| 111 | + | backoff := cfg.initial | |
| 112 | + | for { | |
| 113 | + | if t, err := dial(dir); err == nil { | |
| 114 | + | return t, nil | |
| 115 | + | } | |
| 116 | + | if !time.Now().Before(deadline) { | |
| 117 | + | return nil, ErrDaemonUnreachable | |
| 118 | + | } | |
| 119 | + | select { | |
| 120 | + | case <-ctx.Done(): | |
| 121 | + | return nil, ctx.Err() | |
| 122 | + | case <-time.After(backoff): | |
| 123 | + | } | |
| 124 | + | if backoff *= 2; backoff > cfg.max { | |
| 125 | + | backoff = cfg.max | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // realDial is the production dialer. | |
| 131 | + | func realDial(dir string) (ports.Transport, error) { | |
| 132 | + | return ipc.Dial(dir) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // realSpawn re-execs this binary as a detached daemon: a new session | |
| 136 | + | // (Setsid) with stdio bound to /dev/null so it survives the client's exit | |
| 137 | + | // and never writes to the client's terminal. It logs via slog to the shared | |
| 138 | + | // log file instead. | |
| 139 | + | func realSpawn() error { | |
| 140 | + | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |
| 141 | + | if err != nil { | |
| 142 | + | return fmt.Errorf("opening %s: %w", os.DevNull, err) | |
| 143 | + | } | |
| 144 | + | defer devNull.Close() | |
| 145 | + | ||
| 146 | + | cmd := exec.Command("/proc/self/exe", "--daemon") | |
| 147 | + | cmd.Stdin = devNull | |
| 148 | + | cmd.Stdout = devNull | |
| 149 | + | cmd.Stderr = devNull | |
| 150 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |
| 151 | + | if err := cmd.Start(); err != nil { | |
| 152 | + | return err | |
| 153 | + | } | |
| 154 | + | // Detach: we neither wait for nor signal the daemon after launch. | |
| 155 | + | return cmd.Process.Release() | |
| 156 | + | } | |
| @@ -0,0 +1,156 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "syscall" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // ErrDaemonUnreachable is returned when the daemon socket never becomes | |||
| 18 | + | // dialable within the retry budget, despite a spawn attempt. | |||
| 19 | + | var ErrDaemonUnreachable = errors.New("vev: daemon did not become reachable") | |||
| 20 | + | ||||
| 21 | + | // spawnLockName is the mkdir-based lock directory guarding daemon spawn, so | |||
| 22 | + | // concurrent first-clients elect a single spawner instead of racing to | |||
| 23 | + | // re-exec multiple daemons. | |||
| 24 | + | const spawnLockName = "spawn.lock" | |||
| 25 | + | ||||
| 26 | + | // staleLockAge bounds how long a spawn lock may persist before it is treated | |||
| 27 | + | // as abandoned (spawner crashed mid-flight) and forcibly taken over. | |||
| 28 | + | const staleLockAge = 10 * time.Second | |||
| 29 | + | ||||
| 30 | + | // dialFunc dials the daemon socket in dir. Injected so tests can drive the | |||
| 31 | + | // spawn/backoff logic without a real socket. | |||
| 32 | + | type dialFunc func(dir string) (ports.Transport, error) | |||
| 33 | + | ||||
| 34 | + | // spawnFunc launches a detached daemon process. Injected so tests never | |||
| 35 | + | // re-exec a real binary. | |||
| 36 | + | type spawnFunc func() error | |||
| 37 | + | ||||
| 38 | + | // backoffConfig parameterises retry-dial timing so tests can shrink it. | |||
| 39 | + | type backoffConfig struct { | |||
| 40 | + | initial time.Duration | |||
| 41 | + | max time.Duration | |||
| 42 | + | total time.Duration | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // defaultBackoff is the production retry schedule: 10ms, 20ms, 40ms... capped | |||
| 46 | + | // at 500ms, for roughly 5s total. | |||
| 47 | + | var defaultBackoff = backoffConfig{ | |||
| 48 | + | initial: 10 * time.Millisecond, | |||
| 49 | + | max: 500 * time.Millisecond, | |||
| 50 | + | total: 5 * time.Second, | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // ensureDaemon returns a transport to a running daemon, spawning one if | |||
| 54 | + | // necessary. It first tries a plain dial; on failure it elects a single | |||
| 55 | + | // spawner via an mkdir lock (losers simply wait), then retry-dials with | |||
| 56 | + | // backoff until the socket is live or the budget expires. | |||
| 57 | + | func ensureDaemon(ctx context.Context, dir string, dial dialFunc, spawn spawnFunc, cfg backoffConfig) (ports.Transport, error) { | |||
| 58 | + | if t, err := dial(dir); err == nil { | |||
| 59 | + | return t, nil | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | release, acquired, err := acquireSpawnLock(dir) | |||
| 63 | + | if err != nil { | |||
| 64 | + | return nil, fmt.Errorf("vev: acquiring spawn lock: %w", err) | |||
| 65 | + | } | |||
| 66 | + | if acquired { | |||
| 67 | + | // We won the election: spawn, and hold the lock until the socket is | |||
| 68 | + | // dialable (or spawn fails) so late-arriving clients keep waiting | |||
| 69 | + | // rather than spawning a second daemon. | |||
| 70 | + | defer release() | |||
| 71 | + | if err := spawn(); err != nil { | |||
| 72 | + | return nil, fmt.Errorf("vev: spawning daemon: %w", err) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | return retryDial(ctx, dir, dial, cfg) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // acquireSpawnLock attempts to create the spawn-lock directory. It returns | |||
| 80 | + | // acquired=true with a release closure when it wins, acquired=false (with a | |||
| 81 | + | // no-op release) when another process holds a fresh lock, and takes over a | |||
| 82 | + | // lock older than staleLockAge (crash resilience). | |||
| 83 | + | func acquireSpawnLock(dir string) (release func(), acquired bool, err error) { | |||
| 84 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 85 | + | return nil, false, err | |||
| 86 | + | } | |||
| 87 | + | path := filepath.Join(dir, spawnLockName) | |||
| 88 | + | ||||
| 89 | + | if mkErr := os.Mkdir(path, 0o700); mkErr == nil { | |||
| 90 | + | return func() { _ = os.Remove(path) }, true, nil | |||
| 91 | + | } else if !errors.Is(mkErr, os.ErrExist) { | |||
| 92 | + | return nil, false, mkErr | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Lock exists: take it over only if it looks abandoned. | |||
| 96 | + | if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > staleLockAge { | |||
| 97 | + | _ = os.Remove(path) | |||
| 98 | + | if mkErr := os.Mkdir(path, 0o700); mkErr == nil { | |||
| 99 | + | return func() { _ = os.Remove(path) }, true, nil | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // A live peer is spawning; wait for it via retry-dial. | |||
| 104 | + | return func() {}, false, nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // retryDial dials repeatedly with exponential backoff until the daemon | |||
| 108 | + | // answers, the context is cancelled, or the total budget is exhausted. | |||
| 109 | + | func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, error) { | |||
| 110 | + | deadline := time.Now().Add(cfg.total) | |||
| 111 | + | backoff := cfg.initial | |||
| 112 | + | for { | |||
| 113 | + | if t, err := dial(dir); err == nil { | |||
| 114 | + | return t, nil | |||
| 115 | + | } | |||
| 116 | + | if !time.Now().Before(deadline) { | |||
| 117 | + | return nil, ErrDaemonUnreachable | |||
| 118 | + | } | |||
| 119 | + | select { | |||
| 120 | + | case <-ctx.Done(): | |||
| 121 | + | return nil, ctx.Err() | |||
| 122 | + | case <-time.After(backoff): | |||
| 123 | + | } | |||
| 124 | + | if backoff *= 2; backoff > cfg.max { | |||
| 125 | + | backoff = cfg.max | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // realDial is the production dialer. | |||
| 131 | + | func realDial(dir string) (ports.Transport, error) { | |||
| 132 | + | return ipc.Dial(dir) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // realSpawn re-execs this binary as a detached daemon: a new session | |||
| 136 | + | // (Setsid) with stdio bound to /dev/null so it survives the client's exit | |||
| 137 | + | // and never writes to the client's terminal. It logs via slog to the shared | |||
| 138 | + | // log file instead. | |||
| 139 | + | func realSpawn() error { | |||
| 140 | + | devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) | |||
| 141 | + | if err != nil { | |||
| 142 | + | return fmt.Errorf("opening %s: %w", os.DevNull, err) | |||
| 143 | + | } | |||
| 144 | + | defer devNull.Close() | |||
| 145 | + | ||||
| 146 | + | cmd := exec.Command("/proc/self/exe", "--daemon") | |||
| 147 | + | cmd.Stdin = devNull | |||
| 148 | + | cmd.Stdout = devNull | |||
| 149 | + | cmd.Stderr = devNull | |||
| 150 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |||
| 151 | + | if err := cmd.Start(); err != nil { | |||
| 152 | + | return err | |||
| 153 | + | } | |||
| 154 | + | // Detach: we neither wait for nor signal the daemon after launch. | |||
| 155 | + | return cmd.Process.Release() | |||
| 156 | + | } | |||
A internal/app/spawn_test.go +189 -0
| @@ -0,0 +1,189 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "sync" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | var errDialFailed = errors.New("dial failed") | |
| 18 | + | ||
| 19 | + | // fastBackoff keeps the retry loop snappy for tests. | |
| 20 | + | var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond} | |
| 21 | + | ||
| 22 | + | func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) { | |
| 23 | + | dir := t.TempDir() | |
| 24 | + | const racers = 16 | |
| 25 | + | ||
| 26 | + | var wins atomic.Int32 | |
| 27 | + | var wg sync.WaitGroup | |
| 28 | + | start := make(chan struct{}) | |
| 29 | + | releases := make(chan func(), racers) | |
| 30 | + | ||
| 31 | + | for i := 0; i < racers; i++ { | |
| 32 | + | wg.Add(1) | |
| 33 | + | go func() { | |
| 34 | + | defer wg.Done() | |
| 35 | + | <-start | |
| 36 | + | release, acquired, err := acquireSpawnLock(dir) | |
| 37 | + | if err != nil { | |
| 38 | + | t.Errorf("acquireSpawnLock: %v", err) | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | if acquired { | |
| 42 | + | wins.Add(1) | |
| 43 | + | releases <- release | |
| 44 | + | } | |
| 45 | + | }() | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | close(start) | |
| 49 | + | wg.Wait() | |
| 50 | + | close(releases) | |
| 51 | + | ||
| 52 | + | if got := wins.Load(); got != 1 { | |
| 53 | + | t.Fatalf("expected exactly one winner, got %d", got) | |
| 54 | + | } | |
| 55 | + | for release := range releases { | |
| 56 | + | release() | |
| 57 | + | } | |
| 58 | + | // After release, the lock directory is gone and can be re-acquired. | |
| 59 | + | release, acquired, err := acquireSpawnLock(dir) | |
| 60 | + | if err != nil || !acquired { | |
| 61 | + | t.Fatalf("re-acquire after release: acquired=%v err=%v", acquired, err) | |
| 62 | + | } | |
| 63 | + | release() | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) { | |
| 67 | + | dir := t.TempDir() | |
| 68 | + | lockPath := filepath.Join(dir, spawnLockName) | |
| 69 | + | if err := os.Mkdir(lockPath, 0o700); err != nil { | |
| 70 | + | t.Fatalf("seeding lock: %v", err) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // A fresh lock must NOT be taken over. | |
| 74 | + | if _, acquired, err := acquireSpawnLock(dir); err != nil || acquired { | |
| 75 | + | t.Fatalf("fresh lock taken over: acquired=%v err=%v", acquired, err) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Backdate the lock beyond the stale threshold; now it is taken over. | |
| 79 | + | old := time.Now().Add(-2 * staleLockAge) | |
| 80 | + | if err := os.Chtimes(lockPath, old, old); err != nil { | |
| 81 | + | t.Fatalf("backdating lock: %v", err) | |
| 82 | + | } | |
| 83 | + | release, acquired, err := acquireSpawnLock(dir) | |
| 84 | + | if err != nil || !acquired { | |
| 85 | + | t.Fatalf("stale lock not taken over: acquired=%v err=%v", acquired, err) | |
| 86 | + | } | |
| 87 | + | release() | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) { | |
| 91 | + | want := portsmocks.NewMockTransport(t) | |
| 92 | + | var calls atomic.Int32 | |
| 93 | + | dial := func(string) (ports.Transport, error) { | |
| 94 | + | if calls.Add(1) <= 3 { | |
| 95 | + | return nil, errDialFailed | |
| 96 | + | } | |
| 97 | + | return want, nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | got, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff) | |
| 101 | + | if err != nil { | |
| 102 | + | t.Fatalf("retryDial: %v", err) | |
| 103 | + | } | |
| 104 | + | if got != want { | |
| 105 | + | t.Fatalf("retryDial returned unexpected transport") | |
| 106 | + | } | |
| 107 | + | if calls.Load() != 4 { | |
| 108 | + | t.Fatalf("dial called %d times, want 4", calls.Load()) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestRetryDialPermanentFailure(t *testing.T) { | |
| 113 | + | dial := func(string) (ports.Transport, error) { return nil, errDialFailed } | |
| 114 | + | _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff) | |
| 115 | + | if !errors.Is(err, ErrDaemonUnreachable) { | |
| 116 | + | t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestRetryDialContextCancelled(t *testing.T) { | |
| 121 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 122 | + | cancel() | |
| 123 | + | dial := func(string) (ports.Transport, error) { return nil, errDialFailed } | |
| 124 | + | _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff) | |
| 125 | + | if !errors.Is(err, context.Canceled) { | |
| 126 | + | t.Fatalf("retryDial error = %v, want context.Canceled", err) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestEnsureDaemonSpawnsThenDials(t *testing.T) { | |
| 131 | + | dir := t.TempDir() | |
| 132 | + | want := portsmocks.NewMockTransport(t) | |
| 133 | + | var dialCalls, spawnCalls atomic.Int32 | |
| 134 | + | dial := func(string) (ports.Transport, error) { | |
| 135 | + | // First (pre-spawn) dial fails; the post-spawn retry succeeds. | |
| 136 | + | if dialCalls.Add(1) == 1 { | |
| 137 | + | return nil, errDialFailed | |
| 138 | + | } | |
| 139 | + | return want, nil | |
| 140 | + | } | |
| 141 | + | spawn := func() error { | |
| 142 | + | spawnCalls.Add(1) | |
| 143 | + | return nil | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | got, err := ensureDaemon(context.Background(), dir, dial, spawn, fastBackoff) | |
| 147 | + | if err != nil { | |
| 148 | + | t.Fatalf("ensureDaemon: %v", err) | |
| 149 | + | } | |
| 150 | + | if got != want { | |
| 151 | + | t.Fatalf("ensureDaemon returned unexpected transport") | |
| 152 | + | } | |
| 153 | + | if spawnCalls.Load() != 1 { | |
| 154 | + | t.Fatalf("spawn called %d times, want 1", spawnCalls.Load()) | |
| 155 | + | } | |
| 156 | + | // The spawn lock must be released once ensureDaemon returns. | |
| 157 | + | if _, err := os.Stat(filepath.Join(dir, spawnLockName)); !os.IsNotExist(err) { | |
| 158 | + | t.Fatalf("spawn lock not released: stat err=%v", err) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) { | |
| 163 | + | want := portsmocks.NewMockTransport(t) | |
| 164 | + | var spawnCalls atomic.Int32 | |
| 165 | + | dial := func(string) (ports.Transport, error) { return want, nil } | |
| 166 | + | spawn := func() error { spawnCalls.Add(1); return nil } | |
| 167 | + | ||
| 168 | + | got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff) | |
| 169 | + | if err != nil { | |
| 170 | + | t.Fatalf("ensureDaemon: %v", err) | |
| 171 | + | } | |
| 172 | + | if got != want { | |
| 173 | + | t.Fatalf("ensureDaemon returned unexpected transport") | |
| 174 | + | } | |
| 175 | + | if spawnCalls.Load() != 0 { | |
| 176 | + | t.Fatalf("spawn should not be called when daemon already up, got %d", spawnCalls.Load()) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestEnsureDaemonSpawnFailure(t *testing.T) { | |
| 181 | + | spawnErr := errors.New("exec boom") | |
| 182 | + | dial := func(string) (ports.Transport, error) { return nil, errDialFailed } | |
| 183 | + | spawn := func() error { return spawnErr } | |
| 184 | + | ||
| 185 | + | _, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff) | |
| 186 | + | if !errors.Is(err, spawnErr) { | |
| 187 | + | t.Fatalf("ensureDaemon error = %v, want wrapped %v", err, spawnErr) | |
| 188 | + | } | |
| 189 | + | } | |
| @@ -0,0 +1,189 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "sync" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | var errDialFailed = errors.New("dial failed") | |||
| 18 | + | ||||
| 19 | + | // fastBackoff keeps the retry loop snappy for tests. | |||
| 20 | + | var fastBackoff = backoffConfig{initial: time.Millisecond, max: 5 * time.Millisecond, total: 100 * time.Millisecond} | |||
| 21 | + | ||||
| 22 | + | func TestAcquireSpawnLockSingleWinnerUnderRace(t *testing.T) { | |||
| 23 | + | dir := t.TempDir() | |||
| 24 | + | const racers = 16 | |||
| 25 | + | ||||
| 26 | + | var wins atomic.Int32 | |||
| 27 | + | var wg sync.WaitGroup | |||
| 28 | + | start := make(chan struct{}) | |||
| 29 | + | releases := make(chan func(), racers) | |||
| 30 | + | ||||
| 31 | + | for i := 0; i < racers; i++ { | |||
| 32 | + | wg.Add(1) | |||
| 33 | + | go func() { | |||
| 34 | + | defer wg.Done() | |||
| 35 | + | <-start | |||
| 36 | + | release, acquired, err := acquireSpawnLock(dir) | |||
| 37 | + | if err != nil { | |||
| 38 | + | t.Errorf("acquireSpawnLock: %v", err) | |||
| 39 | + | return | |||
| 40 | + | } | |||
| 41 | + | if acquired { | |||
| 42 | + | wins.Add(1) | |||
| 43 | + | releases <- release | |||
| 44 | + | } | |||
| 45 | + | }() | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | close(start) | |||
| 49 | + | wg.Wait() | |||
| 50 | + | close(releases) | |||
| 51 | + | ||||
| 52 | + | if got := wins.Load(); got != 1 { | |||
| 53 | + | t.Fatalf("expected exactly one winner, got %d", got) | |||
| 54 | + | } | |||
| 55 | + | for release := range releases { | |||
| 56 | + | release() | |||
| 57 | + | } | |||
| 58 | + | // After release, the lock directory is gone and can be re-acquired. | |||
| 59 | + | release, acquired, err := acquireSpawnLock(dir) | |||
| 60 | + | if err != nil || !acquired { | |||
| 61 | + | t.Fatalf("re-acquire after release: acquired=%v err=%v", acquired, err) | |||
| 62 | + | } | |||
| 63 | + | release() | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) { | |||
| 67 | + | dir := t.TempDir() | |||
| 68 | + | lockPath := filepath.Join(dir, spawnLockName) | |||
| 69 | + | if err := os.Mkdir(lockPath, 0o700); err != nil { | |||
| 70 | + | t.Fatalf("seeding lock: %v", err) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // A fresh lock must NOT be taken over. | |||
| 74 | + | if _, acquired, err := acquireSpawnLock(dir); err != nil || acquired { | |||
| 75 | + | t.Fatalf("fresh lock taken over: acquired=%v err=%v", acquired, err) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // Backdate the lock beyond the stale threshold; now it is taken over. | |||
| 79 | + | old := time.Now().Add(-2 * staleLockAge) | |||
| 80 | + | if err := os.Chtimes(lockPath, old, old); err != nil { | |||
| 81 | + | t.Fatalf("backdating lock: %v", err) | |||
| 82 | + | } | |||
| 83 | + | release, acquired, err := acquireSpawnLock(dir) | |||
| 84 | + | if err != nil || !acquired { | |||
| 85 | + | t.Fatalf("stale lock not taken over: acquired=%v err=%v", acquired, err) | |||
| 86 | + | } | |||
| 87 | + | release() | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) { | |||
| 91 | + | want := portsmocks.NewMockTransport(t) | |||
| 92 | + | var calls atomic.Int32 | |||
| 93 | + | dial := func(string) (ports.Transport, error) { | |||
| 94 | + | if calls.Add(1) <= 3 { | |||
| 95 | + | return nil, errDialFailed | |||
| 96 | + | } | |||
| 97 | + | return want, nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | got, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff) | |||
| 101 | + | if err != nil { | |||
| 102 | + | t.Fatalf("retryDial: %v", err) | |||
| 103 | + | } | |||
| 104 | + | if got != want { | |||
| 105 | + | t.Fatalf("retryDial returned unexpected transport") | |||
| 106 | + | } | |||
| 107 | + | if calls.Load() != 4 { | |||
| 108 | + | t.Fatalf("dial called %d times, want 4", calls.Load()) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestRetryDialPermanentFailure(t *testing.T) { | |||
| 113 | + | dial := func(string) (ports.Transport, error) { return nil, errDialFailed } | |||
| 114 | + | _, err := retryDial(context.Background(), t.TempDir(), dial, fastBackoff) | |||
| 115 | + | if !errors.Is(err, ErrDaemonUnreachable) { | |||
| 116 | + | t.Fatalf("retryDial error = %v, want ErrDaemonUnreachable", err) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestRetryDialContextCancelled(t *testing.T) { | |||
| 121 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 122 | + | cancel() | |||
| 123 | + | dial := func(string) (ports.Transport, error) { return nil, errDialFailed } | |||
| 124 | + | _, err := retryDial(ctx, t.TempDir(), dial, fastBackoff) | |||
| 125 | + | if !errors.Is(err, context.Canceled) { | |||
| 126 | + | t.Fatalf("retryDial error = %v, want context.Canceled", err) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestEnsureDaemonSpawnsThenDials(t *testing.T) { | |||
| 131 | + | dir := t.TempDir() | |||
| 132 | + | want := portsmocks.NewMockTransport(t) | |||
| 133 | + | var dialCalls, spawnCalls atomic.Int32 | |||
| 134 | + | dial := func(string) (ports.Transport, error) { | |||
| 135 | + | // First (pre-spawn) dial fails; the post-spawn retry succeeds. | |||
| 136 | + | if dialCalls.Add(1) == 1 { | |||
| 137 | + | return nil, errDialFailed | |||
| 138 | + | } | |||
| 139 | + | return want, nil | |||
| 140 | + | } | |||
| 141 | + | spawn := func() error { | |||
| 142 | + | spawnCalls.Add(1) | |||
| 143 | + | return nil | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | got, err := ensureDaemon(context.Background(), dir, dial, spawn, fastBackoff) | |||
| 147 | + | if err != nil { | |||
| 148 | + | t.Fatalf("ensureDaemon: %v", err) | |||
| 149 | + | } | |||
| 150 | + | if got != want { | |||
| 151 | + | t.Fatalf("ensureDaemon returned unexpected transport") | |||
| 152 | + | } | |||
| 153 | + | if spawnCalls.Load() != 1 { | |||
| 154 | + | t.Fatalf("spawn called %d times, want 1", spawnCalls.Load()) | |||
| 155 | + | } | |||
| 156 | + | // The spawn lock must be released once ensureDaemon returns. | |||
| 157 | + | if _, err := os.Stat(filepath.Join(dir, spawnLockName)); !os.IsNotExist(err) { | |||
| 158 | + | t.Fatalf("spawn lock not released: stat err=%v", err) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestEnsureDaemonReturnsExistingDaemon(t *testing.T) { | |||
| 163 | + | want := portsmocks.NewMockTransport(t) | |||
| 164 | + | var spawnCalls atomic.Int32 | |||
| 165 | + | dial := func(string) (ports.Transport, error) { return want, nil } | |||
| 166 | + | spawn := func() error { spawnCalls.Add(1); return nil } | |||
| 167 | + | ||||
| 168 | + | got, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff) | |||
| 169 | + | if err != nil { | |||
| 170 | + | t.Fatalf("ensureDaemon: %v", err) | |||
| 171 | + | } | |||
| 172 | + | if got != want { | |||
| 173 | + | t.Fatalf("ensureDaemon returned unexpected transport") | |||
| 174 | + | } | |||
| 175 | + | if spawnCalls.Load() != 0 { | |||
| 176 | + | t.Fatalf("spawn should not be called when daemon already up, got %d", spawnCalls.Load()) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestEnsureDaemonSpawnFailure(t *testing.T) { | |||
| 181 | + | spawnErr := errors.New("exec boom") | |||
| 182 | + | dial := func(string) (ports.Transport, error) { return nil, errDialFailed } | |||
| 183 | + | spawn := func() error { return spawnErr } | |||
| 184 | + | ||||
| 185 | + | _, err := ensureDaemon(context.Background(), t.TempDir(), dial, spawn, fastBackoff) | |||
| 186 | + | if !errors.Is(err, spawnErr) { | |||
| 187 | + | t.Fatalf("ensureDaemon error = %v, want wrapped %v", err, spawnErr) | |||
| 188 | + | } | |||
| 189 | + | } | |||
A internal/usecase/client/client.go +331 -0
| @@ -0,0 +1,331 @@ | |||
| 1 | + | // Package client implements vev's thin attach client: it speaks the framed | |
| 2 | + | // IPC protocol to a daemon, pumping terminal input, resize events, and PTY | |
| 3 | + | // output between the controlling terminal and the session. | |
| 4 | + | // | |
| 5 | + | // The package depends only on ports (transport, terminal) plus the ipc | |
| 6 | + | // message codecs, never on a concrete transport or terminal implementation; | |
| 7 | + | // the app layer wires those in. | |
| 8 | + | package client | |
| 9 | + | ||
| 10 | + | import ( | |
| 11 | + | "context" | |
| 12 | + | "errors" | |
| 13 | + | "fmt" | |
| 14 | + | "io" | |
| 15 | + | "log/slog" | |
| 16 | + | "os" | |
| 17 | + | ||
| 18 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 19 | + | "github.com/bnema/vev/internal/domain" | |
| 20 | + | "github.com/bnema/vev/internal/ports" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // ProtocolError is a session- or protocol-level failure reported by the | |
| 24 | + | // daemon before attach could proceed (a Hello rejected with an ErrorMsg). | |
| 25 | + | // The app layer prints it to the user. | |
| 26 | + | type ProtocolError struct { | |
| 27 | + | Code uint16 | |
| 28 | + | Text string | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (e *ProtocolError) Error() string { | |
| 32 | + | if e.Text == "" { | |
| 33 | + | return fmt.Sprintf("vev: daemon rejected attach (code %d)", e.Code) | |
| 34 | + | } | |
| 35 | + | return fmt.Sprintf("vev: %s", e.Text) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // DetachedError reports that the daemon detached this client for a reason | |
| 39 | + | // other than an ordinary user-initiated detach. | |
| 40 | + | type DetachedError struct { | |
| 41 | + | Reason uint8 | |
| 42 | + | Text string | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (e *DetachedError) Error() string { return "vev: " + e.Text } | |
| 46 | + | ||
| 47 | + | // milestones tracks lifecycle progress so a failure can report which stages | |
| 48 | + | // were never reached — the key diagnostic when an attach dies early. | |
| 49 | + | type milestones struct { | |
| 50 | + | dialed bool | |
| 51 | + | helloSent bool | |
| 52 | + | welcomed bool | |
| 53 | + | rawEntered bool | |
| 54 | + | firstOutput bool | |
| 55 | + | detached bool | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (m milestones) missing() []string { | |
| 59 | + | var out []string | |
| 60 | + | if !m.dialed { | |
| 61 | + | out = append(out, "dialed") | |
| 62 | + | } | |
| 63 | + | if !m.helloSent { | |
| 64 | + | out = append(out, "hello_sent") | |
| 65 | + | } | |
| 66 | + | if !m.welcomed { | |
| 67 | + | out = append(out, "welcomed") | |
| 68 | + | } | |
| 69 | + | if !m.rawEntered { | |
| 70 | + | out = append(out, "raw_entered") | |
| 71 | + | } | |
| 72 | + | if !m.firstOutput { | |
| 73 | + | out = append(out, "first_output") | |
| 74 | + | } | |
| 75 | + | if !m.detached { | |
| 76 | + | out = append(out, "detached") | |
| 77 | + | } | |
| 78 | + | return out | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // stdinBufSize is the read buffer for the terminal input pump. | |
| 82 | + | const stdinBufSize = 4096 | |
| 83 | + | ||
| 84 | + | // sendQueueDepth bounds the buffered frames awaiting the single sender | |
| 85 | + | // goroutine, decoupling the input/resize pumps from transport back-pressure. | |
| 86 | + | const sendQueueDepth = 64 | |
| 87 | + | ||
| 88 | + | // Attach connects an already-dialed transport to the controlling terminal | |
| 89 | + | // and runs the attach loop until the session detaches, the daemon | |
| 90 | + | // disappears, or the context is cancelled. | |
| 91 | + | // | |
| 92 | + | // It owns transport for its lifetime and closes it before returning. The | |
| 93 | + | // terminal is put into raw mode after a successful Welcome and restored on | |
| 94 | + | // every exit path — normal return, error, or panic. | |
| 95 | + | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) (retErr error) { | |
| 96 | + | log := slog.Default().With("component", "client") | |
| 97 | + | ms := milestones{dialed: true} // transport arrives already connected | |
| 98 | + | ||
| 99 | + | // Runs last: a single structured diagnostic on failure, to the log file | |
| 100 | + | // (never the terminal). Console output while raw is forbidden. | |
| 101 | + | defer func() { | |
| 102 | + | if retErr != nil { | |
| 103 | + | log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing()) | |
| 104 | + | } | |
| 105 | + | }() | |
| 106 | + | ||
| 107 | + | // Attach owns the transport: close it on every exit path. | |
| 108 | + | defer func() { _ = transport.Close() }() | |
| 109 | + | ||
| 110 | + | // 1. Handshake: send Hello with our size and TERM. | |
| 111 | + | size, err := term.Size() | |
| 112 | + | if err != nil { | |
| 113 | + | return fmt.Errorf("vev: reading terminal size: %w", err) | |
| 114 | + | } | |
| 115 | + | hello := ipc.Hello{ | |
| 116 | + | Version: ports.ProtocolVersion, | |
| 117 | + | Intent: intent, | |
| 118 | + | Name: name, | |
| 119 | + | Size: size, | |
| 120 | + | TermEnv: os.Getenv("TERM"), | |
| 121 | + | } | |
| 122 | + | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ipc.MarshalHello(hello)}); err != nil { | |
| 123 | + | return fmt.Errorf("vev: sending hello: %w", err) | |
| 124 | + | } | |
| 125 | + | ms.helloSent = true | |
| 126 | + | ||
| 127 | + | // 2. Await Welcome or a typed rejection. | |
| 128 | + | reply, err := transport.Recv() | |
| 129 | + | if err != nil { | |
| 130 | + | return fmt.Errorf("vev: awaiting welcome: %w", err) | |
| 131 | + | } | |
| 132 | + | switch reply.Type { | |
| 133 | + | case ports.MsgWelcome: | |
| 134 | + | ms.welcomed = true | |
| 135 | + | case ports.MsgError: | |
| 136 | + | em, derr := ipc.UnmarshalErrorMsg(reply.Payload) | |
| 137 | + | if derr != nil { | |
| 138 | + | return fmt.Errorf("vev: decoding error reply: %w", derr) | |
| 139 | + | } | |
| 140 | + | return &ProtocolError{Code: em.Code, Text: em.Text} | |
| 141 | + | default: | |
| 142 | + | return fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // 3. Enter raw mode; restore on every subsequent exit path. | |
| 146 | + | restore, err := term.EnterRaw() | |
| 147 | + | if err != nil { | |
| 148 | + | return fmt.Errorf("vev: entering raw mode: %w", err) | |
| 149 | + | } | |
| 150 | + | ms.rawEntered = true | |
| 151 | + | defer func() { | |
| 152 | + | if rerr := restore(); rerr != nil && retErr == nil { | |
| 153 | + | retErr = fmt.Errorf("vev: restoring terminal: %w", rerr) | |
| 154 | + | } | |
| 155 | + | }() | |
| 156 | + | ||
| 157 | + | // 4. Derive a cancellable context so the pumps always unwind when the | |
| 158 | + | // loop returns. cancel runs first (LIFO): it signals the pumps; the | |
| 159 | + | // deferred Close above then unblocks any pump parked in a syscall. | |
| 160 | + | loopCtx, cancel := context.WithCancel(ctx) | |
| 161 | + | defer cancel() | |
| 162 | + | ||
| 163 | + | sendCh := make(chan ports.Frame, sendQueueDepth) | |
| 164 | + | sendErrCh := make(chan error, 1) | |
| 165 | + | ||
| 166 | + | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh) | |
| 167 | + | go runStdin(loopCtx, cancel, term.In(), sendCh) | |
| 168 | + | go runResize(loopCtx, term.ResizeEvents(), sendCh) | |
| 169 | + | ||
| 170 | + | // 5. Output/main loop: the only goroutine that touches the terminal. | |
| 171 | + | recvCh := make(chan recvResult, 1) | |
| 172 | + | go runRecv(loopCtx, transport, recvCh) | |
| 173 | + | ||
| 174 | + | for { | |
| 175 | + | select { | |
| 176 | + | case <-loopCtx.Done(): | |
| 177 | + | // A pump initiated shutdown (stdin EOF/detach) or the parent | |
| 178 | + | // context was cancelled. A queued sender error takes priority. | |
| 179 | + | select { | |
| 180 | + | case serr := <-sendErrCh: | |
| 181 | + | return fmt.Errorf("vev: sending to daemon: %w", serr) | |
| 182 | + | default: | |
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | case r := <-recvCh: | |
| 186 | + | if r.err != nil { | |
| 187 | + | if errors.Is(r.err, io.EOF) { | |
| 188 | + | return fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err) | |
| 189 | + | } | |
| 190 | + | return fmt.Errorf("vev: receiving from daemon: %w", r.err) | |
| 191 | + | } | |
| 192 | + | switch r.frame.Type { | |
| 193 | + | case ports.MsgOutput: | |
| 194 | + | o, derr := ipc.UnmarshalOutput(r.frame.Payload) | |
| 195 | + | if derr != nil { | |
| 196 | + | return fmt.Errorf("vev: decoding output: %w", derr) | |
| 197 | + | } | |
| 198 | + | if _, werr := term.Out().Write(o.Data); werr != nil { | |
| 199 | + | return fmt.Errorf("vev: writing terminal output: %w", werr) | |
| 200 | + | } | |
| 201 | + | if ferr := term.Flush(); ferr != nil { | |
| 202 | + | return fmt.Errorf("vev: flushing terminal: %w", ferr) | |
| 203 | + | } | |
| 204 | + | ms.firstOutput = true | |
| 205 | + | case ports.MsgDetached: | |
| 206 | + | d, derr := ipc.UnmarshalDetached(r.frame.Payload) | |
| 207 | + | if derr != nil { | |
| 208 | + | return fmt.Errorf("vev: decoding detached: %w", derr) | |
| 209 | + | } | |
| 210 | + | ms.detached = true | |
| 211 | + | return detachedResult(d.Reason) | |
| 212 | + | case ports.MsgPong: | |
| 213 | + | // Liveness reply; nothing to do. | |
| 214 | + | default: | |
| 215 | + | // Unknown/out-of-band message types are ignored so a newer | |
| 216 | + | // daemon can add server->client messages without breaking us. | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // recvResult carries one framed message (or a read error) from the receive | |
| 223 | + | // pump to the main loop. | |
| 224 | + | type recvResult struct { | |
| 225 | + | frame ports.Frame | |
| 226 | + | err error | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // runRecv reads frames until an error, forwarding each to the main loop. | |
| 230 | + | // It exits on the first error or when the loop context is cancelled. | |
| 231 | + | func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult) { | |
| 232 | + | for { | |
| 233 | + | f, err := transport.Recv() | |
| 234 | + | select { | |
| 235 | + | case out <- recvResult{frame: f, err: err}: | |
| 236 | + | case <-ctx.Done(): | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | if err != nil { | |
| 240 | + | return | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // runSender is the sole caller of transport.Send: serialising the input and | |
| 246 | + | // resize pumps through one goroutine keeps the transport's single-writer | |
| 247 | + | // contract intact. A send failure cancels the loop and is surfaced once. | |
| 248 | + | func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, errCh chan<- error) { | |
| 249 | + | for { | |
| 250 | + | select { | |
| 251 | + | case f := <-in: | |
| 252 | + | if err := transport.Send(f); err != nil { | |
| 253 | + | select { | |
| 254 | + | case errCh <- err: | |
| 255 | + | default: | |
| 256 | + | } | |
| 257 | + | cancel() | |
| 258 | + | return | |
| 259 | + | } | |
| 260 | + | case <-ctx.Done(): | |
| 261 | + | return | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // runStdin pumps terminal input to the daemon as Input frames. A read error | |
| 267 | + | // or EOF is treated as a detach: it best-effort sends a Detach frame and | |
| 268 | + | // cancels the loop. | |
| 269 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame) { | |
| 270 | + | buf := make([]byte, stdinBufSize) | |
| 271 | + | for { | |
| 272 | + | n, rerr := in.Read(buf) | |
| 273 | + | if n > 0 { | |
| 274 | + | data := make([]byte, n) | |
| 275 | + | copy(data, buf[:n]) | |
| 276 | + | frame := ports.Frame{Type: ports.MsgInput, Payload: ipc.MarshalInput(ipc.Input{Data: data})} | |
| 277 | + | select { | |
| 278 | + | case out <- frame: | |
| 279 | + | case <-ctx.Done(): | |
| 280 | + | return | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | if rerr != nil { | |
| 284 | + | // Best-effort detach notification, then unwind. | |
| 285 | + | select { | |
| 286 | + | case out <- ports.Frame{Type: ports.MsgDetach, Payload: ipc.MarshalDetach(ipc.Detach{})}: | |
| 287 | + | case <-ctx.Done(): | |
| 288 | + | } | |
| 289 | + | cancel() | |
| 290 | + | return | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // runResize forwards coalesced terminal resize events to the daemon. It | |
| 296 | + | // tolerates an already-closed resize channel (which the terminal adapter | |
| 297 | + | // hands back when restore ran before ResizeEvents was first called). | |
| 298 | + | func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame) { | |
| 299 | + | for { | |
| 300 | + | select { | |
| 301 | + | case sz, ok := <-events: | |
| 302 | + | if !ok { | |
| 303 | + | return | |
| 304 | + | } | |
| 305 | + | frame := ports.Frame{Type: ports.MsgResize, Payload: ipc.MarshalResize(ipc.Resize{Size: sz})} | |
| 306 | + | select { | |
| 307 | + | case out <- frame: | |
| 308 | + | case <-ctx.Done(): | |
| 309 | + | return | |
| 310 | + | } | |
| 311 | + | case <-ctx.Done(): | |
| 312 | + | return | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // detachedResult maps a Detached reason to a return value: an ordinary | |
| 318 | + | // user-initiated detach is a clean (nil) exit; anything else is surfaced so | |
| 319 | + | // the user learns why the session went away. | |
| 320 | + | func detachedResult(reason uint8) error { | |
| 321 | + | switch reason { | |
| 322 | + | case ipc.ReasonDetach: | |
| 323 | + | return nil | |
| 324 | + | case ipc.ReasonSessionKilled: | |
| 325 | + | return &DetachedError{Reason: reason, Text: "session was killed"} | |
| 326 | + | case ipc.ReasonServerShutdown: | |
| 327 | + | return &DetachedError{Reason: reason, Text: "daemon shut down"} | |
| 328 | + | default: | |
| 329 | + | return &DetachedError{Reason: reason, Text: "detached by daemon"} | |
| 330 | + | } | |
| 331 | + | } | |
| @@ -0,0 +1,331 @@ | |||||
| 1 | + | // Package client implements vev's thin attach client: it speaks the framed | |||
| 2 | + | // IPC protocol to a daemon, pumping terminal input, resize events, and PTY | |||
| 3 | + | // output between the controlling terminal and the session. | |||
| 4 | + | // | |||
| 5 | + | // The package depends only on ports (transport, terminal) plus the ipc | |||
| 6 | + | // message codecs, never on a concrete transport or terminal implementation; | |||
| 7 | + | // the app layer wires those in. | |||
| 8 | + | package client | |||
| 9 | + | ||||
| 10 | + | import ( | |||
| 11 | + | "context" | |||
| 12 | + | "errors" | |||
| 13 | + | "fmt" | |||
| 14 | + | "io" | |||
| 15 | + | "log/slog" | |||
| 16 | + | "os" | |||
| 17 | + | ||||
| 18 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 19 | + | "github.com/bnema/vev/internal/domain" | |||
| 20 | + | "github.com/bnema/vev/internal/ports" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // ProtocolError is a session- or protocol-level failure reported by the | |||
| 24 | + | // daemon before attach could proceed (a Hello rejected with an ErrorMsg). | |||
| 25 | + | // The app layer prints it to the user. | |||
| 26 | + | type ProtocolError struct { | |||
| 27 | + | Code uint16 | |||
| 28 | + | Text string | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (e *ProtocolError) Error() string { | |||
| 32 | + | if e.Text == "" { | |||
| 33 | + | return fmt.Sprintf("vev: daemon rejected attach (code %d)", e.Code) | |||
| 34 | + | } | |||
| 35 | + | return fmt.Sprintf("vev: %s", e.Text) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // DetachedError reports that the daemon detached this client for a reason | |||
| 39 | + | // other than an ordinary user-initiated detach. | |||
| 40 | + | type DetachedError struct { | |||
| 41 | + | Reason uint8 | |||
| 42 | + | Text string | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (e *DetachedError) Error() string { return "vev: " + e.Text } | |||
| 46 | + | ||||
| 47 | + | // milestones tracks lifecycle progress so a failure can report which stages | |||
| 48 | + | // were never reached — the key diagnostic when an attach dies early. | |||
| 49 | + | type milestones struct { | |||
| 50 | + | dialed bool | |||
| 51 | + | helloSent bool | |||
| 52 | + | welcomed bool | |||
| 53 | + | rawEntered bool | |||
| 54 | + | firstOutput bool | |||
| 55 | + | detached bool | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (m milestones) missing() []string { | |||
| 59 | + | var out []string | |||
| 60 | + | if !m.dialed { | |||
| 61 | + | out = append(out, "dialed") | |||
| 62 | + | } | |||
| 63 | + | if !m.helloSent { | |||
| 64 | + | out = append(out, "hello_sent") | |||
| 65 | + | } | |||
| 66 | + | if !m.welcomed { | |||
| 67 | + | out = append(out, "welcomed") | |||
| 68 | + | } | |||
| 69 | + | if !m.rawEntered { | |||
| 70 | + | out = append(out, "raw_entered") | |||
| 71 | + | } | |||
| 72 | + | if !m.firstOutput { | |||
| 73 | + | out = append(out, "first_output") | |||
| 74 | + | } | |||
| 75 | + | if !m.detached { | |||
| 76 | + | out = append(out, "detached") | |||
| 77 | + | } | |||
| 78 | + | return out | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // stdinBufSize is the read buffer for the terminal input pump. | |||
| 82 | + | const stdinBufSize = 4096 | |||
| 83 | + | ||||
| 84 | + | // sendQueueDepth bounds the buffered frames awaiting the single sender | |||
| 85 | + | // goroutine, decoupling the input/resize pumps from transport back-pressure. | |||
| 86 | + | const sendQueueDepth = 64 | |||
| 87 | + | ||||
| 88 | + | // Attach connects an already-dialed transport to the controlling terminal | |||
| 89 | + | // and runs the attach loop until the session detaches, the daemon | |||
| 90 | + | // disappears, or the context is cancelled. | |||
| 91 | + | // | |||
| 92 | + | // It owns transport for its lifetime and closes it before returning. The | |||
| 93 | + | // terminal is put into raw mode after a successful Welcome and restored on | |||
| 94 | + | // every exit path — normal return, error, or panic. | |||
| 95 | + | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) (retErr error) { | |||
| 96 | + | log := slog.Default().With("component", "client") | |||
| 97 | + | ms := milestones{dialed: true} // transport arrives already connected | |||
| 98 | + | ||||
| 99 | + | // Runs last: a single structured diagnostic on failure, to the log file | |||
| 100 | + | // (never the terminal). Console output while raw is forbidden. | |||
| 101 | + | defer func() { | |||
| 102 | + | if retErr != nil { | |||
| 103 | + | log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing()) | |||
| 104 | + | } | |||
| 105 | + | }() | |||
| 106 | + | ||||
| 107 | + | // Attach owns the transport: close it on every exit path. | |||
| 108 | + | defer func() { _ = transport.Close() }() | |||
| 109 | + | ||||
| 110 | + | // 1. Handshake: send Hello with our size and TERM. | |||
| 111 | + | size, err := term.Size() | |||
| 112 | + | if err != nil { | |||
| 113 | + | return fmt.Errorf("vev: reading terminal size: %w", err) | |||
| 114 | + | } | |||
| 115 | + | hello := ipc.Hello{ | |||
| 116 | + | Version: ports.ProtocolVersion, | |||
| 117 | + | Intent: intent, | |||
| 118 | + | Name: name, | |||
| 119 | + | Size: size, | |||
| 120 | + | TermEnv: os.Getenv("TERM"), | |||
| 121 | + | } | |||
| 122 | + | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ipc.MarshalHello(hello)}); err != nil { | |||
| 123 | + | return fmt.Errorf("vev: sending hello: %w", err) | |||
| 124 | + | } | |||
| 125 | + | ms.helloSent = true | |||
| 126 | + | ||||
| 127 | + | // 2. Await Welcome or a typed rejection. | |||
| 128 | + | reply, err := transport.Recv() | |||
| 129 | + | if err != nil { | |||
| 130 | + | return fmt.Errorf("vev: awaiting welcome: %w", err) | |||
| 131 | + | } | |||
| 132 | + | switch reply.Type { | |||
| 133 | + | case ports.MsgWelcome: | |||
| 134 | + | ms.welcomed = true | |||
| 135 | + | case ports.MsgError: | |||
| 136 | + | em, derr := ipc.UnmarshalErrorMsg(reply.Payload) | |||
| 137 | + | if derr != nil { | |||
| 138 | + | return fmt.Errorf("vev: decoding error reply: %w", derr) | |||
| 139 | + | } | |||
| 140 | + | return &ProtocolError{Code: em.Code, Text: em.Text} | |||
| 141 | + | default: | |||
| 142 | + | return fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // 3. Enter raw mode; restore on every subsequent exit path. | |||
| 146 | + | restore, err := term.EnterRaw() | |||
| 147 | + | if err != nil { | |||
| 148 | + | return fmt.Errorf("vev: entering raw mode: %w", err) | |||
| 149 | + | } | |||
| 150 | + | ms.rawEntered = true | |||
| 151 | + | defer func() { | |||
| 152 | + | if rerr := restore(); rerr != nil && retErr == nil { | |||
| 153 | + | retErr = fmt.Errorf("vev: restoring terminal: %w", rerr) | |||
| 154 | + | } | |||
| 155 | + | }() | |||
| 156 | + | ||||
| 157 | + | // 4. Derive a cancellable context so the pumps always unwind when the | |||
| 158 | + | // loop returns. cancel runs first (LIFO): it signals the pumps; the | |||
| 159 | + | // deferred Close above then unblocks any pump parked in a syscall. | |||
| 160 | + | loopCtx, cancel := context.WithCancel(ctx) | |||
| 161 | + | defer cancel() | |||
| 162 | + | ||||
| 163 | + | sendCh := make(chan ports.Frame, sendQueueDepth) | |||
| 164 | + | sendErrCh := make(chan error, 1) | |||
| 165 | + | ||||
| 166 | + | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh) | |||
| 167 | + | go runStdin(loopCtx, cancel, term.In(), sendCh) | |||
| 168 | + | go runResize(loopCtx, term.ResizeEvents(), sendCh) | |||
| 169 | + | ||||
| 170 | + | // 5. Output/main loop: the only goroutine that touches the terminal. | |||
| 171 | + | recvCh := make(chan recvResult, 1) | |||
| 172 | + | go runRecv(loopCtx, transport, recvCh) | |||
| 173 | + | ||||
| 174 | + | for { | |||
| 175 | + | select { | |||
| 176 | + | case <-loopCtx.Done(): | |||
| 177 | + | // A pump initiated shutdown (stdin EOF/detach) or the parent | |||
| 178 | + | // context was cancelled. A queued sender error takes priority. | |||
| 179 | + | select { | |||
| 180 | + | case serr := <-sendErrCh: | |||
| 181 | + | return fmt.Errorf("vev: sending to daemon: %w", serr) | |||
| 182 | + | default: | |||
| 183 | + | return nil | |||
| 184 | + | } | |||
| 185 | + | case r := <-recvCh: | |||
| 186 | + | if r.err != nil { | |||
| 187 | + | if errors.Is(r.err, io.EOF) { | |||
| 188 | + | return fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err) | |||
| 189 | + | } | |||
| 190 | + | return fmt.Errorf("vev: receiving from daemon: %w", r.err) | |||
| 191 | + | } | |||
| 192 | + | switch r.frame.Type { | |||
| 193 | + | case ports.MsgOutput: | |||
| 194 | + | o, derr := ipc.UnmarshalOutput(r.frame.Payload) | |||
| 195 | + | if derr != nil { | |||
| 196 | + | return fmt.Errorf("vev: decoding output: %w", derr) | |||
| 197 | + | } | |||
| 198 | + | if _, werr := term.Out().Write(o.Data); werr != nil { | |||
| 199 | + | return fmt.Errorf("vev: writing terminal output: %w", werr) | |||
| 200 | + | } | |||
| 201 | + | if ferr := term.Flush(); ferr != nil { | |||
| 202 | + | return fmt.Errorf("vev: flushing terminal: %w", ferr) | |||
| 203 | + | } | |||
| 204 | + | ms.firstOutput = true | |||
| 205 | + | case ports.MsgDetached: | |||
| 206 | + | d, derr := ipc.UnmarshalDetached(r.frame.Payload) | |||
| 207 | + | if derr != nil { | |||
| 208 | + | return fmt.Errorf("vev: decoding detached: %w", derr) | |||
| 209 | + | } | |||
| 210 | + | ms.detached = true | |||
| 211 | + | return detachedResult(d.Reason) | |||
| 212 | + | case ports.MsgPong: | |||
| 213 | + | // Liveness reply; nothing to do. | |||
| 214 | + | default: | |||
| 215 | + | // Unknown/out-of-band message types are ignored so a newer | |||
| 216 | + | // daemon can add server->client messages without breaking us. | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // recvResult carries one framed message (or a read error) from the receive | |||
| 223 | + | // pump to the main loop. | |||
| 224 | + | type recvResult struct { | |||
| 225 | + | frame ports.Frame | |||
| 226 | + | err error | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // runRecv reads frames until an error, forwarding each to the main loop. | |||
| 230 | + | // It exits on the first error or when the loop context is cancelled. | |||
| 231 | + | func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult) { | |||
| 232 | + | for { | |||
| 233 | + | f, err := transport.Recv() | |||
| 234 | + | select { | |||
| 235 | + | case out <- recvResult{frame: f, err: err}: | |||
| 236 | + | case <-ctx.Done(): | |||
| 237 | + | return | |||
| 238 | + | } | |||
| 239 | + | if err != nil { | |||
| 240 | + | return | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // runSender is the sole caller of transport.Send: serialising the input and | |||
| 246 | + | // resize pumps through one goroutine keeps the transport's single-writer | |||
| 247 | + | // contract intact. A send failure cancels the loop and is surfaced once. | |||
| 248 | + | func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, errCh chan<- error) { | |||
| 249 | + | for { | |||
| 250 | + | select { | |||
| 251 | + | case f := <-in: | |||
| 252 | + | if err := transport.Send(f); err != nil { | |||
| 253 | + | select { | |||
| 254 | + | case errCh <- err: | |||
| 255 | + | default: | |||
| 256 | + | } | |||
| 257 | + | cancel() | |||
| 258 | + | return | |||
| 259 | + | } | |||
| 260 | + | case <-ctx.Done(): | |||
| 261 | + | return | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // runStdin pumps terminal input to the daemon as Input frames. A read error | |||
| 267 | + | // or EOF is treated as a detach: it best-effort sends a Detach frame and | |||
| 268 | + | // cancels the loop. | |||
| 269 | + | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame) { | |||
| 270 | + | buf := make([]byte, stdinBufSize) | |||
| 271 | + | for { | |||
| 272 | + | n, rerr := in.Read(buf) | |||
| 273 | + | if n > 0 { | |||
| 274 | + | data := make([]byte, n) | |||
| 275 | + | copy(data, buf[:n]) | |||
| 276 | + | frame := ports.Frame{Type: ports.MsgInput, Payload: ipc.MarshalInput(ipc.Input{Data: data})} | |||
| 277 | + | select { | |||
| 278 | + | case out <- frame: | |||
| 279 | + | case <-ctx.Done(): | |||
| 280 | + | return | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | if rerr != nil { | |||
| 284 | + | // Best-effort detach notification, then unwind. | |||
| 285 | + | select { | |||
| 286 | + | case out <- ports.Frame{Type: ports.MsgDetach, Payload: ipc.MarshalDetach(ipc.Detach{})}: | |||
| 287 | + | case <-ctx.Done(): | |||
| 288 | + | } | |||
| 289 | + | cancel() | |||
| 290 | + | return | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | // runResize forwards coalesced terminal resize events to the daemon. It | |||
| 296 | + | // tolerates an already-closed resize channel (which the terminal adapter | |||
| 297 | + | // hands back when restore ran before ResizeEvents was first called). | |||
| 298 | + | func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame) { | |||
| 299 | + | for { | |||
| 300 | + | select { | |||
| 301 | + | case sz, ok := <-events: | |||
| 302 | + | if !ok { | |||
| 303 | + | return | |||
| 304 | + | } | |||
| 305 | + | frame := ports.Frame{Type: ports.MsgResize, Payload: ipc.MarshalResize(ipc.Resize{Size: sz})} | |||
| 306 | + | select { | |||
| 307 | + | case out <- frame: | |||
| 308 | + | case <-ctx.Done(): | |||
| 309 | + | return | |||
| 310 | + | } | |||
| 311 | + | case <-ctx.Done(): | |||
| 312 | + | return | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // detachedResult maps a Detached reason to a return value: an ordinary | |||
| 318 | + | // user-initiated detach is a clean (nil) exit; anything else is surfaced so | |||
| 319 | + | // the user learns why the session went away. | |||
| 320 | + | func detachedResult(reason uint8) error { | |||
| 321 | + | switch reason { | |||
| 322 | + | case ipc.ReasonDetach: | |||
| 323 | + | return nil | |||
| 324 | + | case ipc.ReasonSessionKilled: | |||
| 325 | + | return &DetachedError{Reason: reason, Text: "session was killed"} | |||
| 326 | + | case ipc.ReasonServerShutdown: | |||
| 327 | + | return &DetachedError{Reason: reason, Text: "daemon shut down"} | |||
| 328 | + | default: | |||
| 329 | + | return &DetachedError{Reason: reason, Text: "detached by daemon"} | |||
| 330 | + | } | |||
| 331 | + | } | |||
A internal/usecase/client/client_test.go +234 -0
| @@ -0,0 +1,234 @@ | |||
| 1 | + | package client_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "log/slog" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/vev/internal/adapters/ipc" | |
| 17 | + | "github.com/bnema/vev/internal/domain" | |
| 18 | + | "github.com/bnema/vev/internal/ports" | |
| 19 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 20 | + | "github.com/bnema/vev/internal/usecase/client" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | func init() { | |
| 24 | + | // Keep client diagnostics out of the test runner's stderr. | |
| 25 | + | slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // recvItem is one scripted Recv result. | |
| 29 | + | type recvItem struct { | |
| 30 | + | f ports.Frame | |
| 31 | + | err error | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // scriptRecv wires a MockTransport's Recv to yield the given items in order, | |
| 35 | + | // then block forever (simulating a live but idle connection). It returns a | |
| 36 | + | // cleanup that unblocks the parked reader. | |
| 37 | + | func scriptRecv(tr *portsmocks.MockTransport, items ...recvItem) func() { | |
| 38 | + | ch := make(chan recvItem, len(items)) | |
| 39 | + | for _, it := range items { | |
| 40 | + | ch <- it | |
| 41 | + | } | |
| 42 | + | done := make(chan struct{}) | |
| 43 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 44 | + | select { | |
| 45 | + | case it := <-ch: | |
| 46 | + | return it.f, it.err | |
| 47 | + | case <-done: | |
| 48 | + | return ports.Frame{}, io.EOF | |
| 49 | + | } | |
| 50 | + | }).Maybe() | |
| 51 | + | return func() { close(done) } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func frameOf(t ports.MsgType, payload []byte) ports.Frame { | |
| 55 | + | return ports.Frame{Type: t, Payload: payload} | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // blockingReader blocks on Read until closed, then returns EOF. Stands in | |
| 59 | + | // for a terminal's stdin that produces nothing during the test. | |
| 60 | + | type blockingReader struct{ ch chan struct{} } | |
| 61 | + | ||
| 62 | + | func newBlockingReader() *blockingReader { return &blockingReader{ch: make(chan struct{})} } | |
| 63 | + | ||
| 64 | + | func (b *blockingReader) Read(p []byte) (int, error) { | |
| 65 | + | <-b.ch | |
| 66 | + | return 0, io.EOF | |
| 67 | + | } | |
| 68 | + | func (b *blockingReader) unblock() { close(b.ch) } | |
| 69 | + | ||
| 70 | + | func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) { | |
| 71 | + | tm := portsmocks.NewMockTerminal(t) | |
| 72 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 73 | + | tm.EXPECT().EnterRaw().Return(func() error { | |
| 74 | + | restoreCount.Add(1) | |
| 75 | + | return nil | |
| 76 | + | }, nil).Once() | |
| 77 | + | in := newBlockingReader() | |
| 78 | + | tm.EXPECT().In().Return(in).Maybe() | |
| 79 | + | tm.EXPECT().Out().Return(out).Maybe() | |
| 80 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |
| 81 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 82 | + | return tm, in | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func isType(typ ports.MsgType) any { | |
| 86 | + | return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ }) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestAttachHappyPath(t *testing.T) { | |
| 90 | + | var out bytes.Buffer | |
| 91 | + | var restoreCount atomic.Int32 | |
| 92 | + | resizeCh := make(chan domain.Size) | |
| 93 | + | tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh) | |
| 94 | + | defer in.unblock() | |
| 95 | + | ||
| 96 | + | tr := portsmocks.NewMockTransport(t) | |
| 97 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 98 | + | unblock := scriptRecv(tr, | |
| 99 | + | recvItem{f: frameOf(ports.MsgWelcome, ipc.MarshalWelcome(ipc.Welcome{SessionID: "s1", SessionName: "main"}))}, | |
| 100 | + | recvItem{f: frameOf(ports.MsgOutput, ipc.MarshalOutput(ipc.Output{Data: []byte("hello world")}))}, | |
| 101 | + | recvItem{f: frameOf(ports.MsgDetached, ipc.MarshalDetached(ipc.Detached{Reason: ipc.ReasonDetach}))}, | |
| 102 | + | ) | |
| 103 | + | defer unblock() | |
| 104 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 105 | + | ||
| 106 | + | err := client.Attach(context.Background(), tr, tm, ipc.IntentEphemeral, "") | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | require.Equal(t, "hello world", out.String()) | |
| 109 | + | require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once") | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestAttachVersionMismatch(t *testing.T) { | |
| 113 | + | tm := portsmocks.NewMockTerminal(t) | |
| 114 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 115 | + | // EnterRaw must NOT be called on the error-before-welcome path. | |
| 116 | + | ||
| 117 | + | tr := portsmocks.NewMockTransport(t) | |
| 118 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 119 | + | tr.EXPECT().Recv().Return( | |
| 120 | + | frameOf(ports.MsgError, ipc.MarshalErrorMsg(ipc.ErrorMsg{Code: ipc.ErrVersionMismatch, Text: "version mismatch"})), | |
| 121 | + | nil, | |
| 122 | + | ).Once() | |
| 123 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 124 | + | ||
| 125 | + | err := client.Attach(context.Background(), tr, tm, ipc.IntentAttach, "main") | |
| 126 | + | require.Error(t, err) | |
| 127 | + | var pe *client.ProtocolError | |
| 128 | + | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |
| 129 | + | require.Equal(t, ipc.ErrVersionMismatch, pe.Code) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) { | |
| 133 | + | var out bytes.Buffer | |
| 134 | + | var restoreCount atomic.Int32 | |
| 135 | + | resizeCh := make(chan domain.Size) | |
| 136 | + | tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh) | |
| 137 | + | defer in.unblock() | |
| 138 | + | ||
| 139 | + | tr := portsmocks.NewMockTransport(t) | |
| 140 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 141 | + | boom := errors.New("connection reset") | |
| 142 | + | unblock := scriptRecv(tr, | |
| 143 | + | recvItem{f: frameOf(ports.MsgWelcome, ipc.MarshalWelcome(ipc.Welcome{SessionID: "s1"}))}, | |
| 144 | + | recvItem{err: boom}, | |
| 145 | + | ) | |
| 146 | + | defer unblock() | |
| 147 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 148 | + | ||
| 149 | + | err := client.Attach(context.Background(), tr, tm, ipc.IntentEphemeral, "") | |
| 150 | + | require.Error(t, err) | |
| 151 | + | require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error") | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestAttachDaemonVanishedOnEOF(t *testing.T) { | |
| 155 | + | var out bytes.Buffer | |
| 156 | + | var restoreCount atomic.Int32 | |
| 157 | + | resizeCh := make(chan domain.Size) | |
| 158 | + | tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh) | |
| 159 | + | defer in.unblock() | |
| 160 | + | ||
| 161 | + | tr := portsmocks.NewMockTransport(t) | |
| 162 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 163 | + | unblock := scriptRecv(tr, | |
| 164 | + | recvItem{f: frameOf(ports.MsgWelcome, ipc.MarshalWelcome(ipc.Welcome{SessionID: "s1"}))}, | |
| 165 | + | recvItem{err: io.EOF}, | |
| 166 | + | ) | |
| 167 | + | defer unblock() | |
| 168 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 169 | + | ||
| 170 | + | err := client.Attach(context.Background(), tr, tm, ipc.IntentEphemeral, "") | |
| 171 | + | require.Error(t, err) | |
| 172 | + | require.ErrorIs(t, err, io.EOF) | |
| 173 | + | require.Equal(t, int32(1), restoreCount.Load()) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestAttachForwardsResize(t *testing.T) { | |
| 177 | + | var out bytes.Buffer | |
| 178 | + | var restoreCount atomic.Int32 | |
| 179 | + | resizeCh := make(chan domain.Size) | |
| 180 | + | tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh) | |
| 181 | + | defer in.unblock() | |
| 182 | + | ||
| 183 | + | detachAfterResize := make(chan struct{}) | |
| 184 | + | ||
| 185 | + | tr := portsmocks.NewMockTransport(t) | |
| 186 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 187 | + | // The resize frame is forwarded via the sender goroutine. | |
| 188 | + | gotResize := make(chan ipc.Resize, 1) | |
| 189 | + | tr.EXPECT().Send(isType(ports.MsgResize)).RunAndReturn(func(f ports.Frame) error { | |
| 190 | + | r, _ := ipc.UnmarshalResize(f.Payload) | |
| 191 | + | gotResize <- r | |
| 192 | + | close(detachAfterResize) | |
| 193 | + | return nil | |
| 194 | + | }).Once() | |
| 195 | + | ||
| 196 | + | welcome := frameOf(ports.MsgWelcome, ipc.MarshalWelcome(ipc.Welcome{SessionID: "s1"})) | |
| 197 | + | detached := frameOf(ports.MsgDetached, ipc.MarshalDetached(ipc.Detached{Reason: ipc.ReasonDetach})) | |
| 198 | + | recvCh := make(chan recvItem, 2) | |
| 199 | + | recvCh <- recvItem{f: welcome} | |
| 200 | + | done := make(chan struct{}) | |
| 201 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 202 | + | select { | |
| 203 | + | case it := <-recvCh: | |
| 204 | + | return it.f, it.err | |
| 205 | + | case <-detachAfterResize: | |
| 206 | + | // Deliver the detach once the resize has been observed. | |
| 207 | + | select { | |
| 208 | + | case <-done: | |
| 209 | + | return ports.Frame{}, io.EOF | |
| 210 | + | default: | |
| 211 | + | close(done) | |
| 212 | + | return detached, nil | |
| 213 | + | } | |
| 214 | + | case <-done: | |
| 215 | + | return ports.Frame{}, io.EOF | |
| 216 | + | } | |
| 217 | + | }).Maybe() | |
| 218 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 219 | + | ||
| 220 | + | // Push a resize event shortly after attach begins. | |
| 221 | + | go func() { | |
| 222 | + | time.Sleep(20 * time.Millisecond) | |
| 223 | + | resizeCh <- domain.Size{Cols: 120, Rows: 40} | |
| 224 | + | }() | |
| 225 | + | ||
| 226 | + | err := client.Attach(context.Background(), tr, tm, ipc.IntentEphemeral, "") | |
| 227 | + | require.NoError(t, err) | |
| 228 | + | select { | |
| 229 | + | case r := <-gotResize: | |
| 230 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, r.Size) | |
| 231 | + | default: | |
| 232 | + | t.Fatal("resize was not forwarded") | |
| 233 | + | } | |
| 234 | + | } | |
| @@ -0,0 +1,234 @@ | |||||
| 1 | + | package client_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "io" | |||
| 8 | + | "log/slog" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/vev/internal/adapters/ipc" | |||
| 17 | + | "github.com/bnema/vev/internal/domain" | |||
| 18 | + | "github.com/bnema/vev/internal/ports" | |||
| 19 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 20 | + | "github.com/bnema/vev/internal/usecase/client" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | func init() { | |||
| 24 | + | // Keep client diagnostics out of the test runner's stderr. | |||
| 25 | + | slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // recvItem is one scripted Recv result. | |||
| 29 | + | type recvItem struct { | |||
| 30 | + | f ports.Frame | |||
| 31 | + | err error | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // scriptRecv wires a MockTransport's Recv to yield the given items in order, | |||
| 35 | + | // then block forever (simulating a live but idle connection). It returns a | |||
| 36 | + | // cleanup that unblocks the parked reader. | |||
| 37 | + | func scriptRecv(tr *portsmocks.MockTransport, items ...recvItem) func() { | |||
| 38 | + | ch := make(chan recvItem, len(items)) | |||
| 39 | + | for _, it := range items { | |||
| 40 | + | ch <- it | |||
| 41 | + | } | |||
| 42 | + | done := make(chan struct{}) | |||
| 43 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 44 | + | select { | |||
| 45 | + | case it := <-ch: | |||
| 46 | + | return it.f, it.err | |||
| 47 | + | case <-done: | |||
| 48 | + | return ports.Frame{}, io.EOF | |||
| 49 | + | } | |||
| 50 | + | }).Maybe() | |||
| 51 | + | return func() { close(done) } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func frameOf(t ports.MsgType, payload []byte) ports.Frame { | |||
| 55 | + | return ports.Frame{Type: t, Payload: payload} | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // blockingReader blocks on Read until closed, then returns EOF. Stands in | |||
| 59 | + | // for a terminal's stdin that produces nothing during the test. | |||
| 60 | + | type blockingReader struct{ ch chan struct{} } | |||
| 61 | + | ||||
| 62 | + | func newBlockingReader() *blockingReader { return &blockingReader{ch: make(chan struct{})} } | |||
| 63 | + | ||||
| 64 | + | func (b *blockingReader) Read(p []byte) (int, error) { | |||
| 65 | + | <-b.ch | |||
| 66 | + | return 0, io.EOF | |||
| 67 | + | } | |||
| 68 | + | func (b *blockingReader) unblock() { close(b.ch) } | |||
| 69 | + | ||||
| 70 | + | func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) { | |||
| 71 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 72 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 73 | + | tm.EXPECT().EnterRaw().Return(func() error { | |||
| 74 | + | restoreCount.Add(1) | |||
| 75 | + | return nil | |||
| 76 | + | }, nil).Once() | |||
| 77 | + | in := newBlockingReader() | |||
| 78 | + | tm.EXPECT().In().Return(in).Maybe() | |||
| 79 | + | tm.EXPECT().Out().Return(out).Maybe() | |||
| 80 | + | tm.EXPECT().Flush().Return(nil).Maybe() | |||
| 81 | + | tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |||
| 82 | + | return tm, in | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func isType(typ ports.MsgType) any { | |||
| 86 | + | return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ }) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestAttachHappyPath(t *testing.T) { | |||
| 90 | + | var out bytes.Buffer | |||
| 91 | + | var restoreCount atomic.Int32 | |||
| 92 | + | resizeCh := make(chan domain.Size) | |||
| 93 | + | tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh) | |||
| 94 | + | defer in.unblock() | |||
| 95 | + | ||||
| 96 | + | tr := portsmocks.NewMockTransport(t) | |||
| 97 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 98 | + | unblock := scriptRecv(tr, | |||
| 99 | + | recvItem{f: frameOf(ports.MsgWelcome, ipc.MarshalWelcome(ipc.Welcome{SessionID: "s1", SessionName: "main"}))}, | |||
| 100 | + | recvItem{f: frameOf(ports.MsgOutput, ipc.MarshalOutput(ipc.Output{Data: []byte("hello world")}))}, | |||
| 101 | + | recvItem{f: frameOf(ports.MsgDetached, ipc.MarshalDetached(ipc.Detached{Reason: ipc.ReasonDetach}))}, | |||
| 102 | + | ) | |||
| 103 | + | defer unblock() | |||
| 104 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 105 | + | ||||
| 106 | + | err := client.Attach(context.Background(), tr, tm, ipc.IntentEphemeral, "") | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | require.Equal(t, "hello world", out.String()) | |||
| 109 | + | require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once") | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestAttachVersionMismatch(t *testing.T) { | |||
| 113 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 114 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 115 | + | // EnterRaw must NOT be called on the error-before-welcome path. | |||
| 116 | + | ||||
| 117 | + | tr := portsmocks.NewMockTransport(t) | |||
| 118 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 119 | + | tr.EXPECT().Recv().Return( | |||
| 120 | + | frameOf(ports.MsgError, ipc.MarshalErrorMsg(ipc.ErrorMsg{Code: ipc.ErrVersionMismatch, Text: "version mismatch"})), | |||
| 121 | + | nil, | |||
| 122 | + | ).Once() | |||
| 123 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 124 | + | ||||
| 125 | + | err := client.Attach(context.Background(), tr, tm, ipc.IntentAttach, "main") | |||
| 126 | + | require.Error(t, err) | |||
| 127 | + | var pe *client.ProtocolError | |||
| 128 | + | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |||
| 129 | + | require.Equal(t, ipc.ErrVersionMismatch, pe.Code) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) { | |||
| 133 | + | var out bytes.Buffer | |||
| 134 | + | var restoreCount atomic.Int32 | |||
| 135 | + | resizeCh := make(chan domain.Size) | |||
| 136 | + | tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh) | |||
| 137 | + | defer in.unblock() | |||
| 138 | + | ||||
| 139 | + | tr := portsmocks.NewMockTransport(t) | |||
| 140 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 141 | + | boom := errors.New("connection reset") | |||
| 142 | + | unblock := scriptRecv(tr, | |||
| 143 | + | recvItem{f: frameOf(ports.MsgWelcome, ipc.MarshalWelcome(ipc.Welcome{SessionID: "s1"}))}, | |||
| 144 | + | recvItem{err: boom}, | |||
| 145 | + | ) | |||
| 146 | + | defer unblock() | |||
| 147 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 148 | + | ||||
| 149 | + | err := client.Attach(context.Background(), tr, tm, ipc.IntentEphemeral, "") | |||
| 150 | + | require.Error(t, err) | |||
| 151 | + | require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error") | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestAttachDaemonVanishedOnEOF(t *testing.T) { | |||
| 155 | + | var out bytes.Buffer | |||
| 156 | + | var restoreCount atomic.Int32 | |||
| 157 | + | resizeCh := make(chan domain.Size) | |||
| 158 | + | tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh) | |||
| 159 | + | defer in.unblock() | |||
| 160 | + | ||||
| 161 | + | tr := portsmocks.NewMockTransport(t) | |||
| 162 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 163 | + | unblock := scriptRecv(tr, | |||
| 164 | + | recvItem{f: frameOf(ports.MsgWelcome, ipc.MarshalWelcome(ipc.Welcome{SessionID: "s1"}))}, | |||
| 165 | + | recvItem{err: io.EOF}, | |||
| 166 | + | ) | |||
| 167 | + | defer unblock() | |||
| 168 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 169 | + | ||||
| 170 | + | err := client.Attach(context.Background(), tr, tm, ipc.IntentEphemeral, "") | |||
| 171 | + | require.Error(t, err) | |||
| 172 | + | require.ErrorIs(t, err, io.EOF) | |||
| 173 | + | require.Equal(t, int32(1), restoreCount.Load()) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestAttachForwardsResize(t *testing.T) { | |||
| 177 | + | var out bytes.Buffer | |||
| 178 | + | var restoreCount atomic.Int32 | |||
| 179 | + | resizeCh := make(chan domain.Size) | |||
| 180 | + | tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh) | |||
| 181 | + | defer in.unblock() | |||
| 182 | + | ||||
| 183 | + | detachAfterResize := make(chan struct{}) | |||
| 184 | + | ||||
| 185 | + | tr := portsmocks.NewMockTransport(t) | |||
| 186 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 187 | + | // The resize frame is forwarded via the sender goroutine. | |||
| 188 | + | gotResize := make(chan ipc.Resize, 1) | |||
| 189 | + | tr.EXPECT().Send(isType(ports.MsgResize)).RunAndReturn(func(f ports.Frame) error { | |||
| 190 | + | r, _ := ipc.UnmarshalResize(f.Payload) | |||
| 191 | + | gotResize <- r | |||
| 192 | + | close(detachAfterResize) | |||
| 193 | + | return nil | |||
| 194 | + | }).Once() | |||
| 195 | + | ||||
| 196 | + | welcome := frameOf(ports.MsgWelcome, ipc.MarshalWelcome(ipc.Welcome{SessionID: "s1"})) | |||
| 197 | + | detached := frameOf(ports.MsgDetached, ipc.MarshalDetached(ipc.Detached{Reason: ipc.ReasonDetach})) | |||
| 198 | + | recvCh := make(chan recvItem, 2) | |||
| 199 | + | recvCh <- recvItem{f: welcome} | |||
| 200 | + | done := make(chan struct{}) | |||
| 201 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 202 | + | select { | |||
| 203 | + | case it := <-recvCh: | |||
| 204 | + | return it.f, it.err | |||
| 205 | + | case <-detachAfterResize: | |||
| 206 | + | // Deliver the detach once the resize has been observed. | |||
| 207 | + | select { | |||
| 208 | + | case <-done: | |||
| 209 | + | return ports.Frame{}, io.EOF | |||
| 210 | + | default: | |||
| 211 | + | close(done) | |||
| 212 | + | return detached, nil | |||
| 213 | + | } | |||
| 214 | + | case <-done: | |||
| 215 | + | return ports.Frame{}, io.EOF | |||
| 216 | + | } | |||
| 217 | + | }).Maybe() | |||
| 218 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 219 | + | ||||
| 220 | + | // Push a resize event shortly after attach begins. | |||
| 221 | + | go func() { | |||
| 222 | + | time.Sleep(20 * time.Millisecond) | |||
| 223 | + | resizeCh <- domain.Size{Cols: 120, Rows: 40} | |||
| 224 | + | }() | |||
| 225 | + | ||||
| 226 | + | err := client.Attach(context.Background(), tr, tm, ipc.IntentEphemeral, "") | |||
| 227 | + | require.NoError(t, err) | |||
| 228 | + | select { | |||
| 229 | + | case r := <-gotResize: | |||
| 230 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, r.Size) | |||
| 231 | + | default: | |||
| 232 | + | t.Fatal("resize was not forwarded") | |||
| 233 | + | } | |||
| 234 | + | } | |||
A internal/usecase/daemon/daemon.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |
| 2 | + | // | |
| 3 | + | // The daemon core — accepting client connections, spawning PTY-backed | |
| 4 | + | // sessions, and fanning I/O between them — is implemented in a later | |
| 5 | + | // milestone (Task 11). This file provides only the Serve entry point that | |
| 6 | + | // the app layer wires the hidden --daemon path to, so the client, CLI, and | |
| 7 | + | // auto-spawn machinery can be built and tested against a stable seam now. | |
| 8 | + | package daemon | |
| 9 | + | ||
| 10 | + | import ( | |
| 11 | + | "errors" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // ErrNotImplemented is returned by Serve until the daemon core lands in | |
| 17 | + | // Task 11. It is deliberately a distinct sentinel so the app layer (and | |
| 18 | + | // tests) can recognise the not-yet-implemented state. | |
| 19 | + | var ErrNotImplemented = errors.New("vev: daemon core not implemented yet (Task 11)") | |
| 20 | + | ||
| 21 | + | // Serve runs the daemon's accept loop over ln, owning ln for its lifetime. | |
| 22 | + | // | |
| 23 | + | // TODO(Task 11): implement the real accept/session/dispatch loop. For now | |
| 24 | + | // it accepts the listener and immediately reports ErrNotImplemented without | |
| 25 | + | // closing it (the caller owns ln and closes it). | |
| 26 | + | func Serve(ln ports.Listener) error { | |
| 27 | + | _ = ln | |
| 28 | + | return ErrNotImplemented | |
| 29 | + | } | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | // Package daemon holds vev's server-side session multiplexer use case. | |||
| 2 | + | // | |||
| 3 | + | // The daemon core — accepting client connections, spawning PTY-backed | |||
| 4 | + | // sessions, and fanning I/O between them — is implemented in a later | |||
| 5 | + | // milestone (Task 11). This file provides only the Serve entry point that | |||
| 6 | + | // the app layer wires the hidden --daemon path to, so the client, CLI, and | |||
| 7 | + | // auto-spawn machinery can be built and tested against a stable seam now. | |||
| 8 | + | package daemon | |||
| 9 | + | ||||
| 10 | + | import ( | |||
| 11 | + | "errors" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // ErrNotImplemented is returned by Serve until the daemon core lands in | |||
| 17 | + | // Task 11. It is deliberately a distinct sentinel so the app layer (and | |||
| 18 | + | // tests) can recognise the not-yet-implemented state. | |||
| 19 | + | var ErrNotImplemented = errors.New("vev: daemon core not implemented yet (Task 11)") | |||
| 20 | + | ||||
| 21 | + | // Serve runs the daemon's accept loop over ln, owning ln for its lifetime. | |||
| 22 | + | // | |||
| 23 | + | // TODO(Task 11): implement the real accept/session/dispatch loop. For now | |||
| 24 | + | // it accepts the listener and immediately reports ErrNotImplemented without | |||
| 25 | + | // closing it (the caller owns ln and closes it). | |||
| 26 | + | func Serve(ln ports.Listener) error { | |||
| 27 | + | _ = ln | |||
| 28 | + | return ErrNotImplemented | |||
| 29 | + | } | |||