refactor(sessions): retain ephemeral sessions in daemon
- commit
- 16ed5a6ec6e15aaccf8184f12470be2c76e6e81b
- author
- brice · · 2026-07-07 05:00 UTC
- parent
- bf22b36
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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/ | |||
| 67 | 67 | ||
| 68 | 68 | ## Session flow | |
| 69 | 69 | ||
| 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. | |
| 72 | 72 | - The daemon starts on first use and exits when the last session ends. | |
| 73 | 73 | - Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames. | |
| 74 | 74 | - 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 flow | 68 | ## 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 | |||
| 26 | 26 | ```text | |
| 27 | 27 | vev attach to (or create) an ephemeral session | |
| 28 | 28 | 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 | 30 | vev attach user@host[:session] attach to a remote vev daemon over SSH | |
| 31 | 31 | vev ls list sessions (alias: list) | |
| 32 | - | vev kill <name> kill a named session | |
| 32 | + | vev kill <name> kill a session | |
| 33 | 33 | vev kill -- <name> kill a name that starts with - | |
| 34 | 34 | vev kill --all kill all sessions and stop the daemon | |
| 35 | 35 | vev kill --daemon stop the active vev daemon | |
| @@ -37,7 +37,7 @@ vev --help show help (aliases: -h, help) | |||
| 37 | 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 | 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 | 120 | 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 | 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 | ||
| @@ -26,10 +26,10 @@ go build -o vev . # local binary | |||||
| 26 | ```text | 26 | ```text | ||
| 27 | vev attach to (or create) an ephemeral session | 27 | vev attach to (or create) an ephemeral session | ||
| 28 | vev new <name> create and attach to a named session | 28 | 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 SSH | 30 | 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 session | 32 | + | 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 daemon | 34 | vev kill --all kill all sessions and stop the daemon | ||
| 35 | vev kill --daemon stop the active vev daemon | 35 | 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:session | 120 | 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 { | |||
| 34 | 34 | // The returned served channel receives Serve's result exactly once. | |
| 35 | 35 | func startDaemon(t *testing.T, opts ...daemon.Option) (dir string, served <-chan error) { | |
| 36 | 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 | 42 | ln, err := ipc.Listen(dir) | |
| 39 | 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 | 87 | 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 | 120 | // awaitText decodes MsgOutput frames into a fresh VT screen and returns once | |
| 87 | 121 | // the reconstructed grid contains want. | |
| 88 | 122 | func awaitText(t *testing.T, p *pump, sz domain.Size, want string) { | |
| @@ -236,22 +270,63 @@ func TestIntegration_AltCWithoutPaletteDoesNotCreateTab(t *testing.T) { | |||
| 236 | 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 | 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) | |
| 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()) | |
| 245 | 280 | ||
| 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") | |
| 248 | 310 | require.NoError(t, tr.Close()) | |
| 249 | 311 | ||
| 312 | + | require.NoError(t, runKill(context.Background(), "", false, true)) | |
| 250 | 313 | select { | |
| 251 | 314 | case err := <-served: | |
| 252 | 315 | require.NoError(t, err) | |
| 253 | 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 | ||
| @@ -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, p | 87 | 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 once | 120 | // 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, which | 281 | + | 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 | |||
| 89 | 89 | usage: | |
| 90 | 90 | vev attach to (or create) an ephemeral session | |
| 91 | 91 | 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 | 93 | vev attach user@host[:session] | |
| 94 | 94 | attach through SSH to a remote vev daemon | |
| 95 | 95 | vev ls list sessions | |
| 96 | - | vev kill <name> kill a named session | |
| 96 | + | vev kill <name> kill a session | |
| 97 | 97 | vev kill --all kill all sessions and stop the daemon | |
| 98 | 98 | vev kill --daemon stop the active vev daemon | |
| 99 | 99 | vev --help show this help | |
| @@ -941,7 +941,10 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||
| 941 | 941 | if s.Stopped { | |
| 942 | 942 | state = "stopped" | |
| 943 | 943 | tabs = "-" | |
| 944 | - | } else if s.Attached { | |
| 944 | + | } else if s.Ephemeral { | |
| 945 | + | state = "temporary" | |
| 946 | + | } | |
| 947 | + | if s.Attached { | |
| 945 | 948 | attached = "yes" | |
| 946 | 949 | } | |
| 947 | 950 | _, _ = 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 session | 90 | vev attach to (or create) an ephemeral session | ||
| 91 | vev new <name> create and attach to a named session | 91 | 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 daemon | 94 | attach through SSH to a remote vev daemon | ||
| 95 | vev ls list sessions | 95 | vev ls list sessions | ||
| 96 | - | vev kill <name> kill a named session | 96 | + | vev kill <name> kill a session |
| 97 | vev kill --all kill all sessions and stop the daemon | 97 | vev kill --all kill all sessions and stop the daemon | ||
| 98 | vev kill --daemon stop the active vev daemon | 98 | vev kill --daemon stop the active vev daemon | ||
| 99 | vev --help show this help | 99 | 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) { | |||
| 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 | 152 | func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) { | |
| 140 | 153 | stateRoot, runtimeRoot := t.TempDir(), t.TempDir() | |
| 141 | 154 | 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 | |||
| 164 | 164 | ||
| 165 | 165 | resumeToken := uint64(0) | |
| 166 | 166 | attemptIntent := intent | |
| 167 | + | attemptName := name | |
| 167 | 168 | backoff := defaultReconnectBackoff.initial | |
| 168 | 169 | showingStatus := false | |
| 169 | 170 | var reconnectToastSize domain.Size | |
| @@ -233,7 +234,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 233 | 234 | } | |
| 234 | 235 | 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 | 238 | if result.welcomed { | |
| 238 | 239 | backoff = defaultReconnectBackoff.initial | |
| 239 | 240 | } | |
| @@ -241,6 +242,9 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 241 | 242 | if result.resumeToken != 0 { | |
| 242 | 243 | resumeToken = result.resumeToken | |
| 243 | 244 | } | |
| 245 | + | if result.sessionName != "" { | |
| 246 | + | attemptName = result.sessionName | |
| 247 | + | } | |
| 244 | 248 | if result.err == nil { | |
| 245 | 249 | clearStatus() | |
| 246 | 250 | return nil | |
| @@ -328,6 +332,7 @@ func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 328 | 332 | ||
| 329 | 333 | type attachResult struct { | |
| 330 | 334 | resumeToken uint64 | |
| 335 | + | sessionName string | |
| 331 | 336 | welcomed bool | |
| 332 | 337 | err error | |
| 333 | 338 | } | |
| @@ -384,6 +389,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 384 | 389 | return attachResult{err: fmt.Errorf("vev: decoding welcome: %w", derr)} | |
| 385 | 390 | } | |
| 386 | 391 | resumeToken = welcome.ResumeToken | |
| 392 | + | name = welcome.SessionName | |
| 387 | 393 | ms.welcomed = true | |
| 388 | 394 | log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0) | |
| 389 | 395 | case ports.MsgError: | |
| @@ -391,14 +397,14 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 391 | 397 | if derr != nil { | |
| 392 | 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 | 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 | 405 | // 3. Enter raw mode after Welcome; Run owns restoration. | |
| 400 | 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 | 409 | clearStatus() | |
| 404 | 410 | ||
| @@ -443,9 +449,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 443 | 449 | // context was cancelled. A queued sender error takes priority. | |
| 444 | 450 | select { | |
| 445 | 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 | 453 | default: | |
| 448 | - | return attachResult{resumeToken: resumeToken, welcomed: true} | |
| 454 | + | return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: true} | |
| 449 | 455 | } | |
| 450 | 456 | case <-colorQueryCh: | |
| 451 | 457 | if err := term.QueryColors(); err != nil { | |
| @@ -454,21 +460,21 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 454 | 460 | case r := <-recvCh: | |
| 455 | 461 | if r.err != nil { | |
| 456 | 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 | 467 | switch r.frame.Type { | |
| 462 | 468 | case ports.MsgOutput: | |
| 463 | 469 | o, derr := ports.UnmarshalOutput(r.frame.Payload) | |
| 464 | 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 | 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 | 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 | 479 | if !ms.firstOutput { | |
| 474 | 480 | ms.firstOutput = true | |
| @@ -477,7 +483,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 477 | 483 | case ports.MsgDetached: | |
| 478 | 484 | d, derr := ports.UnmarshalDetached(r.frame.Payload) | |
| 479 | 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 | 488 | ms.detached = true | |
| 483 | 489 | if d.Reason == ports.ReasonDetach { | |
| @@ -485,7 +491,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 485 | 491 | } else { | |
| 486 | 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 | 495 | case ports.MsgPong: | |
| 490 | 496 | // Liveness reply; nothing to do. | |
| 491 | 497 | 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 := intent | 166 | attemptIntent := intent | ||
| 167 | + | attemptName := name | |||
| 167 | backoff := defaultReconnectBackoff.initial | 168 | backoff := defaultReconnectBackoff.initial | ||
| 168 | showingStatus := false | 169 | showingStatus := false | ||
| 169 | var reconnectToastSize domain.Size | 170 | 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 = true | 235 | 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.initial | 239 | 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.resumeToken | 243 | 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 nil | 250 | 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 uint64 | 334 | resumeToken uint64 | ||
| 335 | + | sessionName string | |||
| 331 | welcomed bool | 336 | welcomed bool | ||
| 332 | err error | 337 | 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.ResumeToken | 391 | resumeToken = welcome.ResumeToken | ||
| 392 | + | name = welcome.SessionName | |||
| 387 | ms.welcomed = true | 393 | 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 = true | 480 | 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 = true | 488 | 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 | |||
| 52 | 52 | func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() } | |
| 53 | 53 | ||
| 54 | 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 | 62 | func reconnectToastDetach(reason uint8) ports.Frame { | |
| @@ -229,6 +233,47 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |||
| 229 | 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 | 277 | func TestRemoteReconnectToastLifecycle(t *testing.T) { | |
| 233 | 278 | tests := []struct { | |
| 234 | 279 | 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 string | 279 | name string | ||
M internal/usecase/daemon/client.go +4 -15
| @@ -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. 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. | |
| 479 | 478 | func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) { | |
| 480 | 479 | if failed != nil && !ac.currentTransportIs(failed) { | |
| 481 | 480 | 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 | 510 | _ = ac.closeCapturedTransport(oldTr) | |
| 512 | 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 | 514 | // 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 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. | |
| 572 | 567 | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) { | |
| 573 | 568 | if failed != nil && !ac.currentTransportIs(failed) { | |
| 574 | 569 | return | |
| @@ -583,12 +578,6 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por | |||
| 583 | 578 | } | |
| 584 | 579 | _ = ac.closeCapturedTransport(failed) | |
| 585 | 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 | ||
| @@ -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. An | 476 | + | // 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. When | 477 | + | // 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 client | 480 | 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, reporting | 514 | // 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 other | 565 | + | // detachOnSendError drops a client whose transport failed, leaving the session |
| 570 | - | // detach path, losing the client kills an ephemeral session (its lifetime is | 566 | + | // 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 | return | 569 | 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) { | |||
| 481 | 481 | _ = tr.Send(frameSessions(infos)) | |
| 482 | 482 | } | |
| 483 | 483 | ||
| 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. | |
| 487 | 487 | func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |
| 488 | 488 | defer func() { _ = tr.Close() }() | |
| 489 | 489 | ||
| @@ -648,7 +648,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 648 | 648 | return nil, nil, err | |
| 649 | 649 | } | |
| 650 | 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 | 652 | sess.mu.Lock() | |
| 653 | 653 | sess.terminal = term | |
| 654 | 654 | 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, and | 484 | + | // handleKill terminates the requested live session or stopped named session, |
| 485 | - | // closes the control connection; the resulting EOF is the client's success | 485 | + | // 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, err | 648 | 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 = term | 653 | 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) { | |||
| 652 | 652 | ||
| 653 | 653 | // --- reader EOF -> registry-empty shutdown ---------------------------------- | |
| 654 | 654 | ||
| 655 | - | func TestSendErrorKillsEphemeral(t *testing.T) { | |
| 655 | + | func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |
| 656 | 656 | p := portsmocks.NewMockPTY(t) | |
| 657 | 657 | p.EXPECT().Close().Return(nil).Maybe() | |
| 658 | 658 | ||
| @@ -673,9 +673,15 @@ func TestSendErrorKillsEphemeral(t *testing.T) { | |||
| 673 | 673 | win.focusedPane().screen.Write([]byte("x")) | |
| 674 | 674 | win.mu.Unlock() | |
| 675 | 675 | ||
| 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() | |
| 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 | 685 | d.waitNotifies() | |
| 680 | 686 | } | |
| 681 | 687 | ||
| @@ -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 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() | |||
| 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 { | |||
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | 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 { | |
| 36 | 33 | return false | |
| 37 | 34 | } | |
| 38 | 35 | 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 false | 33 | 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 { | |||
| 57 | 57 | } | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | - | func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) { | |
| 60 | + | func TestNamedLinkLossParks(t *testing.T) { | |
| 61 | 61 | pty, release := newBlockingPTY(t) | |
| 62 | 62 | defer release() | |
| 63 | 63 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| @@ -75,15 +75,35 @@ func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) { | |||
| 75 | 75 | _, parked := d.parked[token] | |
| 76 | 76 | d.mu.Unlock() | |
| 77 | 77 | require.True(t, parked, "named resume-capable link loss is parked") | |
| 78 | + | } | |
| 78 | 79 | ||
| 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) | |
| 84 | 102 | 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) | |
| 87 | 107 | } | |
| 88 | 108 | ||
| 89 | 109 | func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) { | |
| @@ -210,6 +230,35 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) { | |||
| 210 | 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 | 262 | func TestKilledSessionPurgesParkedResumeToken(t *testing.T) { | |
| 214 | 263 | pty, release := newBlockingPTY(t) | |
| 215 | 264 | 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) { | |||
| 288 | 288 | ||
| 289 | 289 | // --- detach semantics ------------------------------------------------------- | |
| 290 | 290 | ||
| 291 | - | func TestDetachKillsEphemeral(t *testing.T) { | |
| 291 | + | func TestDetachKeepsEphemeralHeadless(t *testing.T) { | |
| 292 | 292 | p, releasePTY := newBlockingPTY(t) | |
| 293 | 293 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 294 | 294 | tr, sends, _ := newConn(t, | |
| @@ -299,13 +299,42 @@ func TestDetachKillsEphemeral(t *testing.T) { | |||
| 299 | 299 | var hg sync.WaitGroup | |
| 300 | 300 | hg.Go(func() { d.handleConn(tr) }) | |
| 301 | 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, | |
| 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() | |
| 305 | 312 | ||
| 313 | + | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| 306 | 314 | releasePTY() | |
| 307 | - | hg.Wait() | |
| 308 | 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 | 340 | func TestDetachKeepsNamed(t *testing.T) { | |
| @@ -580,11 +609,6 @@ func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) { | |||
| 580 | 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 | 612 | type mockStoreState struct { | |
| 589 | 613 | mu sync.Mutex | |
| 590 | 614 | 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.WaitGroup | 299 | 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.Mutex | 613 | mu sync.Mutex | ||
| 590 | data map[string][]byte | 614 | data map[string][]byte | ||