From f183f99b43ccb8c94d076d3f5957ea309199de43 Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 19 Aug 2026 11:04:04 +0530 Subject: [PATCH] feat: flue close ends sessions from the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holder-backed sessions outlive the daemon, so stopping it stopped being a way to end them. flue close is the deliberate verb: --all retires every session on the local daemon, ids retire the named ones, and unknown ids are reported by name without failing the rest. The semantics live on the registry (CloseAll, CloseByID), mirroring Reap: victims leave the map under r.mu, are closed outside it, and their meta files go with them. The daemon translates POST /api/sessions/close — behind withAuth, named in methodPolicy — into those calls, and the CLI owns argv, output, and exit codes. Co-Authored-By: Claude Fable 5 --- cmd/flue/close.go | 134 ++++++++++++++++++++ cmd/flue/close_test.go | 156 +++++++++++++++++++++++ cmd/flue/main.go | 3 + internal/daemon/close_test.go | 159 ++++++++++++++++++++++++ internal/daemon/server.go | 77 +++++++++++- internal/session/registry.go | 53 ++++++++ internal/session/registry_close_test.go | 84 +++++++++++++ 7 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 cmd/flue/close.go create mode 100644 cmd/flue/close_test.go create mode 100644 internal/daemon/close_test.go create mode 100644 internal/session/registry_close_test.go 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) + } +}