Skip to content

Commit cf7bbce

Browse files
karngyanclaude
andcommitted
feat: flue close ends sessions from the CLI
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 <noreply@anthropic.com>
1 parent 071d840 commit cf7bbce

7 files changed

Lines changed: 665 additions & 1 deletion

File tree

cmd/flue/close.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"flag"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"net/url"
12+
"os"
13+
14+
"github.com/karnstack/flue/internal/daemon"
15+
"github.com/karnstack/flue/internal/transport/local"
16+
)
17+
18+
// errCloseUsage answers a bare `flue close`, which could mean either form and
19+
// so gets both. A sentinel rather than a plain error because cmdClose exits 2
20+
// on it — the code main uses for an unknown command, and the right one for
21+
// "you have not said what to close".
22+
var errCloseUsage = errors.New("usage: flue close <id>... | flue close --all")
23+
24+
// errUnknownSessions reports that at least one named id closed nothing. The
25+
// per-id lines have already gone to stderr by the time it is returned, so
26+
// cmdClose turns it into a bare exit 1 rather than printing it again.
27+
var errUnknownSessions = errors.New("some sessions were not found")
28+
29+
// cmdClose owns the exit codes runClose cannot: 2 for a usage error and 1 for
30+
// unknown ids, both already explained on stderr. Everything else flows back
31+
// to main's ordinary error path.
32+
func cmdClose(args []string) error {
33+
err := runClose(os.Stdout, os.Stderr, args)
34+
switch {
35+
case errors.Is(err, errCloseUsage):
36+
fmt.Fprintln(os.Stderr, "flue:", err)
37+
os.Exit(2)
38+
case errors.Is(err, errUnknownSessions):
39+
os.Exit(1)
40+
}
41+
return err
42+
}
43+
44+
// runClose ends sessions on the local daemon: every one under --all, the
45+
// named ones otherwise. The writers are the seam — same pattern as statusTo —
46+
// so the tests read both streams without capturing the process's own.
47+
//
48+
// A daemon that is not running is answered with a notice and success, not a
49+
// failure: the user asked for no sessions, and no daemon means exactly that.
50+
// Unknown ids are the one partial outcome — each is named on stderr, the rest
51+
// are closed and counted, and errUnknownSessions carries the failure out.
52+
func runClose(stdout, stderr io.Writer, args []string) error {
53+
fs := flag.NewFlagSet("close", flag.ContinueOnError)
54+
fs.SetOutput(stderr)
55+
all := fs.Bool("all", false, "close every session, running and exited")
56+
if err := fs.Parse(args); err != nil {
57+
return errCloseUsage
58+
}
59+
ids := fs.Args()
60+
if !*all && len(ids) == 0 {
61+
return errCloseUsage
62+
}
63+
64+
port, ok := ourDaemon()
65+
if !ok {
66+
fmt.Fprintln(stdout, "daemon not running; nothing to close")
67+
return nil
68+
}
69+
token, err := loadToken()
70+
if err != nil {
71+
return fmt.Errorf("load auth token: %w", err)
72+
}
73+
74+
closed, missing, err := postSessionsClose(port, token, *all, ids)
75+
if err != nil {
76+
return err
77+
}
78+
for _, id := range missing {
79+
fmt.Fprintf(stderr, "flue: no such session: %s\n", id)
80+
}
81+
noun := "sessions"
82+
if closed == 1 {
83+
noun = "session"
84+
}
85+
fmt.Fprintf(stdout, " ✓ closed %d %s\n", closed, noun)
86+
if len(missing) > 0 {
87+
return errUnknownSessions
88+
}
89+
return nil
90+
}
91+
92+
// postSessionsClose asks the daemon to close sessions and relays its answer.
93+
// The shape mirrors fetchSessions — token in a header, status checked before
94+
// the body is decoded, the body bounded — because it is talking to the same
95+
// daemon under the same rules.
96+
func postSessionsClose(port int, token string, all bool, ids []string) (closed int, missing []string, err error) {
97+
body, err := json.Marshal(map[string]any{"all": all, "ids": ids})
98+
if err != nil {
99+
return 0, nil, err
100+
}
101+
u := &url.URL{
102+
Scheme: "http",
103+
Host: fmt.Sprintf("127.0.0.1:%d", port),
104+
Path: daemon.SessionsClosePath,
105+
}
106+
req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
107+
if err != nil {
108+
return 0, nil, err
109+
}
110+
req.Header.Set("Content-Type", "application/json")
111+
req.Header.Set(local.HeaderName, token)
112+
resp, err := probeClient.Do(req)
113+
if err != nil {
114+
return 0, nil, err
115+
}
116+
defer resp.Body.Close()
117+
118+
switch resp.StatusCode {
119+
case http.StatusOK:
120+
case http.StatusUnauthorized:
121+
return 0, nil, errTokenRejected
122+
default:
123+
return 0, nil, fmt.Errorf("daemon on 127.0.0.1:%d answered %s", port, resp.Status)
124+
}
125+
126+
var out struct {
127+
Closed int `json:"closed"`
128+
Missing []string `json:"missing"`
129+
}
130+
if err := json.NewDecoder(io.LimitReader(resp.Body, maxListingBytes)).Decode(&out); err != nil {
131+
return 0, nil, fmt.Errorf("decode close answer: %w", err)
132+
}
133+
return out.Closed, out.Missing, nil
134+
}

