detached 16ed5a6

refactor(sessions): retain ephemeral sessions in daemon

commit
16ed5a6ec6e15aaccf8184f12470be2c76e6e81b
author
brice · · 2026-07-07 05:00 UTC
parent
bf22b36
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +281 -74
M AGENTS.md +2 -2
@@ -67,8 +67,8 @@ Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/
6767
6868 ## Session flow
6969
70-- Ephemeral numbered sessions die on detach.
71-- Named sessions survive headless.
70+- Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted.
71+- Named sessions survive headless and persist across daemon restarts.
7272 - The daemon starts on first use and exits when the last session ends.
7373 - Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.
7474 - Remote attach runs `ssh -- host vev _stdio [session]` and proxies the same protocol over stdio.
@@ -67,8 +67,8 @@ Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/
67 67
68 ## Session flow68 ## Session flow
69 69
70-- Ephemeral numbered sessions die on detach.70+- Ephemeral numbered sessions survive detach while the daemon retains them, but are not persisted.
71-- Named sessions survive headless.71+- Named sessions survive headless and persist across daemon restarts.
72 - The daemon starts on first use and exits when the last session ends.72 - The daemon starts on first use and exits when the last session ends.
73 - Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.73 - Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.
74 - Remote attach runs `ssh -- host vev _stdio [session]` and proxies the same protocol over stdio.74 - Remote attach runs `ssh -- host vev _stdio [session]` and proxies the same protocol over stdio.
M README.md +4 -4
@@ -26,10 +26,10 @@ go build -o vev . # local binary
2626 ```text
2727 vev attach to (or create) an ephemeral session
2828 vev new <name> create and attach to a named session
29-vev attach <name> attach to an existing named session (alias: a)
29+vev attach <name> attach to an existing session (alias: a)
3030 vev attach user@host[:session] attach to a remote vev daemon over SSH
3131 vev ls list sessions (alias: list)
32-vev kill <name> kill a named session
32+vev kill <name> kill a session
3333 vev kill -- <name> kill a name that starts with -
3434 vev kill --all kill all sessions and stop the daemon
3535 vev kill --daemon stop the active vev daemon
@@ -37,7 +37,7 @@ vev --help show help (aliases: -h, help)
3737 vev --version show version (alias: version)
3838 ```
3939
40-Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. New and renamed session names must start with a letter or number and may contain only letters, numbers, dots, underscores, and dashes.
40+Ephemeral sessions get a number (`0`, `1`, `2`, ...) and are temporary daemon-retained sessions. They survive detach and transient client connection loss while the daemon is running, but they are not persisted to disk and disappear when the daemon is killed, exits, or the machine restarts. Named sessions are persisted, survive daemon restarts, and can be resumed later. New and renamed session names must start with a letter or number and may contain only letters, numbers, dots, underscores, and dashes.
4141
4242 The daemon starts on first use and exits when no sessions remain. Only one client is attached to a session at a time; attaching again displaces the old client.
4343
@@ -120,7 +120,7 @@ vev attach user@host
120120 vev attach user@host:session
121121 ```
122122
123-Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Named sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes.
123+Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Remote sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes, while the daemon still has the session. Named sessions additionally persist across daemon restarts.
124124
125125 The UDP proxy binds a port in a fixed range on the remote (default `61000-61023`), so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP for that range on the remote to fix it — for example `sudo ufw allow 61000:61023/udp`, or on a trusted Tailscale network `sudo ufw allow in on tailscale0`. Override the range with `VEV_UDP_PORT_RANGE` on the remote (e.g. `VEV_UDP_PORT_RANGE=51000-51009`, a single `VEV_UDP_PORT_RANGE=51000`, or `VEV_UDP_PORT_RANGE=0` for a random ephemeral port).
126126
@@ -26,10 +26,10 @@ go build -o vev . # local binary
26 ```text26 ```text
27 vev attach to (or create) an ephemeral session27 vev attach to (or create) an ephemeral session
28 vev new <name> create and attach to a named session28 vev new <name> create and attach to a named session
29-vev attach <name> attach to an existing named session (alias: a)29+vev attach <name> attach to an existing session (alias: a)
30 vev attach user@host[:session] attach to a remote vev daemon over SSH30 vev attach user@host[:session] attach to a remote vev daemon over SSH
31 vev ls list sessions (alias: list)31 vev ls list sessions (alias: list)
32-vev kill <name> kill a named session32+vev kill <name> kill a session
33 vev kill -- <name> kill a name that starts with -33 vev kill -- <name> kill a name that starts with -
34 vev kill --all kill all sessions and stop the daemon34 vev kill --all kill all sessions and stop the daemon
35 vev kill --daemon stop the active vev daemon35 vev kill --daemon stop the active vev daemon
@@ -37,7 +37,7 @@ vev --help show help (aliases: -h, help)
37 vev --version show version (alias: version)37 vev --version show version (alias: version)
38 ```38 ```
39 39
40-Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. New and renamed session names must start with a letter or number and may contain only letters, numbers, dots, underscores, and dashes.40+Ephemeral sessions get a number (`0`, `1`, `2`, ...) and are temporary daemon-retained sessions. They survive detach and transient client connection loss while the daemon is running, but they are not persisted to disk and disappear when the daemon is killed, exits, or the machine restarts. Named sessions are persisted, survive daemon restarts, and can be resumed later. New and renamed session names must start with a letter or number and may contain only letters, numbers, dots, underscores, and dashes.
41 41
42 The daemon starts on first use and exits when no sessions remain. Only one client is attached to a session at a time; attaching again displaces the old client.42 The daemon starts on first use and exits when no sessions remain. Only one client is attached to a session at a time; attaching again displaces the old client.
43 43
@@ -120,7 +120,7 @@ vev attach user@host
120 vev attach user@host:session120 vev attach user@host:session
121 ```121 ```
122 122
123-Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Named sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes.123+Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Remote sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes, while the daemon still has the session. Named sessions additionally persist across daemon restarts.
124 124
125 The UDP proxy binds a port in a fixed range on the remote (default `61000-61023`), so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP for that range on the remote to fix it — for example `sudo ufw allow 61000:61023/udp`, or on a trusted Tailscale network `sudo ufw allow in on tailscale0`. Override the range with `VEV_UDP_PORT_RANGE` on the remote (e.g. `VEV_UDP_PORT_RANGE=51000-51009`, a single `VEV_UDP_PORT_RANGE=51000`, or `VEV_UDP_PORT_RANGE=0` for a random ephemeral port).125 The UDP proxy binds a port in a fixed range on the remote (default `61000-61023`), so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP for that range on the remote to fix it — for example `sudo ufw allow 61000:61023/udp`, or on a trusted Tailscale network `sudo ufw allow in on tailscale0`. Override the range with `VEV_UDP_PORT_RANGE` on the remote (e.g. `VEV_UDP_PORT_RANGE=51000-51009`, a single `VEV_UDP_PORT_RANGE=51000`, or `VEV_UDP_PORT_RANGE=0` for a random ephemeral port).
126 126
M internal/app/daemon_integration_test.go +83 -8
@@ -34,7 +34,11 @@ func discardLog() *slog.Logger {
3434 // The returned served channel receives Serve's result exactly once.
3535 func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) {
3636 t.Helper()
37- dir = t.TempDir()
37+ return startDaemonInDir(t, t.TempDir(), opts...)
38+}
39+
40+func startDaemonInDir(t *testing.T, dir string, opts ...daemon.Option) (string, <-chan error) {
41+ t.Helper()
3842 ln, err := ipc.Listen(dir)
3943 require.NoError(t, err)
4044
@@ -83,6 +87,36 @@ func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size)
8387 return tr, p
8488 }
8589
90+func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
91+ t.Helper()
92+ tr, err := ipc.Dial(dir)
93+ require.NoError(t, err)
94+ defer func() { _ = tr.Close() }()
95+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
96+ f, err := tr.Recv()
97+ require.NoError(t, err)
98+ require.Equal(t, ports.MsgSessions, f.Type)
99+ sessions, err := ports.UnmarshalSessions(f.Payload)
100+ require.NoError(t, err)
101+ return sessions
102+}
103+
104+func killAll(dir string) error {
105+ tr, err := ipc.Dial(dir)
106+ if err != nil {
107+ return err
108+ }
109+ defer func() { _ = tr.Close() }()
110+ if err := tr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil {
111+ return err
112+ }
113+ _, err = tr.Recv()
114+ if err == io.EOF {
115+ return nil
116+ }
117+ return err
118+}
119+
86120 // awaitText decodes MsgOutput frames into a fresh VT screen and returns once
87121 // the reconstructed grid contains want.
88122 func awaitText(t *testing.T, p *pump, sz domain.Size, want string) {
@@ -236,22 +270,63 @@ func TestIntegration_AltCWithoutPaletteDoesNotCreateTab(t *testing.T) {
236270 assertNoTextAfterInput(t, p, sz, " 1 2 ")
237271 }
238272
239-func TestIntegration_EphemeralGoneOnDetach(t *testing.T) {
273+func TestIntegration_EphemeralSurvivesDetachAndReattaches(t *testing.T) {
240274 sz := domain.Size{Cols: 80, Rows: 24}
241- dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf READY; sleep 30"}))
275+ dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf MARKER; sleep 30"}))
242276
243- tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)
244- awaitText(t, p, sz, "READY")
277+ tr1, p1 := attach(t, dir, ports.IntentEphemeral, "", sz)
278+ awaitText(t, p1, sz, "MARKER")
279+ require.NoError(t, tr1.Close())
245280
246- // Detach by dropping the connection: the ephemeral session must die, which
247- // (being the last session) shuts the daemon down.
281+ select {
282+ case err := <-served:
283+ t.Fatalf("daemon shut down while an ephemeral session was alive: %v", err)
284+ case <-time.After(300 * time.Millisecond):
285+ }
286+
287+ tr2, p2 := attach(t, dir, ports.IntentAttach, "0", sz)
288+ defer func() { _ = tr2.Close() }()
289+ awaitText(t, p2, sz, "MARKER")
290+
291+ require.NoError(t, killAll(dir))
292+ select {
293+ case err := <-served:
294+ require.NoError(t, err)
295+ case <-time.After(5 * time.Second):
296+ t.Fatal("daemon did not shut down after kill all")
297+ }
298+}
299+
300+func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
301+ sz := domain.Size{Cols: 80, Rows: 24}
302+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
303+ t.Setenv("XDG_STATE_HOME", stateRoot)
304+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
305+ dir := ipc.SocketDir()
306+
307+ _, served := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "printf TEMP; sleep 30"}))
308+ tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)
309+ awaitText(t, p, sz, "TEMP")
248310 require.NoError(t, tr.Close())
249311
312+ require.NoError(t, runKill(context.Background(), "", false, true))
250313 select {
251314 case err := <-served:
252315 require.NoError(t, err)
253316 case <-time.After(5 * time.Second):
254- t.Fatal("daemon did not shut down after the ephemeral session detached")
317+ t.Fatal("daemon did not stop after kill --daemon")
318+ }
319+
320+ _, served2 := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}))
321+ sessions := listRemoteSessions(t, dir)
322+ require.Empty(t, sessions.Sessions)
323+
324+ require.NoError(t, runKill(context.Background(), "", false, true))
325+ select {
326+ case err := <-served2:
327+ require.NoError(t, err)
328+ case <-time.After(5 * time.Second):
329+ t.Fatal("restarted daemon did not stop after kill --daemon")
255330 }
256331 }
257332
@@ -34,7 +34,11 @@ func discardLog() *slog.Logger {
34 // The returned served channel receives Serve's result exactly once.34 // The returned served channel receives Serve's result exactly once.
35 func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) {35 func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) {
36 t.Helper()36 t.Helper()
37- dir = t.TempDir()37+ return startDaemonInDir(t, t.TempDir(), opts...)
38+}
39+
40+func startDaemonInDir(t *testing.T, dir string, opts ...daemon.Option) (string, <-chan error) {
41+ t.Helper()
38 ln, err := ipc.Listen(dir)42 ln, err := ipc.Listen(dir)
39 require.NoError(t, err)43 require.NoError(t, err)
40 44
@@ -83,6 +87,36 @@ func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size)
83 return tr, p87 return tr, p
84 }88 }
85 89
90+func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
91+ t.Helper()
92+ tr, err := ipc.Dial(dir)
93+ require.NoError(t, err)
94+ defer func() { _ = tr.Close() }()
95+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
96+ f, err := tr.Recv()
97+ require.NoError(t, err)
98+ require.Equal(t, ports.MsgSessions, f.Type)
99+ sessions, err := ports.UnmarshalSessions(f.Payload)
100+ require.NoError(t, err)
101+ return sessions
102+}
103+
104+func killAll(dir string) error {
105+ tr, err := ipc.Dial(dir)
106+ if err != nil {
107+ return err
108+ }
109+ defer func() { _ = tr.Close() }()
110+ if err := tr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil {
111+ return err
112+ }
113+ _, err = tr.Recv()
114+ if err == io.EOF {
115+ return nil
116+ }
117+ return err
118+}
119+
86 // awaitText decodes MsgOutput frames into a fresh VT screen and returns once120 // awaitText decodes MsgOutput frames into a fresh VT screen and returns once
87 // the reconstructed grid contains want.121 // the reconstructed grid contains want.
88 func awaitText(t *testing.T, p *pump, sz domain.Size, want string) {122 func awaitText(t *testing.T, p *pump, sz domain.Size, want string) {
@@ -236,22 +270,63 @@ func TestIntegration_AltCWithoutPaletteDoesNotCreateTab(t *testing.T) {
236 assertNoTextAfterInput(t, p, sz, " 1 2 ")270 assertNoTextAfterInput(t, p, sz, " 1 2 ")
237 }271 }
238 272
239-func TestIntegration_EphemeralGoneOnDetach(t *testing.T) {273+func TestIntegration_EphemeralSurvivesDetachAndReattaches(t *testing.T) {
240 sz := domain.Size{Cols: 80, Rows: 24}274 sz := domain.Size{Cols: 80, Rows: 24}
241- dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf READY; sleep 30"}))275+ dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf MARKER; sleep 30"}))
242 276
243- tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)277+ tr1, p1 := attach(t, dir, ports.IntentEphemeral, "", sz)
244- awaitText(t, p, sz, "READY")278+ awaitText(t, p1, sz, "MARKER")
279+ require.NoError(t, tr1.Close())
245 280
246- // Detach by dropping the connection: the ephemeral session must die, which281+ select {
247- // (being the last session) shuts the daemon down.282+ case err := <-served:
283+ t.Fatalf("daemon shut down while an ephemeral session was alive: %v", err)
284+ case <-time.After(300 * time.Millisecond):
285+ }
286+
287+ tr2, p2 := attach(t, dir, ports.IntentAttach, "0", sz)
288+ defer func() { _ = tr2.Close() }()
289+ awaitText(t, p2, sz, "MARKER")
290+
291+ require.NoError(t, killAll(dir))
292+ select {
293+ case err := <-served:
294+ require.NoError(t, err)
295+ case <-time.After(5 * time.Second):
296+ t.Fatal("daemon did not shut down after kill all")
297+ }
298+}
299+
300+func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
301+ sz := domain.Size{Cols: 80, Rows: 24}
302+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
303+ t.Setenv("XDG_STATE_HOME", stateRoot)
304+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
305+ dir := ipc.SocketDir()
306+
307+ _, served := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "printf TEMP; sleep 30"}))
308+ tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)
309+ awaitText(t, p, sz, "TEMP")
248 require.NoError(t, tr.Close())310 require.NoError(t, tr.Close())
249 311
312+ require.NoError(t, runKill(context.Background(), "", false, true))
250 select {313 select {
251 case err := <-served:314 case err := <-served:
252 require.NoError(t, err)315 require.NoError(t, err)
253 case <-time.After(5 * time.Second):316 case <-time.After(5 * time.Second):
254- t.Fatal("daemon did not shut down after the ephemeral session detached")317+ t.Fatal("daemon did not stop after kill --daemon")
318+ }
319+
320+ _, served2 := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}))
321+ sessions := listRemoteSessions(t, dir)
322+ require.Empty(t, sessions.Sessions)
323+
324+ require.NoError(t, runKill(context.Background(), "", false, true))
325+ select {
326+ case err := <-served2:
327+ require.NoError(t, err)
328+ case <-time.After(5 * time.Second):
329+ t.Fatal("restarted daemon did not stop after kill --daemon")
255 }330 }
256 }331 }
257 332
M internal/app/run.go +6 -3
@@ -89,11 +89,11 @@ const usageText = `vev — a terminal multiplexer
8989 usage:
9090 vev attach to (or create) an ephemeral session
9191 vev new <name> create and attach to a named session
92- vev attach <name> attach to an existing named session (alias: a)
92+ vev attach <name> attach to an existing session (alias: a)
9393 vev attach user@host[:session]
9494 attach through SSH to a remote vev daemon
9595 vev ls list sessions
96- vev kill <name> kill a named session
96+ vev kill <name> kill a session
9797 vev kill --all kill all sessions and stop the daemon
9898 vev kill --daemon stop the active vev daemon
9999 vev --help show this help
@@ -941,7 +941,10 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
941941 if s.Stopped {
942942 state = "stopped"
943943 tabs = "-"
944- } else if s.Attached {
944+ } else if s.Ephemeral {
945+ state = "temporary"
946+ }
947+ if s.Attached {
945948 attached = "yes"
946949 }
947950 _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached)
@@ -89,11 +89,11 @@ const usageText = `vev — a terminal multiplexer
89 usage:89 usage:
90 vev attach to (or create) an ephemeral session90 vev attach to (or create) an ephemeral session
91 vev new <name> create and attach to a named session91 vev new <name> create and attach to a named session
92- vev attach <name> attach to an existing named session (alias: a)92+ vev attach <name> attach to an existing session (alias: a)
93 vev attach user@host[:session]93 vev attach user@host[:session]
94 attach through SSH to a remote vev daemon94 attach through SSH to a remote vev daemon
95 vev ls list sessions95 vev ls list sessions
96- vev kill <name> kill a named session96+ vev kill <name> kill a session
97 vev kill --all kill all sessions and stop the daemon97 vev kill --all kill all sessions and stop the daemon
98 vev kill --daemon stop the active vev daemon98 vev kill --daemon stop the active vev daemon
99 vev --help show this help99 vev --help show this help
@@ -941,7 +941,10 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
941 if s.Stopped {941 if s.Stopped {
942 state = "stopped"942 state = "stopped"
943 tabs = "-"943 tabs = "-"
944- } else if s.Attached {944+ } else if s.Ephemeral {
945+ state = "temporary"
946+ }
947+ if s.Attached {
945 attached = "yes"948 attached = "yes"
946 }949 }
947 _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached)950 _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached)
M internal/app/run_test.go +13 -0
@@ -136,6 +136,19 @@ func TestPrintSessionsShowsStoppedState(t *testing.T) {
136136 }
137137 }
138138
139+func TestPrintSessionsMarksEphemeral(t *testing.T) {
140+ var buf bytes.Buffer
141+ printSessions(&buf, []ports.SessionInfo{
142+ {Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
143+ {Name: "work", Tabs: 2, Attached: true},
144+ {Name: "old", Stopped: true},
145+ })
146+ out := buf.String()
147+ for _, want := range []string{"0", "temporary", "work", "running", "old", "stopped"} {
148+ require.Contains(t, out, want)
149+ }
150+}
151+
139152 func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
140153 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
141154 t.Setenv("XDG_STATE_HOME", stateRoot)
@@ -136,6 +136,19 @@ func TestPrintSessionsShowsStoppedState(t *testing.T) {
136 }136 }
137 }137 }
138 138
139+func TestPrintSessionsMarksEphemeral(t *testing.T) {
140+ var buf bytes.Buffer
141+ printSessions(&buf, []ports.SessionInfo{
142+ {Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
143+ {Name: "work", Tabs: 2, Attached: true},
144+ {Name: "old", Stopped: true},
145+ })
146+ out := buf.String()
147+ for _, want := range []string{"0", "temporary", "work", "running", "old", "stopped"} {
148+ require.Contains(t, out, want)
149+ }
150+}
151+
139 func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {152 func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
140 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()153 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
141 t.Setenv("XDG_STATE_HOME", stateRoot)154 t.Setenv("XDG_STATE_HOME", stateRoot)
M internal/usecase/client/client.go +19 -13
@@ -164,6 +164,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
164164
165165 resumeToken := uint64(0)
166166 attemptIntent := intent
167+ attemptName := name
167168 backoff := defaultReconnectBackoff.initial
168169 showingStatus := false
169170 var reconnectToastSize domain.Size
@@ -233,7 +234,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
233234 }
234235 ms.dialed = true
235236
236- result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard, log)
237+ result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard, log)
237238 if result.welcomed {
238239 backoff = defaultReconnectBackoff.initial
239240 }
@@ -241,6 +242,9 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
241242 if result.resumeToken != 0 {
242243 resumeToken = result.resumeToken
243244 }
245+ if result.sessionName != "" {
246+ attemptName = result.sessionName
247+ }
244248 if result.err == nil {
245249 clearStatus()
246250 return nil
@@ -328,6 +332,7 @@ func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
328332
329333 type attachResult struct {
330334 resumeToken uint64
335+ sessionName string
331336 welcomed bool
332337 err error
333338 }
@@ -384,6 +389,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
384389 return attachResult{err: fmt.Errorf("vev: decoding welcome: %w", derr)}
385390 }
386391 resumeToken = welcome.ResumeToken
392+ name = welcome.SessionName
387393 ms.welcomed = true
388394 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
389395 case ports.MsgError:
@@ -391,14 +397,14 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
391397 if derr != nil {
392398 return attachResult{err: fmt.Errorf("vev: decoding error reply: %w", derr)}
393399 }
394- return attachResult{resumeToken: resumeToken, err: &ProtocolError{Code: em.Code, Text: em.Text}}
400+ return attachResult{resumeToken: resumeToken, sessionName: name, err: &ProtocolError{Code: em.Code, Text: em.Text}}
395401 default:
396- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)}
402+ return attachResult{resumeToken: resumeToken, sessionName: name, err: fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)}
397403 }
398404
399405 // 3. Enter raw mode after Welcome; Run owns restoration.
400406 if err := enterRaw(); err != nil {
401- return attachResult{resumeToken: resumeToken, welcomed: true, err: err}
407+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: err}
402408 }
403409 clearStatus()
404410
@@ -443,9 +449,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
443449 // context was cancelled. A queued sender error takes priority.
444450 select {
445451 case serr := <-sendErrCh:
446- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: sending to daemon: %w", serr)}
452+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: sending to daemon: %w", serr)}
447453 default:
448- return attachResult{resumeToken: resumeToken, welcomed: true}
454+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true}
449455 }
450456 case <-colorQueryCh:
451457 if err := term.QueryColors(); err != nil {
@@ -454,21 +460,21 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
454460 case r := <-recvCh:
455461 if r.err != nil {
456462 if errors.Is(r.err, io.EOF) {
457- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)}
463+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)}
458464 }
459- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: receiving from daemon: %w", r.err)}
465+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: receiving from daemon: %w", r.err)}
460466 }
461467 switch r.frame.Type {
462468 case ports.MsgOutput:
463469 o, derr := ports.UnmarshalOutput(r.frame.Payload)
464470 if derr != nil {
465- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding output: %w", derr)}
471+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: decoding output: %w", derr)}
466472 }
467473 if _, werr := term.Out().Write(o.Data); werr != nil {
468- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: writing terminal output: %w", werr)}
474+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: writing terminal output: %w", werr)}
469475 }
470476 if ferr := term.Flush(); ferr != nil {
471- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}
477+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}
472478 }
473479 if !ms.firstOutput {
474480 ms.firstOutput = true
@@ -477,7 +483,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
477483 case ports.MsgDetached:
478484 d, derr := ports.UnmarshalDetached(r.frame.Payload)
479485 if derr != nil {
480- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)}
486+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)}
481487 }
482488 ms.detached = true
483489 if d.Reason == ports.ReasonDetach {
@@ -485,7 +491,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
485491 } else {
486492 log.Warn("detached by daemon", "reason", d.Reason)
487493 }
488- return attachResult{resumeToken: resumeToken, welcomed: true, err: detachedResult(d.Reason)}
494+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: detachedResult(d.Reason)}
489495 case ports.MsgPong:
490496 // Liveness reply; nothing to do.
491497 default:
@@ -164,6 +164,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
164 164
165 resumeToken := uint64(0)165 resumeToken := uint64(0)
166 attemptIntent := intent166 attemptIntent := intent
167+ attemptName := name
167 backoff := defaultReconnectBackoff.initial168 backoff := defaultReconnectBackoff.initial
168 showingStatus := false169 showingStatus := false
169 var reconnectToastSize domain.Size170 var reconnectToastSize domain.Size
@@ -233,7 +234,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
233 }234 }
234 ms.dialed = true235 ms.dialed = true
235 236
236- result := attachOnce(ctx, transport, term, clk, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard, log)237+ result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, enterRaw, clearStatus, remote, clipboard, log)
237 if result.welcomed {238 if result.welcomed {
238 backoff = defaultReconnectBackoff.initial239 backoff = defaultReconnectBackoff.initial
239 }240 }
@@ -241,6 +242,9 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
241 if result.resumeToken != 0 {242 if result.resumeToken != 0 {
242 resumeToken = result.resumeToken243 resumeToken = result.resumeToken
243 }244 }
245+ if result.sessionName != "" {
246+ attemptName = result.sessionName
247+ }
244 if result.err == nil {248 if result.err == nil {
245 clearStatus()249 clearStatus()
246 return nil250 return nil
@@ -328,6 +332,7 @@ func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
328 332
329 type attachResult struct {333 type attachResult struct {
330 resumeToken uint64334 resumeToken uint64
335+ sessionName string
331 welcomed bool336 welcomed bool
332 err error337 err error
333 }338 }
@@ -384,6 +389,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
384 return attachResult{err: fmt.Errorf("vev: decoding welcome: %w", derr)}389 return attachResult{err: fmt.Errorf("vev: decoding welcome: %w", derr)}
385 }390 }
386 resumeToken = welcome.ResumeToken391 resumeToken = welcome.ResumeToken
392+ name = welcome.SessionName
387 ms.welcomed = true393 ms.welcomed = true
388 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)394 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
389 case ports.MsgError:395 case ports.MsgError:
@@ -391,14 +397,14 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
391 if derr != nil {397 if derr != nil {
392 return attachResult{err: fmt.Errorf("vev: decoding error reply: %w", derr)}398 return attachResult{err: fmt.Errorf("vev: decoding error reply: %w", derr)}
393 }399 }
394- return attachResult{resumeToken: resumeToken, err: &ProtocolError{Code: em.Code, Text: em.Text}}400+ return attachResult{resumeToken: resumeToken, sessionName: name, err: &ProtocolError{Code: em.Code, Text: em.Text}}
395 default:401 default:
396- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)}402+ return attachResult{resumeToken: resumeToken, sessionName: name, err: fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)}
397 }403 }
398 404
399 // 3. Enter raw mode after Welcome; Run owns restoration.405 // 3. Enter raw mode after Welcome; Run owns restoration.
400 if err := enterRaw(); err != nil {406 if err := enterRaw(); err != nil {
401- return attachResult{resumeToken: resumeToken, welcomed: true, err: err}407+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: err}
402 }408 }
403 clearStatus()409 clearStatus()
404 410
@@ -443,9 +449,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
443 // context was cancelled. A queued sender error takes priority.449 // context was cancelled. A queued sender error takes priority.
444 select {450 select {
445 case serr := <-sendErrCh:451 case serr := <-sendErrCh:
446- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: sending to daemon: %w", serr)}452+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: sending to daemon: %w", serr)}
447 default:453 default:
448- return attachResult{resumeToken: resumeToken, welcomed: true}454+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true}
449 }455 }
450 case <-colorQueryCh:456 case <-colorQueryCh:
451 if err := term.QueryColors(); err != nil {457 if err := term.QueryColors(); err != nil {
@@ -454,21 +460,21 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
454 case r := <-recvCh:460 case r := <-recvCh:
455 if r.err != nil {461 if r.err != nil {
456 if errors.Is(r.err, io.EOF) {462 if errors.Is(r.err, io.EOF) {
457- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)}463+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)}
458 }464 }
459- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: receiving from daemon: %w", r.err)}465+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: receiving from daemon: %w", r.err)}
460 }466 }
461 switch r.frame.Type {467 switch r.frame.Type {
462 case ports.MsgOutput:468 case ports.MsgOutput:
463 o, derr := ports.UnmarshalOutput(r.frame.Payload)469 o, derr := ports.UnmarshalOutput(r.frame.Payload)
464 if derr != nil {470 if derr != nil {
465- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding output: %w", derr)}471+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: decoding output: %w", derr)}
466 }472 }
467 if _, werr := term.Out().Write(o.Data); werr != nil {473 if _, werr := term.Out().Write(o.Data); werr != nil {
468- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: writing terminal output: %w", werr)}474+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: writing terminal output: %w", werr)}
469 }475 }
470 if ferr := term.Flush(); ferr != nil {476 if ferr := term.Flush(); ferr != nil {
471- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}477+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}
472 }478 }
473 if !ms.firstOutput {479 if !ms.firstOutput {
474 ms.firstOutput = true480 ms.firstOutput = true
@@ -477,7 +483,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
477 case ports.MsgDetached:483 case ports.MsgDetached:
478 d, derr := ports.UnmarshalDetached(r.frame.Payload)484 d, derr := ports.UnmarshalDetached(r.frame.Payload)
479 if derr != nil {485 if derr != nil {
480- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)}486+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)}
481 }487 }
482 ms.detached = true488 ms.detached = true
483 if d.Reason == ports.ReasonDetach {489 if d.Reason == ports.ReasonDetach {
@@ -485,7 +491,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
485 } else {491 } else {
486 log.Warn("detached by daemon", "reason", d.Reason)492 log.Warn("detached by daemon", "reason", d.Reason)
487 }493 }
488- return attachResult{resumeToken: resumeToken, welcomed: true, err: detachedResult(d.Reason)}494+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true, err: detachedResult(d.Reason)}
489 case ports.MsgPong:495 case ports.MsgPong:
490 // Liveness reply; nothing to do.496 // Liveness reply; nothing to do.
491 default:497 default:
M internal/usecase/client/reconnect_toast_test.go +46 -1
@@ -52,7 +52,11 @@ func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarne
5252 func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }
5353
5454 func reconnectToastWelcome(token uint64) ports.Frame {
55- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", ResumeToken: token, Capabilities: ports.CapabilityResume})}
55+ return reconnectToastWelcomeNamed("", token)
56+}
57+
58+func reconnectToastWelcomeNamed(name string, token uint64) ports.Frame {
59+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: name, ResumeToken: token, Capabilities: ports.CapabilityResume})}
5660 }
5761
5862 func reconnectToastDetach(reason uint8) ports.Frame {
@@ -229,6 +233,47 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
229233 require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
230234 }
231235
236+func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
237+ linkDead := errors.New("remote link dead")
238+ oldSleep := reconnectSleep
239+ oldSleepWithResize := reconnectSleepWithResize
240+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
241+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
242+ return true
243+ }
244+ defer func() {
245+ reconnectSleep = oldSleep
246+ reconnectSleepWithResize = oldSleepWithResize
247+ }()
248+
249+ term := newReconnectToastTerminalHarness(t)
250+ defer term.closeInput()
251+ tr1 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
252+ {frame: reconnectToastWelcomeNamed("0", 44)},
253+ {err: linkDead},
254+ }}
255+ tr2 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
256+ {frame: reconnectToastWelcomeNamed("0", 55)},
257+ {frame: reconnectToastDetach(ports.ReasonDetach)},
258+ }}
259+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
260+
261+ err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler))
262+ require.NoError(t, err)
263+ require.Equal(t, 2, dialer.calls)
264+
265+ firstHello := reconnectToastHelloFromSend(t, tr1)
266+ resumeHello := reconnectToastHelloFromSend(t, tr2)
267+ require.Equal(t, ports.IntentEphemeral, firstHello.Intent)
268+ require.Empty(t, firstHello.Name)
269+ require.Zero(t, firstHello.ResumeToken)
270+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
271+ require.Equal(t, "0", resumeHello.Name)
272+ require.Equal(t, uint64(44), resumeHello.ResumeToken)
273+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
274+ require.Contains(t, term.out.String(), reconnectToastMessage)
275+}
276+
232277 func TestRemoteReconnectToastLifecycle(t *testing.T) {
233278 tests := []struct {
234279 name string
@@ -52,7 +52,11 @@ func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarne
52 func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }52 func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }
53 53
54 func reconnectToastWelcome(token uint64) ports.Frame {54 func reconnectToastWelcome(token uint64) ports.Frame {
55- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", ResumeToken: token, Capabilities: ports.CapabilityResume})}55+ return reconnectToastWelcomeNamed("", token)
56+}
57+
58+func reconnectToastWelcomeNamed(name string, token uint64) ports.Frame {
59+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: name, ResumeToken: token, Capabilities: ports.CapabilityResume})}
56 }60 }
57 61
58 func reconnectToastDetach(reason uint8) ports.Frame {62 func reconnectToastDetach(reason uint8) ports.Frame {
@@ -229,6 +233,47 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
229 require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))233 require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
230 }234 }
231 235
236+func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
237+ linkDead := errors.New("remote link dead")
238+ oldSleep := reconnectSleep
239+ oldSleepWithResize := reconnectSleepWithResize
240+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
241+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
242+ return true
243+ }
244+ defer func() {
245+ reconnectSleep = oldSleep
246+ reconnectSleepWithResize = oldSleepWithResize
247+ }()
248+
249+ term := newReconnectToastTerminalHarness(t)
250+ defer term.closeInput()
251+ tr1 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
252+ {frame: reconnectToastWelcomeNamed("0", 44)},
253+ {err: linkDead},
254+ }}
255+ tr2 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
256+ {frame: reconnectToastWelcomeNamed("0", 55)},
257+ {frame: reconnectToastDetach(ports.ReasonDetach)},
258+ }}
259+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
260+
261+ err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler))
262+ require.NoError(t, err)
263+ require.Equal(t, 2, dialer.calls)
264+
265+ firstHello := reconnectToastHelloFromSend(t, tr1)
266+ resumeHello := reconnectToastHelloFromSend(t, tr2)
267+ require.Equal(t, ports.IntentEphemeral, firstHello.Intent)
268+ require.Empty(t, firstHello.Name)
269+ require.Zero(t, firstHello.ResumeToken)
270+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
271+ require.Equal(t, "0", resumeHello.Name)
272+ require.Equal(t, uint64(44), resumeHello.ResumeToken)
273+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
274+ require.Contains(t, term.out.String(), reconnectToastMessage)
275+}
276+
232 func TestRemoteReconnectToastLifecycle(t *testing.T) {277 func TestRemoteReconnectToastLifecycle(t *testing.T) {
233 tests := []struct {278 tests := []struct {
234 name string279 name string
M internal/usecase/daemon/client.go +4 -15
@@ -473,9 +473,8 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
473473 }
474474 }
475475
476-// clientGone detaches ac if it is still the session's current client. An
477-// ephemeral session dies with its client; a named one survives headless. When
478-
476+// clientGone detaches ac if it is still the session's current client. The
477+// session remains registered and headless after the client is gone.
479478 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
480479 if failed != nil && !ac.currentTransportIs(failed) {
481480 return // stale connection loop; a newer transport owns this client
@@ -510,9 +509,6 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
510509 }
511510 _ = ac.closeCapturedTransport(oldTr)
512511 d.log.Info("client detached", "session", sess.name, "explicit", explicit)
513- if ephemeral {
514- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
515- }
516512 }
517513
518514 // detachIfCurrent clears the client iff ac is the current one, reporting
@@ -566,9 +562,8 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
566562 }
567563 }
568564
569-// detachOnSendError drops a client whose transport failed. Like every other
570-// detach path, losing the client kills an ephemeral session (its lifetime is
571-// its client's); a named session keeps running headless.
565+// detachOnSendError drops a client whose transport failed, leaving the session
566+// registered and headless.
572567 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
573568 if failed != nil && !ac.currentTransportIs(failed) {
574569 return
@@ -583,12 +578,6 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
583578 }
584579 _ = ac.closeCapturedTransport(failed)
585580 d.log.Warn("detached client after send error", "session", sess.name)
586- sess.mu.Lock()
587- ephemeral := sess.ephemeral
588- sess.mu.Unlock()
589- if ephemeral {
590- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
591- }
592581 }
593582 }
594583
@@ -473,9 +473,8 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
473 }473 }
474 }474 }
475 475
476-// clientGone detaches ac if it is still the session's current client. An476+// clientGone detaches ac if it is still the session's current client. The
477-// ephemeral session dies with its client; a named one survives headless. When477+// session remains registered and headless after the client is gone.
478-
479 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {478 func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
480 if failed != nil && !ac.currentTransportIs(failed) {479 if failed != nil && !ac.currentTransportIs(failed) {
481 return // stale connection loop; a newer transport owns this client480 return // stale connection loop; a newer transport owns this client
@@ -510,9 +509,6 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
510 }509 }
511 _ = ac.closeCapturedTransport(oldTr)510 _ = ac.closeCapturedTransport(oldTr)
512 d.log.Info("client detached", "session", sess.name, "explicit", explicit)511 d.log.Info("client detached", "session", sess.name, "explicit", explicit)
513- if ephemeral {
514- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
515- }
516 }512 }
517 513
518 // detachIfCurrent clears the client iff ac is the current one, reporting514 // detachIfCurrent clears the client iff ac is the current one, reporting
@@ -566,9 +562,8 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
566 }562 }
567 }563 }
568 564
569-// detachOnSendError drops a client whose transport failed. Like every other565+// detachOnSendError drops a client whose transport failed, leaving the session
570-// detach path, losing the client kills an ephemeral session (its lifetime is566+// registered and headless.
571-// its client's); a named session keeps running headless.
572 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {567 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
573 if failed != nil && !ac.currentTransportIs(failed) {568 if failed != nil && !ac.currentTransportIs(failed) {
574 return569 return
@@ -583,12 +578,6 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
583 }578 }
584 _ = ac.closeCapturedTransport(failed)579 _ = ac.closeCapturedTransport(failed)
585 d.log.Warn("detached client after send error", "session", sess.name)580 d.log.Warn("detached client after send error", "session", sess.name)
586- sess.mu.Lock()
587- ephemeral := sess.ephemeral
588- sess.mu.Unlock()
589- if ephemeral {
590- _ = d.killSession(sess, ports.ReasonSessionKilled, false)
591- }
592 }581 }
593 }582 }
594 583
M internal/usecase/daemon/daemon.go +4 -4
@@ -481,9 +481,9 @@ func (d *Daemon) handleList(tr ports.Transport) {
481481 _ = tr.Send(frameSessions(infos))
482482 }
483483
484-// handleKill terminates the named session (if any), or all sessions, and
485-// closes the control connection; the resulting EOF is the client's success
486-// signal.
484+// handleKill terminates the requested live session or stopped named session,
485+// or all sessions, and closes the control connection; the resulting EOF is the
486+// client's success signal.
487487 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
488488 defer func() { _ = tr.Close() }()
489489
@@ -648,7 +648,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
648648 return nil, nil, err
649649 }
650650 d.purgeParkedForSessionLocked(sess)
651- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID})
651+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
652652 sess.mu.Lock()
653653 sess.terminal = term
654654 sess.mu.Unlock()
@@ -481,9 +481,9 @@ func (d *Daemon) handleList(tr ports.Transport) {
481 _ = tr.Send(frameSessions(infos))481 _ = tr.Send(frameSessions(infos))
482 }482 }
483 483
484-// handleKill terminates the named session (if any), or all sessions, and484+// handleKill terminates the requested live session or stopped named session,
485-// closes the control connection; the resulting EOF is the client's success485+// or all sessions, and closes the control connection; the resulting EOF is the
486-// signal.486+// client's success signal.
487 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {487 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
488 defer func() { _ = tr.Close() }()488 defer func() { _ = tr.Close() }()
489 489
@@ -648,7 +648,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
648 return nil, nil, err648 return nil, nil, err
649 }649 }
650 d.purgeParkedForSessionLocked(sess)650 d.purgeParkedForSessionLocked(sess)
651- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID})651+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
652 sess.mu.Lock()652 sess.mu.Lock()
653 sess.terminal = term653 sess.terminal = term
654 sess.mu.Unlock()654 sess.mu.Unlock()
M internal/usecase/daemon/render_test.go +9 -3
@@ -652,7 +652,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
652652
653653 // --- reader EOF -> registry-empty shutdown ----------------------------------
654654
655-func TestSendErrorKillsEphemeral(t *testing.T) {
655+func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
656656 p := portsmocks.NewMockPTY(t)
657657 p.EXPECT().Close().Return(nil).Maybe()
658658
@@ -673,9 +673,15 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
673673 win.focusedPane().screen.Write([]byte("x"))
674674 win.mu.Unlock()
675675
676- d.paint(sess, ac, true) // send fails -> detach -> ephemeral killed
676+ d.paint(sess, ac, true)
677+
678+ require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")
679+ sess.mu.Lock()
680+ require.Nil(t, sess.client)
681+ sess.mu.Unlock()
677682
678- require.Equal(t, 0, sessionCount(d), "ephemeral session must die when its client's send fails")
683+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
684+ cancel()
679685 d.waitNotifies()
680686 }
681687
@@ -652,7 +652,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
652 652
653 // --- reader EOF -> registry-empty shutdown ----------------------------------653 // --- reader EOF -> registry-empty shutdown ----------------------------------
654 654
655-func TestSendErrorKillsEphemeral(t *testing.T) {655+func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
656 p := portsmocks.NewMockPTY(t)656 p := portsmocks.NewMockPTY(t)
657 p.EXPECT().Close().Return(nil).Maybe()657 p.EXPECT().Close().Return(nil).Maybe()
658 658
@@ -673,9 +673,15 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
673 win.focusedPane().screen.Write([]byte("x"))673 win.focusedPane().screen.Write([]byte("x"))
674 win.mu.Unlock()674 win.mu.Unlock()
675 675
676- d.paint(sess, ac, true) // send fails -> detach -> ephemeral killed676+ d.paint(sess, ac, true)
677+
678+ require.Equal(t, 1, sessionCount(d), "ephemeral session survives failed client send")
679+ sess.mu.Lock()
680+ require.Nil(t, sess.client)
681+ sess.mu.Unlock()
677 682
678- require.Equal(t, 0, sessionCount(d), "ephemeral session must die when its client's send fails")683+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
684+ cancel()
679 d.waitNotifies()685 d.waitNotifies()
680 }686 }
681 687
M internal/usecase/daemon/resume.go +1 -4
@@ -29,10 +29,7 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
2929 }
3030
3131 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
32- sess.mu.Lock()
33- ephemeral := sess.ephemeral
34- sess.mu.Unlock()
35- if ephemeral || !ac.resumeCapable {
32+ if !ac.resumeCapable {
3633 return false
3734 }
3835 d.mu.Lock()
@@ -29,10 +29,7 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
29 }29 }
30 30
31 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {31 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
32- sess.mu.Lock()32+ if !ac.resumeCapable {
33- ephemeral := sess.ephemeral
34- sess.mu.Unlock()
35- if ephemeral || !ac.resumeCapable {
36 return false33 return false
37 }34 }
38 d.mu.Lock()35 d.mu.Lock()
M internal/usecase/daemon/resume_test.go +57 -8
@@ -57,7 +57,7 @@ func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
5757 }
5858 }
5959
60-func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) {
60+func TestNamedLinkLossParks(t *testing.T) {
6161 pty, release := newBlockingPTY(t)
6262 defer release()
6363 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -75,15 +75,35 @@ func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) {
7575 _, parked := d.parked[token]
7676 d.mu.Unlock()
7777 require.True(t, parked, "named resume-capable link loss is parked")
78+}
7879
79- pty2, release2 := newBlockingPTY(t)
80- defer release2()
81- d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{})
82- tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
83- es, eac, err := d2.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr2)
80+func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
81+ pty, release := newBlockingPTY(t)
82+ defer release()
83+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
84+
85+ tr := &closeTrackingTransport{}
86+ sess, ac, err := d.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr)
87+ require.NoError(t, err)
88+ require.True(t, sess.ephemeral)
89+ token := ac.resumeToken
90+ require.NotZero(t, token, "ephemeral sessions receive resume tokens")
91+
92+ d.clientGone(sess, ac, ac.transport(), false)
93+ require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive")
94+ require.Nil(t, sess.client)
95+ d.mu.Lock()
96+ _, parked := d.parked[token]
97+ d.mu.Unlock()
98+ require.True(t, parked, "ephemeral resume-capable link loss is parked")
99+
100+ newTr := &closeTrackingTransport{}
101+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, sess.name, token), newTr)
84102 require.NoError(t, err)
85- d2.clientGone(es, eac, eac.transport(), false)
86- require.Equal(t, 0, sessionCount(d2), "ephemeral link loss still destroys session")
103+ require.Same(t, sess, resumedSess)
104+ require.Same(t, ac, resumedAC)
105+ require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")
106+ require.Same(t, resumedAC, sess.client)
87107 }
88108
89109 func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
@@ -210,6 +230,35 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
210230 require.Zero(t, parked)
211231 }
212232
233+func TestEphemeralParkExpiryKeepsSession(t *testing.T) {
234+ clk := &signalClock{timers: make(chan *signalTimer, 8)}
235+ pty, release := newBlockingPTY(t)
236+ defer release()
237+ d := newTestDaemon(t, newFactory(t, pty), clk)
238+
239+ tr := &closeTrackingTransport{}
240+ sess, ac, err := d.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr)
241+ require.NoError(t, err)
242+ token := ac.resumeToken
243+ require.NotZero(t, token)
244+
245+ d.clientGone(sess, ac, ac.transport(), false)
246+ timer := <-clk.timers
247+ timer.ch <- clk.Now()
248+
249+ require.Eventually(t, func() bool {
250+ d.mu.Lock()
251+ defer d.mu.Unlock()
252+ _, ok := d.parked[token]
253+ return !ok
254+ }, 2*time.Second, 10*time.Millisecond)
255+
256+ require.Equal(t, 1, sessionCount(d), "token expiry does not kill ephemeral session")
257+ sess.mu.Lock()
258+ require.Nil(t, sess.client)
259+ sess.mu.Unlock()
260+}
261+
213262 func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
214263 pty, release := newBlockingPTY(t)
215264 defer release()
@@ -57,7 +57,7 @@ func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
57 }57 }
58 }58 }
59 59
60-func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) {60+func TestNamedLinkLossParks(t *testing.T) {
61 pty, release := newBlockingPTY(t)61 pty, release := newBlockingPTY(t)
62 defer release()62 defer release()
63 d := newTestDaemon(t, newFactory(t, pty), stubClock{})63 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -75,15 +75,35 @@ func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) {
75 _, parked := d.parked[token]75 _, parked := d.parked[token]
76 d.mu.Unlock()76 d.mu.Unlock()
77 require.True(t, parked, "named resume-capable link loss is parked")77 require.True(t, parked, "named resume-capable link loss is parked")
78+}
78 79
79- pty2, release2 := newBlockingPTY(t)80+func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
80- defer release2()81+ pty, release := newBlockingPTY(t)
81- d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{})82+ defer release()
82- tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))83+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
83- es, eac, err := d2.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr2)84+
85+ tr := &closeTrackingTransport{}
86+ sess, ac, err := d.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr)
87+ require.NoError(t, err)
88+ require.True(t, sess.ephemeral)
89+ token := ac.resumeToken
90+ require.NotZero(t, token, "ephemeral sessions receive resume tokens")
91+
92+ d.clientGone(sess, ac, ac.transport(), false)
93+ require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive")
94+ require.Nil(t, sess.client)
95+ d.mu.Lock()
96+ _, parked := d.parked[token]
97+ d.mu.Unlock()
98+ require.True(t, parked, "ephemeral resume-capable link loss is parked")
99+
100+ newTr := &closeTrackingTransport{}
101+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, sess.name, token), newTr)
84 require.NoError(t, err)102 require.NoError(t, err)
85- d2.clientGone(es, eac, eac.transport(), false)103+ require.Same(t, sess, resumedSess)
86- require.Equal(t, 0, sessionCount(d2), "ephemeral link loss still destroys session")104+ require.Same(t, ac, resumedAC)
105+ require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")
106+ require.Same(t, resumedAC, sess.client)
87 }107 }
88 108
89 func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {109 func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
@@ -210,6 +230,35 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
210 require.Zero(t, parked)230 require.Zero(t, parked)
211 }231 }
212 232
233+func TestEphemeralParkExpiryKeepsSession(t *testing.T) {
234+ clk := &signalClock{timers: make(chan *signalTimer, 8)}
235+ pty, release := newBlockingPTY(t)
236+ defer release()
237+ d := newTestDaemon(t, newFactory(t, pty), clk)
238+
239+ tr := &closeTrackingTransport{}
240+ sess, ac, err := d.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr)
241+ require.NoError(t, err)
242+ token := ac.resumeToken
243+ require.NotZero(t, token)
244+
245+ d.clientGone(sess, ac, ac.transport(), false)
246+ timer := <-clk.timers
247+ timer.ch <- clk.Now()
248+
249+ require.Eventually(t, func() bool {
250+ d.mu.Lock()
251+ defer d.mu.Unlock()
252+ _, ok := d.parked[token]
253+ return !ok
254+ }, 2*time.Second, 10*time.Millisecond)
255+
256+ require.Equal(t, 1, sessionCount(d), "token expiry does not kill ephemeral session")
257+ sess.mu.Lock()
258+ require.Nil(t, sess.client)
259+ sess.mu.Unlock()
260+}
261+
213 func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {262 func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
214 pty, release := newBlockingPTY(t)263 pty, release := newBlockingPTY(t)
215 defer release()264 defer release()
M internal/usecase/daemon/session_test.go +33 -9
@@ -288,7 +288,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
288288
289289 // --- detach semantics -------------------------------------------------------
290290
291-func TestDetachKillsEphemeral(t *testing.T) {
291+func TestDetachKeepsEphemeralHeadless(t *testing.T) {
292292 p, releasePTY := newBlockingPTY(t)
293293 d := newTestDaemon(t, newFactory(t, p), stubClock{})
294294 tr, sends, _ := newConn(t,
@@ -299,13 +299,42 @@ func TestDetachKillsEphemeral(t *testing.T) {
299299 var hg sync.WaitGroup
300300 hg.Go(func() { d.handleConn(tr) })
301301 awaitFrame(t, sends, ports.MsgWelcome)
302+ awaitFrame(t, sends, ports.MsgDetached)
303+ hg.Wait()
302304
303- require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond,
304- "ephemeral session must die on client detach")
305+ require.Equal(t, 1, sessionCount(d), "ephemeral session survives explicit detach")
306+ sess := firstSession(d)
307+ require.NotNil(t, sess)
308+ sess.mu.Lock()
309+ require.True(t, sess.ephemeral)
310+ require.Nil(t, sess.client, "ephemeral session is headless after detach")
311+ sess.mu.Unlock()
305312
313+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
306314 releasePTY()
307- hg.Wait()
308315 d.sessWg.Wait()
316+ d.waitNotifies()
317+}
318+
319+func TestListShowsDetachedEphemeralSession(t *testing.T) {
320+ p, releasePTY := newBlockingPTY(t)
321+ defer releasePTY()
322+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
323+
324+ tr := &closeTrackingTransport{}
325+ sess, ac, err := d.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr)
326+ require.NoError(t, err)
327+ d.clientGone(sess, ac, ac.transport(), true)
328+
329+ got := listSessions(t, d)
330+ require.Len(t, got.Sessions, 1)
331+ require.Equal(t, sess.name, got.Sessions[0].Name)
332+ require.True(t, got.Sessions[0].Ephemeral)
333+ require.False(t, got.Sessions[0].Attached)
334+
335+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
336+ d.sessWg.Wait()
337+ d.waitNotifies()
309338 }
310339
311340 func TestDetachKeepsNamed(t *testing.T) {
@@ -580,11 +609,6 @@ func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
580609 require.Equal(t, 0, sessionCount(d), "session must be torn down despite the wedged client")
581610 }
582611
583-// --- send-error kills ephemeral -----------------------------------------------
584-
585-// TestSendErrorKillsEphemeral: a failed output send detaches the client, and —
586-// like every other detach path — that kills an ephemeral session rather than
587-
588612 type mockStoreState struct {
589613 mu sync.Mutex
590614 data map[string][]byte
@@ -288,7 +288,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
288 288
289 // --- detach semantics -------------------------------------------------------289 // --- detach semantics -------------------------------------------------------
290 290
291-func TestDetachKillsEphemeral(t *testing.T) {291+func TestDetachKeepsEphemeralHeadless(t *testing.T) {
292 p, releasePTY := newBlockingPTY(t)292 p, releasePTY := newBlockingPTY(t)
293 d := newTestDaemon(t, newFactory(t, p), stubClock{})293 d := newTestDaemon(t, newFactory(t, p), stubClock{})
294 tr, sends, _ := newConn(t,294 tr, sends, _ := newConn(t,
@@ -299,13 +299,42 @@ func TestDetachKillsEphemeral(t *testing.T) {
299 var hg sync.WaitGroup299 var hg sync.WaitGroup
300 hg.Go(func() { d.handleConn(tr) })300 hg.Go(func() { d.handleConn(tr) })
301 awaitFrame(t, sends, ports.MsgWelcome)301 awaitFrame(t, sends, ports.MsgWelcome)
302+ awaitFrame(t, sends, ports.MsgDetached)
303+ hg.Wait()
302 304
303- require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond,305+ require.Equal(t, 1, sessionCount(d), "ephemeral session survives explicit detach")
304- "ephemeral session must die on client detach")306+ sess := firstSession(d)
307+ require.NotNil(t, sess)
308+ sess.mu.Lock()
309+ require.True(t, sess.ephemeral)
310+ require.Nil(t, sess.client, "ephemeral session is headless after detach")
311+ sess.mu.Unlock()
305 312
313+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
306 releasePTY()314 releasePTY()
307- hg.Wait()
308 d.sessWg.Wait()315 d.sessWg.Wait()
316+ d.waitNotifies()
317+}
318+
319+func TestListShowsDetachedEphemeralSession(t *testing.T) {
320+ p, releasePTY := newBlockingPTY(t)
321+ defer releasePTY()
322+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
323+
324+ tr := &closeTrackingTransport{}
325+ sess, ac, err := d.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr)
326+ require.NoError(t, err)
327+ d.clientGone(sess, ac, ac.transport(), true)
328+
329+ got := listSessions(t, d)
330+ require.Len(t, got.Sessions, 1)
331+ require.Equal(t, sess.name, got.Sessions[0].Name)
332+ require.True(t, got.Sessions[0].Ephemeral)
333+ require.False(t, got.Sessions[0].Attached)
334+
335+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
336+ d.sessWg.Wait()
337+ d.waitNotifies()
309 }338 }
310 339
311 func TestDetachKeepsNamed(t *testing.T) {340 func TestDetachKeepsNamed(t *testing.T) {
@@ -580,11 +609,6 @@ func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
580 require.Equal(t, 0, sessionCount(d), "session must be torn down despite the wedged client")609 require.Equal(t, 0, sessionCount(d), "session must be torn down despite the wedged client")
581 }610 }
582 611
583-// --- send-error kills ephemeral -----------------------------------------------
584-
585-// TestSendErrorKillsEphemeral: a failed output send detaches the client, and —
586-// like every other detach path — that kills an ephemeral session rather than
587-
588 type mockStoreState struct {612 type mockStoreState struct {
589 mu sync.Mutex613 mu sync.Mutex
590 data map[string][]byte614 data map[string][]byte

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close