diff --git a/cmd/flue/close.go b/cmd/flue/close.go new file mode 100644 index 0000000..502d59a --- /dev/null +++ b/cmd/flue/close.go @@ -0,0 +1,134 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/karnstack/flue/internal/daemon" + "github.com/karnstack/flue/internal/transport/local" +) + +// errCloseUsage answers a bare `flue close`, which could mean either form and +// so gets both. A sentinel rather than a plain error because cmdClose exits 2 +// on it — the code main uses for an unknown command, and the right one for +// "you have not said what to close". +var errCloseUsage = errors.New("usage: flue close ... | flue close --all") + +// errUnknownSessions reports that at least one named id closed nothing. The +// per-id lines have already gone to stderr by the time it is returned, so +// cmdClose turns it into a bare exit 1 rather than printing it again. +var errUnknownSessions = errors.New("some sessions were not found") + +// cmdClose owns the exit codes runClose cannot: 2 for a usage error and 1 for +// unknown ids, both already explained on stderr. Everything else flows back +// to main's ordinary error path. +func cmdClose(args []string) error { + err := runClose(os.Stdout, os.Stderr, args) + switch { + case errors.Is(err, errCloseUsage): + fmt.Fprintln(os.Stderr, "flue:", err) + os.Exit(2) + case errors.Is(err, errUnknownSessions): + os.Exit(1) + } + return err +} + +// runClose ends sessions on the local daemon: every one under --all, the +// named ones otherwise. The writers are the seam — same pattern as statusTo — +// so the tests read both streams without capturing the process's own. +// +// A daemon that is not running is answered with a notice and success, not a +// failure: the user asked for no sessions, and no daemon means exactly that. +// Unknown ids are the one partial outcome — each is named on stderr, the rest +// are closed and counted, and errUnknownSessions carries the failure out. +func runClose(stdout, stderr io.Writer, args []string) error { + fs := flag.NewFlagSet("close", flag.ContinueOnError) + fs.SetOutput(stderr) + all := fs.Bool("all", false, "close every session, running and exited") + if err := fs.Parse(args); err != nil { + return errCloseUsage + } + ids := fs.Args() + if !*all && len(ids) == 0 { + return errCloseUsage + } + + port, ok := ourDaemon() + if !ok { + fmt.Fprintln(stdout, "daemon not running; nothing to close") + return nil + } + token, err := loadToken() + if err != nil { + return fmt.Errorf("load auth token: %w", err) + } + + closed, missing, err := postSessionsClose(port, token, *all, ids) + if err != nil { + return err + } + for _, id := range missing { + fmt.Fprintf(stderr, "flue: no such session: %s\n", id) + } + noun := "sessions" + if closed == 1 { + noun = "session" + } + fmt.Fprintf(stdout, " ✓ closed %d %s\n", closed, noun) + if len(missing) > 0 { + return errUnknownSessions + } + return nil +} + +// postSessionsClose asks the daemon to close sessions and relays its answer. +// The shape mirrors fetchSessions — token in a header, status checked before +// the body is decoded, the body bounded — because it is talking to the same +// daemon under the same rules. +func postSessionsClose(port int, token string, all bool, ids []string) (closed int, missing []string, err error) { + body, err := json.Marshal(map[string]any{"all": all, "ids": ids}) + if err != nil { + return 0, nil, err + } + u := &url.URL{ + Scheme: "http", + Host: fmt.Sprintf("127.0.0.1:%d", port), + Path: daemon.SessionsClosePath, + } + req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body)) + if err != nil { + return 0, nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(local.HeaderName, token) + resp, err := probeClient.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusUnauthorized: + return 0, nil, errTokenRejected + default: + return 0, nil, fmt.Errorf("daemon on 127.0.0.1:%d answered %s", port, resp.Status) + } + + var out struct { + Closed int `json:"closed"` + Missing []string `json:"missing"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxListingBytes)).Decode(&out); err != nil { + return 0, nil, fmt.Errorf("decode close answer: %w", err) + } + return out.Closed, out.Missing, nil +} diff --git a/cmd/flue/close_test.go b/cmd/flue/close_test.go new file mode 100644 index 0000000..bdb135f --- /dev/null +++ b/cmd/flue/close_test.go @@ -0,0 +1,156 @@ +package main + +import ( + "bytes" + "errors" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/karnstack/flue/internal/config" + "github.com/karnstack/flue/internal/daemon" + "github.com/karnstack/flue/internal/session" + "github.com/karnstack/flue/internal/transport/local" +) + +// newCloseTestDaemon is newTestDaemon with the registry exposed, because these +// tests need to spawn the sessions the command is asked to close and to see +// afterwards whether they went. It also writes the runtime record, which is +// how runClose finds the daemon at all. +func newCloseTestDaemon(t *testing.T) *session.Registry { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + token, err := config.LoadOrCreateToken() + if err != nil { + t.Fatalf("LoadOrCreateToken: %v", err) + } + reg := session.NewRegistry(time.Now) + srv := daemon.New(reg, local.NewAuth(token, 0), uiHandler(), version, daemon.Identity{}) + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + t.Cleanup(srv.Shutdown) + + u, err := url.Parse(ts.URL) + if err != nil { + t.Fatalf("parse test server URL %q: %v", ts.URL, err) + } + port, err := strconv.Atoi(u.Port()) + if err != nil { + t.Fatalf("parse port from %q: %v", ts.URL, err) + } + srv.SetAuth(local.NewAuth(token, port)) + if err := daemon.WriteRuntime(port); err != nil { + t.Fatalf("WriteRuntime: %v", err) + } + return reg +} + +func spawnSleeper(t *testing.T, reg *session.Registry) session.Handle { + t.Helper() + h, err := reg.Spawn(session.SpawnOpts{Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + t.Cleanup(func() { _ = h.Close() }) + return h +} + +func TestRunCloseAllClosesEverySession(t *testing.T) { + reg := newCloseTestDaemon(t) + spawnSleeper(t, reg) + spawnSleeper(t, reg) + + var out, errOut bytes.Buffer + if err := runClose(&out, &errOut, []string{"--all"}); err != nil { + t.Fatalf("runClose: %v", err) + } + if !strings.Contains(out.String(), "✓ closed 2 sessions") { + t.Errorf("output %q does not report the two closed sessions", out.String()) + } + if left := reg.List(); len(left) != 0 { + t.Errorf("the registry still holds %d sessions", len(left)) + } +} + +// TestRunCloseByIDClosesOnlyTheNamedOne also pins the singular: one session +// closed is "1 session", not "1 sessions". +func TestRunCloseByIDClosesOnlyTheNamedOne(t *testing.T) { + reg := newCloseTestDaemon(t) + going := spawnSleeper(t, reg) + staying := spawnSleeper(t, reg) + + var out, errOut bytes.Buffer + if err := runClose(&out, &errOut, []string{going.ID()}); err != nil { + t.Fatalf("runClose: %v", err) + } + if !strings.Contains(out.String(), "✓ closed 1 session\n") { + t.Errorf("output %q, want the singular closed line", out.String()) + } + if _, ok := reg.Get(going.ID()); ok { + t.Error("the named session is still in the registry") + } + if _, ok := reg.Get(staying.ID()); !ok { + t.Error("the unnamed session went with it") + } +} + +// TestRunCloseReportsUnknownIDs: each id that named nothing is reported on +// stderr by name, the ones that exist are closed anyway, and the command +// fails — that is the errUnknownSessions cmdClose turns into exit 1. +func TestRunCloseReportsUnknownIDs(t *testing.T) { + reg := newCloseTestDaemon(t) + real := spawnSleeper(t, reg) + + var out, errOut bytes.Buffer + err := runClose(&out, &errOut, []string{real.ID(), "feedfeed00000000"}) + if !errors.Is(err, errUnknownSessions) { + t.Fatalf("runClose = %v, want errUnknownSessions", err) + } + if !strings.Contains(errOut.String(), "no such session: feedfeed00000000") { + t.Errorf("stderr %q does not name the unknown id", errOut.String()) + } + if !strings.Contains(out.String(), "✓ closed 1 session\n") { + t.Errorf("output %q, want the real session still closed and counted", out.String()) + } + if _, ok := reg.Get(real.ID()); ok { + t.Error("the real session is still in the registry") + } +} + +// TestRunCloseWithNoArgumentsIsAUsageError: bare `flue close` could mean +// either form, so it gets the usage line naming both — errCloseUsage, which +// cmdClose turns into exit 2 — and never talks to the daemon at all. +func TestRunCloseWithNoArgumentsIsAUsageError(t *testing.T) { + var out, errOut bytes.Buffer + err := runClose(&out, &errOut, nil) + if !errors.Is(err, errCloseUsage) { + t.Fatalf("runClose = %v, want errCloseUsage", err) + } + for _, form := range []string{"--all", ""} { + if !strings.Contains(err.Error(), form) { + t.Errorf("usage error %q does not show the %s form", err, form) + } + } +} + +func TestRunCloseSaysDaemonNotRunning(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // no runtime record, no daemon + + var out, errOut bytes.Buffer + if err := runClose(&out, &errOut, []string{"--all"}); err != nil { + t.Fatalf("runClose = %v, want nil: nothing to close is not a failure", err) + } + if !strings.Contains(out.String(), "daemon not running; nothing to close") { + t.Errorf("output %q, want the not-running notice", out.String()) + } +} + +func TestUsageMentionsClose(t *testing.T) { + if !strings.Contains(usageText, "flue close") { + t.Fatalf("usage text does not mention %q:\n%s", "flue close", usageText) + } +} diff --git a/cmd/flue/main.go b/cmd/flue/main.go index f1a9d85..ec5d2e7 100644 --- a/cmd/flue/main.go +++ b/cmd/flue/main.go @@ -80,6 +80,8 @@ func main() { err = cmdServe(os.Args[2:]) case "open": err = cmdOpen(os.Args[2:]) + case "close": + err = cmdClose(os.Args[2:]) case "enable": err = cmdEnable() case "disable": @@ -124,6 +126,7 @@ const usageText = `flue — your terminal, as a browser tab flue relay leave take this machine off its relay; the Worker stays deployed flue relay reset empty the relay's fleet directory; the fleet republishes flue open [path] spawn a session in path and open it in the browser + flue close ... close the named sessions; --all closes every one flue serve [--port N] [--open] run the daemon in the foreground flue update download the newest release, swap this binary, restart the daemon flue version print the version (also --version, -v) diff --git a/internal/daemon/close_test.go b/internal/daemon/close_test.go new file mode 100644 index 0000000..5853d5b --- /dev/null +++ b/internal/daemon/close_test.go @@ -0,0 +1,159 @@ +package daemon + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/karnstack/flue/internal/session" + "github.com/karnstack/flue/internal/transport/local" +) + +// postClose issues the request the flue CLI makes: POST, JSON body, session +// token in a header, no browser provenance headers at all. +func postClose(t *testing.T, ts *httptest.Server, body string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPost, ts.URL+SessionsClosePath, strings.NewReader(body)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(local.HeaderName, tok) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST %s: %v", SessionsClosePath, err) + } + t.Cleanup(func() { resp.Body.Close() }) + return resp +} + +// closeAnswer decodes a successful close reply. +func closeAnswer(t *testing.T, resp *http.Response) (closed int, missing []string) { + t.Helper() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("POST %s = %d (%s), want 200", SessionsClosePath, resp.StatusCode, body) + } + var out struct { + Closed int `json:"closed"` + Missing []string `json:"missing"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decoding the close answer: %v", err) + } + return out.Closed, out.Missing +} + +func spawnTwo(t *testing.T, reg *session.Registry) (a, b session.Handle) { + t.Helper() + for _, s := range []*session.Handle{&a, &b} { + h, err := reg.Spawn(session.SpawnOpts{Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + t.Cleanup(func() { _ = h.Close() }) + *s = h + } + return a, b +} + +func TestSessionsCloseAllClosesEverything(t *testing.T) { + ts, reg := newTestServer(t) + spawnTwo(t, reg) + + closed, missing := closeAnswer(t, postClose(t, ts, `{"all":true}`)) + if closed != 2 { + t.Errorf("closed = %d, want 2", closed) + } + if len(missing) != 0 { + t.Errorf("missing = %v, want none", missing) + } + if left := reg.List(); len(left) != 0 { + t.Errorf("the registry still holds %d sessions", len(left)) + } +} + +func TestSessionsCloseByIDClosesTheNamedOneAndReportsTheRest(t *testing.T) { + ts, reg := newTestServer(t) + a, b := spawnTwo(t, reg) + + body := `{"ids":["` + a.ID() + `","feedfeed00000000"]}` + closed, missing := closeAnswer(t, postClose(t, ts, body)) + if closed != 1 { + t.Errorf("closed = %d, want 1", closed) + } + if len(missing) != 1 || missing[0] != "feedfeed00000000" { + t.Errorf("missing = %v, want the unknown id alone", missing) + } + if _, ok := reg.Get(a.ID()); ok { + t.Error("the named session is still in the registry") + } + if _, ok := reg.Get(b.ID()); !ok { + t.Error("the unnamed session went with it") + } +} + +// TestSessionsCloseWithBothFieldsMeansAll pins the tie-break: a body that +// says all and names ids is an all-close, so nothing lands in missing. +func TestSessionsCloseWithBothFieldsMeansAll(t *testing.T) { + ts, reg := newTestServer(t) + spawnTwo(t, reg) + + closed, missing := closeAnswer(t, postClose(t, ts, `{"all":true,"ids":["feedfeed00000000"]}`)) + if closed != 2 { + t.Errorf("closed = %d, want 2", closed) + } + if len(missing) != 0 { + t.Errorf("missing = %v, want none: all outranks ids", missing) + } +} + +func TestSessionsCloseWithNeitherFieldIsRefused(t *testing.T) { + ts, reg := newTestServer(t) + spawnTwo(t, reg) + + for _, body := range []string{`{}`, `{"all":false,"ids":[]}`, `not json`} { + if resp := postClose(t, ts, body); resp.StatusCode != http.StatusBadRequest { + t.Errorf("POST with body %q = %d, want 400", body, resp.StatusCode) + } + } + if left := reg.List(); len(left) != 2 { + t.Errorf("a refused request closed sessions: %d left, want 2", len(left)) + } +} + +func TestSessionsCloseRequiresAuth(t *testing.T) { + ts, reg := newTestServer(t) + spawnTwo(t, reg) + + resp, err := http.Post(ts.URL+SessionsClosePath, "application/json", strings.NewReader(`{"all":true}`)) + if err != nil { + t.Fatalf("POST %s: %v", SessionsClosePath, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated POST %s = %d, want 401", SessionsClosePath, resp.StatusCode) + } + if left := reg.List(); len(left) != 2 { + t.Errorf("an unauthenticated request closed sessions: %d left, want 2", len(left)) + } +} + +// TestSessionsCloseRefusesGET: methodPolicy names this path postable, which +// widens it to GET-or-POST at the routing layer, so the handler itself must +// narrow it back — a GET is the one method a redirect can launder. +func TestSessionsCloseRefusesGET(t *testing.T) { + ts, reg := newTestServer(t) + spawnTwo(t, reg) + + resp := get(t, ts, SessionsClosePath, "same-origin") + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("GET %s = %d, want 405", SessionsClosePath, resp.StatusCode) + } + if left := reg.List(); len(left) != 2 { + t.Errorf("a GET closed sessions: %d left, want 2", len(left)) + } +} diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 6565282..c257e6d 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -74,6 +74,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "net" "net/http" @@ -470,6 +471,9 @@ func (s *Server) Handler() http.Handler { mux.Handle(PairPagePath, s.withProvenance(s.ui)) mux.Handle(uiAssetPrefix, s.withProvenance(s.ui)) mux.Handle("/api/sessions", s.withAuth(http.HandlerFunc(s.handleSessions))) + // The CLI ending sessions. A POST, so methodPolicy names it, and behind + // withAuth like every other mutation the CLI reaches over HTTP. + mux.Handle(SessionsClosePath, s.withAuth(http.HandlerFunc(s.handleSessionsClose))) // The Remote screen's relay endpoints (relayui.go). Loopback-only by the // bind, mutating only by POST — methodPolicy names the ones that mutate. mux.Handle(RelayInfoPath, s.withAuth(http.HandlerFunc(s.handleRelayInfo))) @@ -542,7 +546,8 @@ func methodPolicy(next http.Handler) http.Handler { postable := r.URL.Path == MintPath || r.URL.Path == PairPath || r.URL.Path == RelayDeployPath || r.URL.Path == RelayUpdatePath || r.URL.Path == RelayAddressPath || r.URL.Path == RelayLeavePath || - r.URL.Path == RelayReloadPath || r.URL.Path == EnrolPath + r.URL.Path == RelayReloadPath || r.URL.Path == EnrolPath || + r.URL.Path == SessionsClosePath allowed := r.Method == http.MethodGet || r.Method == http.MethodHead || (postable && r.Method == http.MethodPost) if !allowed { @@ -987,6 +992,76 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"sessions": infos}) } +// SessionsClosePath is `POST /api/sessions/close`: the flue CLI ending +// sessions on this daemon. It exists because sessions no longer die with the +// daemon — each one runs under its own holder — so "stop the daemon" stopped +// being a way to end them, and a deliberate close needs a verb of its own. +// HTTP rather than the wire protocol because its caller is the CLI, which +// already speaks loopback HTTP for everything else it does. +const SessionsClosePath = "/api/sessions/close" + +// maxCloseBytes bounds the close request. The largest honest body is a list +// of session ids at sixteen hex characters each; 64 KiB holds thousands of +// them, and anything bigger is not a close request. +const maxCloseBytes = 64 << 10 + +// handleSessionsClose answers POST SessionsClosePath: `{"all":true}` closes +// every session, `{"ids":[...]}` the named ones, and the reply says how many +// went and which ids named nothing. A body that says both is an all-close — +// the wider instruction subsumes the narrower — and one that says neither is +// the client's bug, refused before anything is touched. +// +// The semantics live on the registry (CloseAll, CloseByID); this handler +// only translates HTTP into those calls. An unknown id lands in missing +// rather than failing the batch, because the caller closing three sessions +// is owed the two that exist whatever became of the third. +func (s *Server) handleSessionsClose(w http.ResponseWriter, r *http.Request) { + // methodPolicy names this path postable, which widens it to GET-or-POST; + // this narrows it back. A GET must never close — it is the one method a + // redirect can launder — and repeating the check locally means the rule + // survives this handler being mounted somewhere the middleware does not + // wrap. + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, maxCloseBytes+1)) + if err != nil || len(body) > maxCloseBytes { + http.Error(w, "request body unreadable or too large", http.StatusBadRequest) + return + } + var req struct { + All bool `json:"all"` + IDs []string `json:"ids"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "request body is not the expected JSON", http.StatusBadRequest) + return + } + + closed, missing := 0, []string{} + switch { + case req.All: + closed = s.reg.CloseAll() + case len(req.IDs) > 0: + for _, id := range req.IDs { + if err := s.reg.CloseByID(id); err != nil { + missing = append(missing, id) + continue + } + closed++ + } + default: + http.Error(w, `the body must set "all" or name "ids"`, http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"closed": closed, "missing": missing}) +} + // handleMint issues a one-time handoff token to a local process that has // proved it can read the session token file. // diff --git a/internal/session/registry.go b/internal/session/registry.go index 6d826f8..ef4d61b 100644 --- a/internal/session/registry.go +++ b/internal/session/registry.go @@ -381,6 +381,59 @@ func (r *Registry) List() []Handle { return out } +// CloseAll retires every session on this daemon, running and exited alike, +// and reports how many went. It is the registry half of `flue close --all`: +// a deliberate "end it all now", so unlike Reap it consults no retention +// window and asks no session whether it has exited. +// +// The choreography is Reap's, for Reap's reasons. Victims are collected and +// removed from the map under r.mu and closed only after it is released, +// because Close signals a process group and waits for the session's +// supervisor to answer — done under r.mu, one stalled session would stall +// Get, List and Spawn for everyone. And each session's meta file goes with +// it: a registry that has finished with a session has nothing left for the +// record to describe. +func (r *Registry) CloseAll() int { + r.mu.Lock() + victims := make([]handle, 0, len(r.sessions)) + for id, s := range r.sessions { + victims = append(victims, s) + delete(r.sessions, id) + } + r.mu.Unlock() + + dir, _ := r.metaSink() + for _, s := range victims { + _ = s.Close() + DeleteMeta(dir, s.ID()) + } + return len(victims) +} + +// CloseByID retires the one session it names, with CloseAll's semantics and +// ErrNotFound for an id the registry does not hold — the caller's cue to say +// "no such session" rather than to fail the rest of a batch. +// +// The close error is discarded the way Reap discards it: the session left +// the registry the moment the map entry went, and nothing the caller could +// do with a failed group signal would put it back. +func (r *Registry) CloseByID(id string) error { + r.mu.Lock() + s, ok := r.sessions[id] + if ok { + delete(r.sessions, id) + } + r.mu.Unlock() + if !ok { + return ErrNotFound + } + + dir, _ := r.metaSink() + _ = s.Close() + DeleteMeta(dir, id) + return nil +} + // Reap removes sessions that exited more than their retention ago — // ExitedRetention ordinarily, EphemeralRetention for a scratch terminal — // and closes the running ephemeral children of parents that have ended. diff --git a/internal/session/registry_close_test.go b/internal/session/registry_close_test.go new file mode 100644 index 0000000..c7a91ad --- /dev/null +++ b/internal/session/registry_close_test.go @@ -0,0 +1,84 @@ +package session + +import ( + "errors" + "path/filepath" + "testing" + "time" +) + +// TestCloseAllRetiresEverySession: `flue close --all` means everything goes — +// running and exited alike, with no retention window to wait out — and each +// session's meta file goes with it, the way Reap's cleanup works, so nothing +// in the meta dir describes a session that no longer exists. +func TestCloseAllRetiresEverySession(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sessions") + r := NewRegistry(time.Now) + r.SetMetaDir(dir, nil) + + running := spawnRunning(t, r) + name := "named" + if _, err := r.UpdateMeta(running.ID(), MetaPatch{Name: &name}); err != nil { + t.Fatalf("UpdateMeta: %v", err) + } + exited := spawnLocal(t, r, SpawnOpts{Cmd: []string{"true"}, Cols: 80, Rows: 24}) + t.Cleanup(func() { _ = exited.Close() }) + waitExited(t, exited, 5*time.Second) + + if got := r.CloseAll(); got != 2 { + t.Fatalf("CloseAll = %d, want 2", got) + } + if left := r.List(); len(left) != 0 { + t.Errorf("List after CloseAll holds %d sessions, want none", len(left)) + } + if metas := LoadMetas(dir); len(metas) != 0 { + t.Errorf("LoadMetas = %+v, want the closed sessions' records gone", metas) + } +} + +// TestCloseByIDRetiresOnlyTheNamedSession: a targeted close takes the session +// it names — registry row and meta file both — and nothing beside it. +func TestCloseByIDRetiresOnlyTheNamedSession(t *testing.T) { + dir := filepath.Join(t.TempDir(), "sessions") + r := NewRegistry(time.Now) + r.SetMetaDir(dir, nil) + + going := spawnRunning(t, r) + staying := spawnRunning(t, r) + // Both named, so a missing meta file below means deleted rather than + // never written. + for _, s := range []*Session{going, staying} { + n := "meta for " + s.ID() + if _, err := r.UpdateMeta(s.ID(), MetaPatch{Name: &n}); err != nil { + t.Fatalf("UpdateMeta: %v", err) + } + } + + if err := r.CloseByID(going.ID()); err != nil { + t.Fatalf("CloseByID: %v", err) + } + if _, ok := r.Get(going.ID()); ok { + t.Error("the closed session is still in the registry") + } + if _, ok := r.Get(staying.ID()); !ok { + t.Error("the other session went with it") + } + + metas := LoadMetas(dir) + if _, ok := metas[going.ID()]; ok { + t.Error("the closed session's meta file survives it") + } + if _, ok := metas[staying.ID()]; !ok { + t.Error("the surviving session's meta file is gone") + } +} + +// TestCloseByIDUnknownIsNotFound: an id the registry does not hold gets the +// same sentinel every other by-id path answers with, so the daemon handler +// can turn it into "missing" rather than a failure. +func TestCloseByIDUnknownIsNotFound(t *testing.T) { + r := NewRegistry(nil) + if err := r.CloseByID("cafebabe00000000"); !errors.Is(err, ErrNotFound) { + t.Fatalf("CloseByID = %v, want ErrNotFound", err) + } +}