detached bb0d730

fix(transport): apply session-neutral connection review fixes

commit
bb0d730c838e160ad5291d864bcabfb76a596adb
author
brice · · 2026-08-04 09:23 UTC
parent
68ba351
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
28 files · +325 -147
M AGENTS.md +1 -1
@@ -75,5 +75,5 @@ Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/
7575 - Named sessions survive headless and persist across daemon restarts.
7676 - The daemon starts on first use and exits when the last session ends.
7777 - Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.
78-- Remote attach runs `ssh -- host vev _stdio [session]` and proxies the same protocol over stdio.
78+- Remote attach runs `ssh -- host vev _stdio` and proxies the same protocol over stdio; session selection is carried in `Hello`.
7979 - Only one client may attach to a session; a new attach replaces the old client.
@@ -75,5 +75,5 @@ Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/
75 - Named sessions survive headless and persist across daemon restarts.75 - Named sessions survive headless and persist across daemon restarts.
76 - The daemon starts on first use and exits when the last session ends.76 - The daemon starts on first use and exits when the last session ends.
77 - Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.77 - Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.
78-- Remote attach runs `ssh -- host vev _stdio [session]` and proxies the same protocol over stdio.78+- Remote attach runs `ssh -- host vev _stdio` and proxies the same protocol over stdio; session selection is carried in `Hello`.
79 - Only one client may attach to a session; a new attach replaces the old client.79 - Only one client may attach to a session; a new attach replaces the old client.
M cmd/vev-perf-harness/evidence_test.go +1 -1
@@ -44,7 +44,7 @@ func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) {
4444 if err := os.WriteFile(m.TracePath, nil, 0o600); err != nil {
4545 t.Fatal(err)
4646 }
47- p, err := l.preparePeer(m, roleCommand{Args: []string{"_udp-proxy", "work"}, Transport: tc.tr})
47+ p, err := l.preparePeer(m, roleCommand{Args: []string{"_udp-proxy"}, Transport: tc.tr})
4848 if err != nil {
4949 t.Fatal(err)
5050 }
@@ -44,7 +44,7 @@ func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) {
44 if err := os.WriteFile(m.TracePath, nil, 0o600); err != nil {44 if err := os.WriteFile(m.TracePath, nil, 0o600); err != nil {
45 t.Fatal(err)45 t.Fatal(err)
46 }46 }
47- p, err := l.preparePeer(m, roleCommand{Args: []string{"_udp-proxy", "work"}, Transport: tc.tr})47+ p, err := l.preparePeer(m, roleCommand{Args: []string{"_udp-proxy"}, Transport: tc.tr})
48 if err != nil {48 if err != nil {
49 t.Fatal(err)49 t.Fatal(err)
50 }50 }
M cmd/vev-perf-harness/hangup_test.go +2 -3
@@ -185,9 +185,8 @@ func TestClientGracefulShutdownDrainsTracedStdioDescendant(t *testing.T) {
185185 case "daemon":
186186 command = roleCommand{Args: []string{"--daemon"}}
187187 case "ssh_stdio_peer":
188- // An empty remote session requests an ephemeral session, avoiding a
189- // separate bootstrap client while still executing the traced _stdio mode.
190- command = roleCommand{Args: []string{"_stdio", ""}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}}
188+ // The transport-neutral _stdio mode requests an ephemeral session.
189+ command = roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}}
191190 case "client":
192191 command = roleCommand{Args: []string{"attach", "harness@127.0.0.1"}}
193192 }
@@ -185,9 +185,8 @@ func TestClientGracefulShutdownDrainsTracedStdioDescendant(t *testing.T) {
185 case "daemon":185 case "daemon":
186 command = roleCommand{Args: []string{"--daemon"}}186 command = roleCommand{Args: []string{"--daemon"}}
187 case "ssh_stdio_peer":187 case "ssh_stdio_peer":
188- // An empty remote session requests an ephemeral session, avoiding a188+ // The transport-neutral _stdio mode requests an ephemeral session.
189- // separate bootstrap client while still executing the traced _stdio mode.189+ command = roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}}
190- command = roleCommand{Args: []string{"_stdio", ""}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}}
191 case "client":190 case "client":
192 command = roleCommand{Args: []string{"attach", "harness@127.0.0.1"}}191 command = roleCommand{Args: []string{"attach", "harness@127.0.0.1"}}
193 }192 }
M cmd/vev-perf-harness/launcher.go +11 -11
@@ -332,7 +332,7 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP
332332 if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
333333 return nil, fail(err)
334334 }
335- if len(role.Args) != 2 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") {
335+ if len(role.Args) != 1 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") {
336336 return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args))
337337 }
338338 route := peerRoute{mapping: m, command: role}
@@ -373,27 +373,27 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP
373373 set -eu
374374 case "$*" in
375375 *"_stdio"*)
376- exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[12]q VEV_PERF_RUN=%[13]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio %[6]q
376+ exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[11]q VEV_PERF_RUN=%[12]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio
377377 ;;
378378 *"_udp-bootstrap"*)
379- rm -f %[7]q %[8]q %[9]q
380- env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[12]q VEV_PERF_RUN=%[13]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _udp-proxy %[6]q >%[7]q 2>%[8]q &
379+ rm -f %[6]q %[7]q %[8]q
380+ env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[11]q VEV_PERF_RUN=%[12]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _udp-proxy >%[6]q 2>%[7]q &
381381 peer=$!
382- printf '%%s\n' "$peer" > %[11]q
382+ printf '%%s\n' "$peer" > %[10]q
383383 i=0
384- while [ ! -s %[7]q ] && [ "$i" -lt 1000 ]; do sleep 0.01; i=$((i+1)); done
385- test -s %[7]q
386- line=$(head -n 1 %[7]q)
387- printf '%%s\n' "$line" > %[9]q
384+ while [ ! -s %[6]q ] && [ "$i" -lt 1000 ]; do sleep 0.01; i=$((i+1)); done
385+ test -s %[6]q
386+ line=$(head -n 1 %[6]q)
387+ printf '%%s\n' "$line" > %[8]q
388388 set -- $line
389389 test "$1" = VEV-UDP
390390 test -n "$3"
391- printf 'VEV-UDP %%s %%s\n' %[10]d "$3"
391+ printf 'VEV-UDP %%s %%s\n' %[9]d "$3"
392392 exit 0
393393 ;;
394394 *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;
395395 esac
396-`, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, role.Args[1], ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run)
396+`, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run)
397397 if err := os.WriteFile(shim, []byte(body), 0o700); err != nil {
398398 return nil, closeNetem(err)
399399 }
@@ -332,7 +332,7 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP
332 if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {332 if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
333 return nil, fail(err)333 return nil, fail(err)
334 }334 }
335- if len(role.Args) != 2 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") {335+ if len(role.Args) != 1 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") {
336 return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args))336 return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args))
337 }337 }
338 route := peerRoute{mapping: m, command: role}338 route := peerRoute{mapping: m, command: role}
@@ -373,27 +373,27 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP
373 set -eu373 set -eu
374 case "$*" in374 case "$*" in
375 *"_stdio"*)375 *"_stdio"*)
376- exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[12]q VEV_PERF_RUN=%[13]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio %[6]q376+ exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[11]q VEV_PERF_RUN=%[12]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio
377 ;;377 ;;
378 *"_udp-bootstrap"*)378 *"_udp-bootstrap"*)
379- rm -f %[7]q %[8]q %[9]q379+ rm -f %[6]q %[7]q %[8]q
380- env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[12]q VEV_PERF_RUN=%[13]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _udp-proxy %[6]q >%[7]q 2>%[8]q &380+ env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[11]q VEV_PERF_RUN=%[12]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _udp-proxy >%[6]q 2>%[7]q &
381 peer=$!381 peer=$!
382- printf '%%s\n' "$peer" > %[11]q382+ printf '%%s\n' "$peer" > %[10]q
383 i=0383 i=0
384- while [ ! -s %[7]q ] && [ "$i" -lt 1000 ]; do sleep 0.01; i=$((i+1)); done384+ while [ ! -s %[6]q ] && [ "$i" -lt 1000 ]; do sleep 0.01; i=$((i+1)); done
385- test -s %[7]q385+ test -s %[6]q
386- line=$(head -n 1 %[7]q)386+ line=$(head -n 1 %[6]q)
387- printf '%%s\n' "$line" > %[9]q387+ printf '%%s\n' "$line" > %[8]q
388 set -- $line388 set -- $line
389 test "$1" = VEV-UDP389 test "$1" = VEV-UDP
390 test -n "$3"390 test -n "$3"
391- printf 'VEV-UDP %%s %%s\n' %[10]d "$3"391+ printf 'VEV-UDP %%s %%s\n' %[9]d "$3"
392 exit 0392 exit 0
393 ;;393 ;;
394 *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;394 *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;
395 esac395 esac
396-`, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, role.Args[1], ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run)396+`, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run)
397 if err := os.WriteFile(shim, []byte(body), 0o700); err != nil {397 if err := os.WriteFile(shim, []byte(body), 0o700); err != nil {
398 return nil, closeNetem(err)398 return nil, closeNetem(err)
399 }399 }
M cmd/vev-perf-harness/launcher_test.go +2 -2
@@ -614,8 +614,8 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
614614 }{
615615 {"daemon", []string{"--daemon"}},
616616 {"client", []string{"new", "perf-s-001"}},
617- {"ssh_stdio_peer", []string{"_stdio", "perf-s-001"}},
618- {"udp_peer", []string{"_udp-proxy", "perf-s-001"}},
617+ {"ssh_stdio_peer", []string{"_stdio"}},
618+ {"udp_peer", []string{"_udp-proxy"}},
619619 } {
620620 t.Run(tc.role, func(t *testing.T) {
621621 got := roleArgs(scenario{ID: "s", Transport: "local"}, processMapping{Role: tc.role, Run: 1})
@@ -614,8 +614,8 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
614 }{614 }{
615 {"daemon", []string{"--daemon"}},615 {"daemon", []string{"--daemon"}},
616 {"client", []string{"new", "perf-s-001"}},616 {"client", []string{"new", "perf-s-001"}},
617- {"ssh_stdio_peer", []string{"_stdio", "perf-s-001"}},617+ {"ssh_stdio_peer", []string{"_stdio"}},
618- {"udp_peer", []string{"_udp-proxy", "perf-s-001"}},618+ {"udp_peer", []string{"_udp-proxy"}},
619 } {619 } {
620 t.Run(tc.role, func(t *testing.T) {620 t.Run(tc.role, func(t *testing.T) {
621 got := roleArgs(scenario{ID: "s", Transport: "local"}, processMapping{Role: tc.role, Run: 1})621 got := roleArgs(scenario{ID: "s", Transport: "local"}, processMapping{Role: tc.role, Run: 1})
M cmd/vev-perf-harness/runner.go +2 -2
@@ -396,9 +396,9 @@ func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand
396396 return roleCommand{Args: remote}
397397 }
398398 case "ssh_stdio_peer":
399- return roleCommand{Args: []string{"_stdio", session}, Transport: transport{ID: s.Transport, Kind: "ssh_stdio"}}
399+ return roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: s.Transport, Kind: "ssh_stdio"}}
400400 case "udp_peer":
401- return roleCommand{Args: []string{"_udp-proxy", session}, Transport: selected}
401+ return roleCommand{Args: []string{"_udp-proxy"}, Transport: selected}
402402 }
403403 return roleCommand{}
404404 }
@@ -396,9 +396,9 @@ func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand
396 return roleCommand{Args: remote}396 return roleCommand{Args: remote}
397 }397 }
398 case "ssh_stdio_peer":398 case "ssh_stdio_peer":
399- return roleCommand{Args: []string{"_stdio", session}, Transport: transport{ID: s.Transport, Kind: "ssh_stdio"}}399+ return roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: s.Transport, Kind: "ssh_stdio"}}
400 case "udp_peer":400 case "udp_peer":
401- return roleCommand{Args: []string{"_udp-proxy", session}, Transport: selected}401+ return roleCommand{Args: []string{"_udp-proxy"}, Transport: selected}
402 }402 }
403 return roleCommand{}403 return roleCommand{}
404 }404 }
M cmd/vev-perf-harness/runner_test.go +2 -2
@@ -256,10 +256,10 @@ func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) {
256256 if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1"}) {
257257 t.Fatalf("client did not use ephemeral public remote attach: %q", client.Args)
258258 }
259- if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio", "perf-route-002"}) {
259+ if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio"}) {
260260 t.Fatalf("ssh peer command=%q", peer.Args)
261261 }
262- if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy", "perf-route-002"}) {
262+ if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy"}) {
263263 t.Fatalf("udp peer command=%q", peer.Args)
264264 }
265265 if peer.Transport.RTTMS != tc.wantRTT || peer.Transport.LossPercent != tc.wantLoss {
@@ -256,10 +256,10 @@ func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) {
256 if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1"}) {256 if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1"}) {
257 t.Fatalf("client did not use ephemeral public remote attach: %q", client.Args)257 t.Fatalf("client did not use ephemeral public remote attach: %q", client.Args)
258 }258 }
259- if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio", "perf-route-002"}) {259+ if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio"}) {
260 t.Fatalf("ssh peer command=%q", peer.Args)260 t.Fatalf("ssh peer command=%q", peer.Args)
261 }261 }
262- if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy", "perf-route-002"}) {262+ if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy"}) {
263 t.Fatalf("udp peer command=%q", peer.Args)263 t.Fatalf("udp peer command=%q", peer.Args)
264 }264 }
265 if peer.Transport.RTTMS != tc.wantRTT || peer.Transport.LossPercent != tc.wantLoss {265 if peer.Transport.RTTMS != tc.wantRTT || peer.Transport.LossPercent != tc.wantLoss {
M internal/adapters/dgram/ssh_dialer.go +6 -7
@@ -39,7 +39,7 @@ func (p execBootstrapProcess) Kill() error { return p.cmd
3939 func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }
4040
4141 var (
42- startUDPBootstrap = func(ctx context.Context, target, session string, stderr io.Writer) bootstrapProcess {
42+ startUDPBootstrap = func(ctx context.Context, target string, stderr io.Writer) bootstrapProcess {
4343 spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", "")
4444 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
4545 cmd.Stderr = stderr
@@ -98,19 +98,18 @@ func (b *limitedBuffer) String() string { return string(b.buf) }
9898 // attach. SSH stdio is selected only through the explicit stdio transport mode.
9999 type RemoteDialer struct {
100100 Target string
101- Session string
102101 BootstrapTimeout time.Duration
103102 ProbeTimeout time.Duration
104103 Log *slog.Logger
105104 RuntimeObserver ports.SerializedRuntimeObserver
106105 }
107106
108-func NewRemoteDialer(target, session string) RemoteDialer {
109- return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout}
107+func NewRemoteDialer(target, _ string) RemoteDialer {
108+ return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout}
110109 }
111110
112-func NewRemoteDialerWithLogger(target, session string, log *slog.Logger) RemoteDialer {
113- return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout, Log: log}
111+func NewRemoteDialerWithLogger(target, _ string, log *slog.Logger) RemoteDialer {
112+ return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout, Log: log}
114113 }
115114
116115 func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
@@ -129,7 +128,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
129128 defer bootstrapCancel()
130129
131130 var stderr limitedBuffer
132- proc := startUDPBootstrap(bootstrapCtx, d.Target, "", &stderr)
131+ proc := startUDPBootstrap(bootstrapCtx, d.Target, &stderr)
133132 stdout, err := proc.StdoutPipe()
134133 if err != nil {
135134 return nil, udpUnavailable("bootstrap stdout", err, &stderr)
@@ -39,7 +39,7 @@ func (p execBootstrapProcess) Kill() error { return p.cmd
39 func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }39 func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }
40 40
41 var (41 var (
42- startUDPBootstrap = func(ctx context.Context, target, session string, stderr io.Writer) bootstrapProcess {42+ startUDPBootstrap = func(ctx context.Context, target string, stderr io.Writer) bootstrapProcess {
43 spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", "")43 spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", "")
44 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)44 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
45 cmd.Stderr = stderr45 cmd.Stderr = stderr
@@ -98,19 +98,18 @@ func (b *limitedBuffer) String() string { return string(b.buf) }
98 // attach. SSH stdio is selected only through the explicit stdio transport mode.98 // attach. SSH stdio is selected only through the explicit stdio transport mode.
99 type RemoteDialer struct {99 type RemoteDialer struct {
100 Target string100 Target string
101- Session string
102 BootstrapTimeout time.Duration101 BootstrapTimeout time.Duration
103 ProbeTimeout time.Duration102 ProbeTimeout time.Duration
104 Log *slog.Logger103 Log *slog.Logger
105 RuntimeObserver ports.SerializedRuntimeObserver104 RuntimeObserver ports.SerializedRuntimeObserver
106 }105 }
107 106
108-func NewRemoteDialer(target, session string) RemoteDialer {107+func NewRemoteDialer(target, _ string) RemoteDialer {
109- return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout}108+ return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout}
110 }109 }
111 110
112-func NewRemoteDialerWithLogger(target, session string, log *slog.Logger) RemoteDialer {111+func NewRemoteDialerWithLogger(target, _ string, log *slog.Logger) RemoteDialer {
113- return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout, Log: log}112+ return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout, Log: log}
114 }113 }
115 114
116 func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {115 func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
@@ -129,7 +128,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
129 defer bootstrapCancel()128 defer bootstrapCancel()
130 129
131 var stderr limitedBuffer130 var stderr limitedBuffer
132- proc := startUDPBootstrap(bootstrapCtx, d.Target, "", &stderr)131+ proc := startUDPBootstrap(bootstrapCtx, d.Target, &stderr)
133 stdout, err := proc.StdoutPipe()132 stdout, err := proc.StdoutPipe()
134 if err != nil {133 if err != nil {
135 return nil, udpUnavailable("bootstrap stdout", err, &stderr)134 return nil, udpUnavailable("bootstrap stdout", err, &stderr)
M internal/adapters/dgram/ssh_dialer_test.go +5 -5
@@ -33,7 +33,7 @@ func (p *fakeBootstrapProcess) Start() error { return p.st
3333 func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }
3434 func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr }
3535
36-func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string, io.Writer) bootstrapProcess) {
36+func withBootstrapStarter(t *testing.T, fn func(context.Context, string, io.Writer) bootstrapProcess) {
3737 t.Helper()
3838 old := startUDPBootstrap
3939 startUDPBootstrap = fn
@@ -71,7 +71,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
7171 if tt.name == "bootstrap wait failure" {
7272 proc.waitErr = errors.New("exit status 255")
7373 }
74- withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc })
74+ withBootstrapStarter(t, func(context.Context, string, io.Writer) bootstrapProcess { return proc })
7575 if tt.listen != nil {
7676 withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen })
7777 }
@@ -120,9 +120,9 @@ func TestListenUDPPacketUsesDualStackWildcard(t *testing.T) {
120120 func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
121121 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
122122 proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}
123- withBootstrapStarter(t, func(_ context.Context, target, session string, _ io.Writer) bootstrapProcess {
124- if target != "127.0.0.1" || session != "" {
125- t.Fatalf("bootstrap target/session = %q/%q", target, session)
123+ withBootstrapStarter(t, func(_ context.Context, target string, _ io.Writer) bootstrapProcess {
124+ if target != "127.0.0.1" {
125+ t.Fatalf("bootstrap target = %q", target)
126126 }
127127 return proc
128128 })
@@ -33,7 +33,7 @@ func (p *fakeBootstrapProcess) Start() error { return p.st
33 func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }33 func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }
34 func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr }34 func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr }
35 35
36-func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string, io.Writer) bootstrapProcess) {36+func withBootstrapStarter(t *testing.T, fn func(context.Context, string, io.Writer) bootstrapProcess) {
37 t.Helper()37 t.Helper()
38 old := startUDPBootstrap38 old := startUDPBootstrap
39 startUDPBootstrap = fn39 startUDPBootstrap = fn
@@ -71,7 +71,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
71 if tt.name == "bootstrap wait failure" {71 if tt.name == "bootstrap wait failure" {
72 proc.waitErr = errors.New("exit status 255")72 proc.waitErr = errors.New("exit status 255")
73 }73 }
74- withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc })74+ withBootstrapStarter(t, func(context.Context, string, io.Writer) bootstrapProcess { return proc })
75 if tt.listen != nil {75 if tt.listen != nil {
76 withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen })76 withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen })
77 }77 }
@@ -120,9 +120,9 @@ func TestListenUDPPacketUsesDualStackWildcard(t *testing.T) {
120 func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {120 func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
121 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))121 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
122 proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}122 proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}
123- withBootstrapStarter(t, func(_ context.Context, target, session string, _ io.Writer) bootstrapProcess {123+ withBootstrapStarter(t, func(_ context.Context, target string, _ io.Writer) bootstrapProcess {
124- if target != "127.0.0.1" || session != "" {124+ if target != "127.0.0.1" {
125- t.Fatalf("bootstrap target/session = %q/%q", target, session)125+ t.Fatalf("bootstrap target = %q", target)
126 }126 }
127 return proc127 return proc
128 })128 })
M internal/adapters/remote/remote_factory.go +0 -1
@@ -46,7 +46,6 @@ func (f DialerFactory) DialerForRemote(target string, session string, mode ports
4646
4747 type stdioDialer struct {
4848 target string
49- session string
5049 log *slog.Logger
5150 observer ports.SerializedRuntimeObserver
5251 }
@@ -46,7 +46,6 @@ func (f DialerFactory) DialerForRemote(target string, session string, mode ports
46 46
47 type stdioDialer struct {47 type stdioDialer struct {
48 target string48 target string
49- session string
50 log *slog.Logger49 log *slog.Logger
51 observer ports.SerializedRuntimeObserver50 observer ports.SerializedRuntimeObserver
52 }51 }
M internal/adapters/remote/remote_factory_test.go +5 -5
@@ -32,16 +32,16 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
3232 if !ok {
3333 t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{})
3434 }
35- if got.Target != "remote.example" || got.Session != "" {
36- t.Fatalf("dialer = %+v, want target and no session metadata", got)
35+ if got.Target != "remote.example" {
36+ t.Fatalf("dialer target = %q, want %q", got.Target, "remote.example")
3737 }
3838 case stdioDialer:
3939 got, ok := dialer.(stdioDialer)
4040 if !ok {
4141 t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{})
4242 }
43- if got.target != "remote.example" || got.session != "" {
44- t.Fatalf("dialer = %+v, want target and no session metadata", got)
43+ if got.target != "remote.example" {
44+ t.Fatalf("dialer target = %q, want %q", got.target, "remote.example")
4545 }
4646 }
4747 })
@@ -64,7 +64,7 @@ func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {
6464 }
6565
6666 func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) {
67- dialer := stdioDialer{target: "remote.example", session: "work"}
67+ dialer := stdioDialer{target: "remote.example"}
6868 ctx, cancel := context.WithCancel(context.Background())
6969 cancel()
7070
@@ -32,16 +32,16 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
32 if !ok {32 if !ok {
33 t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{})33 t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{})
34 }34 }
35- if got.Target != "remote.example" || got.Session != "" {35+ if got.Target != "remote.example" {
36- t.Fatalf("dialer = %+v, want target and no session metadata", got)36+ t.Fatalf("dialer target = %q, want %q", got.Target, "remote.example")
37 }37 }
38 case stdioDialer:38 case stdioDialer:
39 got, ok := dialer.(stdioDialer)39 got, ok := dialer.(stdioDialer)
40 if !ok {40 if !ok {
41 t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{})41 t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{})
42 }42 }
43- if got.target != "remote.example" || got.session != "" {43+ if got.target != "remote.example" {
44- t.Fatalf("dialer = %+v, want target and no session metadata", got)44+ t.Fatalf("dialer target = %q, want %q", got.target, "remote.example")
45 }45 }
46 }46 }
47 })47 })
@@ -64,7 +64,7 @@ func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {
64 }64 }
65 65
66 func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) {66 func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) {
67- dialer := stdioDialer{target: "remote.example", session: "work"}67+ dialer := stdioDialer{target: "remote.example"}
68 ctx, cancel := context.WithCancel(context.Background())68 ctx, cancel := context.WithCancel(context.Background())
69 cancel()69 cancel()
70 70
M internal/adapters/sshstdio/transport.go +3 -3
@@ -332,9 +332,9 @@ func shellQuote(s string) string {
332332 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
333333 }
334334
335-// Dial starts ssh target vev _stdio [session] and returns a Transport over the
336-// child process' stdio. The subprocess is started with exec.Command argv, never
337-// through a shell.
335+// Dial starts ssh target vev _stdio and returns a Transport over the child
336+// process' stdio. Session selection is carried in Hello. The subprocess is
337+// started with exec.Command argv, never through a shell.
338338 func Dial(target, session string) (ports.Transport, error) {
339339 return DialContext(context.Background(), target, session)
340340 }
@@ -332,9 +332,9 @@ func shellQuote(s string) string {
332 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"332 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
333 }333 }
334 334
335-// Dial starts ssh target vev _stdio [session] and returns a Transport over the335+// Dial starts ssh target vev _stdio and returns a Transport over the child
336-// child process' stdio. The subprocess is started with exec.Command argv, never336+// process' stdio. Session selection is carried in Hello. The subprocess is
337-// through a shell.337+// started with exec.Command argv, never through a shell.
338 func Dial(target, session string) (ports.Transport, error) {338 func Dial(target, session string) (ports.Transport, error) {
339 return DialContext(context.Background(), target, session)339 return DialContext(context.Background(), target, session)
340 }340 }
M internal/adapters/sshstdio/transport_test.go +3 -2
@@ -18,8 +18,9 @@ import (
1818 )
1919
2020 func TestBuildCommandUsesExecArgs(t *testing.T) {
21- // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio' [session]`.
22- // Terminal color capability is carried in vev's Hello message, not in ssh env.
21+ // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio'`.
22+ // Session selection and terminal color capability are carried in vev's Hello
23+ // message, not in ssh env.
2324 tests := []struct {
2425 name string
2526 target string
@@ -18,8 +18,9 @@ import (
18 )18 )
19 19
20 func TestBuildCommandUsesExecArgs(t *testing.T) {20 func TestBuildCommandUsesExecArgs(t *testing.T) {
21- // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio' [session]`.21+ // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio'`.
22- // Terminal color capability is carried in vev's Hello message, not in ssh env.22+ // Session selection and terminal color capability are carried in vev's Hello
23+ // message, not in ssh env.
23 tests := []struct {24 tests := []struct {
24 name string25 name string
25 target string26 target string
M internal/app/cmd.go +1 -0
@@ -200,6 +200,7 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
200200 tracker.Fail(requestID, generation, fmt.Errorf("decoding command reply: %w", decodeErr))
201201 return
202202 }
203+ result.RequestID = requestID
203204 tracker.Complete(generation, result)
204205 }()
205206 commandClock := deps.clock
@@ -200,6 +200,7 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
200 tracker.Fail(requestID, generation, fmt.Errorf("decoding command reply: %w", decodeErr))200 tracker.Fail(requestID, generation, fmt.Errorf("decoding command reply: %w", decodeErr))
201 return201 return
202 }202 }
203+ result.RequestID = requestID
203 tracker.Complete(generation, result)204 tracker.Complete(generation, result)
204 }()205 }()
205 commandClock := deps.clock206 commandClock := deps.clock
M internal/app/cmd_test.go +30 -26
@@ -12,7 +12,9 @@ import (
1212 "time"
1313
1414 "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1516 "github.com/bnema/vev/internal/usecase/daemon"
17+ "github.com/stretchr/testify/mock"
1618 "github.com/stretchr/testify/require"
1719 )
1820
@@ -290,8 +292,15 @@ func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) {
290292 }
291293
292294 func TestRunCmdTimesOutPendingRequest(t *testing.T) {
293- timer := &cmdTestTimer{ch: make(chan time.Time, 1)}
294- clock := &cmdTestClock{timer: timer}
295+ timerCh := make(chan time.Time, 1)
296+ timer := portsmocks.NewMockTimer(t)
297+ timer.EXPECT().C().Return(timerCh).Once()
298+ timer.EXPECT().Stop().Return(true).Once()
299+ delayCh := make(chan time.Duration, 1)
300+ clock := portsmocks.NewMockClock(t)
301+ clock.EXPECT().NewTimer(mock.Anything).Run(func(delay time.Duration) {
302+ delayCh <- delay
303+ }).Return(timer).Once()
295304 recvStarted := make(chan struct{})
296305 recvGate := make(chan struct{})
297306 transport := &cmdTestTransport{recvStarted: recvStarted, recvGate: recvGate}
@@ -305,7 +314,10 @@ func TestRunCmdTimesOutPendingRequest(t *testing.T) {
305314 })
306315 }()
307316 <-recvStarted
308- timer.ch <- time.Time{}
317+ if delay := <-delayCh; delay != daemon.CommandRequestTimeout {
318+ t.Fatalf("command timer delay = %s, want %s", delay, daemon.CommandRequestTimeout)
319+ }
320+ timerCh <- time.Time{}
309321 err := <-done
310322 close(recvGate)
311323 if ExitCode(err) != 3 || !errors.Is(err, daemon.ErrCommandRequestTimeout) {
@@ -317,20 +329,22 @@ func TestRunCmdTimesOutPendingRequest(t *testing.T) {
317329 }
318330 }
319331
320-type cmdTestTimer struct{ ch chan time.Time }
321-
322-func (t *cmdTestTimer) C() <-chan time.Time { return t.ch }
323-func (t *cmdTestTimer) Reset(time.Duration) bool { return false }
324-func (t *cmdTestTimer) Stop() bool { return true }
325-
326-type cmdTestClock struct{ timer *cmdTestTimer }
327-
328-func (c *cmdTestClock) Now() time.Time { return time.Time{} }
329-func (c *cmdTestClock) NewTimer(delay time.Duration) ports.Timer {
330- if delay != daemon.CommandRequestTimeout {
331- panic("unexpected command timeout")
332+func TestRunCmdZeroRequestIDReplyReturnsDaemonError(t *testing.T) {
333+ const want = "daemon error"
334+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{
335+ Code: ports.ErrInternal, Text: want,
336+ })}}
337+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
338+ stdout: io.Discard,
339+ getenv: func(string) string { return "" },
340+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
341+ })
342+ if err == nil || err.Error() != want {
343+ t.Fatalf("error = %v, want daemon error %q", err, want)
344+ }
345+ if errors.Is(err, daemon.ErrCommandRequestTimeout) {
346+ t.Fatalf("error = %v, want daemon error instead of timeout", err)
332347 }
333- return c.timer
334348 }
335349
336350 func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
@@ -417,16 +431,6 @@ func (t *cmdTestTransport) Recv() (ports.Frame, error) {
417431 if t.recvGate != nil {
418432 <-t.recvGate
419433 }
420- if t.recv.Type == ports.MsgCommandResult && len(t.sent) != 0 {
421- result, err := ports.UnmarshalCommandResult(t.recv.Payload)
422- if err == nil && result.RequestID == 0 {
423- request, err := ports.UnmarshalCommandRequest(t.sent[0].Payload)
424- if err == nil {
425- result.RequestID = request.RequestID
426- return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}, t.recvErr
427- }
428- }
429- }
430434 return t.recv, t.recvErr
431435 }
432436
@@ -12,7 +12,9 @@ import (
12 "time"12 "time"
13 13
14 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 "github.com/bnema/vev/internal/usecase/daemon"16 "github.com/bnema/vev/internal/usecase/daemon"
17+ "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
17 )19 )
18 20
@@ -290,8 +292,15 @@ func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) {
290 }292 }
291 293
292 func TestRunCmdTimesOutPendingRequest(t *testing.T) {294 func TestRunCmdTimesOutPendingRequest(t *testing.T) {
293- timer := &cmdTestTimer{ch: make(chan time.Time, 1)}295+ timerCh := make(chan time.Time, 1)
294- clock := &cmdTestClock{timer: timer}296+ timer := portsmocks.NewMockTimer(t)
297+ timer.EXPECT().C().Return(timerCh).Once()
298+ timer.EXPECT().Stop().Return(true).Once()
299+ delayCh := make(chan time.Duration, 1)
300+ clock := portsmocks.NewMockClock(t)
301+ clock.EXPECT().NewTimer(mock.Anything).Run(func(delay time.Duration) {
302+ delayCh <- delay
303+ }).Return(timer).Once()
295 recvStarted := make(chan struct{})304 recvStarted := make(chan struct{})
296 recvGate := make(chan struct{})305 recvGate := make(chan struct{})
297 transport := &cmdTestTransport{recvStarted: recvStarted, recvGate: recvGate}306 transport := &cmdTestTransport{recvStarted: recvStarted, recvGate: recvGate}
@@ -305,7 +314,10 @@ func TestRunCmdTimesOutPendingRequest(t *testing.T) {
305 })314 })
306 }()315 }()
307 <-recvStarted316 <-recvStarted
308- timer.ch <- time.Time{}317+ if delay := <-delayCh; delay != daemon.CommandRequestTimeout {
318+ t.Fatalf("command timer delay = %s, want %s", delay, daemon.CommandRequestTimeout)
319+ }
320+ timerCh <- time.Time{}
309 err := <-done321 err := <-done
310 close(recvGate)322 close(recvGate)
311 if ExitCode(err) != 3 || !errors.Is(err, daemon.ErrCommandRequestTimeout) {323 if ExitCode(err) != 3 || !errors.Is(err, daemon.ErrCommandRequestTimeout) {
@@ -317,20 +329,22 @@ func TestRunCmdTimesOutPendingRequest(t *testing.T) {
317 }329 }
318 }330 }
319 331
320-type cmdTestTimer struct{ ch chan time.Time }332+func TestRunCmdZeroRequestIDReplyReturnsDaemonError(t *testing.T) {
321-333+ const want = "daemon error"
322-func (t *cmdTestTimer) C() <-chan time.Time { return t.ch }334+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{
323-func (t *cmdTestTimer) Reset(time.Duration) bool { return false }335+ Code: ports.ErrInternal, Text: want,
324-func (t *cmdTestTimer) Stop() bool { return true }336+ })}}
325-337+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
326-type cmdTestClock struct{ timer *cmdTestTimer }338+ stdout: io.Discard,
327-339+ getenv: func(string) string { return "" },
328-func (c *cmdTestClock) Now() time.Time { return time.Time{} }340+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
329-func (c *cmdTestClock) NewTimer(delay time.Duration) ports.Timer {341+ })
330- if delay != daemon.CommandRequestTimeout {342+ if err == nil || err.Error() != want {
331- panic("unexpected command timeout")343+ t.Fatalf("error = %v, want daemon error %q", err, want)
344+ }
345+ if errors.Is(err, daemon.ErrCommandRequestTimeout) {
346+ t.Fatalf("error = %v, want daemon error instead of timeout", err)
332 }347 }
333- return c.timer
334 }348 }
335 349
336 func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {350 func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
@@ -417,16 +431,6 @@ func (t *cmdTestTransport) Recv() (ports.Frame, error) {
417 if t.recvGate != nil {431 if t.recvGate != nil {
418 <-t.recvGate432 <-t.recvGate
419 }433 }
420- if t.recv.Type == ports.MsgCommandResult && len(t.sent) != 0 {
421- result, err := ports.UnmarshalCommandResult(t.recv.Payload)
422- if err == nil && result.RequestID == 0 {
423- request, err := ports.UnmarshalCommandRequest(t.sent[0].Payload)
424- if err == nil {
425- result.RequestID = request.RequestID
426- return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}, t.recvErr
427- }
428- }
429- }
430 return t.recv, t.recvErr434 return t.recv, t.recvErr
431 }435 }
432 436
M internal/app/run.go +17 -28
@@ -161,32 +161,20 @@ func parseArgs(args []string) (command, error) {
161161 case "--daemon-launcher":
162162 return command{kind: kindDaemonLauncher}, nil
163163 case "_stdio":
164- if len(args) > 2 {
165- return command{}, usagef("`_stdio` accepts at most one session name")
166- }
167- cmd := command{kind: kindStdio}
168- if len(args) == 2 {
169- cmd.name = args[1]
164+ if len(args) != 1 {
165+ return command{}, usagef("`_stdio` does not accept a session name")
170166 }
171- return cmd, nil
167+ return command{kind: kindStdio}, nil
172168 case "_udp-bootstrap":
173- if len(args) > 2 {
174- return command{}, usagef("`_udp-bootstrap` accepts at most one session name")
175- }
176- cmd := command{kind: kindUDPBootstrap}
177- if len(args) == 2 {
178- cmd.name = args[1]
169+ if len(args) != 1 {
170+ return command{}, usagef("`_udp-bootstrap` does not accept a session name")
179171 }
180- return cmd, nil
172+ return command{kind: kindUDPBootstrap}, nil
181173 case "_udp-proxy":
182- if len(args) > 2 {
183- return command{}, usagef("`_udp-proxy` accepts at most one session name")
184- }
185- cmd := command{kind: kindUDPProxy}
186- if len(args) == 2 {
187- cmd.name = args[1]
174+ if len(args) != 1 {
175+ return command{}, usagef("`_udp-proxy` does not accept a session name")
188176 }
189- return cmd, nil
177+ return command{kind: kindUDPProxy}, nil
190178 case "new":
191179 if len(args) < 2 || args[1] == "" {
192180 return command{}, usagef("`new` requires a session name")
@@ -278,9 +266,9 @@ func dispatch(ctx context.Context, cmd command) error {
278266 case kindStdio:
279267 return runStdio(ctx)
280268 case kindUDPBootstrap:
281- return runUDPBootstrap(ctx, cmd.name)
269+ return runUDPBootstrap(ctx)
282270 case kindUDPProxy:
283- return runUDPProxy(ctx, cmd.name, os.Stdout)
271+ return runUDPProxy(ctx, os.Stdout)
284272 case kindList:
285273 return runList(ctx, cmd)
286274 case kindHost:
@@ -1056,21 +1044,22 @@ func runStdio(ctx context.Context) (retErr error) {
10561044 // pipe drained by a small forwarding goroutine.
10571045 stdin, stdout := os.Stdin, os.Stdout
10581046 stdoutReader, stdoutWriter := io.Pipe()
1047+ forwardDone := make(chan struct{})
10591048 go func() {
1049+ defer close(forwardDone)
10601050 if _, err := io.Copy(stdout, stdoutReader); err != nil {
10611051 _ = stdoutReader.CloseWithError(err)
10621052 }
10631053 }()
10641054 defer func() {
10651055 _ = stdoutWriter.Close()
1056+ <-forwardDone
10661057 _ = stdoutReader.Close()
10671058 }()
1068- stdio := sshstdio.NewTransport(nonClosingReader{Reader: stdin}, stdoutWriter, nil, sshstdio.WithRuntimeObserver(observer))
1059+ stdio := sshstdio.NewTransport(stdin, stdoutWriter, nil, sshstdio.WithRuntimeObserver(observer))
10691060 return runStdioProxy(ctx, stdio, transport, log)
10701061 }
10711062
1072-type nonClosingReader struct{ io.Reader }
1073-
10741063 var (
10751064 udpBootstrapTimeout = 5 * time.Second
10761065 udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd {
@@ -1080,7 +1069,7 @@ var (
10801069
10811070 // runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness
10821071 // line, and exits so SSH stdio can close without owning the UDP proxy lifetime.
1083-func runUDPBootstrap(ctx context.Context, _ string) error {
1072+func runUDPBootstrap(ctx context.Context) error {
10841073 // Always start a fresh proxy for a bootstrap request. A client attach begins
10851074 // with MsgHello, which must reach the daemon handshake path; reusing an
10861075 // already-running proxy would forward that Hello into the daemon connection's
@@ -1148,7 +1137,7 @@ func runUDPBootstrap(ctx context.Context, _ string) error {
11481137 }
11491138
11501139 // runUDPProxy is the detached long-lived remote-side datagram proxy.
1151-func runUDPProxy(ctx context.Context, _ string, ready io.Writer) (retErr error) {
1140+func runUDPProxy(ctx context.Context, ready io.Writer) (retErr error) {
11521141 log, logCloser, err := configureLogging(logging.Stdio, false)
11531142 if err != nil {
11541143 return err
@@ -161,32 +161,20 @@ func parseArgs(args []string) (command, error) {
161 case "--daemon-launcher":161 case "--daemon-launcher":
162 return command{kind: kindDaemonLauncher}, nil162 return command{kind: kindDaemonLauncher}, nil
163 case "_stdio":163 case "_stdio":
164- if len(args) > 2 {164+ if len(args) != 1 {
165- return command{}, usagef("`_stdio` accepts at most one session name")165+ return command{}, usagef("`_stdio` does not accept a session name")
166- }
167- cmd := command{kind: kindStdio}
168- if len(args) == 2 {
169- cmd.name = args[1]
170 }166 }
171- return cmd, nil167+ return command{kind: kindStdio}, nil
172 case "_udp-bootstrap":168 case "_udp-bootstrap":
173- if len(args) > 2 {169+ if len(args) != 1 {
174- return command{}, usagef("`_udp-bootstrap` accepts at most one session name")170+ return command{}, usagef("`_udp-bootstrap` does not accept a session name")
175- }
176- cmd := command{kind: kindUDPBootstrap}
177- if len(args) == 2 {
178- cmd.name = args[1]
179 }171 }
180- return cmd, nil172+ return command{kind: kindUDPBootstrap}, nil
181 case "_udp-proxy":173 case "_udp-proxy":
182- if len(args) > 2 {174+ if len(args) != 1 {
183- return command{}, usagef("`_udp-proxy` accepts at most one session name")175+ return command{}, usagef("`_udp-proxy` does not accept a session name")
184- }
185- cmd := command{kind: kindUDPProxy}
186- if len(args) == 2 {
187- cmd.name = args[1]
188 }176 }
189- return cmd, nil177+ return command{kind: kindUDPProxy}, nil
190 case "new":178 case "new":
191 if len(args) < 2 || args[1] == "" {179 if len(args) < 2 || args[1] == "" {
192 return command{}, usagef("`new` requires a session name")180 return command{}, usagef("`new` requires a session name")
@@ -278,9 +266,9 @@ func dispatch(ctx context.Context, cmd command) error {
278 case kindStdio:266 case kindStdio:
279 return runStdio(ctx)267 return runStdio(ctx)
280 case kindUDPBootstrap:268 case kindUDPBootstrap:
281- return runUDPBootstrap(ctx, cmd.name)269+ return runUDPBootstrap(ctx)
282 case kindUDPProxy:270 case kindUDPProxy:
283- return runUDPProxy(ctx, cmd.name, os.Stdout)271+ return runUDPProxy(ctx, os.Stdout)
284 case kindList:272 case kindList:
285 return runList(ctx, cmd)273 return runList(ctx, cmd)
286 case kindHost:274 case kindHost:
@@ -1056,21 +1044,22 @@ func runStdio(ctx context.Context) (retErr error) {
1056 // pipe drained by a small forwarding goroutine.1044 // pipe drained by a small forwarding goroutine.
1057 stdin, stdout := os.Stdin, os.Stdout1045 stdin, stdout := os.Stdin, os.Stdout
1058 stdoutReader, stdoutWriter := io.Pipe()1046 stdoutReader, stdoutWriter := io.Pipe()
1047+ forwardDone := make(chan struct{})
1059 go func() {1048 go func() {
1049+ defer close(forwardDone)
1060 if _, err := io.Copy(stdout, stdoutReader); err != nil {1050 if _, err := io.Copy(stdout, stdoutReader); err != nil {
1061 _ = stdoutReader.CloseWithError(err)1051 _ = stdoutReader.CloseWithError(err)
1062 }1052 }
1063 }()1053 }()
1064 defer func() {1054 defer func() {
1065 _ = stdoutWriter.Close()1055 _ = stdoutWriter.Close()
1056+ <-forwardDone
1066 _ = stdoutReader.Close()1057 _ = stdoutReader.Close()
1067 }()1058 }()
1068- stdio := sshstdio.NewTransport(nonClosingReader{Reader: stdin}, stdoutWriter, nil, sshstdio.WithRuntimeObserver(observer))1059+ stdio := sshstdio.NewTransport(stdin, stdoutWriter, nil, sshstdio.WithRuntimeObserver(observer))
1069 return runStdioProxy(ctx, stdio, transport, log)1060 return runStdioProxy(ctx, stdio, transport, log)
1070 }1061 }
1071 1062
1072-type nonClosingReader struct{ io.Reader }
1073-
1074 var (1063 var (
1075 udpBootstrapTimeout = 5 * time.Second1064 udpBootstrapTimeout = 5 * time.Second
1076 udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd {1065 udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd {
@@ -1080,7 +1069,7 @@ var (
1080 1069
1081 // runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness1070 // runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness
1082 // line, and exits so SSH stdio can close without owning the UDP proxy lifetime.1071 // line, and exits so SSH stdio can close without owning the UDP proxy lifetime.
1083-func runUDPBootstrap(ctx context.Context, _ string) error {1072+func runUDPBootstrap(ctx context.Context) error {
1084 // Always start a fresh proxy for a bootstrap request. A client attach begins1073 // Always start a fresh proxy for a bootstrap request. A client attach begins
1085 // with MsgHello, which must reach the daemon handshake path; reusing an1074 // with MsgHello, which must reach the daemon handshake path; reusing an
1086 // already-running proxy would forward that Hello into the daemon connection's1075 // already-running proxy would forward that Hello into the daemon connection's
@@ -1148,7 +1137,7 @@ func runUDPBootstrap(ctx context.Context, _ string) error {
1148 }1137 }
1149 1138
1150 // runUDPProxy is the detached long-lived remote-side datagram proxy.1139 // runUDPProxy is the detached long-lived remote-side datagram proxy.
1151-func runUDPProxy(ctx context.Context, _ string, ready io.Writer) (retErr error) {1140+func runUDPProxy(ctx context.Context, ready io.Writer) (retErr error) {
1152 log, logCloser, err := configureLogging(logging.Stdio, false)1141 log, logCloser, err := configureLogging(logging.Stdio, false)
1153 if err != nil {1142 if err != nil {
1154 return err1143 return err
M internal/app/run_test.go +10 -9
@@ -482,12 +482,13 @@ func TestParseArgs(t *testing.T) {
482482 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
483483 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
484484 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
485- {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
486- {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
487- {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
488- {name: "udp bootstrap", args: []string{"_udp-bootstrap", "work"}, wantKind: kindUDPBootstrap, wantName: "work"},
489- {name: "udp proxy", args: []string{"_udp-proxy", "work"}, wantKind: kindUDPProxy, wantName: "work"},
490- {name: "udp proxy too many args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
485+ {name: "stdio rejects session", args: []string{"_stdio", "work"}, wantErr: true},
486+ {name: "stdio rejects extra args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
487+ {name: "udp bootstrap", args: []string{"_udp-bootstrap"}, wantKind: kindUDPBootstrap},
488+ {name: "udp bootstrap rejects session", args: []string{"_udp-bootstrap", "work"}, wantErr: true},
489+ {name: "udp proxy", args: []string{"_udp-proxy"}, wantKind: kindUDPProxy},
490+ {name: "udp proxy rejects session", args: []string{"_udp-proxy", "work"}, wantErr: true},
491+ {name: "udp proxy rejects extra args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
491492 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
492493 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
493494 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
@@ -1050,7 +1051,7 @@ func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
10501051 t.Cleanup(func() { udpProxyCommand = oldCommand })
10511052
10521053 got := captureStdout(t, func() {
1053- if err := runUDPBootstrap(context.Background(), "work"); err != nil {
1054+ if err := runUDPBootstrap(context.Background()); err != nil {
10541055 t.Fatalf("runUDPBootstrap() error = %v", err)
10551056 }
10561057 })
@@ -1075,7 +1076,7 @@ func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) {
10751076 }
10761077 t.Cleanup(func() { udpProxyCommand = oldCommand })
10771078
1078- err := runUDPBootstrap(context.Background(), "work")
1079+ err := runUDPBootstrap(context.Background())
10791080 if !errors.Is(err, io.EOF) {
10801081 t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)
10811082 }
@@ -1091,7 +1092,7 @@ func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
10911092 }
10921093 t.Cleanup(func() { udpProxyCommand = oldCommand })
10931094
1094- err := runUDPBootstrap(context.Background(), "work")
1095+ err := runUDPBootstrap(context.Background())
10951096 if !errors.Is(err, context.DeadlineExceeded) {
10961097 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
10971098 }
@@ -482,12 +482,13 @@ func TestParseArgs(t *testing.T) {
482 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},482 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
483 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},483 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
484 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},484 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
485- {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},485+ {name: "stdio rejects session", args: []string{"_stdio", "work"}, wantErr: true},
486- {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},486+ {name: "stdio rejects extra args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
487- {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},487+ {name: "udp bootstrap", args: []string{"_udp-bootstrap"}, wantKind: kindUDPBootstrap},
488- {name: "udp bootstrap", args: []string{"_udp-bootstrap", "work"}, wantKind: kindUDPBootstrap, wantName: "work"},488+ {name: "udp bootstrap rejects session", args: []string{"_udp-bootstrap", "work"}, wantErr: true},
489- {name: "udp proxy", args: []string{"_udp-proxy", "work"}, wantKind: kindUDPProxy, wantName: "work"},489+ {name: "udp proxy", args: []string{"_udp-proxy"}, wantKind: kindUDPProxy},
490- {name: "udp proxy too many args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},490+ {name: "udp proxy rejects session", args: []string{"_udp-proxy", "work"}, wantErr: true},
491+ {name: "udp proxy rejects extra args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
491 {name: "help", args: []string{"--help"}, wantKind: kindHelp},492 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
492 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},493 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
493 {name: "version", args: []string{"--version"}, wantKind: kindVersion},494 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
@@ -1050,7 +1051,7 @@ func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
1050 t.Cleanup(func() { udpProxyCommand = oldCommand })1051 t.Cleanup(func() { udpProxyCommand = oldCommand })
1051 1052
1052 got := captureStdout(t, func() {1053 got := captureStdout(t, func() {
1053- if err := runUDPBootstrap(context.Background(), "work"); err != nil {1054+ if err := runUDPBootstrap(context.Background()); err != nil {
1054 t.Fatalf("runUDPBootstrap() error = %v", err)1055 t.Fatalf("runUDPBootstrap() error = %v", err)
1055 }1056 }
1056 })1057 })
@@ -1075,7 +1076,7 @@ func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) {
1075 }1076 }
1076 t.Cleanup(func() { udpProxyCommand = oldCommand })1077 t.Cleanup(func() { udpProxyCommand = oldCommand })
1077 1078
1078- err := runUDPBootstrap(context.Background(), "work")1079+ err := runUDPBootstrap(context.Background())
1079 if !errors.Is(err, io.EOF) {1080 if !errors.Is(err, io.EOF) {
1080 t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)1081 t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)
1081 }1082 }
@@ -1091,7 +1092,7 @@ func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
1091 }1092 }
1092 t.Cleanup(func() { udpProxyCommand = oldCommand })1093 t.Cleanup(func() { udpProxyCommand = oldCommand })
1093 1094
1094- err := runUDPBootstrap(context.Background(), "work")1095+ err := runUDPBootstrap(context.Background())
1095 if !errors.Is(err, context.DeadlineExceeded) {1096 if !errors.Is(err, context.DeadlineExceeded) {
1096 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)1097 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
1097 }1098 }
M internal/usecase/client/handshake.go +8 -2
@@ -105,13 +105,18 @@ func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, o
105105 }
106106
107107 func boundedDial(ctx context.Context, dialer ports.Dialer) (ports.Transport, error) {
108+ // The dial context only bounds startup. A successful carriage outlives the
109+ // handshake context; canceling that context after Welcome must not kill SSH.
110+ dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
108111 result := make(chan struct {
109112 transport ports.Transport
110113 err error
111114 }, 1)
112115 go func() {
113- transport, err := dialer.Dial(ctx)
114- if ctx.Err() != nil && transport != nil {
116+ transport, err := dialer.Dial(dialCtx)
117+ if err != nil {
118+ cancel()
119+ } else if ctx.Err() != nil && transport != nil {
115120 _ = transport.Close()
116121 }
117122 result <- struct {
@@ -123,6 +128,7 @@ func boundedDial(ctx context.Context, dialer ports.Dialer) (ports.Transport, err
123128 case result := <-result:
124129 return result.transport, result.err
125130 case <-ctx.Done():
131+ cancel()
126132 return nil, ctx.Err()
127133 }
128134 }
@@ -105,13 +105,18 @@ func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, o
105 }105 }
106 106
107 func boundedDial(ctx context.Context, dialer ports.Dialer) (ports.Transport, error) {107 func boundedDial(ctx context.Context, dialer ports.Dialer) (ports.Transport, error) {
108+ // The dial context only bounds startup. A successful carriage outlives the
109+ // handshake context; canceling that context after Welcome must not kill SSH.
110+ dialCtx, cancel := context.WithCancel(context.WithoutCancel(ctx))
108 result := make(chan struct {111 result := make(chan struct {
109 transport ports.Transport112 transport ports.Transport
110 err error113 err error
111 }, 1)114 }, 1)
112 go func() {115 go func() {
113- transport, err := dialer.Dial(ctx)116+ transport, err := dialer.Dial(dialCtx)
114- if ctx.Err() != nil && transport != nil {117+ if err != nil {
118+ cancel()
119+ } else if ctx.Err() != nil && transport != nil {
115 _ = transport.Close()120 _ = transport.Close()
116 }121 }
117 result <- struct {122 result <- struct {
@@ -123,6 +128,7 @@ func boundedDial(ctx context.Context, dialer ports.Dialer) (ports.Transport, err
123 case result := <-result:128 case result := <-result:
124 return result.transport, result.err129 return result.transport, result.err
125 case <-ctx.Done():130 case <-ctx.Done():
131+ cancel()
126 return nil, ctx.Err()132 return nil, ctx.Err()
127 }133 }
128 }134 }
M internal/usecase/daemon/client.go +13 -5
@@ -799,11 +799,19 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
799799 ac.pipelineScratch = composeCacheInput{}
800800 ac.sendMu.Unlock()
801801
802- entry := token.sess
803- // Rendering is attachment-scoped. Run it independently of the connection
804- // reader so a blocked transport cannot hold session dispatch or peers.
805- go d.paint(entry, ac, true, token.lease)
806- return true
802+ sess, ok := localSession(token.sess)
803+ if !ok {
804+ return false
805+ }
806+ rc := sess.renderCoordinator()
807+ if rc == nil {
808+ return false
809+ }
810+ // Rendering is attachment-scoped. Schedule it through the coordinator so
811+ // rapid resize frames coalesce under the attachment lease and deadline.
812+ return rc.scheduleResizeForLease(size, ac, token.lease, func(uint64) {
813+ go d.paint(sess, ac, true, token.lease)
814+ }) != 0
807815 }
808816
809817 // handleClientNotice maps the closed client-event enum to daemon-owned notice
@@ -799,11 +799,19 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
799 ac.pipelineScratch = composeCacheInput{}799 ac.pipelineScratch = composeCacheInput{}
800 ac.sendMu.Unlock()800 ac.sendMu.Unlock()
801 801
802- entry := token.sess802+ sess, ok := localSession(token.sess)
803- // Rendering is attachment-scoped. Run it independently of the connection803+ if !ok {
804- // reader so a blocked transport cannot hold session dispatch or peers.804+ return false
805- go d.paint(entry, ac, true, token.lease)805+ }
806- return true806+ rc := sess.renderCoordinator()
807+ if rc == nil {
808+ return false
809+ }
810+ // Rendering is attachment-scoped. Schedule it through the coordinator so
811+ // rapid resize frames coalesce under the attachment lease and deadline.
812+ return rc.scheduleResizeForLease(size, ac, token.lease, func(uint64) {
813+ go d.paint(sess, ac, true, token.lease)
814+ }) != 0
807 }815 }
808 816
809 // handleClientNotice maps the closed client-event enum to daemon-owned notice817 // handleClientNotice maps the closed client-event enum to daemon-owned notice
M internal/usecase/daemon/command_tracker.go +3 -3
@@ -127,15 +127,15 @@ func (t *CommandRequestTracker) Remove(requestID, generation uint64) {
127127 // Complete accepts only the exact request generation. Unknown, late, and old
128128 // generation results are safe no-ops.
129129 func (t *CommandRequestTracker) Complete(generation uint64, result ports.CommandResult) {
130- t.finish(generation, result.RequestID, CommandRequestOutcome{Result: result})
130+ t.finish(result.RequestID, generation, CommandRequestOutcome{Result: result})
131131 }
132132
133133 // Fail completes the exact request with a transport or decode failure.
134134 func (t *CommandRequestTracker) Fail(requestID, generation uint64, err error) {
135- t.finish(generation, requestID, CommandRequestOutcome{Err: err})
135+ t.finish(requestID, generation, CommandRequestOutcome{Err: err})
136136 }
137137
138-func (t *CommandRequestTracker) finish(generation, requestID uint64, outcome CommandRequestOutcome) {
138+func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) {
139139 if t == nil {
140140 return
141141 }
@@ -127,15 +127,15 @@ func (t *CommandRequestTracker) Remove(requestID, generation uint64) {
127 // Complete accepts only the exact request generation. Unknown, late, and old127 // Complete accepts only the exact request generation. Unknown, late, and old
128 // generation results are safe no-ops.128 // generation results are safe no-ops.
129 func (t *CommandRequestTracker) Complete(generation uint64, result ports.CommandResult) {129 func (t *CommandRequestTracker) Complete(generation uint64, result ports.CommandResult) {
130- t.finish(generation, result.RequestID, CommandRequestOutcome{Result: result})130+ t.finish(result.RequestID, generation, CommandRequestOutcome{Result: result})
131 }131 }
132 132
133 // Fail completes the exact request with a transport or decode failure.133 // Fail completes the exact request with a transport or decode failure.
134 func (t *CommandRequestTracker) Fail(requestID, generation uint64, err error) {134 func (t *CommandRequestTracker) Fail(requestID, generation uint64, err error) {
135- t.finish(generation, requestID, CommandRequestOutcome{Err: err})135+ t.finish(requestID, generation, CommandRequestOutcome{Err: err})
136 }136 }
137 137
138-func (t *CommandRequestTracker) finish(generation, requestID uint64, outcome CommandRequestOutcome) {138+func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) {
139 if t == nil {139 if t == nil {
140 return140 return
141 }141 }
M internal/usecase/daemon/command_tracker_test.go +82 -0
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "sync"
67 "testing"
78 "time"
@@ -68,4 +69,85 @@ func TestCommandRequestTrackerTimeoutAndGenerationIsolation(t *testing.T) {
6869 tracker.Complete(8, ports.CommandResult{RequestID: requestID, OK: true})
6970 result := <-secondOutcome
7071 require.True(t, result.Result.OK)
72+
73+ t.Run("wait outcomes", func(t *testing.T) {
74+ failure := errors.New("transport failed")
75+ tests := []struct {
76+ name string
77+ setup func(*testing.T, *CommandRequestTracker, uint64) (context.Context, ports.Clock, func())
78+ wantErr error
79+ wantPending int
80+ }{
81+ {
82+ name: "canceled context",
83+ setup: func(_ *testing.T, _ *CommandRequestTracker, _ uint64) (context.Context, ports.Clock, func()) {
84+ ctx, cancel := context.WithCancel(context.Background())
85+ cancel()
86+ return ctx, newCommandTrackerTestClock(), nil
87+ },
88+ wantErr: context.Canceled,
89+ wantPending: 0,
90+ },
91+ {
92+ name: "nil clock",
93+ setup: func(_ *testing.T, _ *CommandRequestTracker, _ uint64) (context.Context, ports.Clock, func()) {
94+ return context.Background(), nil, nil
95+ },
96+ wantErr: ErrCommandRequestUnavailable,
97+ wantPending: 1,
98+ },
99+ {
100+ name: "fail",
101+ setup: func(_ *testing.T, tracker *CommandRequestTracker, requestID uint64) (context.Context, ports.Clock, func()) {
102+ tracker.Fail(requestID, 1, failure)
103+ return context.Background(), newCommandTrackerTestClock(), nil
104+ },
105+ wantErr: failure,
106+ wantPending: 0,
107+ },
108+ {
109+ name: "remove",
110+ setup: func(_ *testing.T, tracker *CommandRequestTracker, requestID uint64) (context.Context, ports.Clock, func()) {
111+ tracker.Remove(requestID, 1)
112+ clock := newCommandTrackerTestClock()
113+ return context.Background(), clock, func() {
114+ timer := <-clock.timers
115+ timer.fire()
116+ }
117+ },
118+ wantErr: ErrCommandRequestTimeout,
119+ wantPending: 0,
120+ },
121+ {
122+ name: "duplicate track",
123+ setup: func(t *testing.T, tracker *CommandRequestTracker, requestID uint64) (context.Context, ports.Clock, func()) {
124+ duplicate, ok := tracker.Track(requestID, 1)
125+ require.False(t, ok)
126+ require.Nil(t, duplicate)
127+ ctx, cancel := context.WithCancel(context.Background())
128+ cancel()
129+ return ctx, newCommandTrackerTestClock(), nil
130+ },
131+ wantErr: context.Canceled,
132+ wantPending: 0,
133+ },
134+ }
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ tracker := NewCommandRequestTracker()
138+ requestID, outcome := tracker.Publish(1)
139+ ctx, clock, drive := tt.setup(t, tracker, requestID)
140+ waitDone := make(chan error, 1)
141+ go func() {
142+ _, err := tracker.Wait(ctx, clock, requestID, 1, outcome)
143+ waitDone <- err
144+ }()
145+ if drive != nil {
146+ drive()
147+ }
148+ require.ErrorIs(t, <-waitDone, tt.wantErr)
149+ require.Equal(t, tt.wantPending, tracker.PendingCount())
150+ })
151+ }
152+ })
71153 }
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -68,4 +69,85 @@ func TestCommandRequestTrackerTimeoutAndGenerationIsolation(t *testing.T) {
68 tracker.Complete(8, ports.CommandResult{RequestID: requestID, OK: true})69 tracker.Complete(8, ports.CommandResult{RequestID: requestID, OK: true})
69 result := <-secondOutcome70 result := <-secondOutcome
70 require.True(t, result.Result.OK)71 require.True(t, result.Result.OK)
72+
73+ t.Run("wait outcomes", func(t *testing.T) {
74+ failure := errors.New("transport failed")
75+ tests := []struct {
76+ name string
77+ setup func(*testing.T, *CommandRequestTracker, uint64) (context.Context, ports.Clock, func())
78+ wantErr error
79+ wantPending int
80+ }{
81+ {
82+ name: "canceled context",
83+ setup: func(_ *testing.T, _ *CommandRequestTracker, _ uint64) (context.Context, ports.Clock, func()) {
84+ ctx, cancel := context.WithCancel(context.Background())
85+ cancel()
86+ return ctx, newCommandTrackerTestClock(), nil
87+ },
88+ wantErr: context.Canceled,
89+ wantPending: 0,
90+ },
91+ {
92+ name: "nil clock",
93+ setup: func(_ *testing.T, _ *CommandRequestTracker, _ uint64) (context.Context, ports.Clock, func()) {
94+ return context.Background(), nil, nil
95+ },
96+ wantErr: ErrCommandRequestUnavailable,
97+ wantPending: 1,
98+ },
99+ {
100+ name: "fail",
101+ setup: func(_ *testing.T, tracker *CommandRequestTracker, requestID uint64) (context.Context, ports.Clock, func()) {
102+ tracker.Fail(requestID, 1, failure)
103+ return context.Background(), newCommandTrackerTestClock(), nil
104+ },
105+ wantErr: failure,
106+ wantPending: 0,
107+ },
108+ {
109+ name: "remove",
110+ setup: func(_ *testing.T, tracker *CommandRequestTracker, requestID uint64) (context.Context, ports.Clock, func()) {
111+ tracker.Remove(requestID, 1)
112+ clock := newCommandTrackerTestClock()
113+ return context.Background(), clock, func() {
114+ timer := <-clock.timers
115+ timer.fire()
116+ }
117+ },
118+ wantErr: ErrCommandRequestTimeout,
119+ wantPending: 0,
120+ },
121+ {
122+ name: "duplicate track",
123+ setup: func(t *testing.T, tracker *CommandRequestTracker, requestID uint64) (context.Context, ports.Clock, func()) {
124+ duplicate, ok := tracker.Track(requestID, 1)
125+ require.False(t, ok)
126+ require.Nil(t, duplicate)
127+ ctx, cancel := context.WithCancel(context.Background())
128+ cancel()
129+ return ctx, newCommandTrackerTestClock(), nil
130+ },
131+ wantErr: context.Canceled,
132+ wantPending: 0,
133+ },
134+ }
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ tracker := NewCommandRequestTracker()
138+ requestID, outcome := tracker.Publish(1)
139+ ctx, clock, drive := tt.setup(t, tracker, requestID)
140+ waitDone := make(chan error, 1)
141+ go func() {
142+ _, err := tracker.Wait(ctx, clock, requestID, 1, outcome)
143+ waitDone <- err
144+ }()
145+ if drive != nil {
146+ drive()
147+ }
148+ require.ErrorIs(t, <-waitDone, tt.wantErr)
149+ require.Equal(t, tt.wantPending, tracker.PendingCount())
150+ })
151+ }
152+ })
71 }153 }
M internal/usecase/daemon/control.go +14 -17
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "errors"
77 "fmt"
8+ "runtime"
89 "slices"
910 "sort"
1011 "strings"
@@ -37,14 +38,7 @@ func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) error {
3738
3839 tracker := NewCommandRequestTracker()
3940 const generation = uint64(1)
40- outcome, ok := tracker.Track(request.RequestID, generation)
41- if !ok {
42- return d.sendCommandResult(tr, ports.CommandResult{
43- RequestID: request.RequestID,
44- Code: ports.ErrInternal,
45- Text: "duplicate command request id",
46- })
47- }
41+ outcome, _ := tracker.Track(request.RequestID, generation)
4842 commandCtx := d.serveCtx
4943 if commandCtx == nil {
5044 commandCtx = context.Background()
@@ -60,15 +54,8 @@ func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) error {
6054 if commandClock == nil {
6155 commandClock = systemClock{}
6256 }
63- result, waitErr := tracker.Wait(context.Background(), commandClock, request.RequestID, generation, outcome)
57+ result, waitErr := tracker.Wait(commandCtx, commandClock, request.RequestID, generation, outcome)
6458 if waitErr != nil {
65- if errors.Is(waitErr, ErrCommandRequestTimeout) {
66- return d.sendCommandResult(tr, ports.CommandResult{
67- RequestID: request.RequestID,
68- Code: ports.ErrInternal,
69- Text: waitErr.Error(),
70- })
71- }
7259 return d.sendCommandResult(tr, ports.CommandResult{
7360 RequestID: request.RequestID,
7461 Code: ports.ErrInternal,
@@ -132,7 +119,17 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandReque
132119 return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
133120 }
134121
135- sess.dispatchMu.Lock()
122+ for {
123+ select {
124+ case <-ctx.Done():
125+ return commandFailure(ports.ErrInternal, ctx.Err().Error())
126+ default:
127+ }
128+ if sess.dispatchMu.TryLock() {
129+ break
130+ }
131+ runtime.Gosched()
132+ }
136133 defer sess.dispatchMu.Unlock()
137134
138135 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
@@ -5,6 +5,7 @@ import (
5 "encoding/json"5 "encoding/json"
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "runtime"
8 "slices"9 "slices"
9 "sort"10 "sort"
10 "strings"11 "strings"
@@ -37,14 +38,7 @@ func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) error {
37 38
38 tracker := NewCommandRequestTracker()39 tracker := NewCommandRequestTracker()
39 const generation = uint64(1)40 const generation = uint64(1)
40- outcome, ok := tracker.Track(request.RequestID, generation)41+ outcome, _ := tracker.Track(request.RequestID, generation)
41- if !ok {
42- return d.sendCommandResult(tr, ports.CommandResult{
43- RequestID: request.RequestID,
44- Code: ports.ErrInternal,
45- Text: "duplicate command request id",
46- })
47- }
48 commandCtx := d.serveCtx42 commandCtx := d.serveCtx
49 if commandCtx == nil {43 if commandCtx == nil {
50 commandCtx = context.Background()44 commandCtx = context.Background()
@@ -60,15 +54,8 @@ func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) error {
60 if commandClock == nil {54 if commandClock == nil {
61 commandClock = systemClock{}55 commandClock = systemClock{}
62 }56 }
63- result, waitErr := tracker.Wait(context.Background(), commandClock, request.RequestID, generation, outcome)57+ result, waitErr := tracker.Wait(commandCtx, commandClock, request.RequestID, generation, outcome)
64 if waitErr != nil {58 if waitErr != nil {
65- if errors.Is(waitErr, ErrCommandRequestTimeout) {
66- return d.sendCommandResult(tr, ports.CommandResult{
67- RequestID: request.RequestID,
68- Code: ports.ErrInternal,
69- Text: waitErr.Error(),
70- })
71- }
72 return d.sendCommandResult(tr, ports.CommandResult{59 return d.sendCommandResult(tr, ports.CommandResult{
73 RequestID: request.RequestID,60 RequestID: request.RequestID,
74 Code: ports.ErrInternal,61 Code: ports.ErrInternal,
@@ -132,7 +119,17 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandReque
132 return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)119 return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
133 }120 }
134 121
135- sess.dispatchMu.Lock()122+ for {
123+ select {
124+ case <-ctx.Done():
125+ return commandFailure(ports.ErrInternal, ctx.Err().Error())
126+ default:
127+ }
128+ if sess.dispatchMu.TryLock() {
129+ break
130+ }
131+ runtime.Gosched()
132+ }
136 defer sess.dispatchMu.Unlock()133 defer sess.dispatchMu.Unlock()
137 134
138 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)135 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
M internal/usecase/daemon/control_test.go +4 -3
@@ -129,13 +129,14 @@ func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) {
129129 clock := &signalClock{timers: make(chan *signalTimer, 1)}
130130 d := newTestDaemon(t, factory, clock)
131131 addControlSession(d, "work", "t_work", "p_work")
132- tr, sends, releaseConn := newConn(t, commandFrame(t, ports.CommandRequest{
132+ frame := commandFrame(t, ports.CommandRequest{
133133 RequestID: 17, Slug: "new-tab", TargetSession: "work",
134- }))
134+ })
135+ tr, sends, releaseConn := newConn(t, frame)
135136 defer releaseConn()
136137 done := make(chan error, 1)
137138 go func() {
138- done <- d.handleCommand(tr, commandFrame(t, ports.CommandRequest{RequestID: 17, Slug: "new-tab", TargetSession: "work"}))
139+ done <- d.handleCommand(tr, frame)
139140 }()
140141 <-factory.entered
141142 timer := <-clock.timers
@@ -129,13 +129,14 @@ func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) {
129 clock := &signalClock{timers: make(chan *signalTimer, 1)}129 clock := &signalClock{timers: make(chan *signalTimer, 1)}
130 d := newTestDaemon(t, factory, clock)130 d := newTestDaemon(t, factory, clock)
131 addControlSession(d, "work", "t_work", "p_work")131 addControlSession(d, "work", "t_work", "p_work")
132- tr, sends, releaseConn := newConn(t, commandFrame(t, ports.CommandRequest{132+ frame := commandFrame(t, ports.CommandRequest{
133 RequestID: 17, Slug: "new-tab", TargetSession: "work",133 RequestID: 17, Slug: "new-tab", TargetSession: "work",
134- }))134+ })
135+ tr, sends, releaseConn := newConn(t, frame)
135 defer releaseConn()136 defer releaseConn()
136 done := make(chan error, 1)137 done := make(chan error, 1)
137 go func() {138 go func() {
138- done <- d.handleCommand(tr, commandFrame(t, ports.CommandRequest{RequestID: 17, Slug: "new-tab", TargetSession: "work"}))139+ done <- d.handleCommand(tr, frame)
139 }()140 }()
140 <-factory.entered141 <-factory.entered
141 timer := <-clock.timers142 timer := <-clock.timers
M internal/usecase/daemon/daemon.go +7 -3
@@ -1194,11 +1194,15 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
11941194 }
11951195 postWelcomeTicket.End()
11961196 paintToken := sess.attachmentToken(ac, tr)
1197- painted := false
1197+ painted := make(chan bool, 1)
11981198 if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1199- painted = d.firstPaintForTransition(paintToken)
1199+ painted <- d.firstPaintForTransition(paintToken)
12001200 return nil
1201- }); err != nil || !painted || handshakeCtx.Err() != nil {
1201+ }); err != nil {
1202+ failAttachment()
1203+ return
1204+ }
1205+ if !<-painted || handshakeCtx.Err() != nil {
12021206 failAttachment()
12031207 return
12041208 }
@@ -1194,11 +1194,15 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1194 }1194 }
1195 postWelcomeTicket.End()1195 postWelcomeTicket.End()
1196 paintToken := sess.attachmentToken(ac, tr)1196 paintToken := sess.attachmentToken(ac, tr)
1197- painted := false1197+ painted := make(chan bool, 1)
1198 if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {1198 if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1199- painted = d.firstPaintForTransition(paintToken)1199+ painted <- d.firstPaintForTransition(paintToken)
1200 return nil1200 return nil
1201- }); err != nil || !painted || handshakeCtx.Err() != nil {1201+ }); err != nil {
1202+ failAttachment()
1203+ return
1204+ }
1205+ if !<-painted || handshakeCtx.Err() != nil {
1202 failAttachment()1206 failAttachment()
1203 return1207 return
1204 }1208 }
M internal/usecase/daemon/handshake.go +3 -0
@@ -102,6 +102,9 @@ func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fal
102102 return errors.Join(errHandshakeTimeout, context.DeadlineExceeded)
103103 default:
104104 }
105+ if parent == nil {
106+ return fallback
107+ }
105108 if err := parent.Err(); err != nil {
106109 return err
107110 }
@@ -102,6 +102,9 @@ func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fal
102 return errors.Join(errHandshakeTimeout, context.DeadlineExceeded)102 return errors.Join(errHandshakeTimeout, context.DeadlineExceeded)
103 default:103 default:
104 }104 }
105+ if parent == nil {
106+ return fallback
107+ }
105 if err := parent.Err(); err != nil {108 if err := parent.Err(); err != nil {
106 return err109 return err
107 }110 }
M internal/usecase/daemon/handshake_test.go +38 -0
@@ -107,6 +107,44 @@ func TestBoundedHandshakeCancellationDoesNotWaitForUninterruptibleOperation(t *t
107107 awaitTestCompletion(t, operationDone, "handshake operation did not finish")
108108 }
109109
110+func TestHandshakeFirstPaintCancellationDoesNotRacePaintResult(t *testing.T) {
111+ pty, releasePTY := newBlockingPTY(t)
112+ defer releasePTY()
113+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
114+ ctx, cancel := context.WithCancel(context.Background())
115+ defer cancel()
116+
117+ paintStarted := make(chan struct{})
118+ releasePaint := make(chan struct{})
119+ paintDone := make(chan struct{})
120+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
121+ close(paintStarted)
122+ <-ctx.Done()
123+ <-releasePaint
124+ close(paintDone)
125+ }
126+
127+ tr := newHandshakeBlockingTransport(false)
128+ handshakeDone := make(chan struct{})
129+ go func() {
130+ d.handleHelloWithContext(ctx, make(chan struct{}), func() {}, func() {}, tr,
131+ mustHello(ports.IntentNew, "paint-cancel", domain.Size{Cols: 80, Rows: 24}))
132+ close(handshakeDone)
133+ }()
134+
135+ awaitTestCompletion(t, paintStarted, "first paint did not start")
136+ cancel()
137+ awaitTestCompletion(t, tr.closed, "handshake cancellation did not close the transport")
138+ select {
139+ case <-paintDone:
140+ t.Fatal("first paint completed before cancellation returned")
141+ default:
142+ }
143+ close(releasePaint)
144+ awaitTestCompletion(t, paintDone, "delayed first paint did not finish")
145+ awaitTestCompletion(t, handshakeDone, "handshake cancellation did not finish")
146+}
147+
110148 func TestHandshakeTimeoutClosesBlockedReceive(t *testing.T) {
111149 clock := &signalClock{timers: make(chan *signalTimer, 1)}
112150 d := newTestDaemon(t, nil, clock)
@@ -107,6 +107,44 @@ func TestBoundedHandshakeCancellationDoesNotWaitForUninterruptibleOperation(t *t
107 awaitTestCompletion(t, operationDone, "handshake operation did not finish")107 awaitTestCompletion(t, operationDone, "handshake operation did not finish")
108 }108 }
109 109
110+func TestHandshakeFirstPaintCancellationDoesNotRacePaintResult(t *testing.T) {
111+ pty, releasePTY := newBlockingPTY(t)
112+ defer releasePTY()
113+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
114+ ctx, cancel := context.WithCancel(context.Background())
115+ defer cancel()
116+
117+ paintStarted := make(chan struct{})
118+ releasePaint := make(chan struct{})
119+ paintDone := make(chan struct{})
120+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
121+ close(paintStarted)
122+ <-ctx.Done()
123+ <-releasePaint
124+ close(paintDone)
125+ }
126+
127+ tr := newHandshakeBlockingTransport(false)
128+ handshakeDone := make(chan struct{})
129+ go func() {
130+ d.handleHelloWithContext(ctx, make(chan struct{}), func() {}, func() {}, tr,
131+ mustHello(ports.IntentNew, "paint-cancel", domain.Size{Cols: 80, Rows: 24}))
132+ close(handshakeDone)
133+ }()
134+
135+ awaitTestCompletion(t, paintStarted, "first paint did not start")
136+ cancel()
137+ awaitTestCompletion(t, tr.closed, "handshake cancellation did not close the transport")
138+ select {
139+ case <-paintDone:
140+ t.Fatal("first paint completed before cancellation returned")
141+ default:
142+ }
143+ close(releasePaint)
144+ awaitTestCompletion(t, paintDone, "delayed first paint did not finish")
145+ awaitTestCompletion(t, handshakeDone, "handshake cancellation did not finish")
146+}
147+
110 func TestHandshakeTimeoutClosesBlockedReceive(t *testing.T) {148 func TestHandshakeTimeoutClosesBlockedReceive(t *testing.T) {
111 clock := &signalClock{timers: make(chan *signalTimer, 1)}149 clock := &signalClock{timers: make(chan *signalTimer, 1)}
112 d := newTestDaemon(t, nil, clock)150 d := newTestDaemon(t, nil, clock)
M internal/usecase/daemon/picker.go +5 -6
@@ -682,9 +682,6 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT
682682 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
683683 return errAttachmentTransition
684684 }
685- if guard.closePicker {
686- d.closePicker(token.ac)
687- }
688685 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(ports.AttachTarget{
689686 Endpoint: key.Host,
690687 Session: key.Name,
@@ -692,6 +689,9 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT
692689 })}); err != nil {
693690 return err
694691 }
692+ if guard.closePicker {
693+ d.closePicker(token.ac)
694+ }
695695 d.clientGoneForAttachment(token, false)
696696 return nil
697697 }
@@ -981,15 +981,14 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
981981
982982 // stealClientForTarget is retained for direct-ID callers and tests. Named
983983 // targets must use switchToTarget so resolution and commit share d.mu.
984-func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
984+func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) {
985985 d.mu.Lock()
986986 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
987987 d.mu.Unlock()
988988 if !switched {
989- return nil
989+ return
990990 }
991991 d.deferAttachmentTransitionCleanups(transition)
992- return nil
993992 }
994993
995994 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
@@ -682,9 +682,6 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT
682 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {682 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
683 return errAttachmentTransition683 return errAttachmentTransition
684 }684 }
685- if guard.closePicker {
686- d.closePicker(token.ac)
687- }
688 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(ports.AttachTarget{685 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(ports.AttachTarget{
689 Endpoint: key.Host,686 Endpoint: key.Host,
690 Session: key.Name,687 Session: key.Name,
@@ -692,6 +689,9 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT
692 })}); err != nil {689 })}); err != nil {
693 return err690 return err
694 }691 }
692+ if guard.closePicker {
693+ d.closePicker(token.ac)
694+ }
695 d.clientGoneForAttachment(token, false)695 d.clientGoneForAttachment(token, false)
696 return nil696 return nil
697 }697 }
@@ -981,15 +981,14 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
981 981
982 // stealClientForTarget is retained for direct-ID callers and tests. Named982 // stealClientForTarget is retained for direct-ID callers and tests. Named
983 // targets must use switchToTarget so resolution and commit share d.mu.983 // targets must use switchToTarget so resolution and commit share d.mu.
984-func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {984+func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) {
985 d.mu.Lock()985 d.mu.Lock()
986 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")986 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
987 d.mu.Unlock()987 d.mu.Unlock()
988 if !switched {988 if !switched {
989- return nil989+ return
990 }990 }
991 d.deferAttachmentTransitionCleanups(transition)991 d.deferAttachmentTransitionCleanups(transition)
992- return nil
993 }992 }
994 993
995 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves994 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
M internal/usecase/daemon/remote_picker_test.go +47 -0
@@ -444,6 +444,53 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
444444 d.mu.Unlock()
445445 }
446446
447+func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
448+ d := newRemotePickerDaemon(nil)
449+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
450+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
451+ }})
452+ const sendErr = "remote attach send failed"
453+ tr := &remotePickerSendErrorTransport{err: errors.New(sendErr)}
454+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
455+ ac.initOverlays()
456+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
457+ sess := &session{sessionCore: sessionCore{id: "local", name: "local", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}
458+ publishTiledPaneOwners(sess, tb)
459+ ac.setSession(sess)
460+ d.sessions[sess.id] = sess
461+ ac.overlays.pickerMu.Lock()
462+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
463+ ac.overlays.pickerGeneration++
464+ ac.overlays.pickerMu.Unlock()
465+
466+ gone := make(chan struct{})
467+ d.afterClientGoneDetach = func() { close(gone) }
468+ token := sess.attachmentToken(ac, tr)
469+ effect, admitted := ac.beginAttachmentEffect(token)
470+ require.True(t, admitted)
471+ token.effect = effect
472+ defer effect.End()
473+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
474+ target := picker.Target{Session: key.ID(), RemoteKey: &key}
475+ err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")
476+ require.EqualError(t, err, sendErr)
477+ require.True(t, ac.overlays.pickerActive(), "failed control send must leave the picker open")
478+ select {
479+ case <-gone:
480+ t.Fatal("failed control send reached clientGoneForAttachment")
481+ default:
482+ }
483+ require.Same(t, sess, ac.currentAttachmentSession())
484+}
485+
486+type remotePickerSendErrorTransport struct {
487+ err error
488+}
489+
490+func (t *remotePickerSendErrorTransport) Send(ports.Frame) error { return t.err }
491+func (*remotePickerSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
492+func (*remotePickerSendErrorTransport) Close() error { return nil }
493+
447494 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
448495 t.Helper()
449496 tr, sends := newCapturingTransport(t)
@@ -444,6 +444,53 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
444 d.mu.Unlock()444 d.mu.Unlock()
445 }445 }
446 446
447+func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
448+ d := newRemotePickerDaemon(nil)
449+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
450+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
451+ }})
452+ const sendErr = "remote attach send failed"
453+ tr := &remotePickerSendErrorTransport{err: errors.New(sendErr)}
454+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
455+ ac.initOverlays()
456+ tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
457+ sess := &session{sessionCore: sessionCore{id: "local", name: "local", attachments: map[*attachedClient]struct{}{ac: {}}}, tabs: []*tab{tb}}
458+ publishTiledPaneOwners(sess, tb)
459+ ac.setSession(sess)
460+ d.sessions[sess.id] = sess
461+ ac.overlays.pickerMu.Lock()
462+ ac.overlays.picker = picker.New(nil, picker.SelectionConfig{})
463+ ac.overlays.pickerGeneration++
464+ ac.overlays.pickerMu.Unlock()
465+
466+ gone := make(chan struct{})
467+ d.afterClientGoneDetach = func() { close(gone) }
468+ token := sess.attachmentToken(ac, tr)
469+ effect, admitted := ac.beginAttachmentEffect(token)
470+ require.True(t, admitted)
471+ token.effect = effect
472+ defer effect.End()
473+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
474+ target := picker.Target{Session: key.ID(), RemoteKey: &key}
475+ err := d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select")
476+ require.EqualError(t, err, sendErr)
477+ require.True(t, ac.overlays.pickerActive(), "failed control send must leave the picker open")
478+ select {
479+ case <-gone:
480+ t.Fatal("failed control send reached clientGoneForAttachment")
481+ default:
482+ }
483+ require.Same(t, sess, ac.currentAttachmentSession())
484+}
485+
486+type remotePickerSendErrorTransport struct {
487+ err error
488+}
489+
490+func (t *remotePickerSendErrorTransport) Send(ports.Frame) error { return t.err }
491+func (*remotePickerSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
492+func (*remotePickerSendErrorTransport) Close() error { return nil }
493+
447 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {494 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
448 t.Helper()495 t.Helper()
449 tr, sends := newCapturingTransport(t)496 tr, sends := newCapturingTransport(t)

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