detached 05ac8ad

Merge pull request #35 from bnema/refactor/ephemeral-daemon-retained

refactor(sessions): retain ephemeral sessions in daemon
commit
05ac8ad2829edca3fd26d521124371c7ac84e315
author
bnema · · 2026-07-07 06:10 UTC
committer
GitHub · · 2026-07-07 06:10 UTC
merge of
bf22b36 + 758670c · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
14 files · +311 -78
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 +84 -8
@@ -4,6 +4,7 @@ package app
44
55 import (
66 "context"
7+ "errors"
78 "io"
89 "log/slog"
910 "strings"
@@ -34,7 +35,11 @@ func discardLog() *slog.Logger {
3435 // The returned served channel receives Serve's result exactly once.
3536 func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) {
3637 t.Helper()
37- dir = t.TempDir()
38+ return startDaemonInDir(t, t.TempDir(), opts...)
39+}
40+
41+func startDaemonInDir(t *testing.T, dir string, opts ...daemon.Option) (string, <-chan error) {
42+ t.Helper()
3843 ln, err := ipc.Listen(dir)
3944 require.NoError(t, err)
4045
@@ -83,6 +88,36 @@ func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size)
8388 return tr, p
8489 }
8590
91+func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
92+ t.Helper()
93+ tr, err := ipc.Dial(dir)
94+ require.NoError(t, err)
95+ defer func() { _ = tr.Close() }()
96+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
97+ f, err := tr.Recv()
98+ require.NoError(t, err)
99+ require.Equal(t, ports.MsgSessions, f.Type)
100+ sessions, err := ports.UnmarshalSessions(f.Payload)
101+ require.NoError(t, err)
102+ return sessions
103+}
104+
105+func killAll(dir string) error {
106+ tr, err := ipc.Dial(dir)
107+ if err != nil {
108+ return err
109+ }
110+ defer func() { _ = tr.Close() }()
111+ if err := tr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil {
112+ return err
113+ }
114+ _, err = tr.Recv()
115+ if errors.Is(err, io.EOF) {
116+ return nil
117+ }
118+ return err
119+}
120+
86121 // awaitText decodes MsgOutput frames into a fresh VT screen and returns once
87122 // the reconstructed grid contains want.
88123 func awaitText(t *testing.T, p *pump, sz domain.Size, want string) {
@@ -236,22 +271,63 @@ func TestIntegration_AltCWithoutPaletteDoesNotCreateTab(t *testing.T) {
236271 assertNoTextAfterInput(t, p, sz, " 1 2 ")
237272 }
238273
239-func TestIntegration_EphemeralGoneOnDetach(t *testing.T) {
274+func TestIntegration_EphemeralSurvivesDetachAndReattaches(t *testing.T) {
240275 sz := domain.Size{Cols: 80, Rows: 24}
241- dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf READY; sleep 30"}))
276+ dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf MARKER; sleep 30"}))
242277
243- tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)
244- awaitText(t, p, sz, "READY")
278+ tr1, p1 := attach(t, dir, ports.IntentEphemeral, "", sz)
279+ awaitText(t, p1, sz, "MARKER")
280+ require.NoError(t, tr1.Close())
245281
246- // Detach by dropping the connection: the ephemeral session must die, which
247- // (being the last session) shuts the daemon down.
282+ select {
283+ case err := <-served:
284+ t.Fatalf("daemon shut down while an ephemeral session was alive: %v", err)
285+ case <-time.After(300 * time.Millisecond):
286+ }
287+
288+ tr2, p2 := attach(t, dir, ports.IntentAttach, "0", sz)
289+ defer func() { _ = tr2.Close() }()
290+ awaitText(t, p2, sz, "MARKER")
291+
292+ require.NoError(t, killAll(dir))
293+ select {
294+ case err := <-served:
295+ require.NoError(t, err)
296+ case <-time.After(5 * time.Second):
297+ t.Fatal("daemon did not shut down after kill all")
298+ }
299+}
300+
301+func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
302+ sz := domain.Size{Cols: 80, Rows: 24}
303+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
304+ t.Setenv("XDG_STATE_HOME", stateRoot)
305+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
306+ dir := ipc.SocketDir()
307+
308+ _, served := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "printf TEMP; sleep 30"}))
309+ tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)
310+ awaitText(t, p, sz, "TEMP")
248311 require.NoError(t, tr.Close())
249312
313+ require.NoError(t, runKill(context.Background(), "", false, true))
250314 select {
251315 case err := <-served:
252316 require.NoError(t, err)
253317 case <-time.After(5 * time.Second):
254- t.Fatal("daemon did not shut down after the ephemeral session detached")
318+ t.Fatal("daemon did not stop after kill --daemon")
319+ }
320+
321+ _, served2 := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}))
322+ sessions := listRemoteSessions(t, dir)
323+ require.Empty(t, sessions.Sessions)
324+
325+ require.NoError(t, runKill(context.Background(), "", false, true))
326+ select {
327+ case err := <-served2:
328+ require.NoError(t, err)
329+ case <-time.After(5 * time.Second):
330+ t.Fatal("restarted daemon did not stop after kill --daemon")
255331 }
256332 }
257333
@@ -4,6 +4,7 @@ package app
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "io"8 "io"
8 "log/slog"9 "log/slog"
9 "strings"10 "strings"
@@ -34,7 +35,11 @@ func discardLog() *slog.Logger {
34 // The returned served channel receives Serve's result exactly once.35 // The returned served channel receives Serve's result exactly once.
35 func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) {36 func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) {
36 t.Helper()37 t.Helper()
37- dir = t.TempDir()38+ return startDaemonInDir(t, t.TempDir(), opts...)
39+}
40+
41+func startDaemonInDir(t *testing.T, dir string, opts ...daemon.Option) (string, <-chan error) {
42+ t.Helper()
38 ln, err := ipc.Listen(dir)43 ln, err := ipc.Listen(dir)
39 require.NoError(t, err)44 require.NoError(t, err)
40 45
@@ -83,6 +88,36 @@ func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size)
83 return tr, p88 return tr, p
84 }89 }
85 90
91+func listRemoteSessions(t *testing.T, dir string) ports.Sessions {
92+ t.Helper()
93+ tr, err := ipc.Dial(dir)
94+ require.NoError(t, err)
95+ defer func() { _ = tr.Close() }()
96+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}))
97+ f, err := tr.Recv()
98+ require.NoError(t, err)
99+ require.Equal(t, ports.MsgSessions, f.Type)
100+ sessions, err := ports.UnmarshalSessions(f.Payload)
101+ require.NoError(t, err)
102+ return sessions
103+}
104+
105+func killAll(dir string) error {
106+ tr, err := ipc.Dial(dir)
107+ if err != nil {
108+ return err
109+ }
110+ defer func() { _ = tr.Close() }()
111+ if err := tr.Send(ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{All: true})}); err != nil {
112+ return err
113+ }
114+ _, err = tr.Recv()
115+ if errors.Is(err, io.EOF) {
116+ return nil
117+ }
118+ return err
119+}
120+
86 // awaitText decodes MsgOutput frames into a fresh VT screen and returns once121 // awaitText decodes MsgOutput frames into a fresh VT screen and returns once
87 // the reconstructed grid contains want.122 // the reconstructed grid contains want.
88 func awaitText(t *testing.T, p *pump, sz domain.Size, want string) {123 func awaitText(t *testing.T, p *pump, sz domain.Size, want string) {
@@ -236,22 +271,63 @@ func TestIntegration_AltCWithoutPaletteDoesNotCreateTab(t *testing.T) {
236 assertNoTextAfterInput(t, p, sz, " 1 2 ")271 assertNoTextAfterInput(t, p, sz, " 1 2 ")
237 }272 }
238 273
239-func TestIntegration_EphemeralGoneOnDetach(t *testing.T) {274+func TestIntegration_EphemeralSurvivesDetachAndReattaches(t *testing.T) {
240 sz := domain.Size{Cols: 80, Rows: 24}275 sz := domain.Size{Cols: 80, Rows: 24}
241- dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf READY; sleep 30"}))276+ dir, served := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf MARKER; sleep 30"}))
242 277
243- tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)278+ tr1, p1 := attach(t, dir, ports.IntentEphemeral, "", sz)
244- awaitText(t, p, sz, "READY")279+ awaitText(t, p1, sz, "MARKER")
280+ require.NoError(t, tr1.Close())
245 281
246- // Detach by dropping the connection: the ephemeral session must die, which282+ select {
247- // (being the last session) shuts the daemon down.283+ case err := <-served:
284+ t.Fatalf("daemon shut down while an ephemeral session was alive: %v", err)
285+ case <-time.After(300 * time.Millisecond):
286+ }
287+
288+ tr2, p2 := attach(t, dir, ports.IntentAttach, "0", sz)
289+ defer func() { _ = tr2.Close() }()
290+ awaitText(t, p2, sz, "MARKER")
291+
292+ require.NoError(t, killAll(dir))
293+ select {
294+ case err := <-served:
295+ require.NoError(t, err)
296+ case <-time.After(5 * time.Second):
297+ t.Fatal("daemon did not shut down after kill all")
298+ }
299+}
300+
301+func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
302+ sz := domain.Size{Cols: 80, Rows: 24}
303+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
304+ t.Setenv("XDG_STATE_HOME", stateRoot)
305+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
306+ dir := ipc.SocketDir()
307+
308+ _, served := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "printf TEMP; sleep 30"}))
309+ tr, p := attach(t, dir, ports.IntentEphemeral, "", sz)
310+ awaitText(t, p, sz, "TEMP")
248 require.NoError(t, tr.Close())311 require.NoError(t, tr.Close())
249 312
313+ require.NoError(t, runKill(context.Background(), "", false, true))
250 select {314 select {
251 case err := <-served:315 case err := <-served:
252 require.NoError(t, err)316 require.NoError(t, err)
253 case <-time.After(5 * time.Second):317 case <-time.After(5 * time.Second):
254- t.Fatal("daemon did not shut down after the ephemeral session detached")318+ t.Fatal("daemon did not stop after kill --daemon")
319+ }
320+
321+ _, served2 := startDaemonInDir(t, dir, daemon.WithShell("/bin/sh", []string{"-c", "sleep 30"}))
322+ sessions := listRemoteSessions(t, dir)
323+ require.Empty(t, sessions.Sessions)
324+
325+ require.NoError(t, runKill(context.Background(), "", false, true))
326+ select {
327+ case err := <-served2:
328+ require.NoError(t, err)
329+ case <-time.After(5 * time.Second):
330+ t.Fatal("restarted daemon did not stop after kill --daemon")
255 }331 }
256 }332 }
257 333
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 +25 -15
@@ -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 }
@@ -377,28 +382,33 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
377382 if err != nil {
378383 return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)}
379384 }
385+ result := func(welcomed bool, err error) attachResult {
386+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err}
387+ }
380388 switch reply.Type {
381389 case ports.MsgWelcome:
382390 welcome, derr := ports.UnmarshalWelcome(reply.Payload)
383391 if derr != nil {
384- return attachResult{err: fmt.Errorf("vev: decoding welcome: %w", derr)}
392+ return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))
385393 }
386394 resumeToken = welcome.ResumeToken
395+ name = welcome.SessionName
387396 ms.welcomed = true
388397 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
389398 case ports.MsgError:
390399 em, derr := ports.UnmarshalErrorMsg(reply.Payload)
391400 if derr != nil {
392- return attachResult{err: fmt.Errorf("vev: decoding error reply: %w", derr)}
401+ return result(false, fmt.Errorf("vev: decoding error reply: %w", derr))
393402 }
394- return attachResult{resumeToken: resumeToken, err: &ProtocolError{Code: em.Code, Text: em.Text}}
403+ return result(false, &ProtocolError{Code: em.Code, Text: em.Text})
395404 default:
396- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)}
405+ return result(false, fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type))
397406 }
407+ welcomedResult := func(err error) attachResult { return result(true, err) }
398408
399409 // 3. Enter raw mode after Welcome; Run owns restoration.
400410 if err := enterRaw(); err != nil {
401- return attachResult{resumeToken: resumeToken, welcomed: true, err: err}
411+ return welcomedResult(err)
402412 }
403413 clearStatus()
404414
@@ -443,9 +453,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
443453 // context was cancelled. A queued sender error takes priority.
444454 select {
445455 case serr := <-sendErrCh:
446- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: sending to daemon: %w", serr)}
456+ return welcomedResult(fmt.Errorf("vev: sending to daemon: %w", serr))
447457 default:
448- return attachResult{resumeToken: resumeToken, welcomed: true}
458+ return welcomedResult(nil)
449459 }
450460 case <-colorQueryCh:
451461 if err := term.QueryColors(); err != nil {
@@ -454,21 +464,21 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
454464 case r := <-recvCh:
455465 if r.err != nil {
456466 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)}
467+ return welcomedResult(fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err))
458468 }
459- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: receiving from daemon: %w", r.err)}
469+ return welcomedResult(fmt.Errorf("vev: receiving from daemon: %w", r.err))
460470 }
461471 switch r.frame.Type {
462472 case ports.MsgOutput:
463473 o, derr := ports.UnmarshalOutput(r.frame.Payload)
464474 if derr != nil {
465- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding output: %w", derr)}
475+ return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
466476 }
467477 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)}
478+ return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
469479 }
470480 if ferr := term.Flush(); ferr != nil {
471- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}
481+ return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
472482 }
473483 if !ms.firstOutput {
474484 ms.firstOutput = true
@@ -477,7 +487,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
477487 case ports.MsgDetached:
478488 d, derr := ports.UnmarshalDetached(r.frame.Payload)
479489 if derr != nil {
480- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)}
490+ return welcomedResult(fmt.Errorf("vev: decoding detached: %w", derr))
481491 }
482492 ms.detached = true
483493 if d.Reason == ports.ReasonDetach {
@@ -485,7 +495,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
485495 } else {
486496 log.Warn("detached by daemon", "reason", d.Reason)
487497 }
488- return attachResult{resumeToken: resumeToken, welcomed: true, err: detachedResult(d.Reason)}
498+ return welcomedResult(detachedResult(d.Reason))
489499 case ports.MsgPong:
490500 // Liveness reply; nothing to do.
491501 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 }
@@ -377,28 +382,33 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
377 if err != nil {382 if err != nil {
378 return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)}383 return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)}
379 }384 }
385+ result := func(welcomed bool, err error) attachResult {
386+ return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err}
387+ }
380 switch reply.Type {388 switch reply.Type {
381 case ports.MsgWelcome:389 case ports.MsgWelcome:
382 welcome, derr := ports.UnmarshalWelcome(reply.Payload)390 welcome, derr := ports.UnmarshalWelcome(reply.Payload)
383 if derr != nil {391 if derr != nil {
384- return attachResult{err: fmt.Errorf("vev: decoding welcome: %w", derr)}392+ return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))
385 }393 }
386 resumeToken = welcome.ResumeToken394 resumeToken = welcome.ResumeToken
395+ name = welcome.SessionName
387 ms.welcomed = true396 ms.welcomed = true
388 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)397 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
389 case ports.MsgError:398 case ports.MsgError:
390 em, derr := ports.UnmarshalErrorMsg(reply.Payload)399 em, derr := ports.UnmarshalErrorMsg(reply.Payload)
391 if derr != nil {400 if derr != nil {
392- return attachResult{err: fmt.Errorf("vev: decoding error reply: %w", derr)}401+ return result(false, fmt.Errorf("vev: decoding error reply: %w", derr))
393 }402 }
394- return attachResult{resumeToken: resumeToken, err: &ProtocolError{Code: em.Code, Text: em.Text}}403+ return result(false, &ProtocolError{Code: em.Code, Text: em.Text})
395 default:404 default:
396- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)}405+ return result(false, fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type))
397 }406 }
407+ welcomedResult := func(err error) attachResult { return result(true, err) }
398 408
399 // 3. Enter raw mode after Welcome; Run owns restoration.409 // 3. Enter raw mode after Welcome; Run owns restoration.
400 if err := enterRaw(); err != nil {410 if err := enterRaw(); err != nil {
401- return attachResult{resumeToken: resumeToken, welcomed: true, err: err}411+ return welcomedResult(err)
402 }412 }
403 clearStatus()413 clearStatus()
404 414
@@ -443,9 +453,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
443 // context was cancelled. A queued sender error takes priority.453 // context was cancelled. A queued sender error takes priority.
444 select {454 select {
445 case serr := <-sendErrCh:455 case serr := <-sendErrCh:
446- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: sending to daemon: %w", serr)}456+ return welcomedResult(fmt.Errorf("vev: sending to daemon: %w", serr))
447 default:457 default:
448- return attachResult{resumeToken: resumeToken, welcomed: true}458+ return welcomedResult(nil)
449 }459 }
450 case <-colorQueryCh:460 case <-colorQueryCh:
451 if err := term.QueryColors(); err != nil {461 if err := term.QueryColors(); err != nil {
@@ -454,21 +464,21 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
454 case r := <-recvCh:464 case r := <-recvCh:
455 if r.err != nil {465 if r.err != nil {
456 if errors.Is(r.err, io.EOF) {466 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)}467+ return welcomedResult(fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err))
458 }468 }
459- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: receiving from daemon: %w", r.err)}469+ return welcomedResult(fmt.Errorf("vev: receiving from daemon: %w", r.err))
460 }470 }
461 switch r.frame.Type {471 switch r.frame.Type {
462 case ports.MsgOutput:472 case ports.MsgOutput:
463 o, derr := ports.UnmarshalOutput(r.frame.Payload)473 o, derr := ports.UnmarshalOutput(r.frame.Payload)
464 if derr != nil {474 if derr != nil {
465- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding output: %w", derr)}475+ return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
466 }476 }
467 if _, werr := term.Out().Write(o.Data); werr != nil {477 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)}478+ return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
469 }479 }
470 if ferr := term.Flush(); ferr != nil {480 if ferr := term.Flush(); ferr != nil {
471- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}481+ return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
472 }482 }
473 if !ms.firstOutput {483 if !ms.firstOutput {
474 ms.firstOutput = true484 ms.firstOutput = true
@@ -477,7 +487,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
477 case ports.MsgDetached:487 case ports.MsgDetached:
478 d, derr := ports.UnmarshalDetached(r.frame.Payload)488 d, derr := ports.UnmarshalDetached(r.frame.Payload)
479 if derr != nil {489 if derr != nil {
480- return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)}490+ return welcomedResult(fmt.Errorf("vev: decoding detached: %w", derr))
481 }491 }
482 ms.detached = true492 ms.detached = true
483 if d.Reason == ports.ReasonDetach {493 if d.Reason == ports.ReasonDetach {
@@ -485,7 +495,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
485 } else {495 } else {
486 log.Warn("detached by daemon", "reason", d.Reason)496 log.Warn("detached by daemon", "reason", d.Reason)
487 }497 }
488- return attachResult{resumeToken: resumeToken, welcomed: true, err: detachedResult(d.Reason)}498+ return welcomedResult(detachedResult(d.Reason))
489 case ports.MsgPong:499 case ports.MsgPong:
490 // Liveness reply; nothing to do.500 // Liveness reply; nothing to do.
491 default:501 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 +5 -16
@@ -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,29 +562,22 @@ 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
575570 }
576571 if sess.detachIfCurrent(ac) {
577572 d.unregisterPreview(ac)
578- d.resetScreenDefaultColors(sess)
579573 if d.parkAttachment(sess, ac) {
580574 _ = ac.closeCapturedTransport(failed)
581575 d.log.Warn("parked client after send error", "session", sess.name)
582576 return
583577 }
578+ d.resetScreenDefaultColors(sess)
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,29 +562,22 @@ 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
575 }570 }
576 if sess.detachIfCurrent(ac) {571 if sess.detachIfCurrent(ac) {
577 d.unregisterPreview(ac)572 d.unregisterPreview(ac)
578- d.resetScreenDefaultColors(sess)
579 if d.parkAttachment(sess, ac) {573 if d.parkAttachment(sess, ac) {
580 _ = ac.closeCapturedTransport(failed)574 _ = ac.closeCapturedTransport(failed)
581 d.log.Warn("parked client after send error", "session", sess.name)575 d.log.Warn("parked client after send error", "session", sess.name)
582 return576 return
583 }577 }
578+ d.resetScreenDefaultColors(sess)
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 +7 -5
@@ -15,7 +15,9 @@
1515 // scheduler).
1616 // - The daemon exits (Serve returns) when the last session is removed, or
1717 // when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
18+// attached clients with ReasonServerShutdown). Client detach does not end
19+// a session: ephemeral sessions stay in memory until explicitly killed or
20+// until the daemon exits, and named sessions can also persist on disk.
1921 //
2022 // Locking: a pane's screen/scrollback and per-client renderer shadow are
2123 // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
@@ -481,9 +483,9 @@ func (d *Daemon) handleList(tr ports.Transport) {
481483 _ = tr.Send(frameSessions(infos))
482484 }
483485
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.
486+// handleKill terminates the requested live session or stopped named session,
487+// or all sessions, and closes the control connection; the resulting EOF is the
488+// client's success signal.
487489 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
488490 defer func() { _ = tr.Close() }()
489491
@@ -648,7 +650,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
648650 return nil, nil, err
649651 }
650652 d.purgeParkedForSessionLocked(sess)
651- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID})
653+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
652654 sess.mu.Lock()
653655 sess.terminal = term
654656 sess.mu.Unlock()
@@ -15,7 +15,9 @@
15 // scheduler).15 // scheduler).
16 // - The daemon exits (Serve returns) when the last session is removed, or16 // - The daemon exits (Serve returns) when the last session is removed, or
17 // when the parent context is cancelled (graceful shutdown notifies any17 // when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).18+// attached clients with ReasonServerShutdown). Client detach does not end
19+// a session: ephemeral sessions stay in memory until explicitly killed or
20+// until the daemon exits, and named sessions can also persist on disk.
19 //21 //
20 // Locking: a pane's screen/scrollback and per-client renderer shadow are22 // Locking: a pane's screen/scrollback and per-client renderer shadow are
21 // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by23 // guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
@@ -481,9 +483,9 @@ func (d *Daemon) handleList(tr ports.Transport) {
481 _ = tr.Send(frameSessions(infos))483 _ = tr.Send(frameSessions(infos))
482 }484 }
483 485
484-// handleKill terminates the named session (if any), or all sessions, and486+// handleKill terminates the requested live session or stopped named session,
485-// closes the control connection; the resulting EOF is the client's success487+// or all sessions, and closes the control connection; the resulting EOF is the
486-// signal.488+// client's success signal.
487 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {489 func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
488 defer func() { _ = tr.Close() }()490 defer func() { _ = tr.Close() }()
489 491
@@ -648,7 +650,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
648 return nil, nil, err650 return nil, nil, err
649 }651 }
650 d.purgeParkedForSessionLocked(sess)652 d.purgeParkedForSessionLocked(sess)
651- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID})653+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
652 sess.mu.Lock()654 sess.mu.Lock()
653 sess.terminal = term655 sess.terminal = term
654 sess.mu.Unlock()656 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
M internal/usecase/daemon/status_test.go +19 -0
@@ -363,6 +363,25 @@ func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
363363 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
364364 }
365365
366+func TestDetachOnSendErrorParkPreservesScreenDefaultColors(t *testing.T) {
367+ p, release := newBlockingPTY(t)
368+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
369+ defer release()
370+ ac.resumeCapable = true
371+ d.applyTheme(sess, ac, ports.Theme{
372+ HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
373+ HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
374+ })
375+
376+ d.detachOnSendError(sess, ac, ac.transport())
377+
378+ tb := sess.activeTab()
379+ var got []byte
380+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
381+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
382+ require.NotEmpty(t, got, "parked clients resume the same attachment, so screen default colors must be preserved")
383+}
384+
366385 func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
367386 p, release := newBlockingPTY(t)
368387 d, sess, old, _ := newManualSessionWithPTYs(t, p)
@@ -363,6 +363,25 @@ func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
363 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")363 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
364 }364 }
365 365
366+func TestDetachOnSendErrorParkPreservesScreenDefaultColors(t *testing.T) {
367+ p, release := newBlockingPTY(t)
368+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
369+ defer release()
370+ ac.resumeCapable = true
371+ d.applyTheme(sess, ac, ports.Theme{
372+ HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
373+ HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
374+ })
375+
376+ d.detachOnSendError(sess, ac, ac.transport())
377+
378+ tb := sess.activeTab()
379+ var got []byte
380+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
381+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
382+ require.NotEmpty(t, got, "parked clients resume the same attachment, so screen default colors must be preserved")
383+}
384+
366 func TestApplyThemeIgnoresReplacedClient(t *testing.T) {385 func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
367 p, release := newBlockingPTY(t)386 p, release := newBlockingPTY(t)
368 d, sess, old, _ := newManualSessionWithPTYs(t, p)387 d, sess, old, _ := newManualSessionWithPTYs(t, p)

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