cmd/flue/close_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"net/http/httptest"
7+
"net/url"
8+
"strconv"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
"github.com/karnstack/flue/internal/config"
14+
"github.com/karnstack/flue/internal/daemon"
15+
"github.com/karnstack/flue/internal/session"
16+
"github.com/karnstack/flue/internal/transport/local"
17+
)
18+
19+
// newCloseTestDaemon is newTestDaemon with the registry exposed, because these
20+
// tests need to spawn the sessions the command is asked to close and to see
21+
// afterwards whether they went. It also writes the runtime record, which is
22+
// how runClose finds the daemon at all.
23+
func newCloseTestDaemon(t *testing.T) *session.Registry {
24+
t.Helper()
25+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
26+
27+
token, err := config.LoadOrCreateToken()
28+
if err != nil {
29+
t.Fatalf("LoadOrCreateToken: %v", err)
30+
}
31+
reg := session.NewRegistry(time.Now)
32+
srv := daemon.New(reg, local.NewAuth(token, 0), uiHandler(), version, daemon.Identity{})
33+
ts := httptest.NewServer(srv.Handler())
34+
t.Cleanup(ts.Close)
35+
t.Cleanup(srv.Shutdown)
36+
37+
u, err := url.Parse(ts.URL)
38+
if err != nil {
39+
t.Fatalf("parse test server URL %q: %v", ts.URL, err)
40+
}
41+
port, err := strconv.Atoi(u.Port())
42+
if err != nil {
43+
t.Fatalf("parse port from %q: %v", ts.URL, err)
44+
}
45+
srv.SetAuth(local.NewAuth(token, port))
46+
if err := daemon.WriteRuntime(port); err != nil {
47+
t.Fatalf("WriteRuntime: %v", err)
48+
}
49+
return reg
50+
}
51+
52+
func spawnSleeper(t *testing.T, reg *session.Registry) session.Handle {
53+
t.Helper()
54+
h, err := reg.Spawn(session.SpawnOpts{Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24})
55+
if err != nil {
56+
t.Fatalf("Spawn: %v", err)
57+
}
58+
t.Cleanup(func() { _ = h.Close() })
59+
return h
60+
}
61+
62+
func TestRunCloseAllClosesEverySession(t *testing.T) {
63+
reg := newCloseTestDaemon(t)
64+
spawnSleeper(t, reg)
65+
spawnSleeper(t, reg)
66+
67+
var out, errOut bytes.Buffer
68+
if err := runClose(&out, &errOut, []string{"--all"}); err != nil {
69+
t.Fatalf("runClose: %v", err)
70+
}
71+
if !strings.Contains(out.String(), "✓ closed 2 sessions") {
72+
t.Errorf("output %q does not report the two closed sessions", out.String())
73+
}
74+
if left := reg.List(); len(left) != 0 {
75+
t.Errorf("the registry still holds %d sessions", len(left))
76+
}
77+
}
78+
79+
// TestRunCloseByIDClosesOnlyTheNamedOne also pins the singular: one session
80+
// closed is "1 session", not "1 sessions".
81+
func TestRunCloseByIDClosesOnlyTheNamedOne(t *testing.T) {
82+
reg := newCloseTestDaemon(t)
83+
going := spawnSleeper(t, reg)
84+
staying := spawnSleeper(t, reg)
85+
86+
var out, errOut bytes.Buffer
87+
if err := runClose(&out, &errOut, []string{going.ID()}); err != nil {
88+
t.Fatalf("runClose: %v", err)
89+
}
90+
if !strings.Contains(out.String(), "✓ closed 1 session\n") {
91+
t.Errorf("output %q, want the singular closed line", out.String())
92+
}
93+
if _, ok := reg.Get(going.ID()); ok {
94+
t.Error("the named session is still in the registry")
95+
}
96+
if _, ok := reg.Get(staying.ID()); !ok {
97+
t.Error("the unnamed session went with it")
98+
}
99+
}
100+
101+
// TestRunCloseReportsUnknownIDs: each id that named nothing is reported on
102+
// stderr by name, the ones that exist are closed anyway, and the command
103+
// fails — that is the errUnknownSessions cmdClose turns into exit 1.
104+
func TestRunCloseReportsUnknownIDs(t *testing.T) {
105+
reg := newCloseTestDaemon(t)
106+
real := spawnSleeper(t, reg)
107+
108+
var out, errOut bytes.Buffer
109+
err := runClose(&out, &errOut, []string{real.ID(), "feedfeed00000000"})
110+
if !errors.Is(err, errUnknownSessions) {
111+
t.Fatalf("runClose = %v, want errUnknownSessions", err)
112+
}
113+
if !strings.Contains(errOut.String(), "no such session: feedfeed00000000") {
114+
t.Errorf("stderr %q does not name the unknown id", errOut.String())
115+
}
116+
if !strings.Contains(out.String(), "✓ closed 1 session\n") {
117+
t.Errorf("output %q, want the real session still closed and counted", out.String())
118+
}
119+
if _, ok := reg.Get(real.ID()); ok {
120+
t.Error("the real session is still in the registry")
121+
}
122+
}
123+
124+
// TestRunCloseWithNoArgumentsIsAUsageError: bare `flue close` could mean
125+
// either form, so it gets the usage line naming both — errCloseUsage, which
126+
// cmdClose turns into exit 2 — and never talks to the daemon at all.
127+
func TestRunCloseWithNoArgumentsIsAUsageError(t *testing.T) {
128+
var out, errOut bytes.Buffer
129+
err := runClose(&out, &errOut, nil)
130+
if !errors.Is(err, errCloseUsage) {
131+
t.Fatalf("runClose = %v, want errCloseUsage", err)
132+
}
133+
for _, form := range []string{"--all", "<id>"} {
134+
if !strings.Contains(err.Error(), form) {
135+
t.Errorf("usage error %q does not show the %s form", err, form)
136+
}
137+
}
138+
}
139+
140+
func TestRunCloseSaysDaemonNotRunning(t *testing.T) {
141+
t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // no runtime record, no daemon
142+
143+
var out, errOut bytes.Buffer
144+
if err := runClose(&out, &errOut, []string{"--all"}); err != nil {
145+
t.Fatalf("runClose = %v, want nil: nothing to close is not a failure", err)
146+
}
147+
if !strings.Contains(out.String(), "daemon not running; nothing to close") {
148+
t.Errorf("output %q, want the not-running notice", out.String())
149+
}
150+
}
151+
152+
func TestUsageMentionsClose(t *testing.T) {
153+
if !strings.Contains(usageText, "flue close") {
154+
t.Fatalf("usage text does not mention %q:\n%s", "flue close", usageText)
155+
}
156+
}

cmd/flue/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ func main() {
8080
err = cmdServe(os.Args[2:])
8181
case "open":
8282
err = cmdOpen(os.Args[2:])
83+
case "close":
84+
err = cmdClose(os.Args[2:])
8385
case "enable":
8486
err = cmdEnable()
8587
case "disable":
@@ -124,6 +126,7 @@ const usageText = `flue — your terminal, as a browser tab
124126
flue relay leave take this machine off its relay; the Worker stays deployed
125127
flue relay reset empty the relay's fleet directory; the fleet republishes
126128
flue open [path] spawn a session in path and open it in the browser
129+
flue close <id>... close the named sessions; --all closes every one
127130
flue serve [--port N] [--open] run the daemon in the foreground
128131
flue update download the newest release, swap this binary, restart the daemon
129132
flue version print the version (also --version, -v)

0 commit comments

Comments
 (0)