detached 630045d

Merge pull request #175 from bnema/refactor/transport-neutral-session-connections

refactor(connection): make session access transport-neutral
commit
630045d822fa52aa3ace50a38db43504c183615c
author
bnema · · 2026-08-04 10:03 UTC
committer
GitHub · · 2026-08-04 10:03 UTC
merge of
ee0f20d + bb0d730 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
74 files · +2296 -6865
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.
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 }
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 }
M cmd/vev-perf-harness/launcher.go +12 -12
@@ -301,7 +301,7 @@ func (l *cliLauncher) releaseRuntime(runDir string) error {
301301
302302 // preparePeer installs a per-run ssh command seam. The ordinary public client
303303 // still executes its documented remote attach command; its ssh child invokes
304-// only the parsed public _stdio or _udp-proxy command. This makes the peer
304+// only the parsed public _stdio or _udp-proxy entrypoint. This makes the peer
305305 // carrying client traffic the declared, exclusively traced role rather than a
306306 // separately launched but unused process.
307307 func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedProcess, error) {
@@ -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 }
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})
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 }
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 {
M internal/adapters/dgram/proxy.go +1 -1
@@ -50,8 +50,8 @@ type proxyCopyDirection struct {
5050 dst ports.Transport
5151 recvKind proxyCopyErrKind
5252 sendKind proxyCopyErrKind
53- retryRecoverable bool
5453 transform func(ports.Frame) ports.Frame
54+ retryRecoverable bool
5555 }
5656
5757 func (p ProxyRuntime) Run(ctx context.Context) error {
D internal/adapters/dgram/proxy_registry.go +0 -158
@@ -1,158 +0,0 @@
1-package dgram
2-
3-import (
4- "crypto/sha256"
5- "encoding/base64"
6- "encoding/json"
7- "errors"
8- "os"
9- "path/filepath"
10- "syscall"
11- "time"
12-
13- "github.com/bnema/vev/pkg/safedir"
14-)
15-
16-type ProxyRecord struct {
17- Session string `json:"session"`
18- PID int `json:"pid"`
19- Port int `json:"port"`
20- // KeyFingerprint is a non-secret ownership tag (SHA-256 of the AEAD key).
21- // The raw key must never be persisted by vev.
22- KeyFingerprint string `json:"key_fingerprint"`
23- Created time.Time `json:"created"`
24-}
25-
26-// KeyFingerprint derives the non-secret registry ownership tag.
27-func KeyFingerprint(key []byte) string {
28- sum := sha256.Sum256(key)
29- return base64.StdEncoding.EncodeToString(sum[:])
30-}
31-
32-type ProxyRegistry struct{ dir string }
33-
34-func NewProxyRegistry(dir string) *ProxyRegistry { return &ProxyRegistry{dir: dir} }
35-
36-func (r *ProxyRegistry) Lookup(session string) (ProxyRecord, bool) {
37- var rec ProxyRecord
38- ok := false
39- _ = r.withLock(func() error {
40- var err error
41- rec, err = r.read(session)
42- if err != nil {
43- return nil
44- }
45- if !processAlive(rec.PID) {
46- _ = r.removeLocked(session)
47- return nil
48- }
49- if rec.PID != os.Getpid() {
50- return nil
51- }
52- ok = true
53- return nil
54- })
55- return rec, ok
56-}
57-
58-func (r *ProxyRegistry) Publish(rec ProxyRecord) error {
59- if rec.Created.IsZero() {
60- rec.Created = time.Now()
61- }
62- return r.withLock(func() error {
63- b, err := json.Marshal(rec)
64- if err != nil {
65- return err
66- }
67- tmp, err := os.CreateTemp(r.dir, ".udp-proxy-*.tmp")
68- if err != nil {
69- return err
70- }
71- tmpName := tmp.Name()
72- defer func() { _ = os.Remove(tmpName) }()
73- if _, err := tmp.Write(b); err != nil {
74- _ = tmp.Close()
75- return err
76- }
77- if err := tmp.Close(); err != nil {
78- return err
79- }
80- return os.Rename(tmpName, r.path(rec.Session))
81- })
82-}
83-
84-func (r *ProxyRegistry) Remove(session string) error {
85- return r.withLock(func() error { return r.removeLocked(session) })
86-}
87-
88-func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error {
89- return r.withLock(func() error {
90- cur, err := r.read(rec.Session)
91- if errors.Is(err, os.ErrNotExist) {
92- return nil
93- }
94- if err != nil {
95- return err
96- }
97- if cur.PID != rec.PID || cur.Port != rec.Port || cur.KeyFingerprint != rec.KeyFingerprint {
98- return nil
99- }
100- return r.removeLocked(rec.Session)
101- })
102-}
103-
104-func (r *ProxyRegistry) withLock(fn func() error) error {
105- if err := safedir.EnsurePrivate(r.dir); err != nil {
106- return err
107- }
108- lock, err := os.OpenFile(filepath.Join(r.dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o600)
109- if err != nil {
110- return err
111- }
112- defer func() { _ = lock.Close() }()
113- if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
114- return err
115- }
116- defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) }()
117- return fn()
118-}
119-
120-func (r *ProxyRegistry) removeLocked(session string) error {
121- err := os.Remove(r.path(session))
122- if errors.Is(err, os.ErrNotExist) {
123- return nil
124- }
125- return err
126-}
127-
128-func (r *ProxyRegistry) read(session string) (ProxyRecord, error) {
129- b, err := os.ReadFile(r.path(session))
130- if err != nil {
131- return ProxyRecord{}, err
132- }
133- var rec ProxyRecord
134- if err := json.Unmarshal(b, &rec); err != nil {
135- return ProxyRecord{}, err
136- }
137- return rec, nil
138-}
139-
140-func (r *ProxyRegistry) path(session string) string {
141- if session == "" {
142- return filepath.Join(r.dir, "udp-proxy-empty.json")
143- }
144- name := base64.RawURLEncoding.EncodeToString([]byte(session))
145- return filepath.Join(r.dir, "udp-proxy-"+name+".json")
146-}
147-
148-func processAlive(pid int) bool {
149- if pid <= 0 {
150- return false
151- }
152- p, err := os.FindProcess(pid)
153- if err != nil {
154- return false
155- }
156- err = p.Signal(syscall.Signal(0))
157- return err == nil || errors.Is(err, syscall.EPERM)
158-}
D internal/adapters/dgram/proxy_registry_test.go +0 -153
@@ -1,153 +0,0 @@
1-package dgram
2-
3-import (
4- "bytes"
5- "crypto/sha256"
6- "encoding/base64"
7- "encoding/json"
8- "os"
9- "path/filepath"
10- "testing"
11-)
12-
13-func privateDir(t *testing.T) string {
14- t.Helper()
15- return filepath.Join(t.TempDir(), "vev")
16-}
17-
18-func TestKeyFingerprint(t *testing.T) {
19- key := bytes.Repeat([]byte{0xA5}, 32)
20- sum := sha256.Sum256(key)
21- want := base64.StdEncoding.EncodeToString(sum[:])
22- if got := KeyFingerprint(key); got != want {
23- t.Fatalf("KeyFingerprint()=%q, want %q", got, want)
24- }
25- other := bytes.Repeat([]byte{0xA6}, 32)
26- if KeyFingerprint(key) == KeyFingerprint(other) {
27- t.Fatal("distinct keys produced identical fingerprints")
28- }
29-}
30-
31-func TestProxyRegistryFileHoldsNoKeyMaterial(t *testing.T) {
32- r := NewProxyRegistry(privateDir(t))
33- key := bytes.Repeat([]byte{0xA5}, 32)
34- fingerprint := KeyFingerprint(key)
35- rec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: fingerprint}
36- if err := r.Publish(rec); err != nil {
37- t.Fatal(err)
38- }
39-
40- data, err := os.ReadFile(r.path("work"))
41- if err != nil {
42- t.Fatal(err)
43- }
44- if bytes.Contains(data, key) || bytes.Contains(data, []byte(base64.StdEncoding.EncodeToString(key))) {
45- t.Fatalf("registry file contains raw key material: %s", data)
46- }
47- var fields map[string]json.RawMessage
48- if err := json.Unmarshal(data, &fields); err != nil {
49- t.Fatal(err)
50- }
51- if _, ok := fields["key"]; ok {
52- t.Fatalf("registry file contains raw key field: %s", data)
53- }
54- var got string
55- if err := json.Unmarshal(fields["key_fingerprint"], &got); err != nil {
56- t.Fatal(err)
57- }
58- if got != fingerprint {
59- t.Fatalf("key_fingerprint=%q, want %q", got, fingerprint)
60- }
61-}
62-
63-func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
64- t.Run("reuses live matching record", func(t *testing.T) {
65- r := NewProxyRegistry(privateDir(t))
66- want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}
67- if err := r.Publish(want); err != nil {
68- t.Fatal(err)
69- }
70- got, ok := r.Lookup("work")
71- if !ok {
72- t.Fatal("Lookup ok=false, want true")
73- }
74- if got.Port != want.Port || got.KeyFingerprint != want.KeyFingerprint || got.PID != want.PID {
75- t.Fatalf("Lookup()=%+v, want %+v", got, want)
76- }
77- })
78- t.Run("removes stale record", func(t *testing.T) {
79- r := NewProxyRegistry(privateDir(t))
80- if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}); err != nil {
81- t.Fatal(err)
82- }
83- if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key_fingerprint":"abc"}`), 0o600); err != nil {
84- t.Fatal(err)
85- }
86- if _, ok := r.Lookup("work"); ok {
87- t.Fatal("Lookup ok=true for stale pid")
88- }
89- if _, err := os.Stat(r.path("work")); !os.IsNotExist(err) {
90- t.Fatalf("stale record stat err=%v, want not exist", err)
91- }
92- })
93- t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {
94- r := NewProxyRegistry(privateDir(t))
95- old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "old"}
96- if err := r.Publish(old); err != nil {
97- t.Fatal(err)
98- }
99- newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, KeyFingerprint: "new"}
100- if err := r.Publish(newRec); err != nil {
101- t.Fatal(err)
102- }
103- if err := r.RemoveOwned(old); err != nil {
104- t.Fatal(err)
105- }
106- got, ok := r.Lookup("work")
107- if !ok {
108- t.Fatal("new live record removed by stale owner")
109- }
110- if got.Port != newRec.Port || got.KeyFingerprint != newRec.KeyFingerprint {
111- t.Fatalf("Lookup()=%+v, want %+v", got, newRec)
112- }
113- if err := r.RemoveOwned(newRec); err != nil {
114- t.Fatal(err)
115- }
116- if _, ok := r.Lookup("work"); ok {
117- t.Fatal("owned remove left registry record")
118- }
119- })
120-}
121-
122-func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
123- r := NewProxyRegistry(privateDir(t))
124- tests := []struct {
125- a string
126- b string
127- }{
128- {a: "", b: "default"},
129- {a: "a/b", b: "a_b"},
130- {a: `a\b`, b: "a_b"},
131- }
132- for _, tt := range tests {
133- if r.path(tt.a) == r.path(tt.b) {
134- t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a))
135- }
136- first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, KeyFingerprint: "first"}
137- second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, KeyFingerprint: "second"}
138- if err := r.Publish(first); err != nil {
139- t.Fatal(err)
140- }
141- if err := r.Publish(second); err != nil {
142- t.Fatal(err)
143- }
144- gotFirst, ok := r.Lookup(tt.a)
145- if !ok || gotFirst.KeyFingerprint != first.KeyFingerprint {
146- t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok)
147- }
148- gotSecond, ok := r.Lookup(tt.b)
149- if !ok || gotSecond.KeyFingerprint != second.KeyFingerprint {
150- t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok)
151- }
152- }
153-}
M internal/adapters/dgram/ssh_dialer.go +7 -8
@@ -39,8 +39,8 @@ 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 {
43- spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", session)
42+ startUDPBootstrap = func(ctx context.Context, target string, stderr io.Writer) bootstrapProcess {
43+ spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", "")
4444 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
4545 cmd.Stderr = stderr
4646 return execBootstrapProcess{cmd: cmd}
@@ -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, d.Session, &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)
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 != "work" {
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 })
M internal/adapters/remote/remote_factory.go +3 -4
@@ -31,14 +31,14 @@ func (f DialerFactory) DialerForRemote(target string, session string, mode ports
3131 if log != nil {
3232 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
3333 }
34- dialer := dgram.NewRemoteDialerWithLogger(target, session, log)
34+ dialer := dgram.NewRemoteDialerWithLogger(target, "", log)
3535 dialer.RuntimeObserver = f.observer
3636 return dialer, nil
3737 case ports.RemoteTransportStdio:
3838 if log != nil {
3939 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
4040 }
41- return stdioDialer{target: target, session: session, log: log, observer: f.observer}, nil
41+ return stdioDialer{target: target, log: log, observer: f.observer}, nil
4242 default:
4343 return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
4444 }
@@ -46,11 +46,10 @@ 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 }
5352
5453 func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {
55- return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, d.session, d.log, sshstdio.WithRuntimeObserver(d.observer))
54+ return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, "", d.log, sshstdio.WithRuntimeObserver(d.observer))
5655 }
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 != "work" {
36- t.Fatalf("dialer = %+v, want target/session copied", 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 != "work" {
44- t.Fatalf("dialer = %+v, want target/session copied", 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
M internal/adapters/sshstdio/transport.go +35 -27
@@ -70,6 +70,8 @@ type transport struct {
7070 operationCount int
7171 closing bool
7272 operationsDone chan struct{}
73+ closeOnce sync.Once
74+ closeErr error
7375
7476 mu sync.Mutex
7577 readBuf []byte
@@ -160,22 +162,32 @@ func (t *transport) mapEOFError(err error) error {
160162 }
161163
162164 func (t *transport) Close() error {
163- done := t.beginShutdown()
164- // Closing the reader first releases a Recv blocked in io.ReadFull even if
165- // the subprocess shutdown callback reports an error. This lets Recv emit
166- // its failed end mark before the process observer is closed.
167- var readErr error
168- if r, ok := t.r.(io.Closer); ok {
169- readErr = r.Close()
170- }
171- closeErr := t.close()
172- if done != nil {
173- <-done
174- }
175- if closeErr != nil {
176- return closeErr
177- }
178- return readErr
165+ t.closeOnce.Do(func() {
166+ done := t.beginShutdown()
167+ // Close owned streams before waiting for in-flight operations. The
168+ // writer is what releases a Send blocked on a child stdin pipe; the
169+ // reader releases a Recv blocked in io.ReadFull.
170+ var writeErr, readErr error
171+ if w, ok := t.w.(io.Closer); ok {
172+ writeErr = w.Close()
173+ }
174+ if r, ok := t.r.(io.Closer); ok {
175+ readErr = r.Close()
176+ }
177+ closeErr := t.close()
178+ if done != nil {
179+ <-done
180+ }
181+ switch {
182+ case closeErr != nil:
183+ t.closeErr = closeErr
184+ case readErr != nil:
185+ t.closeErr = readErr
186+ default:
187+ t.closeErr = writeErr
188+ }
189+ })
190+ return t.closeErr
179191 }
180192
181193 func (t *transport) beginObservedOperation() bool {
@@ -294,18 +306,14 @@ type CommandSpec struct {
294306 // BuildCommand constructs the local ssh subprocess argv without invoking a
295307 // local shell. OpenSSH sends the remote command as one string for the remote
296308 // user's shell to interpret, so every remote argv word is POSIX single-quoted.
297-func BuildCommand(target, session string) CommandSpec {
298- return BuildCommandForMode(target, "_stdio", session)
309+func BuildCommand(target, _ string) CommandSpec {
310+ return BuildCommandForMode(target, "_stdio", "")
299311 }
300312
301313 // BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev
302314 // remote mode such as _stdio or _udp-bootstrap.
303-func BuildCommandForMode(target, mode, session string) CommandSpec {
304- remote := []string{"vev", mode}
305- if session != "" {
306- remote = append(remote, session)
307- }
308- return BuildCommandForRemoteCommand(target, remote...)
315+func BuildCommandForMode(target, mode, _ string) CommandSpec {
316+ return BuildCommandForRemoteCommand(target, "vev", mode)
309317 }
310318
311319 // BuildCommandForRemoteCommand constructs ssh argv for an arbitrary remote
@@ -324,9 +332,9 @@ func shellQuote(s string) string {
324332 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
325333 }
326334
327-// Dial starts ssh target vev _stdio [session] and returns a Transport over the
328-// child process' stdio. The subprocess is started with exec.Command argv, never
329-// 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.
330338 func Dial(target, session string) (ports.Transport, error) {
331339 return DialContext(context.Background(), target, session)
332340 }
M internal/adapters/sshstdio/transport_test.go +43 -6
@@ -9,6 +9,7 @@ import (
99 "log/slog"
1010 "os/exec"
1111 "strings"
12+ "sync"
1213 "testing"
1314 "time"
1415
@@ -17,8 +18,9 @@ import (
1718 )
1819
1920 func TestBuildCommandUsesExecArgs(t *testing.T) {
20- // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio' [session]`.
21- // 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.
2224 tests := []struct {
2325 name string
2426 target string
@@ -26,9 +28,8 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
2628 want []string
2729 }{
2830 {name: "no session", target: "user@example.com", want: []string{"--", "user@example.com", "'vev' '_stdio'"}},
29- {name: "session shell metacharacters are quoted in remote command", target: "user@example.com", session: "work; rm -rf /", want: []string{"--", "user@example.com", "'vev' '_stdio' 'work; rm -rf /'"}},
30- {name: "single quote in session is posix escaped", target: "user@example.com", session: "it's fine", want: []string{"--", "user@example.com", "'vev' '_stdio' 'it'\\''s fine'"}},
31- {name: "target kept as single local arg after option terminator", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"--", "user@host; touch /tmp/pwn", "'vev' '_stdio' 'work'"}},
31+ {name: "session is carried by Hello rather than ssh command", target: "user@example.com", session: "work; rm -rf /", want: []string{"--", "user@example.com", "'vev' '_stdio'"}},
32+ {name: "target kept as single local arg after option terminator", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"--", "user@host; touch /tmp/pwn", "'vev' '_stdio'"}},
3233 }
3334
3435 for _, tt := range tests {
@@ -51,7 +52,7 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
5152
5253 func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {
5354 got := BuildCommandForMode("user@example.com", "_udp-bootstrap", "work")
54- want := []string{"--", "user@example.com", "'vev' '_udp-bootstrap' 'work'"}
55+ want := []string{"--", "user@example.com", "'vev' '_udp-bootstrap'"}
5556 if got.Path != "ssh" {
5657 t.Fatalf("Path = %q, want ssh", got.Path)
5758 }
@@ -107,6 +108,42 @@ func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) {
107108 }
108109 }
109110
111+func TestCloseInterruptsBlockedSend(t *testing.T) {
112+ writer := &blockedWriter{started: make(chan struct{}), released: make(chan struct{})}
113+ transport := NewTransport(nil, writer, nil)
114+
115+ sendDone := make(chan error, 1)
116+ go func() { sendDone <- transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("blocked")}) }()
117+ <-writer.started
118+ if err := transport.Close(); err != nil {
119+ t.Fatalf("Close() error = %v", err)
120+ }
121+ if err := <-sendDone; err == nil {
122+ t.Fatal("Send() error = nil after Close()")
123+ }
124+}
125+
126+type blockedWriter struct {
127+ started chan struct{}
128+ released chan struct{}
129+ once sync.Once
130+}
131+
132+func (w *blockedWriter) Write([]byte) (int, error) {
133+ w.once.Do(func() { close(w.started) })
134+ <-w.released
135+ return 0, io.ErrClosedPipe
136+}
137+
138+func (w *blockedWriter) Close() error {
139+ select {
140+ case <-w.released:
141+ default:
142+ close(w.released)
143+ }
144+ return nil
145+}
146+
110147 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
111148 clientRead, serverWrite := io.Pipe()
112149 serverRead, clientWrite := io.Pipe()
M internal/app/cmd.go +40 -15
@@ -10,9 +10,11 @@ import (
1010 "strings"
1111 "text/tabwriter"
1212
13+ "github.com/bnema/vev/internal/adapters/clock"
1314 "github.com/bnema/vev/internal/adapters/ipc"
1415 "github.com/bnema/vev/internal/ports"
1516 commandusecase "github.com/bnema/vev/internal/usecase/command"
17+ "github.com/bnema/vev/internal/usecase/daemon"
1618 )
1719
1820 type cmdInvocation struct {
@@ -126,10 +128,11 @@ type cmdDeps struct {
126128 stdout io.Writer
127129 getenv func(string) string
128130 dial func(context.Context, string) (ports.Transport, error)
131+ clock ports.Clock
129132 }
130133
131134 func runCmd(ctx context.Context, invocation cmdInvocation) error {
132- return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial})
135+ return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial, clock: clock.New()})
133136 }
134137
135138 func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
@@ -157,11 +160,9 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
157160 return usagef("--self requires running inside a vev pane")
158161 }
159162
160- payload, err := ports.MarshalCommandRequest(request)
161- if errors.Is(err, ports.ErrTooManyCommandArgs) {
163+ if _, err := ports.MarshalCommandRequest(request); errors.Is(err, ports.ErrTooManyCommandArgs) {
162164 return usagef("too many command arguments")
163- }
164- if err != nil {
165+ } else if err != nil {
165166 return fmt.Errorf("encoding command: %w", err)
166167 }
167168
@@ -171,20 +172,44 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
171172 }
172173 defer func() { _ = transport.Close() }()
173174
174- frame := ports.Frame{Type: ports.MsgCommand, Payload: payload}
175- if err := transport.Send(frame); err != nil {
176- return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
177- }
178- reply, err := transport.Recv()
175+ tracker := daemon.NewCommandRequestTracker()
176+ const generation = uint64(1)
177+ requestID, outcome := tracker.Publish(generation)
178+ request.RequestID = requestID
179+ payload, err := ports.MarshalCommandRequest(request)
179180 if err != nil {
180- return &exitCoded{code: 3, err: fmt.Errorf("reading command reply: %w", err)}
181+ tracker.Remove(requestID, generation)
182+ return fmt.Errorf("encoding command: %w", err)
181183 }
182- if reply.Type != ports.MsgCommandResult {
183- return fmt.Errorf("unexpected command reply type %d", reply.Type)
184+ if err := transport.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
185+ tracker.Remove(requestID, generation)
186+ return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
184187 }
185- result, err := ports.UnmarshalCommandResult(reply.Payload)
188+ go func() {
189+ reply, recvErr := transport.Recv()
190+ if recvErr != nil {
191+ tracker.Fail(requestID, generation, fmt.Errorf("reading command reply: %w", recvErr))
192+ return
193+ }
194+ if reply.Type != ports.MsgCommandResult {
195+ tracker.Fail(requestID, generation, fmt.Errorf("unexpected command reply type %d", reply.Type))
196+ return
197+ }
198+ result, decodeErr := ports.UnmarshalCommandResult(reply.Payload)
199+ if decodeErr != nil {
200+ tracker.Fail(requestID, generation, fmt.Errorf("decoding command reply: %w", decodeErr))
201+ return
202+ }
203+ result.RequestID = requestID
204+ tracker.Complete(generation, result)
205+ }()
206+ commandClock := deps.clock
207+ if commandClock == nil {
208+ commandClock = clock.New()
209+ }
210+ result, err := tracker.Wait(ctx, commandClock, requestID, generation, outcome)
186211 if err != nil {
187- return fmt.Errorf("decoding command reply: %w", err)
212+ return &exitCoded{code: 3, err: err}
188213 }
189214 if !result.OK {
190215 if result.Code == ports.ErrInvalidCommandArgs {
M internal/app/cmd_test.go +73 -5
@@ -9,8 +9,12 @@ import (
99 "strconv"
1010 "strings"
1111 "testing"
12+ "time"
1213
1314 "github.com/bnema/vev/internal/ports"
15+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
16+ "github.com/bnema/vev/internal/usecase/daemon"
17+ "github.com/stretchr/testify/mock"
1418 "github.com/stretchr/testify/require"
1519 )
1620
@@ -287,6 +291,62 @@ func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) {
287291 }
288292 }
289293
294+func TestRunCmdTimesOutPendingRequest(t *testing.T) {
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()
304+ recvStarted := make(chan struct{})
305+ recvGate := make(chan struct{})
306+ transport := &cmdTestTransport{recvStarted: recvStarted, recvGate: recvGate}
307+ done := make(chan error, 1)
308+ go func() {
309+ done <- runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
310+ stdout: io.Discard,
311+ getenv: func(string) string { return "" },
312+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
313+ clock: clock,
314+ })
315+ }()
316+ <-recvStarted
317+ if delay := <-delayCh; delay != daemon.CommandRequestTimeout {
318+ t.Fatalf("command timer delay = %s, want %s", delay, daemon.CommandRequestTimeout)
319+ }
320+ timerCh <- time.Time{}
321+ err := <-done
322+ close(recvGate)
323+ if ExitCode(err) != 3 || !errors.Is(err, daemon.ErrCommandRequestTimeout) {
324+ t.Fatalf("timeout error=%v code=%d", err, ExitCode(err))
325+ }
326+ request, decodeErr := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
327+ if decodeErr != nil || request.RequestID == 0 {
328+ t.Fatalf("request=%+v decode=%v, want non-zero request ID", request, decodeErr)
329+ }
330+}
331+
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)
347+ }
348+}
349+
290350 func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
291351 dials := 0
292352 dialErr := errors.New("unreachable")
@@ -349,11 +409,13 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
349409 }
350410
351411 type cmdTestTransport struct {
352- sent []ports.Frame
353- recv ports.Frame
354- recvErr error
355- recvCalls int
356- closeCalls int
412+ sent []ports.Frame
413+ recv ports.Frame
414+ recvErr error
415+ recvCalls int
416+ closeCalls int
417+ recvStarted chan struct{}
418+ recvGate <-chan struct{}
357419 }
358420
359421 func (t *cmdTestTransport) Send(frame ports.Frame) error {
@@ -363,6 +425,12 @@ func (t *cmdTestTransport) Send(frame ports.Frame) error {
363425
364426 func (t *cmdTestTransport) Recv() (ports.Frame, error) {
365427 t.recvCalls++
428+ if t.recvStarted != nil {
429+ close(t.recvStarted)
430+ }
431+ if t.recvGate != nil {
432+ <-t.recvGate
433+ }
366434 return t.recv, t.recvErr
367435 }
368436
M internal/app/run.go +128 -194
@@ -47,7 +47,6 @@ import (
4747 "github.com/bnema/vev/internal/platform"
4848 "github.com/bnema/vev/internal/ports"
4949 "github.com/bnema/vev/internal/usecase/client"
50- "github.com/bnema/vev/internal/usecase/confirm"
5150 "github.com/bnema/vev/internal/usecase/daemon"
5251 "github.com/bnema/vev/internal/usecase/recovery"
5352 pdgram "github.com/bnema/vev/pkg/dgram"
@@ -162,32 +161,20 @@ func parseArgs(args []string) (command, error) {
162161 case "--daemon-launcher":
163162 return command{kind: kindDaemonLauncher}, nil
164163 case "_stdio":
165- if len(args) > 2 {
166- return command{}, usagef("`_stdio` accepts at most one session name")
167- }
168- cmd := command{kind: kindStdio}
169- if len(args) == 2 {
170- cmd.name = args[1]
164+ if len(args) != 1 {
165+ return command{}, usagef("`_stdio` does not accept a session name")
171166 }
172- return cmd, nil
167+ return command{kind: kindStdio}, nil
173168 case "_udp-bootstrap":
174- if len(args) > 2 {
175- return command{}, usagef("`_udp-bootstrap` accepts at most one session name")
169+ if len(args) != 1 {
170+ return command{}, usagef("`_udp-bootstrap` does not accept a session name")
176171 }
177- cmd := command{kind: kindUDPBootstrap}
178- if len(args) == 2 {
179- cmd.name = args[1]
180- }
181- return cmd, nil
172+ return command{kind: kindUDPBootstrap}, nil
182173 case "_udp-proxy":
183- if len(args) > 2 {
184- return command{}, usagef("`_udp-proxy` accepts at most one session name")
174+ if len(args) != 1 {
175+ return command{}, usagef("`_udp-proxy` does not accept a session name")
185176 }
186- cmd := command{kind: kindUDPProxy}
187- if len(args) == 2 {
188- cmd.name = args[1]
189- }
190- return cmd, nil
177+ return command{kind: kindUDPProxy}, nil
191178 case "new":
192179 if len(args) < 2 || args[1] == "" {
193180 return command{}, usagef("`new` requires a session name")
@@ -208,6 +195,14 @@ func parseArgs(args []string) (command, error) {
208195 }
209196 cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]}
210197 if target, session, ok := parseRemoteAttachTarget(args[1]); ok {
198+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
199+ return command{}, err
200+ }
201+ if session != "" {
202+ if err := domain.ValidateSessionName(session); err != nil {
203+ return command{}, err
204+ }
205+ }
211206 cmd.remoteTarget = target
212207 cmd.name = session
213208 if session == "" {
@@ -271,9 +266,9 @@ func dispatch(ctx context.Context, cmd command) error {
271266 case kindStdio:
272267 return runStdio(ctx)
273268 case kindUDPBootstrap:
274- return runUDPBootstrap(ctx, cmd.name)
269+ return runUDPBootstrap(ctx)
275270 case kindUDPProxy:
276- return runUDPProxy(ctx, cmd.name, os.Stdout)
271+ return runUDPProxy(ctx, os.Stdout)
277272 case kindList:
278273 return runList(ctx, cmd)
279274 case kindHost:
@@ -788,6 +783,16 @@ func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error)
788783 }
789784 }
790785
786+func validateRemoteAttachHandoff(target ports.AttachTarget) error {
787+ if err := ports.ValidateAttachTarget(target); err != nil {
788+ return err
789+ }
790+ if err := domain.ValidateRemoteHostTarget(target.Endpoint); err != nil {
791+ return err
792+ }
793+ return domain.ValidateSessionName(target.Session)
794+}
795+
791796 // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from
792797 // the same validated transport selection used by direct remote attach.
793798 func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRuntimeObserver, transport string) (daemon.Option, error) {
@@ -804,6 +809,12 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti
804809 ), nil
805810 }
806811
812+type attachHandoffKey struct {
813+ endpoint string
814+ session string
815+ intent uint8
816+}
817+
807818 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
808819 if activeSession != "" {
809820 if remoteTarget == "" && intent == ports.IntentNew {
@@ -816,100 +827,85 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
816827 if runClient == nil {
817828 runClient = runClientWithDeps
818829 }
830+ localDialer := deps.localDialer
831+ if localDialer == nil {
832+ localDialer = defaultLocalDialer
833+ }
834+ mode, modeErr := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
835+ factory := deps.remoteDialerFactory
836+ seenHandoffs := make(map[attachHandoffKey]struct{})
819837 if remoteTarget != "" {
820- mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
821- if err != nil {
822- return err
838+ if modeErr != nil {
839+ return modeErr
823840 }
824- factory := deps.remoteDialerFactory
825841 if factory == nil {
826842 factory = defaultRemoteDialerFactory()
827843 }
828- if log != nil {
829- log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
830- }
831- dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log)
832- if err != nil {
833- return err
834- }
835- return runClient(ctx, client.Dependencies{
836- Dialer: dialer,
837- Terminal: term.New(),
838- Clock: clock.New(),
839- Clipboard: deps.clipboard,
840- Logger: log,
841- RuntimeObserver: deps.runtimeObserver,
842- RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
843- }, client.AttachRequest{Intent: intent, SessionName: name, Remote: true})
844+ seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
844845 }
845846
846- localDialer := deps.localDialer
847- if localDialer == nil {
848- localDialer = defaultLocalDialer
849- }
850- return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
851- confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
852- attach: func(ctx context.Context, intent uint8, name string) error {
847+ for {
848+ var err error
849+ if remoteTarget != "" {
850+ if log != nil {
851+ log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
852+ }
853+ dialer, dialErr := factory.DialerForRemote(remoteTarget, name, mode, log)
854+ if dialErr != nil {
855+ return dialErr
856+ }
857+ err = runClient(ctx, client.Dependencies{
858+ Dialer: dialer,
859+ Terminal: term.New(),
860+ Clock: clock.New(),
861+ Clipboard: deps.clipboard,
862+ Logger: log,
863+ RuntimeObserver: deps.runtimeObserver,
864+ RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
865+ Remote: true,
866+ }, client.AttachRequest{Intent: intent, SessionName: name})
867+ } else {
853868 if log != nil {
854869 log.Info("attaching to local session", "intent", intent, "name", name)
855870 }
856- return runClient(ctx, client.Dependencies{
871+ err = runClient(ctx, client.Dependencies{
857872 Dialer: localDialer(),
858873 Terminal: term.New(),
859874 Clock: clock.New(),
860875 Logger: log,
861876 RuntimeObserver: deps.runtimeObserver,
877+ Remote: false,
862878 }, client.AttachRequest{Intent: intent, SessionName: name})
863- },
864- killDaemon: requestDaemonStop,
865- settleAfterKill: waitForDaemonStop,
866- })
867-}
868-
869-type attachRecoveryDeps struct {
870- confirmer confirm.Confirmer
871- attach func(context.Context, uint8, string) error
872- killDaemon func(context.Context) error
873- settleAfterKill func(context.Context) error
874-}
875-
876-const (
877- daemonStopTimeout = 2 * time.Second
878- daemonRestartSettle = 50 * time.Millisecond
879- legacyMalformedHelloSignal = "malformed hello"
880-)
881-
882-func runLocalAttachWithRecovery(ctx context.Context, intent uint8, name string, deps attachRecoveryDeps) error {
883- err := deps.attach(ctx, intent, name)
884- if !isDaemonVersionDrift(err) {
885- return err
886- }
879+ }
887880
888- ok, promptErr := deps.confirmer.Confirm("Your vev version differs from the running daemon; kill it and restart?")
889- if promptErr != nil {
890- return fmt.Errorf("vev: reading confirmation: %w", promptErr)
891- }
892- if !ok {
893- return err
894- }
895- if killErr := deps.killDaemon(ctx); killErr != nil {
896- return killErr
897- }
898- if deps.settleAfterKill != nil {
899- if settleErr := deps.settleAfterKill(ctx); settleErr != nil {
900- return settleErr
881+ var handoff *client.AttachTargetError
882+ if !errors.As(err, &handoff) {
883+ return err
884+ }
885+ if handoff == nil {
886+ return fmt.Errorf("vev: invalid remote attach handoff")
887+ }
888+ if err := validateRemoteAttachHandoff(handoff.Target); err != nil {
889+ return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
890+ }
891+ next := attachHandoffKey{endpoint: handoff.Target.Endpoint, session: handoff.Target.Session, intent: handoff.Target.Intent}
892+ if _, seen := seenHandoffs[next]; seen {
893+ return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session)
894+ }
895+ seenHandoffs[next] = struct{}{}
896+ remoteTarget = handoff.Target.Endpoint
897+ name = handoff.Target.Session
898+ intent = handoff.Target.Intent
899+ if modeErr != nil {
900+ return modeErr
901+ }
902+ if factory == nil {
903+ factory = defaultRemoteDialerFactory()
901904 }
902905 }
903- return deps.attach(ctx, intent, name)
904906 }
905907
906-func isDaemonVersionDrift(err error) bool {
907- var protocolErr *client.ProtocolError
908- if !errors.As(err, &protocolErr) {
909- return false
910- }
911- return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal
912-}
908+const daemonStopTimeout = 2 * time.Second
913909
914910 type localDaemonDialer struct {
915911 dir string
@@ -989,17 +985,6 @@ func createDetachedLocalSession(ctx context.Context, name string) error {
989985 }
990986 }
991987
992-func waitForDaemonStop(ctx context.Context) error {
993- timer := time.NewTimer(daemonRestartSettle)
994- defer timer.Stop()
995- select {
996- case <-ctx.Done():
997- return ctx.Err()
998- case <-timer.C:
999- return nil
1000- }
1001-}
1002-
1003988 func requestDaemonStop(ctx context.Context) error {
1004989 ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout)
1005990 defer cancel()
@@ -1052,8 +1037,27 @@ func runStdio(ctx context.Context) (retErr error) {
10521037 return err
10531038 }
10541039 defer func() { _ = transport.Close() }()
1055- stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer))
1056- return runStdioProxy(ctx, stdio, transport, os.Environ(), log)
1040+
1041+ // sshstdio.Close owns and closes writers that implement io.Closer so a
1042+ // blocked Send can unwind. Keep process-global stdin/stdout outside that
1043+ // ownership boundary: input is a non-closing view and output is an owned
1044+ // pipe drained by a small forwarding goroutine.
1045+ stdin, stdout := os.Stdin, os.Stdout
1046+ stdoutReader, stdoutWriter := io.Pipe()
1047+ forwardDone := make(chan struct{})
1048+ go func() {
1049+ defer close(forwardDone)
1050+ if _, err := io.Copy(stdout, stdoutReader); err != nil {
1051+ _ = stdoutReader.CloseWithError(err)
1052+ }
1053+ }()
1054+ defer func() {
1055+ _ = stdoutWriter.Close()
1056+ <-forwardDone
1057+ _ = stdoutReader.Close()
1058+ }()
1059+ stdio := sshstdio.NewTransport(stdin, stdoutWriter, nil, sshstdio.WithRuntimeObserver(observer))
1060+ return runStdioProxy(ctx, stdio, transport, log)
10571061 }
10581062
10591063 var (
@@ -1065,12 +1069,12 @@ var (
10651069
10661070 // runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness
10671071 // line, and exits so SSH stdio can close without owning the UDP proxy lifetime.
1068-func runUDPBootstrap(ctx context.Context, session string) error {
1072+func runUDPBootstrap(ctx context.Context) error {
10691073 // Always start a fresh proxy for a bootstrap request. A client attach begins
10701074 // with MsgHello, which must reach the daemon handshake path; reusing an
10711075 // already-running proxy would forward that Hello into the daemon connection's
1072- // post-handshake runConnLoop, where it is intentionally ignored. The new
1073- // proxy publishes itself and retires any older registry entry once ready.
1076+ // post-handshake runConnLoop, where it is intentionally ignored. Every
1077+ // bootstrap starts a fresh byte-only carriage.
10741078 r, w, err := os.Pipe()
10751079 if err != nil {
10761080 return err
@@ -1083,9 +1087,6 @@ func runUDPBootstrap(ctx context.Context, session string) error {
10831087 return err
10841088 }
10851089 args := []string{"_udp-proxy"}
1086- if session != "" {
1087- args = append(args, session)
1088- }
10891090 devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
10901091 if err != nil {
10911092 _ = w.Close()
@@ -1136,13 +1137,13 @@ func runUDPBootstrap(ctx context.Context, session string) error {
11361137 }
11371138
11381139 // runUDPProxy is the detached long-lived remote-side datagram proxy.
1139-func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr error) {
1140+func runUDPProxy(ctx context.Context, ready io.Writer) (retErr error) {
11401141 log, logCloser, err := configureLogging(logging.Stdio, false)
11411142 if err != nil {
11421143 return err
11431144 }
11441145 defer func() { _ = logCloser.Close() }()
1145- log.Debug("udp proxy starting", "session", session)
1146+ log.Debug("udp carriage starting")
11461147
11471148 observer, observerCloser, err := newPerformanceTrace(clock.New())
11481149 if err != nil {
@@ -1173,13 +1174,9 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
11731174 if !ok {
11741175 return errors.New("vev: udp proxy did not get a UDP address")
11751176 }
1176- registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies"))
1177-
11781177 var (
1179- dg *dgram.Transport
1180- record dgram.ProxyRecord
1181- published bool
1182- setupErr error
1178+ dg *dgram.Transport
1179+ setupErr error
11831180 )
11841181 pdgram.SecretDo(func() {
11851182 key := make([]byte, pdgram.KeySize)
@@ -1191,12 +1188,6 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
11911188 if setupErr != nil {
11921189 return
11931190 }
1194- record = dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, KeyFingerprint: dgram.KeyFingerprint(key)}
1195- if setupErr = registry.Publish(record); setupErr != nil {
1196- return
1197- }
1198- published = true
1199-
12001191 readiness := append([]byte("VEV-UDP "), strconv.Itoa(addr.Port)...)
12011192 readiness = append(readiness, ' ')
12021193 readiness = base64.StdEncoding.AppendEncode(readiness, key)
@@ -1210,15 +1201,10 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
12101201 if dg != nil {
12111202 _ = dg.Close()
12121203 }
1213- if published {
1214- _ = registry.RemoveOwned(record)
1215- }
12161204 return setupErr
12171205 }
12181206 defer func() { _ = dg.Close() }()
1219- defer func() { _ = registry.RemoveOwned(record) }()
1220-
1221- return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log)
1207+ return runUDPProxyRuntime(ctx, dg, daemonTr, log)
12221208 }
12231209
12241210 const udpProxyIdleTTL = 15 * time.Minute
@@ -1309,66 +1295,14 @@ func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, erro
13091295 return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
13101296 }
13111297
1312-// firstHelloTransport applies proxy-owned values to exactly the first Hello
1313-// crossing a remote boundary. It intentionally leaves all later frames and
1314-// malformed Hellos untouched so proxying preserves the client stream.
1315-type firstHelloTransport struct {
1316- ports.Transport
1317- mu sync.Mutex
1318- seen bool
1319- session string
1320- env []string
1321-}
1322-
1323-func newFirstHelloTransport(transport ports.Transport, session string, env []string) *firstHelloTransport {
1324- return &firstHelloTransport{
1325- Transport: transport,
1326- session: session,
1327- env: append([]string(nil), env...),
1328- }
1329-}
1330-
1331-func (t *firstHelloTransport) Recv() (ports.Frame, error) {
1332- f, err := t.Transport.Recv()
1333- if err != nil || f.Type != ports.MsgHello {
1334- return f, err
1335- }
1336-
1337- t.mu.Lock()
1338- defer t.mu.Unlock()
1339- if t.seen {
1340- return f, nil
1341- }
1342- t.seen = true
1343-
1344- h, err := ports.UnmarshalHello(f.Payload)
1345- if err != nil {
1346- return f, nil
1347- }
1348- h.Env = append([]string(nil), t.env...)
1349- if h.Name == "" {
1350- h.Name = t.session
1351- }
1352- f.Payload = ports.MarshalHello(h)
1353- return f, nil
1354-}
1355-
1356-// runStdioProxy applies the remote host environment before the stdio stream
1357-// reaches the daemon. Its explicit environment input keeps this host boundary
1358-// independently testable.
1359-func runStdioProxy(ctx context.Context, client, daemon ports.Transport, env []string, log *slog.Logger) error {
1360- return proxyTransports(ctx, newFirstHelloTransport(client, "", env), daemon, log)
1298+// runStdioProxy forwards the framed transport without interpreting protocol
1299+// messages. Session selection remains in the Hello sent by the thin client.
1300+func runStdioProxy(ctx context.Context, client, daemon ports.Transport, log *slog.Logger) error {
1301+ return proxyTransports(ctx, client, daemon, log)
13611302 }
13621303
1363-// runUDPProxyRuntime applies the remote host environment and selected session
1364-// before the datagram stream reaches the daemon.
1365-func runUDPProxyRuntime(ctx context.Context, session string, client, daemon ports.Transport, env []string, log *slog.Logger) error {
1366- return dgram.ProxyRuntime{
1367- Client: newFirstHelloTransport(client, session, env),
1368- Daemon: daemon,
1369- Log: log,
1370- IdleTTL: udpProxyIdleTTL,
1371- }.Run(ctx)
1304+func runUDPProxyRuntime(ctx context.Context, client, daemon ports.Transport, log *slog.Logger) error {
1305+ return dgram.ProxyRuntime{Client: client, Daemon: daemon, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)
13721306 }
13731307
13741308 func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger) error {
M internal/app/run_test.go +139 -277
@@ -23,7 +23,6 @@ import (
2323 "github.com/bnema/vev/internal/ports"
2424 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2525 "github.com/bnema/vev/internal/usecase/client"
26- "github.com/bnema/vev/internal/usecase/confirm"
2726 "github.com/bnema/vev/internal/usecase/daemon"
2827 "github.com/bnema/vev/pkg/kv"
2928 "github.com/bnema/vev/pkg/safedir"
@@ -411,139 +410,6 @@ func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
411410 // scriptRecv makes a transport yield frames in order, then wait for done
412411 // before returning EOF. The shared done channel coordinates proxy readers
413412 // without relying on scheduling or sleeps.
414-func scriptRecv(tr *portsmocks.MockTransport, done <-chan struct{}, frames ...ports.Frame) *portsmocks.MockTransport_Recv_Call {
415- script := make(chan ports.Frame, len(frames))
416- for _, frame := range frames {
417- script <- frame
418- }
419- return tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
420- select {
421- case frame := <-script:
422- return frame, nil
423- case <-done:
424- return ports.Frame{}, io.EOF
425- }
426- })
427-}
428-
429-func TestFirstHelloTransportReplacesRemoteEnvironment(t *testing.T) {
430- for _, tt := range []struct {
431- name string
432- session string
433- wantName string
434- }{
435- {name: "stdio replaces environment", wantName: ""},
436- {name: "udp proxy replaces environment and retains session rewrite", session: "work", wantName: "work"},
437- } {
438- t.Run(tt.name, func(t *testing.T) {
439- original := ports.Hello{
440- Version: ports.ProtocolVersion,
441- Intent: ports.IntentAttach,
442- ClientID: [16]byte{1},
443- ResumeToken: 2,
444- Size: domain.Size{Cols: 80, Rows: 24},
445- TermEnv: "client-term",
446- Cwd: "/client",
447- TrueColor: true,
448- MaxOutputInFlight: 8,
449- Env: []string{"CLIENT=value"},
450- }
451- nonHello := ports.Frame{Type: ports.MsgInput, Payload: []byte("unchanged")}
452- transport := portsmocks.NewMockTransport(t)
453- done := make(chan struct{})
454- scriptRecv(transport, done,
455- ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(original)},
456- nonHello,
457- ).Times(2)
458- wrapped := newFirstHelloTransport(transport, tt.session, []string{"REMOTE=one", "TOKEN=a=b=c"})
459-
460- frame, err := wrapped.Recv()
461- require.NoError(t, err)
462- got, err := ports.UnmarshalHello(frame.Payload)
463- require.NoError(t, err)
464- original.Name = tt.wantName
465- original.Env = []string{"REMOTE=one", "TOKEN=a=b=c"}
466- require.Equal(t, original, got)
467-
468- frame, err = wrapped.Recv()
469- require.NoError(t, err)
470- require.Equal(t, nonHello, frame)
471- })
472- }
473-}
474-
475-func TestRunStdioProxyReplacesHelloEnvironmentAtDaemonBoundary(t *testing.T) {
476- original := remoteProxyHello()
477- client, daemon, sent := newRemoteProxyTransports(t, original)
478-
479- err := runStdioProxy(context.Background(), client, daemon, []string{"REMOTE=one", "TOKEN=a=b=c"}, nil)
480- require.NoError(t, err)
481-
482- got := firstHello(t, sent)
483- original.Env = []string{"REMOTE=one", "TOKEN=a=b=c"}
484- require.Equal(t, original, got)
485-}
486-
487-func TestRunUDPProxyRuntimeReplacesHelloEnvironmentAndSessionAtDaemonBoundary(t *testing.T) {
488- original := remoteProxyHello()
489- client, daemon, sent := newRemoteProxyTransports(t, original)
490-
491- err := runUDPProxyRuntime(context.Background(), "remote-work", client, daemon, []string{"REMOTE=udp", "TOKEN=a=b=c"}, nil)
492- require.NoError(t, err)
493-
494- got := firstHello(t, sent)
495- original.Name = "remote-work"
496- original.Env = []string{"REMOTE=udp", "TOKEN=a=b=c"}
497- require.Equal(t, original, got)
498-}
499-
500-func remoteProxyHello() ports.Hello {
501- return ports.Hello{
502- Version: ports.ProtocolVersion,
503- Intent: ports.IntentAttach,
504- ClientID: [16]byte{1},
505- ResumeToken: 2,
506- Size: domain.Size{Cols: 80, Rows: 24},
507- TermEnv: "client-term",
508- Cwd: "/client",
509- TrueColor: true,
510- MaxOutputInFlight: 1,
511- Env: []string{"CLIENT=value"},
512- }
513-}
514-
515-func newRemoteProxyTransports(t *testing.T, hello ports.Hello) (*portsmocks.MockTransport, *portsmocks.MockTransport, <-chan ports.Frame) {
516- t.Helper()
517- client := portsmocks.NewMockTransport(t)
518- daemon := portsmocks.NewMockTransport(t)
519- clientDone := make(chan struct{})
520- daemonDone := make(chan struct{})
521- sent := make(chan ports.Frame, 1)
522-
523- scriptRecv(client, clientDone, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}).Maybe()
524- client.EXPECT().Close().RunAndReturn(func() error {
525- close(clientDone)
526- return nil
527- }).Once()
528- daemon.EXPECT().Send(mock.Anything).RunAndReturn(func(frame ports.Frame) error {
529- sent <- frame
530- close(daemonDone)
531- return nil
532- }).Once()
533- scriptRecv(daemon, daemonDone).Once()
534- daemon.EXPECT().Close().Return(nil).Once()
535-
536- return client, daemon, sent
537-}
538-
539-func firstHello(t *testing.T, sent <-chan ports.Frame) ports.Hello {
540- t.Helper()
541- frame := <-sent
542- hello, err := ports.UnmarshalHello(frame.Payload)
543- require.NoError(t, err)
544- return hello
545-}
546-
547413 func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
548414 require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
549415 }
@@ -579,9 +445,12 @@ func TestParseArgs(t *testing.T) {
579445 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
580446 {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
581447 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
582- {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
583- {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
584- {name: "attach remote with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
448+ {name: "attach remote host uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
449+ {name: "attach remote host with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
450+ {name: "attach remote host with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
451+ {name: "attach remote ipv4 with session", args: []string{"attach", "user@192.0.2.10:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@192.0.2.10"},
452+ {name: "attach remote ipv6 with session", args: []string{"attach", "user@[2001:db8::1]:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@[2001:db8::1]"},
453+ {name: "attach remote invalid session", args: []string{"attach", "user@example.com:my work"}, wantErr: true, nonUsageErr: true},
585454 {name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true},
586455 {name: "attach without name", args: []string{"attach"}, wantErr: true},
587456 {name: "ls", args: []string{"ls"}, wantKind: kindList},
@@ -613,12 +482,13 @@ func TestParseArgs(t *testing.T) {
613482 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
614483 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
615484 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
616- {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
617- {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
618- {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
619- {name: "udp bootstrap", args: []string{"_udp-bootstrap", "work"}, wantKind: kindUDPBootstrap, wantName: "work"},
620- {name: "udp proxy", args: []string{"_udp-proxy", "work"}, wantKind: kindUDPProxy, wantName: "work"},
621- {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},
622492 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
623493 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
624494 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
@@ -633,13 +503,13 @@ func TestParseArgs(t *testing.T) {
633503 t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)
634504 }
635505 if tt.nonUsageErr {
636- var ue *usageError
637- if errors.As(err, &ue) {
506+ _, usage := errors.AsType[*usageError](err)
507+ if usage {
638508 t.Fatalf("parseArgs(%q) error = *usageError, want non-usage error", tt.args)
639509 }
640510 } else {
641- var ue *usageError
642- if !errors.As(err, &ue) {
511+ _, usage := errors.AsType[*usageError](err)
512+ if !usage {
643513 t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)
644514 }
645515 }
@@ -689,6 +559,31 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
689559 }
690560 }
691561
562+func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
563+ tests := []struct {
564+ name string
565+ arg string
566+ want client.AttachRequest
567+ }{
568+ {name: "local host", arg: "work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
569+ {name: "remote host", arg: "user@example.com:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
570+ {name: "remote ipv4", arg: "user@192.0.2.10:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
571+ {name: "remote ipv6", arg: "user@[2001:db8::1]:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
572+ }
573+ for _, tt := range tests {
574+ t.Run(tt.name, func(t *testing.T) {
575+ cmd, err := parseArgs([]string{"attach", tt.arg})
576+ require.NoError(t, err)
577+ connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{
578+ Intent: cmd.intent,
579+ SessionName: cmd.name,
580+ })
581+ require.NoError(t, err)
582+ require.Equal(t, tt.want, connection.AttachRequest())
583+ })
584+ }
585+}
586+
692587 func TestListShowsBroken(t *testing.T) {
693588 var out bytes.Buffer
694589 printSessions(&out, []ports.SessionInfo{
@@ -859,131 +754,6 @@ func captureStdout(t *testing.T, fn func()) string {
859754 return string(out)
860755 }
861756
862-func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) {
863- var prompts bytes.Buffer
864- answers := strings.NewReader("y\n")
865- attachCalls := 0
866- killCalls := 0
867-
868- err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
869- confirmer: confirm.NewConfirmer(answers, &prompts),
870- attach: func(context.Context, uint8, string) error {
871- attachCalls++
872- if attachCalls == 1 {
873- return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
874- }
875- return nil
876- },
877- killDaemon: func(context.Context) error {
878- killCalls++
879- return nil
880- },
881- })
882- if err != nil {
883- t.Fatalf("runLocalAttachWithRecovery returned error: %v", err)
884- }
885- if attachCalls != 2 {
886- t.Fatalf("attach calls = %d, want 2", attachCalls)
887- }
888- if killCalls != 1 {
889- t.Fatalf("kill calls = %d, want 1", killCalls)
890- }
891- if got := prompts.String(); !strings.Contains(got, "Your vev version differs") || !strings.Contains(got, "kill it") {
892- t.Fatalf("prompt = %q, want version/kill prompt", got)
893- }
894-}
895-
896-func TestRunLocalAttachPropagatesPromptError(t *testing.T) {
897- readErr := errors.New("read failed")
898- err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
899- confirmer: confirm.NewConfirmer(errorReader{err: readErr}, &bytes.Buffer{}),
900- attach: func(context.Context, uint8, string) error {
901- return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
902- },
903- killDaemon: func(context.Context) error {
904- t.Fatal("killDaemon should not be called after prompt error")
905- return nil
906- },
907- })
908- if !errors.Is(err, readErr) {
909- t.Fatalf("error = %v, want wrapped %v", err, readErr)
910- }
911-}
912-
913-func TestRunLocalAttachPropagatesKillError(t *testing.T) {
914- killErr := errors.New("kill failed")
915- err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
916- confirmer: confirm.NewConfirmer(strings.NewReader("y\n"), &bytes.Buffer{}),
917- attach: func(context.Context, uint8, string) error {
918- return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
919- },
920- killDaemon: func(context.Context) error { return killErr },
921- })
922- if !errors.Is(err, killErr) {
923- t.Fatalf("error = %v, want %v", err, killErr)
924- }
925-}
926-
927-func TestRunLocalAttachSettlesBeforeRetry(t *testing.T) {
928- var order []string
929- err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
930- confirmer: confirm.NewConfirmer(strings.NewReader("y\n"), &bytes.Buffer{}),
931- attach: func(context.Context, uint8, string) error {
932- order = append(order, "attach")
933- if len(order) == 1 {
934- return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
935- }
936- return nil
937- },
938- killDaemon: func(context.Context) error {
939- order = append(order, "kill")
940- return nil
941- },
942- settleAfterKill: func(context.Context) error {
943- order = append(order, "settle")
944- return nil
945- },
946- })
947- if err != nil {
948- t.Fatalf("runLocalAttachWithRecovery returned error: %v", err)
949- }
950- if got, want := strings.Join(order, ","), "attach,kill,settle,attach"; got != want {
951- t.Fatalf("order = %s, want %s", got, want)
952- }
953-}
954-
955-type errorReader struct{ err error }
956-
957-func (r errorReader) Read([]byte) (int, error) { return 0, r.err }
958-
959-func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
960- answers := strings.NewReader("n\n")
961- wantErr := &client.ProtocolError{Code: ports.ErrInternal, Text: "malformed hello"}
962- attachCalls := 0
963- killCalls := 0
964-
965- err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
966- confirmer: confirm.NewConfirmer(answers, &bytes.Buffer{}),
967- attach: func(context.Context, uint8, string) error {
968- attachCalls++
969- return wantErr
970- },
971- killDaemon: func(context.Context) error {
972- killCalls++
973- return nil
974- },
975- })
976- if !errors.Is(err, wantErr) {
977- t.Fatalf("error = %v, want original %v", err, wantErr)
978- }
979- if attachCalls != 1 {
980- t.Fatalf("attach calls = %d, want 1", attachCalls)
981- }
982- if killCalls != 0 {
983- t.Fatalf("kill calls = %d, want 0", killCalls)
984- }
985-}
986-
987757 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
988758 called := false
989759 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
@@ -1073,7 +843,10 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
1073843 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
1074844 }
1075845 gotDialer = nd.name
1076- gotRemote = request.Remote
846+ gotRemote = deps.Remote
847+ if request.Remote {
848+ t.Fatal("remote carriage metadata must not be in the attach request")
849+ }
1077850 gotClipboard = deps.Clipboard
1078851 if request.Intent != ports.IntentAttach || request.SessionName != "work" {
1079852 t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)
@@ -1097,6 +870,92 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
1097870 }
1098871 }
1099872
873+func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
874+ factory := portsmocks.NewMockRemoteDialerFactory(t)
875+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
876+ factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
877+ var calls int
878+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
879+ remoteDialerFactory: factory,
880+ runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
881+ calls++
882+ require.NotNil(t, deps.Dialer)
883+ if calls == 1 {
884+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
885+ }
886+ return nil
887+ },
888+ })
889+ require.NoError(t, err)
890+ require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")
891+}
892+
893+func TestRunAttachWithDepsRejectsHandoffLoop(t *testing.T) {
894+ factory := portsmocks.NewMockRemoteDialerFactory(t)
895+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
896+
897+ calls := 0
898+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
899+ remoteDialerFactory: factory,
900+ runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
901+ calls++
902+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}
903+ },
904+ })
905+ require.ErrorContains(t, err, "attach handoff loop")
906+ require.Equal(t, 1, calls)
907+}
908+
909+func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
910+ factory := portsmocks.NewMockRemoteDialerFactory(t)
911+ factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
912+
913+ localCalls, remoteCalls := 0, 0
914+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "", "", nil, runAttachDeps{
915+ localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
916+ remoteDialerFactory: factory,
917+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
918+ if deps.Remote {
919+ remoteCalls++
920+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked"}, request)
921+ return nil
922+ }
923+ localCalls++
924+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, request)
925+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
926+ },
927+ })
928+ require.NoError(t, err)
929+ require.Equal(t, 1, localCalls, "the failed local picker attach must not retry locally")
930+ require.Equal(t, 1, remoteCalls, "the selected target must be dialed and attached exactly once")
931+}
932+
933+func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
934+ tests := []struct {
935+ name string
936+ target ports.AttachTarget
937+ }{
938+ {name: "invalid host", target: ports.AttachTarget{Endpoint: "remote host", Session: "work", Intent: ports.IntentAttach}},
939+ {name: "invalid session", target: ports.AttachTarget{Endpoint: "remote.example", Session: "bad name", Intent: ports.IntentAttach}},
940+ }
941+ for _, tt := range tests {
942+ t.Run(tt.name, func(t *testing.T) {
943+ factory := portsmocks.NewMockRemoteDialerFactory(t)
944+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
945+ calls := 0
946+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
947+ remoteDialerFactory: factory,
948+ runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
949+ calls++
950+ return &client.AttachTargetError{Target: tt.target}
951+ },
952+ })
953+ require.ErrorContains(t, err, "invalid remote attach handoff")
954+ require.Equal(t, 1, calls)
955+ })
956+ }
957+}
958+
1100959 func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
1101960 factory := portsmocks.NewMockRemoteDialerFactory(t)
1102961 runClientCalled := false
@@ -1153,7 +1012,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
11531012 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
11541013 }
11551014 gotDialer = nd.name
1156- gotRemote = request.Remote
1015+ gotRemote = deps.Remote
1016+ if request.Remote {
1017+ t.Fatal("local carriage metadata must not be in the attach request")
1018+ }
11571019 gotClipboard = deps.Clipboard
11581020 if request.Intent != ports.IntentEphemeral || request.SessionName != "" {
11591021 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)
@@ -1189,7 +1051,7 @@ func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
11891051 t.Cleanup(func() { udpProxyCommand = oldCommand })
11901052
11911053 got := captureStdout(t, func() {
1192- if err := runUDPBootstrap(context.Background(), "work"); err != nil {
1054+ if err := runUDPBootstrap(context.Background()); err != nil {
11931055 t.Fatalf("runUDPBootstrap() error = %v", err)
11941056 }
11951057 })
@@ -1214,7 +1076,7 @@ func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) {
12141076 }
12151077 t.Cleanup(func() { udpProxyCommand = oldCommand })
12161078
1217- err := runUDPBootstrap(context.Background(), "work")
1079+ err := runUDPBootstrap(context.Background())
12181080 if !errors.Is(err, io.EOF) {
12191081 t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)
12201082 }
@@ -1230,7 +1092,7 @@ func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
12301092 }
12311093 t.Cleanup(func() { udpProxyCommand = oldCommand })
12321094
1233- err := runUDPBootstrap(context.Background(), "work")
1095+ err := runUDPBootstrap(context.Background())
12341096 if !errors.Is(err, context.DeadlineExceeded) {
12351097 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
12361098 }
M internal/ports/frame.go +6 -0
@@ -1,5 +1,11 @@
11 package ports
22
3+import "time"
4+
5+// HandshakeTimeout bounds every transport handshake from connect through the
6+// first committed publication.
7+const HandshakeTimeout = 15 * time.Second
8+
39 // ProtocolVersion is the current vev IPC wire protocol version.
410 const ProtocolVersion uint16 = 23
511
M internal/usecase/client/client.go +147 -81
@@ -129,9 +129,9 @@ const stdinBufSize = 4096
129129 // goroutine, decoupling the input/resize pumps from transport back-pressure.
130130 const sendQueueDepth = 64
131131
132-// preWelcomeTimeout bounds each blocking operation before the daemon has
133-// accepted the attach. Mosh uses the same 15-second startup budget.
134-const preWelcomeTimeout = 15 * time.Second
132+// preWelcomeTimeout remains the focused-test name for the shared handshake
133+// budget, which now also covers connect and initial publication.
134+const preWelcomeTimeout = ports.HandshakeTimeout
135135
136136 // remoteHostLearnerShutdownTimeout bounds how long Run waits for async remote
137137 // host learning after terminal restoration. Learning is best-effort; a stalled
@@ -155,9 +155,13 @@ type Dependencies struct {
155155 Logger *slog.Logger
156156 RuntimeObserver ports.SerializedRuntimeObserver
157157 RemoteHostLearner ports.RemoteHostLearner
158+ // Remote selects client-side carriage presentation only; it never enters
159+ // the daemon-facing session request.
160+ Remote bool
158161 }
159162
160-// AttachRequest identifies the session and transport mode for one client run.
163+// AttachRequest identifies the session for one client run. Remote is
164+// client-only presentation metadata; it is never serialized into Hello.
161165 type AttachRequest struct {
162166 Intent uint8
163167 SessionName string
@@ -173,6 +177,7 @@ type Runner struct {
173177 logger *slog.Logger
174178 runtimeObserver ports.SerializedRuntimeObserver
175179 remoteHostLearner ports.RemoteHostLearner
180+ remote bool
176181
177182 inputMu sync.Mutex
178183 input *terminalInputPump
@@ -192,6 +197,7 @@ func NewRunner(deps Dependencies) *Runner {
192197 logger: log,
193198 runtimeObserver: deps.RuntimeObserver,
194199 remoteHostLearner: deps.RemoteHostLearner,
200+ remote: deps.Remote,
195201 }
196202 }
197203
@@ -282,9 +288,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
282288 }
283289 }
284290 }()
291+ remote := request.Remote || r.remote
285292 reconnect := &reconnectUI{
286293 term: r.term,
287- remote: request.Remote,
294+ remote: remote,
288295 rawEntered: &rawEntered,
289296 stage: reconnectStageOfflineRetrying,
290297 }
@@ -306,8 +313,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
306313 }
307314
308315 for {
309- transport, err := r.dialer.Dial(ctx)
316+ handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
317+ transport, err := boundedDial(handshakeCtx, r.dialer)
310318 if err != nil {
319+ err = handshakeContextError(ctx, timedOut, err)
320+ finishHandshake()
311321 if resumeToken == 0 || ctx.Err() != nil {
312322 if clearErr := reconnect.clear(); clearErr != nil {
313323 return errors.Join(err, clearErr)
@@ -333,32 +343,57 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
333343 continue
334344 }
335345 ms.dialed = true
346+ if transport == nil {
347+ finishHandshake()
348+ return errors.New("vev: dialer returned nil transport")
349+ }
350+ stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
351+ connection, err := NewSessionConnection(transport, SessionTarget{
352+ Intent: attemptRequest.Intent,
353+ SessionName: attemptRequest.SessionName,
354+ })
355+ if err != nil {
356+ stopHandshakeTransport()
357+ finishHandshake()
358+ _ = transport.Close()
359+ return err
360+ }
336361
337362 var linkEvents <-chan ports.LinkEvent
338- if reporter, ok := transport.(ports.LinkStateReporter); ok {
363+ if reporter, ok := connection.Transport().(ports.LinkStateReporter); ok {
339364 linkEvents = reporter.LinkEvents()
340365 }
341366 result := (&attachAttempt{
342- runner: r,
343- transport: transport,
344- request: attemptRequest,
345- resumeToken: resumeToken,
346- clientID: processClientID,
347- milestones: &ms,
348- themeState: themeState,
349- enterRaw: enterRaw,
350- reconnect: reconnect,
351- linkEvents: linkEvents,
352- rememberRemoteHost: rememberRemoteHost,
367+ runner: r,
368+ connection: connection,
369+ remote: remote,
370+ handshakeCtx: handshakeCtx,
371+ handshakeTimedOut: timedOut,
372+ finishHandshake: finishHandshake,
373+ stopHandshakeTransport: stopHandshakeTransport,
374+ request: attemptRequest,
375+ resumeToken: resumeToken,
376+ clientID: processClientID,
377+ milestones: &ms,
378+ themeState: themeState,
379+ enterRaw: enterRaw,
380+ reconnect: reconnect,
381+ linkEvents: linkEvents,
382+ rememberRemoteHost: rememberRemoteHost,
353383 terminalInput: func() *terminalInputPump {
354384 return input
355385 },
356386 }).run(ctx)
387+ stopHandshakeTransport()
388+ finishHandshake()
357389 if result.welcomed {
358390 backoff = defaultReconnectBackoff.initial
359391 }
360392 if !result.transportClosed {
361- _ = transport.Close()
393+ _ = connection.Close()
394+ }
395+ if result.target != nil {
396+ return &AttachTargetError{Target: *result.target}
362397 }
363398 if result.resumeToken != 0 {
364399 resumeToken = result.resumeToken
@@ -514,18 +549,24 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
514549 }
515550
516551 type attachAttempt struct {
517- runner *Runner
518- transport ports.Transport
519- request AttachRequest
520- resumeToken uint64
521- clientID [16]byte
522- milestones *milestones
523- themeState *terminalThemeState
524- enterRaw func() error
525- reconnect *reconnectUI
526- linkEvents <-chan ports.LinkEvent
527- rememberRemoteHost func()
528- terminalInput func() *terminalInputPump
552+ runner *Runner
553+ connection *SessionConnection
554+ remote bool
555+ transport ports.Transport
556+ handshakeCtx context.Context
557+ handshakeTimedOut <-chan struct{}
558+ finishHandshake func()
559+ stopHandshakeTransport func()
560+ request AttachRequest
561+ resumeToken uint64
562+ clientID [16]byte
563+ milestones *milestones
564+ themeState *terminalThemeState
565+ enterRaw func() error
566+ reconnect *reconnectUI
567+ linkEvents <-chan ports.LinkEvent
568+ rememberRemoteHost func()
569+ terminalInput func() *terminalInputPump
529570 }
530571
531572 type attachResult struct {
@@ -533,48 +574,21 @@ type attachResult struct {
533574 sessionName string
534575 welcomed bool
535576 transportClosed bool
577+ target *ports.AttachTarget
536578 err error
537579 }
538580
539-// boundedPreWelcome runs one blocking handshake operation. Transport methods
540-// do not accept a context, so cancellation and expiry close this attempt's
541-// transport to interrupt the call, then wait for its goroutine to exit.
542-func boundedPreWelcome(ctx context.Context, clk ports.Clock, transport ports.Transport, operation func() error) (bool, error) {
543- if err := ctx.Err(); err != nil {
544- _ = transport.Close()
545- return true, err
546- }
547-
548- timer := clk.NewTimer(preWelcomeTimeout)
549- defer timer.Stop()
550- completed := make(chan error, 1)
551- go func() { completed <- operation() }()
581+// AttachTargetError requests that the composition root replace the current
582+// transport with the selected endpoint. The daemon never opens that endpoint.
583+type AttachTargetError struct {
584+ Target ports.AttachTarget
585+}
552586
553- select {
554- case err := <-completed:
555- // Prefer a cancellation/expiry observed concurrently with completion.
556- // That event owns the transport lifetime even though the operation has
557- // already returned by the time we observe it here.
558- if ctx.Err() != nil {
559- _ = transport.Close()
560- return true, ctx.Err()
561- }
562- select {
563- case <-timer.C():
564- _ = transport.Close()
565- return true, context.DeadlineExceeded
566- default:
567- return false, err
568- }
569- case <-ctx.Done():
570- _ = transport.Close()
571- <-completed
572- return true, ctx.Err()
573- case <-timer.C():
574- _ = transport.Close()
575- <-completed
576- return true, context.DeadlineExceeded
587+func (e *AttachTargetError) Error() string {
588+ if e == nil {
589+ return "client: attach target handoff"
577590 }
591+ return fmt.Sprintf("client: attach target %q/%q", e.Target.Endpoint, e.Target.Session)
578592 }
579593
580594 // paletteSlot validates the scanner's signed slot before narrowing it for the
@@ -601,10 +615,15 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
601615
602616 func (a *attachAttempt) run(ctx context.Context) attachResult {
603617 transport := a.transport
618+ request := a.request
619+ if a.connection != nil {
620+ transport = a.connection.Transport()
621+ request = a.connection.AttachRequest()
622+ }
604623 term := a.runner.term
605624 clk := a.runner.clock
606- intent := a.request.Intent
607- name := a.request.SessionName
625+ intent := request.Intent
626+ name := request.SessionName
608627 resumeToken := a.resumeToken
609628 clientID := a.clientID
610629 ms := a.milestones
@@ -612,10 +631,45 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
612631 enterRaw := a.enterRaw
613632 reconnect := a.reconnect
614633 linkEvents := a.linkEvents
615- remote := a.request.Remote
634+ remote := a.remote || a.request.Remote
616635 clipboard := a.runner.clipboard
617636 log := a.runner.logger
618637 observer := a.runner.runtimeObserver
638+ handshakeCtx := a.handshakeCtx
639+ handshakeTimedOut := a.handshakeTimedOut
640+ finishHandshake := a.finishHandshake
641+ stopHandshakeTransport := a.stopHandshakeTransport
642+ if handshakeCtx == nil {
643+ handshakeCtx, handshakeTimedOut, finishHandshake = newHandshakeContext(ctx, clk)
644+ stopHandshakeTransport = watchHandshakeTransport(handshakeCtx, transport)
645+ }
646+ handshakeFinished := false
647+ endHandshake := func() {
648+ if handshakeFinished {
649+ return
650+ }
651+ handshakeFinished = true
652+ if stopHandshakeTransport != nil {
653+ stopHandshakeTransport()
654+ }
655+ if finishHandshake != nil {
656+ finishHandshake()
657+ }
658+ }
659+ defer endHandshake()
660+ handshakeFailure := func(stage string, err error) attachResult {
661+ if handshakeTimedOut != nil {
662+ err = handshakeContextError(ctx, handshakeTimedOut, err)
663+ }
664+ return attachResult{transportClosed: handshakeCtx.Err() != nil, err: fmt.Errorf("vev: %s: %w", stage, err)}
665+ }
666+ sendHandshake := func(operation func() error) error {
667+ err := boundedHandshakeOperation(handshakeCtx, transport, operation)
668+ if err != nil {
669+ return handshakeContextError(ctx, handshakeTimedOut, err)
670+ }
671+ return nil
672+ }
619673 // 1. Handshake: send Hello with our size and TERM.
620674 size, err := term.Size()
621675 if err != nil {
@@ -642,22 +696,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
642696 MaxOutputInFlight: requestedOutputWindow(transport),
643697 Env: os.Environ(),
644698 }
645- if closed, err := boundedPreWelcome(ctx, clk, transport, func() error {
699+ if err := sendHandshake(func() error {
646700 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
647701 }); err != nil {
648- return attachResult{transportClosed: closed, err: fmt.Errorf("vev: sending hello: %w", err)}
702+ return handshakeFailure("sending hello", err)
649703 }
650704 ms.helloSent = true
651705
652706 // 2. Await Welcome or a typed rejection.
653707 var reply ports.Frame
654- closed, err := boundedPreWelcome(ctx, clk, transport, func() error {
708+ if err := boundedHandshakeOperation(handshakeCtx, transport, func() error {
655709 var recvErr error
656710 reply, recvErr = transport.Recv()
657711 return recvErr
658- })
659- if err != nil {
660- return attachResult{transportClosed: closed, err: fmt.Errorf("vev: awaiting welcome: %w", err)}
712+ }); err != nil {
713+ return handshakeFailure("awaiting welcome", err)
661714 }
662715 result := func(welcomed bool, err error) attachResult {
663716 return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err}
@@ -686,6 +739,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
686739 default:
687740 return result(false, fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type))
688741 }
742+ if err := handshakeCtx.Err(); err != nil {
743+ return handshakeFailure("validating welcome", err)
744+ }
689745 welcomedResult := func(err error) attachResult { return result(true, err) }
690746
691747 // 3. Enter raw mode after Welcome; Run owns restoration.
@@ -747,7 +803,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
747803 // keeps the generation publication ordered before the first query and
748804 // avoids leaving a queued Theme behind an immediate detach.
749805 if !senderStarted {
750- if err := transport.Send(frame); err != nil {
806+ if err := sendHandshake(func() error { return transport.Send(frame) }); err != nil {
751807 return fmt.Errorf("sending initial theme: %w", err)
752808 }
753809 return nil
@@ -836,7 +892,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
836892 if reconnect.remote {
837893 // Before the asynchronous sender starts, preserve transport ordering by
838894 // requesting the reset synchronously after the initial Theme publication.
839- closed, resetErr := boundedPreWelcome(ctx, clk, transport, func() error {
895+ resetErr := sendHandshake(func() error {
840896 payload, err := ports.MarshalResize(ports.Resize{Size: size})
841897 if err != nil {
842898 return err
@@ -844,8 +900,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
844900 return transport.Send(ports.Frame{Type: ports.MsgResize, Payload: payload})
845901 })
846902 if resetErr != nil {
847- resetResult := welcomedResult(fmt.Errorf("requesting reconnect reconciliation: %w", resetErr))
848- resetResult.transportClosed = closed
903+ resetErr = handshakeContextError(ctx, handshakeTimedOut, resetErr)
904+ resetResult := welcomedResult(fmt.Errorf("vev: requesting reconnect reconciliation: %w", resetErr))
905+ resetResult.transportClosed = handshakeCtx.Err() != nil
849906 return resetResult
850907 }
851908 awaitingReconnectReset = true
@@ -1042,7 +1099,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10421099 if !ms.firstOutput {
10431100 ms.firstOutput = true
10441101 log.Debug("received first output")
1102+ endHandshake()
1103+ }
1104+ case ports.MsgAttachTarget:
1105+ target, derr := ports.UnmarshalAttachTarget(r.frame.Payload)
1106+ if derr != nil {
1107+ return welcomedResult(fmt.Errorf("vev: decoding attach target: %w", derr))
10451108 }
1109+ handoff := welcomedResult(nil)
1110+ handoff.target = &target
1111+ return handoff
10461112 case ports.MsgDetached:
10471113 d, derr := ports.UnmarshalDetached(r.frame.Payload)
10481114 if derr != nil {
M internal/usecase/client/client_test.go +53 -19
@@ -182,7 +182,7 @@ func attachTestDependencies(transport ports.Transport, terminal ports.Terminal,
182182 return testDependencies(transportDialer{transport: transport}, terminal, clock, nil, nil)
183183 }
184184
185-const testPreWelcomeTimeout = 15 * time.Second
185+const testPreWelcomeTimeout = ports.HandshakeTimeout
186186
187187 func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-chan chan time.Time) {
188188 t.Helper()
@@ -199,6 +199,26 @@ func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-c
199199 return clk, created
200200 }
201201
202+func TestRunBoundsBlockedDial(t *testing.T) {
203+ clk, createdTimers := newHandshakeClock(t, 1)
204+ started := make(chan struct{})
205+ dialer := portsmocks.NewMockDialer(t)
206+ dialer.EXPECT().Dial(mock.Anything).RunAndReturn(func(ctx context.Context) (ports.Transport, error) {
207+ close(started)
208+ <-ctx.Done()
209+ return nil, ctx.Err()
210+ }).Once()
211+
212+ errCh := make(chan error, 1)
213+ go func() {
214+ errCh <- runTestClient(context.Background(), testDependencies(dialer, nil, clk, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main"})
215+ }()
216+ timerC := <-createdTimers
217+ <-started
218+ timerC <- time.Time{}
219+ require.ErrorIs(t, <-errCh, context.DeadlineExceeded)
220+}
221+
202222 func TestRunBoundsPreWelcomeOperations(t *testing.T) {
203223 tests := []struct {
204224 name string
@@ -216,11 +236,7 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
216236 t.Run(tt.name, func(t *testing.T) {
217237 ctx, cancel := context.WithCancel(context.Background())
218238 defer cancel()
219- timerCount := 1
220- if tt.phase == "recv" {
221- timerCount = 2
222- }
223- clk, createdTimers := newHandshakeClock(t, timerCount)
239+ clk, createdTimers := newHandshakeClock(t, 1)
224240
225241 term := portsmocks.NewMockTerminal(t)
226242 term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
@@ -228,9 +244,10 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
228244
229245 started := make(chan struct{})
230246 closed := make(chan struct{})
247+ var closeOnce sync.Once
231248 operationExited := make(chan struct{})
232249 tr := portsmocks.NewMockTransport(t)
233- tr.EXPECT().Close().Run(func() { close(closed) }).Return(nil).Once()
250+ tr.EXPECT().Close().Run(func() { closeOnce.Do(func() { close(closed) }) }).Return(nil).Maybe()
234251
235252 block := func() error {
236253 close(started)
@@ -260,21 +277,13 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
260277 select {
261278 case timerC = <-createdTimers:
262279 case <-time.After(time.Second):
263- t.Fatal("pre-Welcome timer was not created")
280+ t.Fatal("handshake timer was not created")
264281 }
265282 select {
266283 case <-started:
267284 case <-time.After(time.Second):
268- t.Fatal("pre-Welcome operation did not start")
269- }
270- if tt.phase == "recv" {
271- select {
272- case timerC = <-createdTimers:
273- case <-time.After(time.Second):
274- t.Fatal("Welcome timer was not created")
275- }
285+ t.Fatal("handshake operation did not start")
276286 }
277-
278287 if tt.end == "cancel" {
279288 cancel()
280289 } else {
@@ -285,12 +294,12 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
285294 case err := <-errCh:
286295 require.ErrorIs(t, err, tt.want)
287296 case <-time.After(time.Second):
288- t.Fatal("Run did not return after pre-Welcome cancellation")
297+ t.Fatal("Run did not return after handshake cancellation")
289298 }
290299 select {
291300 case <-operationExited:
292301 case <-time.After(time.Second):
293- t.Fatal("pre-Welcome operation leaked after Run returned")
302+ t.Fatal("handshake operation leaked after Run returned")
294303 }
295304 tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme))
296305 })
@@ -337,6 +346,31 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
337346 }
338347 }
339348
349+func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
350+ var out bytes.Buffer
351+ var restoreCount atomic.Int32
352+ resizeCh := make(chan domain.Size)
353+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
354+ defer in.unblock()
355+
356+ target := ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}
357+ tr := portsmocks.NewMockTransport(t)
358+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
359+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
360+ unblock := scriptRecv(tr,
361+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "local"}))},
362+ recvItem{f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(target))},
363+ )
364+ defer unblock()
365+ tr.EXPECT().Close().Return(nil).Once()
366+
367+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"})
368+ var handoff *client.AttachTargetError
369+ require.ErrorAs(t, err, &handoff)
370+ require.Equal(t, target, handoff.Target)
371+ require.Equal(t, int32(1), restoreCount.Load(), "handoff must restore the terminal after closing the old connection")
372+}
373+
340374 func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
341375 t.Setenv("VEV_CLIENT_ENV_TEST", "TOKEN=a=b=c")
342376
A internal/usecase/client/handshake.go +146 -0
@@ -0,0 +1,146 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+var errHandshakeTimeout = errors.New("handshake timed out")
13+
14+type systemClock struct{}
15+type systemTimer struct{ *time.Timer }
16+
17+func (systemClock) Now() time.Time { return time.Now() }
18+func (systemClock) NewTimer(delay time.Duration) ports.Timer {
19+ return systemTimer{Timer: time.NewTimer(delay)}
20+}
21+func (t systemTimer) C() <-chan time.Time { return t.Timer.C }
22+func (t systemTimer) Reset(d time.Duration) bool { return t.Timer.Reset(d) }
23+func (t systemTimer) Stop() bool { return t.Timer.Stop() }
24+
25+// newHandshakeContext owns one deadline for the complete outbound handshake.
26+// The caller must stop it before entering the long-lived connection loop.
27+func newHandshakeContext(parent context.Context, clock ports.Clock) (context.Context, <-chan struct{}, func()) {
28+ if parent == nil {
29+ parent = context.Background()
30+ }
31+ ctx, cancel := context.WithCancel(parent)
32+ timedOut := make(chan struct{})
33+ if clock == nil {
34+ clock = systemClock{}
35+ }
36+ timer := clock.NewTimer(ports.HandshakeTimeout)
37+ if timer == nil {
38+ timer = systemClock{}.NewTimer(ports.HandshakeTimeout)
39+ }
40+ stop := make(chan struct{})
41+ done := make(chan struct{})
42+ go func() {
43+ defer close(done)
44+ select {
45+ case <-timer.C():
46+ close(timedOut)
47+ cancel()
48+ case <-stop:
49+ }
50+ }()
51+ var once sync.Once
52+ finish := func() {
53+ once.Do(func() {
54+ close(stop)
55+ <-done
56+ timer.Stop()
57+ cancel()
58+ })
59+ }
60+ return ctx, timedOut, finish
61+}
62+
63+// watchHandshakeTransport closes a transport when the handshake context ends.
64+// Transport.Close is required to interrupt blocked Send and Recv operations.
65+func watchHandshakeTransport(ctx context.Context, transport ports.Transport) func() {
66+ stop := make(chan struct{})
67+ done := make(chan struct{})
68+ go func() {
69+ defer close(done)
70+ select {
71+ case <-ctx.Done():
72+ _ = transport.Close()
73+ case <-stop:
74+ }
75+ }()
76+ var once sync.Once
77+ return func() {
78+ once.Do(func() {
79+ close(stop)
80+ <-done
81+ })
82+ }
83+}
84+
85+func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error {
86+ if err := ctx.Err(); err != nil {
87+ _ = transport.Close()
88+ return err
89+ }
90+ completed := make(chan error, 1)
91+ go func() { completed <- operation() }()
92+ select {
93+ case err := <-completed:
94+ if ctxErr := ctx.Err(); ctxErr != nil {
95+ _ = transport.Close()
96+ return ctxErr
97+ }
98+ return err
99+ case <-ctx.Done():
100+ _ = transport.Close()
101+ // The result channel is buffered, so the operation can publish its
102+ // completion after Close without keeping this cancellation path stuck.
103+ return ctx.Err()
104+ }
105+}
106+
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))
111+ result := make(chan struct {
112+ transport ports.Transport
113+ err error
114+ }, 1)
115+ go func() {
116+ transport, err := dialer.Dial(dialCtx)
117+ if err != nil {
118+ cancel()
119+ } else if ctx.Err() != nil && transport != nil {
120+ _ = transport.Close()
121+ }
122+ result <- struct {
123+ transport ports.Transport
124+ err error
125+ }{transport: transport, err: err}
126+ }()
127+ select {
128+ case result := <-result:
129+ return result.transport, result.err
130+ case <-ctx.Done():
131+ cancel()
132+ return nil, ctx.Err()
133+ }
134+}
135+
136+func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fallback error) error {
137+ select {
138+ case <-timedOut:
139+ return errors.Join(errHandshakeTimeout, context.DeadlineExceeded)
140+ default:
141+ }
142+ if err := parent.Err(); err != nil {
143+ return err
144+ }
145+ return fallback
146+}
M internal/usecase/client/palette_attach_generation_test.go +37 -17
@@ -20,22 +20,33 @@ import (
2020
2121 // attachPaletteClock deliberately exposes each independently-created timer.
2222 // Tests fire only the deadline they intend to exercise; no wall clock is used.
23-type attachPaletteClock struct{ timers chan *attachPaletteTimer }
23+type attachPaletteClock struct {
24+ timers chan *attachPaletteTimer
25+ handshakeTimers chan *attachPaletteTimer
26+}
2427
2528 type attachPaletteTimer struct {
2629 ch chan time.Time
2730 stopped chan struct{}
2831 stopOnce sync.Once
32+ duration time.Duration
2933 }
3034
3135 func newAttachPaletteClock() *attachPaletteClock {
32- return &attachPaletteClock{timers: make(chan *attachPaletteTimer, 16)}
36+ return &attachPaletteClock{
37+ timers: make(chan *attachPaletteTimer, 16),
38+ handshakeTimers: make(chan *attachPaletteTimer, 1),
39+ }
3340 }
3441
3542 func (*attachPaletteClock) Now() time.Time { return time.Time{} }
36-func (c *attachPaletteClock) NewTimer(time.Duration) ports.Timer {
37- timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{})}
38- c.timers <- timer
43+func (c *attachPaletteClock) NewTimer(delay time.Duration) ports.Timer {
44+ timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{}), duration: delay}
45+ if delay == ports.HandshakeTimeout {
46+ c.handshakeTimers <- timer
47+ } else {
48+ c.timers <- timer
49+ }
3950 return timer
4051 }
4152 func (t *attachPaletteTimer) C() <-chan time.Time { return t.ch }
@@ -160,11 +171,12 @@ func (t *attachPaletteTransport) snapshots() []ports.Theme {
160171 }
161172
162173 type attachPaletteHarness struct {
163- clock *attachPaletteClock
164- reader *attachPaletteReader
165- writer *attachPaletteWriter
166- transport *attachPaletteTransport
167- done chan attachResult
174+ clock *attachPaletteClock
175+ handshakeTimer *attachPaletteTimer
176+ reader *attachPaletteReader
177+ writer *attachPaletteWriter
178+ transport *attachPaletteTransport
179+ done chan attachResult
168180 }
169181
170182 func startAttachPaletteHarness(state *terminalThemeState) *attachPaletteHarness {
@@ -196,13 +208,15 @@ func startAttachPaletteHarnessWithInputAndWriteError(state *terminalThemeState,
196208 return input
197209 },
198210 }
199- h := &attachPaletteHarness{clock: clock, reader: reader, writer: writer, transport: transport, done: make(chan attachResult, 1)}
211+ h := &attachPaletteHarness{
212+ clock: clock,
213+ reader: reader,
214+ writer: writer,
215+ transport: transport,
216+ done: make(chan attachResult, 1),
217+ }
200218 go func() { h.done <- attempt.run(context.Background()) }()
201- // The handshake's two bounded-pre-welcome timers are unrelated to palette
202- // acquisition. Remove them in this harness so every exposed timer is a
203- // generation deadline (or a timer deliberately created by input handling).
204- <-clock.timers
205- <-clock.timers
219+ h.handshakeTimer = <-clock.handshakeTimers
206220 return h
207221 }
208222
@@ -221,7 +235,13 @@ func (h *attachPaletteHarness) nextTimer(t *testing.T) *attachPaletteTimer {
221235 func (h *attachPaletteHarness) detach(t *testing.T) {
222236 t.Helper()
223237 h.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}
224- require.NoError(t, (<-h.done).err)
238+ result := <-h.done
239+ require.NoError(t, result.err)
240+ select {
241+ case <-h.handshakeTimer.stopped:
242+ default:
243+ t.Fatal("handshake timer was not canceled after initial publication")
244+ }
225245 }
226246
227247 func paletteReply(foreground, background string, slot uint8, color string) string {
M internal/usecase/client/reconnect_toast_test.go +3 -5
@@ -936,7 +936,7 @@ func TestPreSenderReconnectResetIsBounded(t *testing.T) {
936936 trigger: func(_ context.CancelFunc, timer *reconnectResetTimer) {
937937 timer.ch <- time.Time{}
938938 },
939- wantErr: context.DeadlineExceeded,
939+ wantErr: errHandshakeTimeout,
940940 },
941941 {
942942 name: "cancellation",
@@ -961,15 +961,13 @@ func TestPreSenderReconnectResetIsBounded(t *testing.T) {
961961 resultCh := make(chan attachResult, 1)
962962 go func() { resultCh <- attempt.run(ctx) }()
963963
964- <-clock.preWelcome // Hello Send
965- <-clock.preWelcome // Welcome Recv
966- resetTimer := <-clock.preWelcome
964+ handshakeTimer := <-clock.preWelcome
967965 select {
968966 case <-tr.entered:
969967 case <-time.After(time.Second):
970968 t.Fatal("timed out waiting for synchronous reconnect reset Send")
971969 }
972- tt.trigger(cancel, resetTimer)
970+ tt.trigger(cancel, handshakeTimer)
973971
974972 result := requireAttachResult(t, resultCh)
975973 require.ErrorIs(t, result.err, tt.wantErr)
A internal/usecase/client/session_connection.go +78 -0
@@ -0,0 +1,78 @@
1+package client
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+var errNilSessionTransport = errors.New("client: nil session transport")
12+
13+// SessionTarget is the validated daemon-facing session selection. Endpoint
14+// details are resolved before a transport is opened and never enter this type.
15+type SessionTarget struct {
16+ Intent uint8
17+ SessionName string
18+}
19+
20+func (t SessionTarget) validate() error {
21+ switch t.Intent {
22+ case ports.IntentEphemeral:
23+ if t.SessionName == "" {
24+ return nil
25+ }
26+ case ports.IntentNew, ports.IntentAttach, ports.IntentResume:
27+ if t.SessionName == "" {
28+ return domain.ErrInvalidSessionName
29+ }
30+ default:
31+ return fmt.Errorf("invalid session intent %d", t.Intent)
32+ }
33+ return domain.ValidateSessionName(t.SessionName)
34+}
35+
36+// SessionConnection owns one already-open transport and its daemon-facing
37+// session target. Local sockets and remote carriages use this same owner.
38+type SessionConnection struct {
39+ transport ports.Transport
40+ target SessionTarget
41+}
42+
43+// NewSessionConnection creates a connection for an already-selected transport.
44+// Target validation happens before the connection is used for a Hello.
45+func NewSessionConnection(transport ports.Transport, target SessionTarget) (*SessionConnection, error) {
46+ if transport == nil {
47+ return nil, errNilSessionTransport
48+ }
49+ if err := target.validate(); err != nil {
50+ return nil, fmt.Errorf("invalid session target: %w", err)
51+ }
52+ return &SessionConnection{transport: transport, target: target}, nil
53+}
54+
55+// Transport returns the owned carriage for this connection.
56+func (c *SessionConnection) Transport() ports.Transport {
57+ if c == nil {
58+ return nil
59+ }
60+ return c.transport
61+}
62+
63+// AttachRequest returns the common request shape used after transport
64+// selection. It contains no remote/proxy semantic.
65+func (c *SessionConnection) AttachRequest() AttachRequest {
66+ if c == nil {
67+ return AttachRequest{}
68+ }
69+ return AttachRequest{Intent: c.target.Intent, SessionName: c.target.SessionName}
70+}
71+
72+// Close releases the owned transport.
73+func (c *SessionConnection) Close() error {
74+ if c == nil || c.transport == nil {
75+ return nil
76+ }
77+ return c.transport.Close()
78+}
A internal/usecase/client/session_connection_test.go +58 -0
@@ -0,0 +1,58 @@
1+package client_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
8+ "github.com/bnema/vev/internal/usecase/client"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestSessionConnectionUsesOneAttachShapeForLocalAndRemoteTransports(t *testing.T) {
13+ target := client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}
14+ local := portsmocks.NewMockTransport(t)
15+ remote := portsmocks.NewMockTransport(t)
16+
17+ localConnection, err := client.NewSessionConnection(local, target)
18+ require.NoError(t, err)
19+ remoteConnection, err := client.NewSessionConnection(remote, target)
20+ require.NoError(t, err)
21+
22+ require.Equal(t, localConnection.AttachRequest(), remoteConnection.AttachRequest())
23+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, localConnection.AttachRequest())
24+ require.Same(t, local, localConnection.Transport())
25+ require.Same(t, remote, remoteConnection.Transport())
26+}
27+
28+func TestSessionTargetValidation(t *testing.T) {
29+ tests := []struct {
30+ name string
31+ target client.SessionTarget
32+ wantErr bool
33+ }{
34+ {name: "ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral}},
35+ {name: "new", target: client.SessionTarget{Intent: ports.IntentNew, SessionName: "work"}},
36+ {name: "attach", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}},
37+ {name: "resume", target: client.SessionTarget{Intent: ports.IntentResume, SessionName: "work"}},
38+ {name: "missing named session", target: client.SessionTarget{Intent: ports.IntentAttach}, wantErr: true},
39+ {name: "unsafe session", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
40+ {name: "unknown intent", target: client.SessionTarget{Intent: 99, SessionName: "work"}, wantErr: true},
41+ }
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ transport := portsmocks.NewMockTransport(t)
45+ _, err := client.NewSessionConnection(transport, tt.target)
46+ if tt.wantErr {
47+ require.Error(t, err)
48+ return
49+ }
50+ require.NoError(t, err)
51+ })
52+ }
53+}
54+
55+func TestSessionConnectionRequiresTransport(t *testing.T) {
56+ _, err := client.NewSessionConnection(nil, client.SessionTarget{Intent: ports.IntentEphemeral})
57+ require.Error(t, err)
58+}
M internal/usecase/daemon/attachment_transition.go +0 -1
@@ -168,7 +168,6 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
168168 if err != nil {
169169 return result, err
170170 }
171- d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveAttachment)
172171 return result, nil
173172 }
174173
M internal/usecase/daemon/client.go +22 -42
@@ -85,6 +85,13 @@ type attachedClient struct {
8585 previousSession Guarded[attachmentSession]
8686 linkMu sync.Mutex
8787 sendMu sync.Mutex
88+ // routeCreatedSession marks a session created by this attachment's route.
89+ // A handshake that never commits Welcome must tear down that exact empty
90+ // session, while an attachment routed to an existing session must not.
91+ routeCreatedSession bool
92+ // routeSessionPurge distinguishes a brand-new session from a restored
93+ // stopped session when failed-handshake cleanup removes the live instance.
94+ routeSessionPurge bool
8895 // beforeAttachmentTokenValidation is a deterministic lifecycle-race seam
8996 // used only by package tests.
9097 beforeAttachmentTokenValidation func()
@@ -136,9 +143,7 @@ func (ac *attachedClient) initOverlays() {
136143
137144 func (ac *attachedClient) currentAttachmentSession() attachmentSession { return ac.sess.Get() }
138145
139-// currentSession is the explicit local-only narrowing used until proxy-aware
140-// consumers are introduced. Local PTY/tab paths must treat non-local entries as
141-// unavailable rather than infer proxy behavior.
146+// currentSession narrows the attachment to its owning local session.
142147 func (ac *attachedClient) currentSession() *session {
143148 sess, _ := localSession(ac.currentAttachmentSession())
144149 return sess
@@ -344,31 +349,6 @@ func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportS
344349 return err
345350 }
346351
347-// sendExpectedSideEffectForAttachment captures the output epoch, view revision,
348-// and window while sendMu is held. A resize or handoff therefore cannot put a
349-// side-effect frame from the old attachment state onto the new transport state.
350-func (ac *attachedClient) sendExpectedSideEffectForAttachment(expected transportSnapshot, data []byte, echoAck uint64, ticket *attachmentEffectTicket) error {
351- ac.sendMu.Lock()
352- defer ac.sendMu.Unlock()
353- if ticket == nil {
354- return errAttachmentTransition
355- }
356- if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil {
357- return errAttachmentTransition
358- }
359- ac.output.lockView()
360- defer ac.output.unlockView()
361- frame, err := ac.output.sideEffectLocked(data, echoAck)
362- if err == nil {
363- err = expected.transport.Send(frame)
364- }
365- if err != nil {
366- ticket.reportTransportFailure(expected)
367- }
368- ticket.endTransportSend()
369- return err
370-}
371-
372352 // boundedSend sends f to ac with a deadline watchdog: if the send (including
373353 // waiting on sendMu behind a wedged paint) does not complete within
374354 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -608,8 +588,7 @@ func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordina
608588 }
609589
610590 // ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an
611-// exact attachment implementation. The caller holds entry.core().mu; proxy
612-// VT state is deliberately not touched here.
591+// exact attachment implementation. The caller holds entry.core().mu.
613592 func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSession) *renderCoordinator {
614593 if rc := attachmentRenderCoordinator(entry); rc != nil {
615594 return rc
@@ -705,10 +684,6 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
705684 token.ac.captureFrames = nil
706685 token.ac.sendMu.Unlock()
707686 }
708- if _, ok := token.sess.(*proxySession); ok {
709- // Remote ordinary output arrives independently after the proxy Welcome.
710- return true
711- }
712687 sess, ok := localSession(token.sess)
713688 if !ok {
714689 return false
@@ -819,19 +794,24 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
819794 view.windowTop = 0
820795 view.revision++
821796 ac.publishView(view)
797+ ac.rebaseOutput()
822798 ac.pipelineCache = composeCacheInput{}
823799 ac.pipelineScratch = composeCacheInput{}
824800 ac.sendMu.Unlock()
825801
826- entry := token.sess
827- // Rendering is attachment-scoped. Run it independently of the connection
828- // reader so a blocked transport cannot hold session dispatch or peers.
829- if proxy, ok := entry.(*proxySession); ok {
830- _, _ = proxy.resize(size)
831- return true
802+ sess, ok := localSession(token.sess)
803+ if !ok {
804+ return false
832805 }
833- go d.paint(entry, ac, true, token.lease)
834- return true
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
835815 }
836816
837817 // handleClientNotice maps the closed client-event enum to daemon-owned notice
M internal/usecase/daemon/client_frame_routing.go +3 -17
@@ -54,13 +54,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
5454 if !ok {
5555 return
5656 }
57- if proxy, ok := sess.(*proxySession); ok {
58- d.detachProxyOnSendError(proxy, ac, tr)
59- } else if local, ok := localSession(sess); ok {
60- d.clientGone(local, ac, tr, false)
61- } else {
57+ local, ok := localSession(sess)
58+ if !ok {
6259 return
6360 }
61+ d.clientGone(local, ac, tr, false)
6462 current := ac.currentAttachmentSession()
6563 if current == sess || !ac.currentTransportIs(tr) || current == nil {
6664 return
@@ -204,12 +202,6 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
204202 result.Text = request.Slug + " is not scriptable"
205203 return result
206204 }
207- if !proxyAttachedCommandOwnedRemotely(cmd.Slug) {
208- result.Code = ports.ErrNotScriptable
209- result.Text = request.Slug + " is owned by the local proxy daemon"
210- return result
211- }
212-
213205 // The frame's attachment token is the target capability. No registry/name lookup is
214206 // performed, so the command cannot escape to another remote session.
215207 err := sess.runMutation(func() error {
@@ -229,12 +221,6 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
229221 return result
230222 }
231223
232-// proxyAttachedCommandOwnedRemotely applies the palette's canonical ownership
233-// policy to command frames received by the remote daemon.
234-func proxyAttachedCommandOwnedRemotely(slug string) bool {
235- return proxyPaletteCommandOwnership(slug) == proxyPaletteRemote
236-}
237-
238224 // resetOutput rebases only the exact proxied attachment and transport admitted
239225 // by the frame router. sendMu serializes the rebase with every output send;
240226 // transport revalidation under that lock rejects a link replaced while waiting.
M internal/usecase/daemon/client_transport_errors.go +0 -57
@@ -37,14 +37,6 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
3737 }
3838 token.effect.bindActionEnd(d, "detach")
3939 token.effect.End()
40- if proxy, ok := token.sess.(*proxySession); ok {
41- if !d.detachIfAttachmentCurrent(token) {
42- return false
43- }
44- d.finishProxyClientGone(proxy, token.ac, token.transport.transport, explicit)
45- d.armProxyWarm(proxy)
46- return true
47- }
4840 sess, ok := localSession(token.sess)
4941 if !ok {
5042 return false
@@ -124,48 +116,6 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
124116 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
125117 }
126118
127-// detachProxyOnSendError is detachOnSendError for an attachment that has no
128-// local session behind it. Proxy attachments own no resumable parking
129-// lifecycle, so the remote link is retained as a warm headless session instead
130-// of being parked for the same client to resume.
131-func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, failed ports.Transport) {
132- if d == nil || p == nil || ac == nil {
133- return
134- }
135- expected := transportSnapshot{}
136- oldTr := failed
137- if failed != nil {
138- expected = ac.transportSnapshot()
139- if expected.transport != failed {
140- return
141- }
142- } else {
143- oldTr = ac.transport()
144- }
145- if !d.detachProxyIfCurrentTransport(p, ac, expected) {
146- return
147- }
148- d.finishProxyClientGone(p, ac, oldTr, false)
149-}
150-
151-// finishProxyClientGone retires external client ownership after the exact proxy
152-// attachment has been unpublished. Warm-timer publication remains with the caller so
153-// each detach path can arm once, after membership is empty.
154-func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) {
155- if rc := p.coordinator.Load(); rc != nil {
156- rc.noteDetach(ac)
157- }
158- if ac.overlays != nil {
159- d.closePicker(ac)
160- }
161- ac.clearPreviousSession()
162- if explicit {
163- d.boundedSend(ac, frameDetached(ports.ReasonDetach))
164- }
165- _ = ac.closeCapturedTransport(failed)
166- d.log.Warn("detached proxy client", "host", p.key.Host, "session", p.key.Name, "explicit", explicit)
167-}
168-
169119 func (d *Daemon) detachOnAttachmentSendError(token attachmentConnectionToken, failed ports.Transport) {
170120 d.detachOnAttachmentSendErrorUntil(token, failed, nil)
171121 }
@@ -176,13 +126,6 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
176126 if failed == nil || failed != token.transport.transport {
177127 return
178128 }
179- if proxy, ok := token.sess.(*proxySession); ok {
180- if d.detachIfAttachmentCurrentUntil(token, done) {
181- d.finishProxyClientGone(proxy, token.ac, failed, false)
182- d.armProxyWarm(proxy)
183- }
184- return
185- }
186129 sess, ok := localSession(token.sess)
187130 if !ok {
188131 return
A internal/usecase/daemon/command_tracker.go +161 -0
@@ -0,0 +1,161 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+const CommandRequestTimeout = 10 * time.Second
13+
14+var (
15+ ErrCommandRequestTimeout = errors.New("command request timed out")
16+ ErrCommandRequestUnavailable = errors.New("command request connection is unavailable")
17+)
18+
19+// CommandRequestOutcome is the result delivered for one tracked request.
20+type CommandRequestOutcome struct {
21+ Result ports.CommandResult
22+ Err error
23+}
24+
25+// CommandRequestTracker correlates request results for one connection. The
26+// generation is part of every pending entry so a reused request ID from an
27+// older connection cannot complete a newer request.
28+type CommandRequestTracker struct {
29+ mu sync.Mutex
30+ next uint64
31+ pending map[uint64]commandRequestPending
32+}
33+
34+type commandRequestPending struct {
35+ generation uint64
36+ outcome chan CommandRequestOutcome
37+}
38+
39+// NewCommandRequestTracker constructs an empty per-connection tracker.
40+func NewCommandRequestTracker() *CommandRequestTracker {
41+ return &CommandRequestTracker{}
42+}
43+
44+// Publish allocates and records a request before its bytes are sent.
45+func (t *CommandRequestTracker) Publish(generation uint64) (uint64, <-chan CommandRequestOutcome) {
46+ t.mu.Lock()
47+ defer t.mu.Unlock()
48+ if t.pending == nil {
49+ t.pending = make(map[uint64]commandRequestPending)
50+ }
51+ t.next++
52+ if t.next == 0 {
53+ t.next++
54+ }
55+ outcome := t.trackLocked(t.next, generation)
56+ return t.next, outcome
57+}
58+
59+// Track records an existing wire request ID, as used by an inbound one-shot
60+// command connection. It rejects duplicate IDs on that connection.
61+func (t *CommandRequestTracker) Track(requestID, generation uint64) (<-chan CommandRequestOutcome, bool) {
62+ t.mu.Lock()
63+ defer t.mu.Unlock()
64+ if t.pending == nil {
65+ t.pending = make(map[uint64]commandRequestPending)
66+ }
67+ if _, exists := t.pending[requestID]; exists {
68+ return nil, false
69+ }
70+ if requestID > t.next {
71+ t.next = requestID
72+ }
73+ outcome := t.trackLocked(requestID, generation)
74+ return outcome, true
75+}
76+
77+func (t *CommandRequestTracker) trackLocked(requestID, generation uint64) <-chan CommandRequestOutcome {
78+ outcome := make(chan CommandRequestOutcome, 1)
79+ t.pending[requestID] = commandRequestPending{generation: generation, outcome: outcome}
80+ return outcome
81+}
82+
83+// Wait waits without holding the tracker or connection sender lock. Timeout
84+// and cancellation remove only the matching request.
85+func (t *CommandRequestTracker) Wait(ctx context.Context, clock ports.Clock, requestID, generation uint64, outcome <-chan CommandRequestOutcome) (ports.CommandResult, error) {
86+ if t == nil || clock == nil {
87+ return ports.CommandResult{}, ErrCommandRequestUnavailable
88+ }
89+ if ctx == nil {
90+ ctx = context.Background()
91+ }
92+ if err := ctx.Err(); err != nil {
93+ t.Remove(requestID, generation)
94+ return ports.CommandResult{}, err
95+ }
96+ timer := clock.NewTimer(CommandRequestTimeout)
97+ if timer == nil {
98+ t.Remove(requestID, generation)
99+ return ports.CommandResult{}, ErrCommandRequestTimeout
100+ }
101+ defer timer.Stop()
102+ select {
103+ case completed := <-outcome:
104+ return completed.Result, completed.Err
105+ case <-ctx.Done():
106+ t.Remove(requestID, generation)
107+ return ports.CommandResult{}, ctx.Err()
108+ case <-timer.C():
109+ t.Remove(requestID, generation)
110+ return ports.CommandResult{}, ErrCommandRequestTimeout
111+ }
112+}
113+
114+// Remove abandons only the exact request generation.
115+func (t *CommandRequestTracker) Remove(requestID, generation uint64) {
116+ if t == nil {
117+ return
118+ }
119+ t.mu.Lock()
120+ pending, ok := t.pending[requestID]
121+ if ok && pending.generation == generation {
122+ delete(t.pending, requestID)
123+ }
124+ t.mu.Unlock()
125+}
126+
127+// Complete accepts only the exact request generation. Unknown, late, and old
128+// generation results are safe no-ops.
129+func (t *CommandRequestTracker) Complete(generation uint64, result ports.CommandResult) {
130+ t.finish(result.RequestID, generation, CommandRequestOutcome{Result: result})
131+}
132+
133+// Fail completes the exact request with a transport or decode failure.
134+func (t *CommandRequestTracker) Fail(requestID, generation uint64, err error) {
135+ t.finish(requestID, generation, CommandRequestOutcome{Err: err})
136+}
137+
138+func (t *CommandRequestTracker) finish(requestID, generation uint64, outcome CommandRequestOutcome) {
139+ if t == nil {
140+ return
141+ }
142+ t.mu.Lock()
143+ pending, ok := t.pending[requestID]
144+ if !ok || pending.generation != generation {
145+ t.mu.Unlock()
146+ return
147+ }
148+ delete(t.pending, requestID)
149+ t.mu.Unlock()
150+ pending.outcome <- outcome
151+}
152+
153+// PendingCount is intended for deterministic lifecycle tests.
154+func (t *CommandRequestTracker) PendingCount() int {
155+ if t == nil {
156+ return 0
157+ }
158+ t.mu.Lock()
159+ defer t.mu.Unlock()
160+ return len(t.pending)
161+}
A internal/usecase/daemon/command_tracker_test.go +153 -0
@@ -0,0 +1,153 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+type commandTrackerTestTimer struct {
16+ ch chan time.Time
17+ once sync.Once
18+}
19+
20+func (t *commandTrackerTestTimer) C() <-chan time.Time { return t.ch }
21+func (t *commandTrackerTestTimer) Reset(time.Duration) bool { return false }
22+func (t *commandTrackerTestTimer) Stop() bool { return true }
23+func (t *commandTrackerTestTimer) fire() { t.once.Do(func() { t.ch <- time.Time{} }) }
24+
25+type commandTrackerTestClock struct {
26+ timers chan *commandTrackerTestTimer
27+ delay time.Duration
28+}
29+
30+func newCommandTrackerTestClock() *commandTrackerTestClock {
31+ return &commandTrackerTestClock{timers: make(chan *commandTrackerTestTimer, 8)}
32+}
33+
34+func (c *commandTrackerTestClock) Now() time.Time { return time.Time{} }
35+
36+func (c *commandTrackerTestClock) NewTimer(delay time.Duration) ports.Timer {
37+ c.delay = delay
38+ timer := &commandTrackerTestTimer{ch: make(chan time.Time, 1)}
39+ c.timers <- timer
40+ return timer
41+}
42+
43+func TestCommandRequestTrackerTimeoutAndGenerationIsolation(t *testing.T) {
44+ tracker := NewCommandRequestTracker()
45+ clock := newCommandTrackerTestClock()
46+ requestID, outcome := tracker.Publish(4)
47+ waitDone := make(chan error, 1)
48+ go func() {
49+ _, err := tracker.Wait(context.Background(), clock, requestID, 4, outcome)
50+ waitDone <- err
51+ }()
52+
53+ timer := <-clock.timers
54+ require.Equal(t, CommandRequestTimeout, clock.delay)
55+ timer.fire()
56+ require.ErrorIs(t, <-waitDone, ErrCommandRequestTimeout)
57+ require.Zero(t, tracker.PendingCount())
58+
59+ // Reuse the timed-out wire ID with a new generation. A late result from the
60+ // old connection must not complete the new request.
61+ secondOutcome, ok := tracker.Track(requestID, 8)
62+ require.True(t, ok)
63+ tracker.Complete(4, ports.CommandResult{RequestID: requestID, OK: true})
64+ select {
65+ case <-secondOutcome:
66+ t.Fatal("late result from the old generation completed a newer request")
67+ default:
68+ }
69+ tracker.Complete(8, ports.CommandResult{RequestID: requestID, OK: true})
70+ result := <-secondOutcome
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+ })
153+}
M internal/usecase/daemon/control.go +51 -4
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "errors"
77 "fmt"
8+ "runtime"
89 "slices"
910 "sort"
1011 "strings"
@@ -34,21 +35,57 @@ func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) error {
3435 Text: "attached command relay is not enabled",
3536 })
3637 }
37- result := d.dispatchCommand(d.serveCtx, request)
38- result.RequestID = request.RequestID
38+
39+ tracker := NewCommandRequestTracker()
40+ const generation = uint64(1)
41+ outcome, _ := tracker.Track(request.RequestID, generation)
42+ commandCtx := d.serveCtx
43+ if commandCtx == nil {
44+ commandCtx = context.Background()
45+ }
46+ commandCtx, cancel := context.WithCancel(commandCtx)
47+ defer cancel()
48+ go func() {
49+ result := d.dispatchCommand(commandCtx, request)
50+ result.RequestID = request.RequestID
51+ tracker.Complete(generation, result)
52+ }()
53+ commandClock := d.clock
54+ if commandClock == nil {
55+ commandClock = systemClock{}
56+ }
57+ result, waitErr := tracker.Wait(commandCtx, commandClock, request.RequestID, generation, outcome)
58+ if waitErr != nil {
59+ return d.sendCommandResult(tr, ports.CommandResult{
60+ RequestID: request.RequestID,
61+ Code: ports.ErrInternal,
62+ Text: waitErr.Error(),
63+ })
64+ }
3965 return d.sendCommandResult(tr, result)
4066 }
4167
4268 // sendCommandResult gives one-shot control responses the same observable
4369 // transport-failure behavior regardless of which validation path produced it.
4470 func (d *Daemon) sendCommandResult(tr ports.Transport, result ports.CommandResult) error {
45- if err := tr.Send(frameCommandResult(result)); err != nil {
71+ if err := d.boundedControlSend(tr, frameCommandResult(result)); err != nil {
4672 d.log.Warn("command response send failed", "err", err)
4773 return err
4874 }
4975 return nil
5076 }
5177
78+// boundedControlSend keeps one-shot control handlers from waiting forever on a
79+// client that stopped reading. A timeout closes the exact transport so a
80+// blocked Send can unwind before the handler returns.
81+func (d *Daemon) boundedControlSend(tr ports.Transport, frame ports.Frame) error {
82+ _, err := d.boundedSendWith(tr, func() error { return tr.Send(frame) })
83+ if errors.Is(err, errSendTimedOut) {
84+ _ = tr.Close()
85+ }
86+ return err
87+}
88+
5289 func frameCommandResult(result ports.CommandResult) ports.Frame {
5390 return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}
5491 }
@@ -82,7 +119,17 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandReque
82119 return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
83120 }
84121
85- 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+ }
86133 defer sess.dispatchMu.Unlock()
87134
88135 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
M internal/usecase/daemon/control_test.go +77 -0
@@ -124,6 +124,56 @@ func (s *actionRunnerSpy) Run(request daemonActionRequest) error {
124124 return s.err
125125 }
126126
127+func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) {
128+ factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
129+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
130+ d := newTestDaemon(t, factory, clock)
131+ addControlSession(d, "work", "t_work", "p_work")
132+ frame := commandFrame(t, ports.CommandRequest{
133+ RequestID: 17, Slug: "new-tab", TargetSession: "work",
134+ })
135+ tr, sends, releaseConn := newConn(t, frame)
136+ defer releaseConn()
137+ done := make(chan error, 1)
138+ go func() {
139+ done <- d.handleCommand(tr, frame)
140+ }()
141+ <-factory.entered
142+ timer := <-clock.timers
143+ require.Equal(t, CommandRequestTimeout, timer.duration)
144+ timer.ch <- time.Time{}
145+ result := awaitCommandResult(t, sends)
146+ require.Equal(t, uint64(17), result.RequestID)
147+ require.Equal(t, ports.ErrInternal, result.Code)
148+ require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text)
149+ require.NoError(t, <-done)
150+ close(factory.release)
151+}
152+
153+func TestHandleCommandResponseSendTimeoutClosesTransport(t *testing.T) {
154+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
155+ d := newTestDaemon(t, nil, clock)
156+ tr := newBlockingControlSendTransport()
157+ frame := commandFrame(t, ports.CommandRequest{RequestID: 41, Attached: true})
158+ done := make(chan error, 1)
159+ go func() { done <- d.handleCommand(tr, frame) }()
160+
161+ <-tr.started
162+ timer := <-clock.timers
163+ timer.ch <- time.Time{}
164+ select {
165+ case err := <-done:
166+ require.ErrorIs(t, err, errSendTimedOut)
167+ case <-time.After(testWaitTimeout):
168+ t.Fatal("bounded control response did not return")
169+ }
170+ select {
171+ case <-tr.closed:
172+ default:
173+ t.Fatal("timed-out control response did not close its transport")
174+ }
175+}
176+
127177 func TestHandleCommandAttachedRejectionReturnsSendFailure(t *testing.T) {
128178 d := newTestDaemon(t, nil, stubClock{})
129179 var logs bytes.Buffer
@@ -1272,6 +1322,33 @@ func (f *controlPTYFactory) close() {
12721322 }
12731323 }
12741324
1325+type blockingControlSendTransport struct {
1326+ started chan struct{}
1327+ closed chan struct{}
1328+ once sync.Once
1329+}
1330+
1331+func newBlockingControlSendTransport() *blockingControlSendTransport {
1332+ return &blockingControlSendTransport{started: make(chan struct{}), closed: make(chan struct{})}
1333+}
1334+
1335+func (tr *blockingControlSendTransport) Send(ports.Frame) error {
1336+ tr.once.Do(func() { close(tr.started) })
1337+ <-tr.closed
1338+ return io.ErrClosedPipe
1339+}
1340+
1341+func (*blockingControlSendTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1342+func (tr *blockingControlSendTransport) Close() error {
1343+ tr.once.Do(func() { close(tr.started) })
1344+ select {
1345+ case <-tr.closed:
1346+ default:
1347+ close(tr.closed)
1348+ }
1349+ return nil
1350+}
1351+
12751352 type commandSendErrorTransport struct {
12761353 err error
12771354 closed bool
M internal/usecase/daemon/daemon.go +130 -45
@@ -49,8 +49,8 @@ const detachNotifyTimeout = time.Second
4949 // not yet acked by the client) before paint defers rather than composing
5050 // another diff. It bounds the daemon's paint rate to the client's ack rate, so
5151 // heavy output degrades to lower fps on a slow link instead of overflowing the
52-// transport. It must stay well under the UDP proxy's 32-frame client window so
53-// the proxy's reliable queue never fills from painting alone.
52+// transport. It must stay well under the datagram carriage's 32-frame client
53+// window so its reliable queue never fills from painting alone.
5454 const maxUnackedOutputStates = 8
5555
5656 // normalizeOutputWindow bounds the untrusted Hello value. Zero deliberately
@@ -74,10 +74,7 @@ type Daemon struct {
7474 stopped map[string]stoppedSession
7575 // creating reserves names while durable creation I/O runs without mu.
7676 creating map[string]struct{}
77- // proxyConstructions serializes remote IntentAttach handshakes by remote
78- // key. Entries are guarded by mu and never retain it across dial or link I/O.
79- proxyConstructions map[domain.RemoteSessionKey]*proxyConstruction
80- nextID uint64
77+ nextID uint64
8178 // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
8279 // mark. It is guarded by mu and prevents a wall-clock regression from
8380 // reusing a lifecycle identity.
@@ -948,32 +945,50 @@ func (d *Daemon) sessionsSnapshotLocked() []*session {
948945 }
949946
950947 // handleConn reads the first frame off a fresh connection and routes it. A
951-// context watcher closes the transport on serve-context cancel so a handler
952-// parked in Recv (mid-handshake or between input frames) unwinds on shutdown.
948+// context watcher closes the transport on serve-context cancel or handshake
949+// timeout so blocked transport operations unwind.
953950 func (d *Daemon) handleConn(tr ports.Transport) {
954- stop := context.AfterFunc(d.hardCtx, func() { _ = tr.Close() })
955- defer stop()
956-
957- first, err := tr.Recv()
951+ handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(d.hardCtx)
952+ stopTransport := watchHandshakeTransport(handshakeCtx, tr)
953+ defer finishHandshake()
954+ defer stopTransport()
955+
956+ var first ports.Frame
957+ err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
958+ var recvErr error
959+ first, recvErr = tr.Recv()
960+ return recvErr
961+ })
958962 if err != nil {
963+ err = handshakeContextError(d.hardCtx, timedOut, err)
959964 d.log.Warn("connection closed before hello", "err", err)
960965 _ = tr.Close()
961966 return
962967 }
963968 switch first.Type {
964969 case ports.MsgList:
970+ stopTransport()
971+ finishHandshake()
965972 d.handleList(tr)
966973 case ports.MsgCommand:
974+ stopTransport()
975+ finishHandshake()
967976 if err := d.handleCommand(tr, first); err != nil {
968977 d.log.Warn("command handler failed", "err", err)
969978 }
970979 case ports.MsgKill:
980+ stopTransport()
981+ finishHandshake()
971982 d.handleKill(tr, first)
972983 case ports.MsgHello:
973- d.handleHello(tr, first)
984+ d.handleHelloWithContext(handshakeCtx, timedOut, stopTransport, finishHandshake, tr, first)
974985 default:
975986 d.log.Warn("hello rejected", "err", "expected hello", "type", first.Type)
976- _ = tr.Send(frameError(ports.ErrInternal, "expected hello"))
987+ _ = boundedHandshakeOperation(handshakeCtx, tr, func() error {
988+ return tr.Send(frameError(ports.ErrInternal, "expected hello"))
989+ })
990+ stopTransport()
991+ finishHandshake()
977992 _ = tr.Close()
978993 }
979994 }
@@ -1019,7 +1034,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
10191034 d.mu.Unlock()
10201035
10211036 sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })
1022- _ = tr.Send(frameSessions(infos))
1037+ _ = d.boundedControlSend(tr, frameSessions(infos))
10231038 }
10241039
10251040 // handleKill terminates the requested live session or stopped named session,
@@ -1030,7 +1045,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
10301045
10311046 k, err := ports.UnmarshalKill(f.Payload)
10321047 if err != nil {
1033- _ = tr.Send(frameError(ports.ErrInternal, "malformed kill request"))
1048+ _ = d.boundedControlSend(tr, frameError(ports.ErrInternal, "malformed kill request"))
10341049 return
10351050 }
10361051 if k.All {
@@ -1047,7 +1062,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
10471062 // deletion order as live and offline purges.
10481063 if err := d.retryStoppedPurge(k.Name); err != nil {
10491064 d.log.Warn("deleting stopped session failed", "err", err, "session", k.Name)
1050- _ = tr.Send(frameError(ports.ErrInternal, "deleting stopped session failed"))
1065+ _ = d.boundedControlSend(tr, frameError(ports.ErrInternal, "deleting stopped session failed"))
10511066 }
10521067 return
10531068 }
@@ -1055,48 +1070,77 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
10551070 d.mu.Unlock()
10561071
10571072 if target == nil {
1058- _ = tr.Send(frameError(ports.ErrNoSuchSession, "no such session: "+k.Name))
1073+ _ = d.boundedControlSend(tr, frameError(ports.ErrNoSuchSession, "no such session: "+k.Name))
10591074 return
10601075 }
10611076 if err := d.killSession(target, ports.ReasonSessionKilled, true); err != nil {
1062- _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted session failed"))
1077+ _ = d.boundedControlSend(tr, frameError(ports.ErrInternal, "deleting persisted session failed"))
10631078 }
10641079 }
10651080
1066-// handleHello runs the attach handshake: version check, intent routing,
1067-// Welcome, guaranteed first paint, then the per-connection input loop.
1081+// handleHello runs the attach handshake for direct package callers. Accepted
1082+// connections use handleHelloWithContext so the deadline also covers the first
1083+// frame read in handleConn.
10681084 func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
1085+ handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(context.Background())
1086+ stopTransport := watchHandshakeTransport(handshakeCtx, tr)
1087+ defer finishHandshake()
1088+ defer stopTransport()
1089+ d.handleHelloWithContext(handshakeCtx, timedOut, stopTransport, finishHandshake, tr, f)
1090+}
1091+
1092+// handleHelloWithContext runs the attach handshake: version check, intent
1093+// routing, Welcome, guaranteed first paint, then the per-connection input loop.
1094+func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <-chan struct{}, stopHandshakeTransport, finishHandshake func(), tr ports.Transport, f ports.Frame) {
1095+ if err := handshakeContextError(context.Background(), timedOut, handshakeCtx.Err()); err != nil {
1096+ _ = tr.Close()
1097+ return
1098+ }
1099+ sendHandshake := func(frame ports.Frame) error {
1100+ err := boundedHandshakeOperation(handshakeCtx, tr, func() error { return tr.Send(frame) })
1101+ if err != nil {
1102+ return handshakeContextError(context.Background(), timedOut, err)
1103+ }
1104+ return nil
1105+ }
10691106 h, err := ports.UnmarshalHello(f.Payload)
10701107 if err != nil {
10711108 if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion {
10721109 d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", version, "expected", ports.ProtocolVersion)
1073- _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
1110+ _ = sendHandshake(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
10741111 } else {
10751112 d.log.Warn("hello rejected", "err", err)
1076- _ = tr.Send(frameError(ports.ErrInternal, "malformed hello"))
1113+ _ = sendHandshake(frameError(ports.ErrInternal, "malformed hello"))
10771114 }
10781115 _ = tr.Close()
10791116 return
10801117 }
10811118 if h.Version != ports.ProtocolVersion {
10821119 d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", h.Version, "expected", ports.ProtocolVersion, "intent", h.Intent, "session", h.Name)
1083- _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
1120+ _ = sendHandshake(frameError(ports.ErrVersionMismatch, "protocol version mismatch"))
10841121 _ = tr.Close()
10851122 return
10861123 }
10871124
1088- sess, ac, rerr := d.route(h, tr)
1125+ sess, ac, rerr := d.routeWithContext(handshakeCtx, h, tr)
10891126 if rerr != nil {
10901127 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
10911128 if pe, ok := errors.AsType[*protoErr](rerr); ok {
1092- _ = tr.Send(frameError(pe.code, pe.text))
1129+ _ = sendHandshake(frameError(pe.code, pe.text))
10931130 } else {
1094- _ = tr.Send(frameError(ports.ErrInternal, rerr.Error()))
1131+ _ = sendHandshake(frameError(ports.ErrInternal, rerr.Error()))
10951132 }
10961133 _ = tr.Close()
10971134 return
10981135 }
10991136
1137+ failAttachment := func() {
1138+ d.failHandshakeAttachment(sess, ac, tr)
1139+ }
1140+ if err := handshakeCtx.Err(); err != nil {
1141+ failAttachment()
1142+ return
1143+ }
11001144 rc := sess.renderCoordinator()
11011145 lease := (*attachmentLease)(nil)
11021146 if rc != nil {
@@ -1115,18 +1159,16 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
11151159 }
11161160 return
11171161 }
1118- if err := ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {
1162+ if err := handshakeCtx.Err(); err != nil {
11191163 welcomeTicket.End()
1120- if !d.abortResumeClaim(ac) {
1121- d.clientGone(sess, ac, tr, false)
1122- }
1164+ failAttachment()
11231165 return
11241166 }
1125- if !d.commitResumeClaim(ac) {
1167+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1168+ return ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, ac), welcomeTicket)
1169+ }); err != nil {
11261170 welcomeTicket.End()
1127- if !d.abortResumeClaim(ac) {
1128- d.clientGone(sess, ac, tr, false)
1129- }
1171+ failAttachment()
11301172 return
11311173 }
11321174 // Release Welcome's effect before discovering post-handshake authority so a
@@ -1134,28 +1176,42 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
11341176 welcomeTicket.End()
11351177 postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr)
11361178 if !admitted {
1137- d.clientGone(sess, ac, tr, false)
1179+ failAttachment()
11381180 return
11391181 }
11401182 postWelcomeLease := postWelcomeToken.lease
11411183 if postWelcomeToken.ac == nil {
11421184 postWelcomeTicket.End()
1143- d.clientGone(sess, ac, tr, false)
1185+ failAttachment()
11441186 return
11451187 }
11461188 if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {
11471189 postWelcomeTicket.End()
11481190 // The attachment was detached while Welcome was in flight; never let
11491191 // this stale handshake emit an Output frame.
1150- d.clientGone(sess, ac, tr, false)
1192+ failAttachment()
11511193 return
11521194 }
11531195 postWelcomeTicket.End()
11541196 paintToken := sess.attachmentToken(ac, tr)
1155- if !d.firstPaintForTransition(paintToken) {
1156- d.clientGone(sess, ac, tr, false)
1197+ painted := make(chan bool, 1)
1198+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1199+ painted <- d.firstPaintForTransition(paintToken)
1200+ return nil
1201+ }); err != nil {
1202+ failAttachment()
1203+ return
1204+ }
1205+ if !<-painted || handshakeCtx.Err() != nil {
1206+ failAttachment()
11571207 return
11581208 }
1209+ if !d.commitResumeClaim(ac) {
1210+ failAttachment()
1211+ return
1212+ }
1213+ stopHandshakeTransport()
1214+ finishHandshake()
11591215 d.runConnLoop(ac)
11601216 _ = tr.Close()
11611217 }
@@ -1253,8 +1309,23 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
12531309 }
12541310
12551311 // route resolves a Hello to a session and a freshly attached client, creating
1256-// the session for ephemeral/new intents.
1312+// the session for ephemeral/new intents. Direct package callers retain the
1313+// daemon lifetime context; inbound handshakes use routeWithContext.
12571314 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
1315+ ctx := d.serveCtx
1316+ if ctx == nil {
1317+ ctx = context.Background()
1318+ }
1319+ return d.routeWithContext(ctx, h, tr)
1320+}
1321+
1322+func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
1323+ if ctx == nil {
1324+ ctx = context.Background()
1325+ }
1326+ if err := ctx.Err(); err != nil {
1327+ return nil, nil, err
1328+ }
12581329 sz := h.Size
12591330 if !sz.Valid() {
12601331 return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
@@ -1299,10 +1370,6 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
12991370 }
13001371 }
13011372 if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {
1302- ctx := d.serveCtx
1303- if ctx == nil {
1304- ctx = context.Background()
1305- }
13061373 if err := d.waitForTargetRestore(ctx, h.Name); err != nil {
13071374 return nil, nil, err
13081375 }
@@ -1320,6 +1387,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13201387 case ports.IntentResume:
13211388 // Resume miss/expiry falls back to normal attach semantics.
13221389 sess := d.findByNameLocked(h.Name)
1390+ created := false
13231391 if sess == nil {
13241392 stopped, ok := d.stopped[h.Name]
13251393 if !ok || stopped.purging {
@@ -1333,8 +1401,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13331401 d.mu.Unlock()
13341402 return nil, nil, err
13351403 }
1404+ created = true
13361405 }
13371406 ac, err := d.finishAttach(sess, tr, sz, term, h)
1407+ if err == nil && created {
1408+ ac.routeCreatedSession = true
1409+ }
13381410 return sess, ac, err
13391411
13401412 case ports.IntentEphemeral:
@@ -1345,6 +1417,10 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13451417 return nil, nil, err
13461418 }
13471419 ac, err := d.finishAttach(sess, tr, sz, term, h)
1420+ if err == nil {
1421+ ac.routeCreatedSession = true
1422+ ac.routeSessionPurge = true
1423+ }
13481424 return sess, ac, err
13491425
13501426 case ports.IntentNew:
@@ -1366,10 +1442,15 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13661442 return nil, nil, err
13671443 }
13681444 ac, err := d.finishAttach(sess, tr, sz, term, h)
1445+ if err == nil {
1446+ ac.routeCreatedSession = true
1447+ ac.routeSessionPurge = true
1448+ }
13691449 return sess, ac, err
13701450
13711451 case ports.IntentAttach:
13721452 sess := d.findByNameLocked(h.Name)
1453+ created := false
13731454 if sess == nil {
13741455 stopped, ok := d.stopped[h.Name]
13751456 if !ok || stopped.purging {
@@ -1383,8 +1464,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13831464 d.mu.Unlock()
13841465 return nil, nil, err
13851466 }
1467+ created = true
13861468 }
13871469 ac, err := d.finishAttach(sess, tr, sz, term, h)
1470+ if err == nil && created {
1471+ ac.routeCreatedSession = true
1472+ }
13881473 return sess, ac, err
13891474
13901475 default:
M internal/usecase/daemon/daemon_test.go +2 -0
@@ -664,6 +664,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
664664 }()
665665
666666 awaitTestCompletion(t, tr.welcomeEntered, "timed out waiting for Welcome send")
667+ handshakeTimer := awaitHandshakeTimer(t, clock)
667668 welcome := awaitFrame(t, tr.sends, ports.MsgWelcome)
668669 require.Equal(t, ports.MsgWelcome, welcome.Type)
669670 sess := firstSession(d)
@@ -694,6 +695,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
694695 require.Zero(t, first.Base, "the first post-Welcome frame is full")
695696 tr.finish()
696697 awaitTestCompletion(t, done, "timed out waiting for Welcome handler completion")
698+ awaitTestCompletion(t, handshakeTimer.stopped, "handshake timer was not canceled after publication")
697699 requireNoCoordinatorOutputFrame(t, tr.sends)
698700 }
699701
A internal/usecase/daemon/handshake.go +147 -0
@@ -0,0 +1,147 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+var errHandshakeTimeout = errors.New("handshake timed out")
12+
13+// newHandshakeContext owns one deadline for the complete inbound handshake.
14+// The caller must stop it before entering the long-lived connection loop.
15+func (d *Daemon) newHandshakeContext(parent context.Context) (context.Context, <-chan struct{}, func()) {
16+ if parent == nil {
17+ parent = context.Background()
18+ }
19+ ctx, cancel := context.WithCancel(parent)
20+ timedOut := make(chan struct{})
21+ clock := ports.Clock(systemClock{})
22+ if d != nil && d.clock != nil {
23+ clock = d.clock
24+ }
25+ timer := clock.NewTimer(ports.HandshakeTimeout)
26+ if timer == nil {
27+ timer = systemClock{}.NewTimer(ports.HandshakeTimeout)
28+ }
29+ stop := make(chan struct{})
30+ done := make(chan struct{})
31+ go func() {
32+ defer close(done)
33+ select {
34+ case <-timer.C():
35+ close(timedOut)
36+ cancel()
37+ case <-stop:
38+ }
39+ }()
40+ var once sync.Once
41+ finish := func() {
42+ once.Do(func() {
43+ close(stop)
44+ <-done
45+ timer.Stop()
46+ cancel()
47+ })
48+ }
49+ return ctx, timedOut, finish
50+}
51+
52+// watchHandshakeTransport closes a transport when the handshake context ends.
53+// Transport.Close is required to interrupt blocked Send and Recv operations.
54+func watchHandshakeTransport(ctx context.Context, transport ports.Transport) func() {
55+ stop := make(chan struct{})
56+ done := make(chan struct{})
57+ go func() {
58+ defer close(done)
59+ select {
60+ case <-ctx.Done():
61+ _ = transport.Close()
62+ case <-stop:
63+ }
64+ }()
65+ var once sync.Once
66+ return func() {
67+ once.Do(func() {
68+ close(stop)
69+ <-done
70+ })
71+ }
72+}
73+
74+// boundedHandshakeOperation waits for one transport operation while the shared
75+// handshake context remains live. Closing the transport is the interruption
76+// mechanism because ports.Transport methods do not accept contexts.
77+func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error {
78+ if err := ctx.Err(); err != nil {
79+ _ = transport.Close()
80+ return err
81+ }
82+ completed := make(chan error, 1)
83+ go func() { completed <- operation() }()
84+ select {
85+ case err := <-completed:
86+ if ctxErr := ctx.Err(); ctxErr != nil {
87+ _ = transport.Close()
88+ return ctxErr
89+ }
90+ return err
91+ case <-ctx.Done():
92+ _ = transport.Close()
93+ // The result channel is buffered, so the operation can publish its
94+ // completion after Close without keeping this cancellation path stuck.
95+ return ctx.Err()
96+ }
97+}
98+
99+func handshakeContextError(parent context.Context, timedOut <-chan struct{}, fallback error) error {
100+ select {
101+ case <-timedOut:
102+ return errors.Join(errHandshakeTimeout, context.DeadlineExceeded)
103+ default:
104+ }
105+ if parent == nil {
106+ return fallback
107+ }
108+ if err := parent.Err(); err != nil {
109+ return err
110+ }
111+ return fallback
112+}
113+
114+// failHandshakeAttachment synchronously retires the exact connection admitted
115+// by route. A fresh route-owned session is purged only after its attachment is
116+// removed; existing sessions and their unrelated attachments are preserved.
117+func (d *Daemon) failHandshakeAttachment(sess *session, ac *attachedClient, tr ports.Transport) {
118+ if sess == nil || ac == nil {
119+ if tr != nil {
120+ _ = tr.Close()
121+ }
122+ return
123+ }
124+ if d.abortResumeClaim(ac) {
125+ // abortResumeClaim closes the replacement transport and restores the
126+ // original parked credential. Closing the exact handshake link again is
127+ // harmless for transports and makes this boundary synchronous.
128+ if tr != nil {
129+ _ = tr.Close()
130+ }
131+ return
132+ }
133+ d.clientGone(sess, ac, tr, true)
134+ if tr != nil {
135+ _ = tr.Close()
136+ }
137+ if ac.routeCreatedSession {
138+ sess.mu.Lock()
139+ empty := len(sess.attachments) == 0
140+ sess.mu.Unlock()
141+ if empty {
142+ // killSession revalidates the attachment set under its publication
143+ // locks before removing the session.
144+ _ = d.killSession(sess, ports.ReasonSessionKilled, ac.routeSessionPurge)
145+ }
146+ }
147+}
A internal/usecase/daemon/handshake_test.go +342 -0
@@ -0,0 +1,342 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+type handshakeBlockingTransport struct {
17+ blockWelcome bool
18+ welcome chan struct{}
19+ closed chan struct{}
20+ closeOnce sync.Once
21+}
22+
23+func newHandshakeBlockingTransport(blockWelcome bool) *handshakeBlockingTransport {
24+ return &handshakeBlockingTransport{
25+ blockWelcome: blockWelcome,
26+ welcome: make(chan struct{}),
27+ closed: make(chan struct{}),
28+ }
29+}
30+
31+func (t *handshakeBlockingTransport) Send(frame ports.Frame) error {
32+ if t.blockWelcome && frame.Type == ports.MsgWelcome {
33+ close(t.welcome)
34+ <-t.closed
35+ return io.ErrClosedPipe
36+ }
37+ return nil
38+}
39+
40+func (t *handshakeBlockingTransport) Recv() (ports.Frame, error) {
41+ <-t.closed
42+ return ports.Frame{}, io.ErrClosedPipe
43+}
44+
45+func (t *handshakeBlockingTransport) Close() error {
46+ t.closeOnce.Do(func() { close(t.closed) })
47+ return nil
48+}
49+
50+func TestBoundedHandshakeCompletionCancellationDoesNotDoubleReceive(t *testing.T) {
51+ ctx := &completionCancellationContext{}
52+ tr := &closeTrackingTransport{}
53+ done := make(chan error, 1)
54+ go func() {
55+ done <- boundedHandshakeOperation(ctx, tr, func() error {
56+ ctx.canceled = true
57+ return nil
58+ })
59+ }()
60+
61+ select {
62+ case err := <-done:
63+ require.ErrorIs(t, err, context.Canceled)
64+ case <-time.After(testWaitTimeout):
65+ t.Fatal("handshake completion/cancellation race deadlocked")
66+ }
67+}
68+
69+type completionCancellationContext struct{ canceled bool }
70+
71+func (c *completionCancellationContext) Deadline() (time.Time, bool) { return time.Time{}, false }
72+func (c *completionCancellationContext) Done() <-chan struct{} { return nil }
73+func (c *completionCancellationContext) Err() error {
74+ if c.canceled {
75+ return context.Canceled
76+ }
77+ return nil
78+}
79+func (*completionCancellationContext) Value(any) any { return nil }
80+
81+func TestBoundedHandshakeCancellationDoesNotWaitForUninterruptibleOperation(t *testing.T) {
82+ ctx, cancel := context.WithCancel(context.Background())
83+ defer cancel()
84+ tr := &closeTrackingTransport{}
85+ started := make(chan struct{})
86+ release := make(chan struct{})
87+ operationDone := make(chan struct{})
88+ result := make(chan error, 1)
89+ go func() {
90+ result <- boundedHandshakeOperation(ctx, tr, func() error {
91+ close(started)
92+ <-release
93+ close(operationDone)
94+ return nil
95+ })
96+ }()
97+
98+ <-started
99+ cancel()
100+ select {
101+ case err := <-result:
102+ require.ErrorIs(t, err, context.Canceled)
103+ case <-time.After(testWaitTimeout):
104+ t.Fatal("handshake cancellation waited for the transport operation")
105+ }
106+ close(release)
107+ awaitTestCompletion(t, operationDone, "handshake operation did not finish")
108+}
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+
148+func TestHandshakeTimeoutClosesBlockedReceive(t *testing.T) {
149+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
150+ d := newTestDaemon(t, nil, clock)
151+ tr := newHandshakeBlockingTransport(false)
152+ done := make(chan struct{})
153+ go func() {
154+ d.handleConn(tr)
155+ close(done)
156+ }()
157+
158+ timer := <-clock.timers
159+ require.Equal(t, ports.HandshakeTimeout, timer.duration)
160+ timer.ch <- time.Time{}
161+ awaitTestCompletion(t, done, "handshake receive did not stop after timeout")
162+ requireClosedHandshakeTransport(t, tr)
163+ d.mu.Lock()
164+ require.Empty(t, d.sessions)
165+ require.Empty(t, d.parked)
166+ d.mu.Unlock()
167+}
168+
169+func TestFailedResumeHandshakeRestoresParkedCredential(t *testing.T) {
170+ pty, release := newBlockingPTY(t)
171+ defer release()
172+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
173+ oldTransport := newHandshakeBlockingTransport(false)
174+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "resume-timeout", 0), oldTransport)
175+ require.NoError(t, err)
176+ token := ac.resumeToken
177+ d.clientGone(sess, ac, oldTransport, false)
178+ require.Empty(t, sess.snapshotAttachments())
179+
180+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
181+ d.clock = clock
182+ resumeTransport := newHandshakeBlockingTransport(true)
183+ done := make(chan struct{})
184+ go func() {
185+ d.handleHello(resumeTransport, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(helloResumeCapable(ports.IntentResume, sess.name, token))})
186+ close(done)
187+ }()
188+
189+ timer := <-clock.timers
190+ require.Equal(t, ports.HandshakeTimeout, timer.duration)
191+ <-resumeTransport.welcome
192+ timer.ch <- time.Time{}
193+ awaitTestCompletion(t, done, "failed resume handshake did not finish")
194+ requireClosedHandshakeTransport(t, resumeTransport)
195+ d.mu.Lock()
196+ parked := d.parked[token]
197+ d.mu.Unlock()
198+ require.NotNil(t, parked, "failed pre-claim resume must restore the parked credential")
199+ require.Same(t, ac, parked.ac)
200+ require.Equal(t, token, ac.resumeToken)
201+ require.True(t, ac.parked)
202+ require.Empty(t, sess.snapshotAttachments())
203+}
204+
205+func TestHandshakeTimeoutClosesBlockedWelcomeSend(t *testing.T) {
206+ pty, release := newBlockingPTY(t)
207+ defer release()
208+ clock := &signalClock{timers: make(chan *signalTimer, 4)}
209+ d := newTestDaemon(t, newFactory(t, pty), clock)
210+ tr := newHandshakeBlockingTransport(true)
211+ done := make(chan struct{})
212+ go func() {
213+ d.handleHello(tr, mustHello(ports.IntentNew, "timeout-send", domain.Size{Cols: 80, Rows: 24}))
214+ close(done)
215+ }()
216+
217+ timer := <-clock.timers
218+ require.Equal(t, ports.HandshakeTimeout, timer.duration)
219+ <-tr.welcome
220+ timer.ch <- time.Time{}
221+ awaitTestCompletion(t, done, "handshake send did not stop after timeout")
222+ requireClosedHandshakeTransport(t, tr)
223+ d.mu.Lock()
224+ require.Empty(t, d.sessions, "a timed-out newly-created handshake must not leave an empty session")
225+ require.Empty(t, d.parked)
226+ d.mu.Unlock()
227+}
228+
229+func requireClosedHandshakeTransport(t *testing.T, tr *handshakeBlockingTransport) {
230+ t.Helper()
231+ select {
232+ case <-tr.closed:
233+ default:
234+ t.Fatal("handshake timeout did not close transport")
235+ }
236+}
237+
238+func TestHandshakeTimeoutCancelsRouteRestoreWait(t *testing.T) {
239+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
240+ d := newTestDaemon(t, nil, clock)
241+ restoreDone := make(chan struct{})
242+ d.mu.Lock()
243+ d.stopped["restoring"] = stoppedSession{name: "restoring", restoreDone: restoreDone}
244+ d.mu.Unlock()
245+
246+ tr := newHandshakeBlockingTransport(false)
247+ done := make(chan struct{})
248+ go func() {
249+ d.handleHello(tr, mustHello(ports.IntentAttach, "restoring", domain.Size{Cols: 80, Rows: 24}))
250+ close(done)
251+ }()
252+
253+ timer := <-clock.timers
254+ require.Equal(t, ports.HandshakeTimeout, timer.duration)
255+ timer.ch <- time.Time{}
256+ awaitTestCompletion(t, done, "route restore wait did not stop after handshake timeout")
257+ requireClosedHandshakeTransport(t, tr)
258+ d.mu.Lock()
259+ require.Empty(t, d.sessions)
260+ require.Empty(t, d.parked)
261+ d.mu.Unlock()
262+}
263+
264+func TestHandshakeTimeoutRemovesRestoredEmptySession(t *testing.T) {
265+ pty, release := newBlockingPTY(t)
266+ defer release()
267+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
268+ d.mu.Lock()
269+ d.stopped["restored"] = stoppedSession{name: "restored", cwd: "/tmp"}
270+ d.mu.Unlock()
271+
272+ clock := &signalClock{timers: make(chan *signalTimer, 4)}
273+ d.clock = clock
274+ tr := newHandshakeBlockingTransport(true)
275+ done := make(chan struct{})
276+ go func() {
277+ d.handleHello(tr, mustHello(ports.IntentAttach, "restored", domain.Size{Cols: 80, Rows: 24}))
278+ close(done)
279+ }()
280+
281+ timer := <-clock.timers
282+ require.Equal(t, ports.HandshakeTimeout, timer.duration)
283+ <-tr.welcome
284+ timer.ch <- time.Time{}
285+ awaitTestCompletion(t, done, "timed-out restored attachment cleanup did not finish")
286+ requireClosedHandshakeTransport(t, tr)
287+ d.mu.Lock()
288+ require.Empty(t, d.sessions)
289+ _, retained := d.stopped["restored"]
290+ d.mu.Unlock()
291+ require.True(t, retained, "failed attach must retain the stopped session authority")
292+}
293+
294+func TestHandshakeTimeoutPreservesUnrelatedAttachment(t *testing.T) {
295+ pty, release := newBlockingPTY(t)
296+ defer release()
297+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
298+ oldTransport := &closeTrackingTransport{}
299+ sess, old, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "shared", Size: domain.Size{Cols: 80, Rows: 24}}, oldTransport)
300+ require.NoError(t, err)
301+
302+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
303+ d.clock = clock
304+ tr := newHandshakeBlockingTransport(true)
305+ done := make(chan struct{})
306+ go func() {
307+ d.handleHello(tr, mustHello(ports.IntentAttach, "shared", domain.Size{Cols: 80, Rows: 24}))
308+ close(done)
309+ }()
310+
311+ timer := <-clock.timers
312+ require.Equal(t, ports.HandshakeTimeout, timer.duration)
313+ <-tr.welcome
314+ timer.ch <- time.Time{}
315+ awaitTestCompletion(t, done, "timed-out attachment cleanup did not finish")
316+ requireClosedHandshakeTransport(t, tr)
317+ require.False(t, oldTransport.Closed(), "cleanup closed an unrelated attachment transport")
318+ require.Same(t, sess, old.currentAttachmentSession())
319+ require.Equal(t, []*attachedClient{old}, sess.snapshotAttachments())
320+ d.mu.Lock()
321+ require.Len(t, d.sessions, 1)
322+ require.Empty(t, d.parked)
323+ d.mu.Unlock()
324+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
325+}
326+
327+func TestSuccessfulRouteOutlivesStoppedHandshakeContext(t *testing.T) {
328+ pty, release := newBlockingPTY(t)
329+ defer release()
330+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
331+ ctx, cancel := context.WithCancel(context.Background())
332+ sess, ac, err := d.routeWithContext(ctx, ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "lifetime", Size: domain.Size{Cols: 80, Rows: 24}}, &closeTrackingTransport{})
333+ require.NoError(t, err)
334+ cancel()
335+ select {
336+ case <-sess.ctx.Done():
337+ t.Fatal("session lifetime was parented to the handshake context")
338+ default:
339+ }
340+ require.NotNil(t, ac)
341+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
342+}
M internal/usecase/daemon/input.go +0 -17
@@ -43,23 +43,6 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
4343 func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) {
4444 ac := token.ac
4545 ac.initOverlays()
46- if proxy, ok := token.sess.(*proxySession); ok {
47- handler := proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: token}
48- ac.mouseScan.Scan(data,
49- func(ev mouse.Event) {
50- if token.attachmentEffectCurrent() && !proxyMouseOwnedLocally(ac.overlays) {
51- handler.Mouse(ev)
52- }
53- },
54- func(b []byte) {
55- if !token.attachmentEffectCurrent() || ac.overlays.HandleInput(d, b, token.effect) || !token.attachmentEffectCurrent() {
56- return
57- }
58- ac.keys.RouteWithHandler(b, handler)
59- },
60- )
61- return
62- }
6346 ac.mouseScan.Scan(data,
6447 func(ev mouse.Event) {
6548 if token.attachmentEffectCurrent() {
M internal/usecase/daemon/notices_overlay.go +0 -2
@@ -228,8 +228,6 @@ func (d *Daemon) yankNotice(entry attachmentSession, ac *attachedClient, n domai
228228 if err != nil {
229229 if sess, ok := localSession(entry); ok {
230230 d.detachOnSendError(sess, ac, failed)
231- } else if proxy, ok := entry.(*proxySession); ok {
232- d.detachProxyOnSendError(proxy, ac, failed)
233231 } else {
234232 _ = ac.closeCapturedTransport(failed)
235233 }
M internal/usecase/daemon/notices_overlay_test.go +0 -34
@@ -431,40 +431,6 @@ func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
431431 require.Equal(t, "no notifications yet", history[0].Message)
432432 }
433433
434-func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T) {
435- d, proxy, ac, _, handler := newProxyInputHarness(t)
436- handler.enterPalette()
437- d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect)
438-
439- history := d.notices.history()
440- require.Len(t, history, 1)
441- require.Equal(t, domain.SessionID(""), history[0].SessionID)
442- require.Same(t, proxy, ac.currentAttachmentSession())
443-}
444-
445-func TestProxyNoticeYankSendFailureDetachesAndArmsWarmExpiry(t *testing.T) {
446- d, proxy, ac, _, handler := newProxyInputHarness(t)
447- handler.connectionToken.effect.End()
448- client, ok := ac.transport().(*proxyTestTransport)
449- require.True(t, ok)
450- client.sendFails.Store(true)
451-
452- d.yankNotice(proxy, ac, domain.Notification{Code: domain.NoticeClipboard, Message: "copy", Time: time.Unix(1, 0)})
453-
454- require.Nil(t, ac.currentAttachmentSession())
455- select {
456- case <-client.closed:
457- default:
458- t.Fatal("proxy client transport was not closed")
459- }
460- proxy.mu.Lock()
461- warm := proxy.warm
462- proxy.mu.Unlock()
463- require.NotNil(t, warm, "proxy send failure must retain one warm expiry path")
464- warm.stop()
465- awaitTestCompletion(t, warm.done, "warm expiry did not stop during cleanup")
466-}
467-
468434 func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {
469435 p, release := newBlockingPTY(t)
470436 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/notify_test.go +2 -7
@@ -21,16 +21,11 @@ import (
2121 func TestReportAttachmentErrorPreservesLocalOrNilScope(t *testing.T) {
2222 d := newTestDaemon(t, nil, stubClock{})
2323 local := &session{sessionCore: sessionCore{id: "local", name: "local"}}
24- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize)
25- require.NoError(t, err)
26-
2724 d.reportAttachmentError(local, domain.UserWarn(domain.NoticeClipboard, "local failure", nil))
28- d.reportAttachmentError(proxy, domain.UserWarn(domain.NoticeClipboard, "proxy failure", nil))
2925
3026 history := d.notices.history()
31- require.Len(t, history, 2, "each attachment error must report exactly once")
32- require.Equal(t, local.id, history[1].SessionID)
33- require.Equal(t, domain.SessionID(""), history[0].SessionID)
27+ require.Len(t, history, 1)
28+ require.Equal(t, local.id, history[0].SessionID)
3429 }
3530
3631 func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {
M internal/usecase/daemon/palette.go +0 -90
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "context"
54 "errors"
65 "sort"
76 "time"
@@ -21,34 +20,6 @@ const (
2120 paletteRailWidth = 64
2221 )
2322
24-type proxyPaletteOwnership uint8
25-
26-const (
27- proxyPaletteRejected proxyPaletteOwnership = iota
28- proxyPaletteLocal
29- proxyPaletteRemote
30-)
31-
32-// proxyPaletteCommandOwnership is exhaustive by policy: an unknown future
33-// palette command is rejected until its proxy ownership is deliberately added.
34-func proxyPaletteCommandOwnership(slug string) proxyPaletteOwnership {
35- switch slug {
36- case "new-tab", "close-tab",
37- "split-right", "split-left", "split-up", "split-down",
38- "consume-or-expel-pane-left", "consume-or-expel-pane-right",
39- "stack-pane", "toggle-stack", "toggle-floating-pane", "close-pane",
40- "focus-pane-left", "focus-pane-right", "focus-pane-up", "focus-pane-down",
41- "resize-pane", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes",
42- "next-tab", "previous-tab", "rename-tab":
43- return proxyPaletteRemote
44- case "new-session", "back-session", "jump-recent-session", "session-picker",
45- "notifications", "yank-last-notification", "detach":
46- return proxyPaletteLocal
47- default:
48- return proxyPaletteRejected
49- }
50-}
51-
5223 var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
5324
5425 func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
@@ -180,7 +151,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
180151 return
181152 }
182153 sess, local := localSession(entry)
183- proxy, remote := entry.(*proxySession)
184154 var cmd command.Command
185155 var sessionTarget palette.Result
186156 var hasSessionTarget bool
@@ -347,66 +317,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
347317 }
348318 return
349319 }
350- ownership := proxyPaletteCommandOwnership(cmd.Slug)
351- if remote && ownership == proxyPaletteRemote {
352- if !ac.closeExecutedPalette(generation, rawQuery) {
353- return
354- }
355- ctx := d.serveCtx
356- if ctx == nil {
357- ctx = context.Background()
358- }
359- result, err := proxy.sendCommand(ctx, d.clock, cmd.Slug, args)
360- if err != nil {
361- d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, "remote command failed", err)
362- return
363- }
364- if !result.OK {
365- text := result.Text
366- if text == "" {
367- text = "remote command failed"
368- }
369- d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, text, nil)
370- return
371- }
372- d.recordPaletteUse(cmd.Code)
373- d.invalidateRender(proxy, ac, true, "palette.go")
374- return
375- }
376- if remote && ownership == proxyPaletteLocal {
377- // Every local action supersedes this exact remote palette generation.
378- // Close it before role handoff or opening another overlay so a delayed
379- // action cannot leave the old palette rendered over the new state.
380- if !ac.closeExecutedPalette(generation, rawQuery) {
381- return
382- }
383- var err error
384- switch cmd.Slug {
385- case "new-session", "yank-last-notification":
386- err = cmd.Run(paletteExec{d: d, ac: ac, effect: effect, attachment: proxy}, args)
387- if err == nil {
388- d.recordPaletteUse(cmd.Code)
389- }
390- case "back-session":
391- if effect != nil {
392- err = d.backSessionForAttachment(effect.connectionToken())
393- }
394- case "detach":
395- if effect != nil && !d.clientGoneForAttachment(effect.connectionToken(), true) {
396- err = errAttachmentTransition
397- }
398- case "session-picker":
399- d.enterPicker(proxy, ac)
400- case "notifications":
401- d.enterNotices(proxy, ac)
402- default:
403- err = errors.New(cmd.Name + " is unavailable for a remote session")
404- }
405- if err != nil && !errors.Is(err, errAttachmentTransition) {
406- d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, err.Error(), nil)
407- }
408- return
409- }
410320 if !local {
411321 if ac.closeExecutedPalette(generation, rawQuery) {
412322 d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, cmd.Name+" is unavailable for this attachment", nil)
M internal/usecase/daemon/palette_test.go +0 -22
@@ -125,28 +125,6 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
125125 // only a log line. The new-tab spawn failure is wrapped as a domain.UserError
126126 // with NoticeTabSpawn (session.go createTab), so it surfaces under that code
127127 // rather than the NoticeInternal catch-all.
128-func TestRemotePaletteClosesBeforeOpeningLocalOverlay(t *testing.T) {
129- tests := []struct {
130- name string
131- code string
132- active func(*overlayRuntime) bool
133- }{
134- {name: "session picker", code: "SSP", active: func(rt *overlayRuntime) bool { return rt.pickerActive() }},
135- {name: "notifications", code: "NTC", active: func(rt *overlayRuntime) bool { return rt.noticesActive() }},
136- }
137- for _, test := range tests {
138- t.Run(test.name, func(t *testing.T) {
139- d, _, ac, link, handler := newProxyInputHarness(t)
140- handler.enterPalette()
141- d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.connectionToken.effect)
142-
143- require.False(t, ac.overlays.paletteActive(), "stale remote palette remained active")
144- require.True(t, test.active(ac.overlays), "replacement local overlay was not opened")
145- requireNoProxyFrame(t, link)
146- })
147- }
148-}
149-
150128 func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) {
151129 p, release := newBlockingPTY(t)
152130 defer release()
M internal/usecase/daemon/performance_benchmark_test.go +0 -134
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "bytes"
54 "context"
65 "errors"
76 "fmt"
@@ -18,7 +17,6 @@ import (
1817 "github.com/bnema/vev/internal/usecase/layout"
1918 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2019 "github.com/bnema/vev/pkg/renderer"
21- "github.com/bnema/vev/pkg/vt"
2220 )
2321
2422 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
@@ -198,114 +196,6 @@ func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *tes
198196 require.Equal(t, payload, transport.lastPayload(), "the counting transport must retain the payload slice header, not copy it")
199197 }
200198
201-func TestProxyANSIBenchmarkFixtures(t *testing.T) {
202- fixtures := proxyANSIBenchmarkFixtures()
203- require.Len(t, fixtures, 4)
204- require.Equal(t, []string{
205- "absolute-position-one-cell",
206- "120-column-full-line",
207- "fragmented-truecolor-styled-line",
208- "full-width-40-row-scroll",
209- }, []string{fixtures[0].name, fixtures[1].name, fixtures[2].name, fixtures[3].name})
210- require.Equal(t, fixtures, proxyANSIBenchmarkFixtures(), "fixtures must be deterministic")
211- require.Equal(t, []byte("\x1b[20;60HX"), fixtures[0].data)
212- require.Equal(t, append([]byte("\x1b[1;1H"), bytes.Repeat([]byte("l"), 120)...), fixtures[1].data)
213- require.Equal(t, 12, bytes.Count(fixtures[2].data, []byte("\x1b[38;2;")))
214- require.Equal(t, 40, bytes.Count(fixtures[3].data, []byte("\r\n")))
215-
216- for _, fixture := range fixtures {
217- t.Run(fixture.name, func(t *testing.T) {
218- state := newProxyANSIApplyState(t)
219-
220- ack, reset, changed := state.apply(ports.Output{
221- Base: 0,
222- New: 1,
223- Data: fixture.data,
224- })
225-
226- require.Equal(t, uint64(1), ack)
227- require.False(t, reset)
228- require.True(t, changed)
229- require.Equal(t, domain.Size{Cols: 120, Rows: 40}, domain.Size{Cols: state.screen.Frame.Width, Rows: state.screen.Frame.Height})
230- })
231- }
232-}
233-
234-// proxyANSIApplyState is deliberately a test-only copy of the removed ANSI
235-// relay state: each operation copies transport bytes, feeds vt.Screen.Write,
236-// and advances a dependent state chain. It keeps this historical benchmark
237-// independent of structured snapshots and renderer-frame cloning.
238-type proxyANSIApplyState struct {
239- mu sync.Mutex
240- screen *vt.Screen
241- appliedState uint64
242- awaitingReset bool
243-}
244-
245-func newProxyANSIApplyState(t testing.TB) *proxyANSIApplyState {
246- t.Helper()
247- return &proxyANSIApplyState{screen: vt.NewScreen(120, 40)}
248-}
249-
250-func (s *proxyANSIApplyState) apply(out ports.Output) (ack uint64, requestReset, changed bool) {
251- data := append([]byte(nil), out.Data...)
252- s.mu.Lock()
253- defer s.mu.Unlock()
254- if out.New == 0 {
255- s.screen.Write(data)
256- return 0, false, len(data) != 0
257- }
258- if out.Base == 0 {
259- if !s.awaitingReset && out.New <= s.appliedState {
260- return 0, false, false
261- }
262- s.screen = vt.NewScreen(s.screen.Frame.Width, s.screen.Frame.Height)
263- s.screen.Write(data)
264- s.appliedState = out.New
265- s.awaitingReset = false
266- return out.New, false, true
267- }
268- if s.awaitingReset || out.Base != s.appliedState || out.New != out.Base+1 {
269- if !s.awaitingReset {
270- s.awaitingReset = true
271- requestReset = true
272- }
273- return 0, requestReset, false
274- }
275- s.screen.Write(data)
276- s.appliedState = out.New
277- return out.New, false, len(data) != 0
278-}
279-
280-type proxyANSIBenchmarkFixture struct {
281- name string
282- data []byte
283-}
284-
285-func proxyANSIBenchmarkFixtures() []proxyANSIBenchmarkFixture {
286- fullLine := append([]byte("\x1b[1;1H"), bytes.Repeat([]byte("l"), 120)...)
287-
288- var styledLine []byte
289- for fragment := range 12 {
290- styledLine = fmt.Appendf(styledLine, "\x1b[12;%dH\x1b[38;2;%d;%d;%dm", fragment*10+1, fragment*17, fragment*13, fragment*7)
291- styledLine = append(styledLine, bytes.Repeat([]byte{byte('a' + fragment)}, 10)...)
292- }
293- styledLine = append(styledLine, "\x1b[0m"...)
294-
295- scroll := make([]byte, 0, 40*122)
296- for row := range 40 {
297- scroll = append(scroll, performanceFullWidthRow(120, 0, row)...)
298- scroll = append(scroll, '\r', '\n')
299- }
300-
301- return []proxyANSIBenchmarkFixture{
302- {name: "absolute-position-one-cell", data: []byte("\x1b[20;60HX")},
303- {name: "120-column-full-line", data: fullLine},
304- {name: "fragmented-truecolor-styled-line", data: styledLine},
305- {name: "full-width-40-row-scroll", data: scroll},
306- }
307-}
308-
309199 func TestPerformanceFixtureCounters(t *testing.T) {
310200 fixture := newPerformanceFixture(t, performanceConfig{})
311201
@@ -469,30 +359,6 @@ var daemonSnapshotTopologies = []daemonHistoryTopology{
469359 {name: "1tab-4panes", tabs: 1, panes: 4},
470360 }
471361
472-// BenchmarkProxyANSIApply is a component diagnostic for proxy ANSI parsing and
473-// apply only; it is not a pipeline comparison with structured output.
474-func BenchmarkProxyANSIApply(b *testing.B) {
475- for _, fixture := range proxyANSIBenchmarkFixtures() {
476- b.Run(fixture.name, func(b *testing.B) {
477- state := newProxyANSIApplyState(b)
478- b.ReportAllocs()
479- b.SetBytes(int64(len(fixture.data)))
480- for b.Loop() {
481- next := state.appliedState + 1
482- ack, reset, changed := state.apply(ports.Output{
483- Base: state.appliedState,
484- New: next,
485- Data: fixture.data,
486- })
487- if ack != next || reset || !changed {
488- b.Fatalf("apply output = ack %d, reset %t, changed %t; want ack %d, reset false, changed true", ack, reset, changed, next)
489- }
490- }
491- b.ReportMetric(float64(len(fixture.data)), "ansibytes/op")
492- })
493- }
494-}
495-
496362 func BenchmarkDaemonHistoryLivePaint(b *testing.B) {
497363 benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })
498364 }
M internal/usecase/daemon/picker.go +36 -141
@@ -86,12 +86,8 @@ func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model
8686 func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {
8787 d.mu.Lock()
8888 sessions := make([]attachmentSession, 0, len(d.sessions))
89- proxies := make(map[domain.SessionID]*proxySession)
90- for id, entry := range d.sessions {
89+ for _, entry := range d.sessions {
9190 sessions = append(sessions, entry)
92- if proxy, ok := entry.(*proxySession); ok {
93- proxies[id] = proxy
94- }
9591 }
9692 stopped := make([]stoppedSession, 0, len(d.stopped))
9793 for _, s := range d.stopped {
@@ -114,8 +110,8 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
114110 }
115111 // One snapshot per live session: sorting and view building read the same
116112 // capture, so comparators cannot observe a concurrent touchMRU or
117- // renameSession mid-sort. Proxy and remote-catalog locks are released before
118- // sorting and picker-row construction.
113+ // renameSession mid-sort. Remote-catalog locks are released before sorting
114+ // and picker-row construction.
119115 live := make([]liveSnapshot, 0, len(sessions))
120116 var current picker.SourceFilter
121117 for _, entry := range sessions {
@@ -126,10 +122,7 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
126122 }
127123 snap := entry.snapshotView(opts)
128124 if entry == cur {
129- if proxy, ok := entry.(*proxySession); ok {
130- key := proxy.key
131- current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, RemoteKey: &key}
132- } else if _, ok := localSession(entry); ok && ac != nil {
125+ if _, ok := localSession(entry); ok && ac != nil {
133126 view := ac.viewSnapshot()
134127 if view.tabID != "" {
135128 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}
@@ -175,10 +168,6 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
175168 }
176169 views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows)
177170 for _, item := range live {
178- if proxy, ok := item.entry.(*proxySession); ok {
179- views = append(views, remoteProxyPickerView(proxy.key, item.view))
180- continue
181- }
182171 views = append(views, item.view.pickerView())
183172 }
184173 for _, host := range catalog {
@@ -187,9 +176,6 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
187176 if key.Validate() != nil {
188177 continue
189178 }
190- if proxy, ok := proxies[key.ID()]; ok && proxy.key == key {
191- continue
192- }
193179 views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))
194180 }
195181 if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
@@ -640,7 +626,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
640626 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
641627 })
642628 if err != nil {
643- // Losing the exact source attachment is a benign stale action, not a notice for
629+ // Losing the exact source role is a benign stale action, not a notice for
644630 // whichever attachment replaced the initiator.
645631 if !token.attachmentCurrent() {
646632 return nil
@@ -676,14 +662,11 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
676662 if token.sess == nil || token.ac == nil || token.effect == nil {
677663 return nil
678664 }
679- token.effect.bindActionEnd(d, action)
680- token.effect.End()
681665 if target.RemoteKey != nil {
682- return d.switchToRemoteTargetForAttachment(token, target, *target.RemoteKey, guard, action)
683- }
684- if token.sess.isProxy() && !target.Stopped {
685- return d.switchActiveTargetForAttachmentGuarded(token, target, guard, action)
666+ return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
686667 }
668+ token.effect.bindActionEnd(d, action)
669+ token.effect.End()
687670 sess, ok := localSession(token.sess)
688671 if !ok {
689672 return errAttachmentTransition
@@ -691,132 +674,45 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
691674 return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action)
692675 }
693676
694-// switchToRemoteTargetForAttachment performs dialing after the initiating effect has
695-// ended and without attachment-effect or architecture locks. transitionAttachment then
696-// freezes and revalidates the exact source attachment and exact structured-key proxy
697-// before publishing ownership.
698-func (d *Daemon) switchToRemoteTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error {
699- if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" {
700- return errAttachmentTransition
701- }
702- ctx := d.serveCtx
703- if ctx == nil {
704- ctx = context.Background()
705- }
706- // Only the caller that observed neither a published proxy nor an in-flight
707- // constructor may own failure cleanup. Waiters and existing-proxy users must
708- // never tear down the shared link when their source transition goes stale.
709- d.mu.Lock()
710- _, alreadyPublished := d.sessions[key.ID()]
711- constructionInFlight := d.proxyConstructions[key] != nil
712- d.mu.Unlock()
713- ownsCandidate := !alreadyPublished && !constructionInFlight
714- proxy, err := d.openProxySession(ctx, key, token.ac.size)
715- if err != nil {
716- if !token.attachmentCurrent() {
717- return errAttachmentTransition
718- }
719- return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't connect to that remote session", err)
720- }
721- if proxy == nil || proxy.key != key || proxy.id != key.ID() {
677+// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
678+// endpoint to the thin client. The daemon owns no remote session shadow: after
679+// the target is sent, the local attachment is detached and the client opens a
680+// fresh connection to the owning daemon.
681+func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
682+ if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
722683 return errAttachmentTransition
723684 }
724- proxy.mu.Lock()
725- candidateGeneration := proxy.generation
726- proxy.mu.Unlock()
727-
728- transition, err := d.transitionAttachment(attachmentTransitionRequest{
729- source: token.sess, target: proxy, next: token.ac,
730-
731- expectedTransport: token.transport, sourceToken: &token, action: action,
732- preserveAttachment: proxy == token.sess, ready: true,
733- })
734- if err != nil {
735- if ownsCandidate {
736- d.discardUnattachedProxyGeneration(proxy, candidateGeneration)
737- }
738- return errAttachmentTransition
685+ if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(ports.AttachTarget{
686+ Endpoint: key.Host,
687+ Session: key.Name,
688+ Intent: ports.IntentAttach,
689+ })}); err != nil {
690+ return err
739691 }
740692 if guard.closePicker {
741- fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
742- if admitted {
743- d.closePicker(token.ac)
744- fresh.End()
745- }
746- }
747- if proxy != token.sess {
748- token.ac.recordPreviousSession(token.sess)
749- }
750- d.deferAttachmentTransitionCleanups(transition)
751- if proxy == token.sess {
752- d.invalidateRender(proxy, token.ac, true, "picker.go")
753- } else {
754- d.firstPaintForTransition(transition.published)
693+ d.closePicker(token.ac)
755694 }
695+ d.clientGoneForAttachment(token, false)
756696 return nil
757697 }
758698
759-// discardUnattachedProxyGeneration removes only a transition-owned candidate
760-// that never acquired a client. It mirrors normal proxy teardown fencing but
761-// deliberately does not arm the five-minute warm lifetime for an attachment
762-// that was never published.
763-func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generation uint64) bool {
764- if d == nil || proxy == nil {
765- return false
766- }
767- d.mu.Lock()
768- if d.sessions[proxy.id] != proxy {
769- d.mu.Unlock()
770- return false
771- }
772- proxy.sessionCore.mu.Lock()
773- if len(proxy.attachments) != 0 {
774- proxy.sessionCore.mu.Unlock()
775- d.mu.Unlock()
776- return false
777- }
778- proxy.mu.Lock()
779- if proxy.generation != generation || proxy.expired || proxy.warm != nil {
780- proxy.mu.Unlock()
781- proxy.sessionCore.mu.Unlock()
782- d.mu.Unlock()
783- return false
784- }
785- proxy.generation++
786- proxy.expired = true
787- cancel := proxy.cancel
788- transport := proxy.transport
789- coordinator := proxy.coordinator.Load()
790- proxy.mu.Unlock()
791- if !d.unregisterSessionLocked(proxy) {
792- proxy.sessionCore.mu.Unlock()
793- d.mu.Unlock()
699+func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
700+ if d == nil {
794701 return false
795702 }
796- proxy.sessionCore.mu.Unlock()
797- empty := len(d.sessions) == 0
798- if empty {
799- d.closing = true
800- }
801- d.mu.Unlock()
802-
803- if cancel != nil {
804- cancel()
805- }
806- if transport != nil {
807- _ = transport.Close()
808- }
809- if coordinator != nil {
810- coordinator.beginSessionTeardown().finish()
811- coordinator.waitForTimerWorkers()
812- }
813- proxy.finish()
814- if empty {
815- d.doneOnce.Do(func() { close(d.done) })
816- } else {
817- d.refreshRemoteOpenPickers()
703+ d.remoteCatalog.mu.Lock()
704+ defer d.remoteCatalog.mu.Unlock()
705+ for _, entry := range d.remoteCatalog.cache {
706+ if entry.Host != key.Host {
707+ continue
708+ }
709+ for _, session := range entry.Sessions {
710+ if session.Name == key.Name {
711+ return d.remoteCatalog.status[key.Host] != remoteHostVersionMismatch
712+ }
713+ }
818714 }
819- return true
715+ return false
820716 }
821717
822718 // switchToTargetGuarded is retained for daemon-internal and headless callers.
@@ -1122,7 +1018,6 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attac
11221018 return d.killPickerTarget(target)
11231019 }
11241020 if target.RemoteKey != nil {
1125- d.enterRemoteKillConfirmation(token.sess, token.ac, target)
11261021 return nil
11271022 }
11281023 if target.Stopped {
M internal/usecase/daemon/prompt.go +1 -2
@@ -47,8 +47,7 @@ func (d *Daemon) closePrompt(ac *attachedClient) {
4747 func promptValidationError(err error) bool {
4848 return errors.Is(err, domain.ErrInvalidSessionName) ||
4949 errors.Is(err, errSessionNameRequired) ||
50- errors.Is(err, errSessionNameInUse) ||
51- errors.Is(err, errProxyKillConfirmation)
50+ errors.Is(err, errSessionNameInUse)
5251 }
5352
5453 func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
D internal/usecase/daemon/proxy_input.go +0 -223
@@ -1,223 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
8- "github.com/bnema/vev/internal/usecase/keys"
9- "github.com/bnema/vev/internal/usecase/mouse"
10- "github.com/bnema/vev/internal/usecase/palette"
11-)
12-
13-// sendInput is the sole proxy input sender. Sequence assignment and transport
14-// Send are serialized with every other proxy link write; raw is never rebuilt
15-// from an interpreted key action.
16-func (p *proxySession) sendInput(raw []byte) error {
17- if p == nil || len(raw) == 0 {
18- return nil
19- }
20- data := append([]byte(nil), raw...)
21- p.sendMu.Lock()
22- defer p.sendMu.Unlock()
23-
24- p.mu.Lock()
25- transport := p.transport
26- p.mu.Unlock()
27- if transport == nil {
28- return errors.New("proxy session: input link is unavailable")
29- }
30- p.inputNext++
31- return transport.Send(ports.Frame{
32- Type: ports.MsgInput,
33- Payload: ports.MarshalInput(ports.Input{
34- InputSeq: p.inputNext,
35- Data: data,
36- }),
37- })
38-}
39-
40-// proxyKeyHandler pins input ownership while a local client is attached to a
41-// proxy. Local UI actions never reach the link. Every remote-owned action is
42-// sent using the exact bytes supplied by keys.Router.
43-type proxyKeyHandler struct {
44- d *Daemon
45- proxy *proxySession
46- ac *attachedClient
47- connectionToken attachmentConnectionToken
48-}
49-
50-func (h proxyKeyHandler) acquireAttachmentEffect() (*attachmentEffectTicket, bool, bool) {
51- if h.proxy == nil || h.ac == nil {
52- return nil, false, false
53- }
54- if h.connectionToken.ac == nil {
55- return nil, false, false
56- }
57- if h.connectionToken.sess != h.proxy || h.connectionToken.ac != h.ac {
58- return nil, false, false
59- }
60- if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
61- return effect, false, true
62- }
63- effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
64- if h.d != nil && h.d.afterDelayedKeyEffectAttempt != nil {
65- h.d.afterDelayedKeyEffectAttempt(admitted)
66- }
67- if !admitted {
68- return nil, false, false
69- }
70- if h.d != nil && h.d.afterAttachmentEffectAdmitted != nil {
71- token := h.connectionToken
72- token.effect = effect
73- h.d.afterAttachmentEffectAdmitted(token)
74- }
75- return effect, true, true
76-}
77-
78-func (h proxyKeyHandler) sendOwned(raw []byte) {
79- if len(raw) == 0 {
80- return
81- }
82- if err := h.proxy.sendInput(raw); err != nil && h.d != nil {
83- h.d.log.Warn("proxy input send failed", "host", h.proxy.key.Host, "session", h.proxy.key.Name, "err", err)
84- }
85-}
86-
87-func (h proxyKeyHandler) send(raw []byte) {
88- effect, owned, ok := h.acquireAttachmentEffect()
89- if !ok {
90- return
91- }
92- if owned {
93- defer effect.End()
94- }
95- h.sendOwned(raw)
96-}
97-
98-func (h proxyKeyHandler) Forward(raw []byte) { h.send(raw) }
99-
100-func (h proxyKeyHandler) Mouse(event mouse.Event) { h.send(event.Raw) }
101-
102-func (h proxyKeyHandler) Action(action keys.Action, raw []byte) {
103- effect, owned, ok := h.acquireAttachmentEffect()
104- if !ok {
105- return
106- }
107- if owned {
108- defer effect.End()
109- }
110- h.connectionToken.effect = effect
111- switch action {
112- case keys.ActionOpenPalette:
113- h.enterPalette()
114- case keys.ActionJumpAttention:
115- if h.proxy.hasRemoteAttention() {
116- h.sendOwned(raw)
117- return
118- }
119- h.jumpLocalAttention()
120- case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
121- keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
122- keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9,
123- keys.ActionFocusPaneLeft, keys.ActionFocusPaneRight, keys.ActionFocusPaneUp, keys.ActionFocusPaneDown,
124- keys.ActionToggleFloatingPane,
125- keys.ActionGrowPaneWidth, keys.ActionShrinkPaneWidth,
126- keys.ActionGrowPaneHeight, keys.ActionShrinkPaneHeight,
127- keys.ActionEqualizePanes,
128- keys.ActionConsumeOrExpelPaneLeft, keys.ActionConsumeOrExpelPaneRight:
129- h.sendOwned(raw)
130- }
131-}
132-
133-func (p *proxySession) hasRemoteAttention() bool {
134- if p == nil {
135- return false
136- }
137- p.mu.Lock()
138- defer p.mu.Unlock()
139- for _, tab := range p.meta.Tabs {
140- if tab.Attention {
141- return true
142- }
143- }
144- return false
145-}
146-
147-func (h proxyKeyHandler) enterPalette() {
148- if h.d == nil || h.ac == nil || h.proxy == nil {
149- return
150- }
151- // paletteResults still accepts a local current session. Build its immutable
152- // snapshot without one and remove this proxy by its structured opaque ID.
153- recent := h.d.recentSessions(h.proxy)
154- commands := h.d.paletteCommands()
155- results := h.d.paletteResults(nil, commands)
156- filtered := results[:0]
157- for _, result := range results {
158- if id, ok := result.SessionID(); ok && id == h.proxy.id {
159- continue
160- }
161- filtered = append(filtered, result)
162- }
163- h.ac.overlays.paletteMu.Lock()
164- h.ac.overlays.paletteGeneration++
165- h.ac.overlays.palette = palette.New(filtered)
166- h.ac.overlays.paletteRecent = recent
167- h.ac.overlays.paletteHints = palette.ContextualHints{}
168- h.ac.overlays.paletteFeedback = ""
169- h.ac.overlays.palettePending = nil
170- h.ac.overlays.paletteMu.Unlock()
171- h.d.invalidateRender(h.proxy, h.ac, true, "proxy_input.go")
172-}
173-
174-// jumpLocalAttention handles only the selected-session part of attention
175-// navigation. A pending tab on the proxy remains remote-owned; without one,
176-// the local daemon may move the client to another local session.
177-func (h proxyKeyHandler) jumpLocalAttention() {
178- if h.d == nil || h.connectionToken.ac == nil || h.connectionToken.effect == nil {
179- return
180- }
181- target, ok := h.d.oldestOtherSessionAttention(nil)
182- if !ok {
183- return
184- }
185- h.d.mu.Lock()
186- targetSession, ok := localSession(h.d.sessions[target.sessionID])
187- h.d.mu.Unlock()
188- if !ok {
189- return
190- }
191-
192- token := h.connectionToken
193- token.effect.bindActionEnd(h.d, "proxy-jump-attention")
194- token.effect.End()
195- transition, err := h.d.transitionAttachment(attachmentTransitionRequest{
196- source: h.proxy, target: targetSession, next: h.ac,
197-
198- expectedTransport: token.transport, sourceToken: &token,
199- action: "proxy-jump-attention", activateTargetTab: true,
200- targetTabIndex: target.tabIndex, ready: true,
201- })
202- if err != nil {
203- return
204- }
205- h.d.touchMRU(targetSession)
206- h.ac.recordPreviousSession(h.proxy)
207- h.d.deferAttachmentTransitionCleanups(transition)
208- h.d.firstPaintForTransition(transition.published)
209-}
210-
211-func proxyMouseOwnedLocally(rt *overlayRuntime) bool {
212- // copyActive cannot be reached for proxy attachments through production input
213- // routing: copy mode is local-session-only and proxy keys never enter it.
214- return rt != nil && (rt.promptActive() || rt.paletteActive() || rt.pickerActive() ||
215- rt.noticesActive() || rt.resizeModeActive())
216-}
217-
218-func proxiedNavConfig(cfg domain.NavConfig) domain.NavConfig {
219- cfg.OverflowSessions = false
220- return cfg
221-}
222-
223-func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied }
D internal/usecase/daemon/proxy_input_test.go +0 -1007
@@ -1,1007 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "io"
7- "log/slog"
8- "sync"
9- "sync/atomic"
10- "testing"
11- "time"
12-
13- "github.com/stretchr/testify/mock"
14- "github.com/stretchr/testify/require"
15-
16- "github.com/bnema/vev/internal/domain"
17- "github.com/bnema/vev/internal/ports"
18- portsmocks "github.com/bnema/vev/internal/ports/mocks"
19- "github.com/bnema/vev/internal/usecase/command"
20- "github.com/bnema/vev/internal/usecase/keys"
21- "github.com/bnema/vev/internal/usecase/mouse"
22- "github.com/bnema/vev/internal/usecase/picker"
23-)
24-
25-func newProxyInputHarness(t *testing.T) (*Daemon, *proxySession, *attachedClient, *proxyTestTransport, proxyKeyHandler) {
26- t.Helper()
27- d := newTestDaemon(t, nil, stubClock{})
28- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
29- require.NoError(t, err)
30- link := newProxyTestTransport()
31- proxy.mu.Lock()
32- proxy.transport = link
33- proxy.linkGeneration = 1
34- proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}, {Index: 1, Name: "build"}}}
35- proxy.mu.Unlock()
36- ac := &attachedClient{size: domain.Size{Cols: 80, Rows: 24}, output: newOutputStateStream()}
37- ac.initOverlays()
38- token := attachmentEffectForTest(t, attachProxyInputRole(t, d, proxy, ac))
39- return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: token}
40-}
41-
42-func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *attachedClient) attachmentConnectionToken {
43- t.Helper()
44- clientTransport := newProxyTestTransport()
45- ac.replaceTransport(clientTransport)
46- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
47- d.mu.Lock()
48- require.True(t, d.registerSessionLocked(proxy))
49- d.mu.Unlock()
50- transition, err := d.transitionAttachment(attachmentTransitionRequest{
51- target: proxy, next: ac,
52-
53- expectedTransport: ac.transportSnapshot(), ready: true,
54- })
55- require.NoError(t, err)
56- d.deferAttachmentTransitionCleanups(transition)
57- return transition.published
58-}
59-
60-func attachmentEffectForTest(t *testing.T, token attachmentConnectionToken) attachmentConnectionToken {
61- t.Helper()
62- effect, admitted := token.ac.beginAttachmentEffect(token)
63- require.True(t, admitted)
64- token.effect = effect
65- t.Cleanup(effect.End)
66- return token
67-}
68-
69-func requireProxyInputFrame(t *testing.T, link *proxyTestTransport, wantSeq uint64, want []byte) {
70- t.Helper()
71- select {
72- case frame := <-link.sent:
73- require.Equal(t, ports.MsgInput, frame.Type)
74- input, err := ports.UnmarshalInput(frame.Payload)
75- require.NoError(t, err)
76- require.Equal(t, wantSeq, input.InputSeq)
77- require.Equal(t, want, input.Data)
78- default:
79- t.Fatal("missing proxy input frame")
80- }
81- select {
82- case frame := <-link.sent:
83- t.Fatalf("input was sent more than once: %#v", frame)
84- default:
85- }
86-}
87-
88-func requireNoProxyFrame(t *testing.T, link *proxyTestTransport) {
89- t.Helper()
90- select {
91- case frame := <-link.sent:
92- t.Fatalf("unexpected proxy frame: %#v", frame)
93- default:
94- }
95-}
96-
97-func TestProxyInputForwardsOrdinaryPasteMouseAndEscapeBytesExactlyOnce(t *testing.T) {
98- _, _, _, link, handler := newProxyInputHarness(t)
99- paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), []byte("a\x1bj\x00")...), ports.BracketedPasteCloseMarker...)
100- inputs := []struct {
101- name string
102- raw []byte
103- }{
104- {name: "ordinary keyboard", raw: []byte("hello")},
105- {name: "bracketed paste", raw: paste},
106- {name: "terminal escape", raw: []byte("\x1b[15~")},
107- }
108- for i, input := range inputs {
109- t.Run(input.name, func(t *testing.T) {
110- handler.Forward(input.raw)
111- requireProxyInputFrame(t, link, uint64(i+1), input.raw)
112- })
113- }
114-
115- mouseRaw := []byte("\x1b[<0;12;8M")
116- handler.Mouse(mouse.Event{Raw: mouseRaw})
117- requireProxyInputFrame(t, link, 4, mouseRaw)
118-}
119-
120-func TestProxyInputRemoteActionsForwardOriginalChordWithoutLocalExecution(t *testing.T) {
121- remoteActions := []keys.Action{
122- keys.ActionSwitchTab1, keys.ActionSwitchTab9,
123- keys.ActionFocusPaneLeft, keys.ActionFocusPaneRight, keys.ActionFocusPaneUp, keys.ActionFocusPaneDown,
124- keys.ActionToggleFloatingPane,
125- keys.ActionGrowPaneWidth, keys.ActionShrinkPaneWidth, keys.ActionGrowPaneHeight, keys.ActionShrinkPaneHeight,
126- keys.ActionEqualizePanes, keys.ActionConsumeOrExpelPaneLeft, keys.ActionConsumeOrExpelPaneRight,
127- }
128- for _, action := range remoteActions {
129- t.Run(action.Name(), func(t *testing.T) {
130- _, proxy, _, link, handler := newProxyInputHarness(t)
131- before := proxy.snapshotView(viewOptions{tabDetails: true})
132- raw := []byte{keys.ESC, byte('A' + action%26), 0x00}
133- handler.Action(action, raw)
134- requireProxyInputFrame(t, link, 1, raw)
135- require.Equal(t, before, proxy.snapshotView(viewOptions{tabDetails: true}), "remote action must not mutate local proxy metadata")
136- })
137- }
138-}
139-
140-func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) {
141- t.Run("router action bytes", func(t *testing.T) {
142- _, _, _, link, handler := newProxyInputHarness(t)
143- bindings := keys.DefaultBindings()
144- var published atomic.Pointer[keys.Bindings]
145- published.Store(bindings)
146- router := keys.NewRouter(stubClock{}, handler, &published)
147-
148- router.Route([]byte{keys.ESC, 'j'})
149- requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'})
150- })
151-
152- t.Run("retained escape reacquires its original role", func(t *testing.T) {
153- d, proxy, ac, link, initial := newProxyInputHarness(t)
154- base := initial.connectionToken
155- base.effect.End()
156- base.effect = nil
157-
158- firstEffect, admitted := ac.beginAttachmentEffect(base)
159- require.True(t, admitted)
160- first := base
161- first.effect = firstEffect
162- ac.keys.RouteWithHandler([]byte{keys.ESC}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: first})
163- firstEffect.End()
164-
165- secondEffect, admitted := ac.beginAttachmentEffect(base)
166- require.True(t, admitted)
167- second := base
168- second.effect = secondEffect
169- ac.keys.RouteWithHandler([]byte{'j'}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: second})
170- secondEffect.End()
171-
172- requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'})
173- })
174-}
175-
176-func TestProxyInputLocalPaletteNeverForwards(t *testing.T) {
177- d, _, ac, link, handler := newProxyInputHarness(t)
178- token := handler.connectionToken
179- handler.Action(keys.ActionOpenPalette, []byte{keys.ESC, ' '})
180- require.True(t, ac.overlays.paletteActive())
181- requireNoProxyFrame(t, link)
182-
183- // Keyboard and mouse navigation are consumed by the local overlay before
184- // either the proxy router or proxy mouse sender can observe them.
185- d.handleInputForAttachment(token, []byte("x"))
186- d.handleInputForAttachment(token, []byte("\x1b[<0;12;8M"))
187- requireNoProxyFrame(t, link)
188-}
189-
190-func TestProxyInputAttentionOwnership(t *testing.T) {
191- t.Run("pending remote tab attention forwards original chord", func(t *testing.T) {
192- _, proxy, _, link, handler := newProxyInputHarness(t)
193- proxy.mu.Lock()
194- proxy.meta.Tabs[1].Attention = true
195- proxy.mu.Unlock()
196- raw := []byte{keys.ESC, 'a'}
197- handler.Action(keys.ActionJumpAttention, raw)
198- requireProxyInputFrame(t, link, 1, raw)
199- })
200-
201- t.Run("without remote attention changes selected session locally", func(t *testing.T) {
202- d, _, ac, link, handler := newProxyInputHarness(t)
203- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
204- tb.attention = true
205- tb.attentionAt = time.Unix(1, 0)
206- local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
207- publishTiledPaneOwners(local, tb)
208- d.mu.Lock()
209- require.True(t, d.registerSessionLocked(local))
210- d.mu.Unlock()
211-
212- handler.Action(keys.ActionJumpAttention, []byte{keys.ESC, 'a'})
213-
214- require.Same(t, local, ac.currentSession())
215- requireNoProxyFrame(t, link)
216- })
217-}
218-
219-func TestProxyInputRemoteOwnershipGuards(t *testing.T) {
220- _, proxy, _, link, _ := newProxyInputHarness(t)
221- require.True(t, proxy.caps.cannotAcceptMoves, "a proxy must reject move destinations locally")
222- require.True(t, proxy.caps.cannotYieldMoves, "a proxy must reject move sources locally")
223- requireNoProxyFrame(t, link)
224-
225- cfg := domain.NavConfig{OverflowTabs: true, OverflowSessions: true}
226- require.Equal(t, domain.NavConfig{OverflowTabs: true}, proxiedNavConfig(cfg), "focus overflow must not cross remote-daemon sessions")
227- require.False(t, proxiedJumpSearchesOtherSessions(true), "proxied jump attention must stay in its attached session")
228- require.True(t, proxiedJumpSearchesOtherSessions(false))
229-}
230-
231-func TestProxyPaletteCommandOwnershipIsComplete(t *testing.T) {
232- expected := map[string]proxyPaletteOwnership{
233- "new-tab": proxyPaletteRemote, "new-session": proxyPaletteLocal, "close-tab": proxyPaletteRemote,
234- "split-right": proxyPaletteRemote, "split-left": proxyPaletteRemote, "split-up": proxyPaletteRemote, "split-down": proxyPaletteRemote,
235- "consume-or-expel-pane-left": proxyPaletteRemote, "consume-or-expel-pane-right": proxyPaletteRemote,
236- "stack-pane": proxyPaletteRemote, "toggle-stack": proxyPaletteRemote, "toggle-floating-pane": proxyPaletteRemote, "close-pane": proxyPaletteRemote,
237- "move-pane": proxyPaletteRejected, "move-tab": proxyPaletteRejected,
238- "focus-pane-left": proxyPaletteRemote, "focus-pane-right": proxyPaletteRemote, "focus-pane-up": proxyPaletteRemote, "focus-pane-down": proxyPaletteRemote,
239- "resize-pane": proxyPaletteRemote, "grow-pane-width": proxyPaletteRemote, "shrink-pane-width": proxyPaletteRemote,
240- "grow-pane-height": proxyPaletteRemote, "shrink-pane-height": proxyPaletteRemote, "equalize-panes": proxyPaletteRemote,
241- "next-tab": proxyPaletteRemote, "previous-tab": proxyPaletteRemote,
242- "back-session": proxyPaletteLocal, "jump-recent-session": proxyPaletteLocal, "session-picker": proxyPaletteLocal,
243- "notifications": proxyPaletteLocal, "yank-last-notification": proxyPaletteLocal,
244- "visual-mode": proxyPaletteRejected, "rename-session": proxyPaletteRejected, "rename-tab": proxyPaletteRemote,
245- "detach": proxyPaletteLocal,
246- }
247-
248- commands := command.PaletteRegistry()
249- require.Len(t, expected, len(commands), "ownership table must change whenever the palette registry changes")
250- for _, cmd := range commands {
251- want, covered := expected[cmd.Slug]
252- require.True(t, covered, "palette-visible command %q has no ownership assertion", cmd.Slug)
253- require.Equal(t, want, proxyPaletteCommandOwnership(cmd.Slug), cmd.Slug)
254- require.Equal(t, want == proxyPaletteRemote, proxyAttachedCommandOwnedRemotely(cmd.Slug), cmd.Slug)
255- }
256-}
257-
258-func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) {
259- t.Run("new session opens local transition prompt", func(t *testing.T) {
260- d, proxy, ac, link, handler := newProxyInputHarness(t)
261- handler.enterPalette()
262- d.handlePaletteInput(ac, []byte("CNS\r"), handler.connectionToken.effect)
263-
264- require.False(t, ac.overlays.paletteActive())
265- require.True(t, ac.overlays.promptActive())
266- require.Same(t, proxy, ac.currentAttachmentSession())
267- requireNoProxyFrame(t, link)
268- })
269-
270- t.Run("notification yank uses local notice center and clipboard", func(t *testing.T) {
271- d, proxy, ac, link, handler := newProxyInputHarness(t)
272- d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "local notice", Time: time.Unix(1, 0)})
273- handler.enterPalette()
274- client, ok := ac.transport().(*proxyTestTransport)
275- require.True(t, ok)
276- for len(client.sent) > 0 {
277- <-client.sent
278- }
279- d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect)
280-
281- require.False(t, ac.overlays.paletteActive())
282- require.Same(t, proxy, ac.currentAttachmentSession())
283- frame := awaitTestValue(t, client.sent, "local notification yank did not reach the client clipboard")
284- require.Equal(t, ports.MsgOutput, frame.Type)
285- output, err := ports.UnmarshalOutput(frame.Payload)
286- require.NoError(t, err)
287- require.Contains(t, string(output.Data), "\x1b]52;")
288- requireNoProxyFrame(t, link)
289- })
290-}
291-
292-type proxyCommandTestTimer struct {
293- ch chan time.Time
294- once sync.Once
295-}
296-
297-func newProxyCommandTestTimer() *proxyCommandTestTimer {
298- return &proxyCommandTestTimer{ch: make(chan time.Time, 1)}
299-}
300-
301-func (t *proxyCommandTestTimer) C() <-chan time.Time { return t.ch }
302-func (t *proxyCommandTestTimer) Reset(time.Duration) bool { return false }
303-func (t *proxyCommandTestTimer) Stop() bool { return true }
304-func (t *proxyCommandTestTimer) Fire() {
305- t.once.Do(func() { t.ch <- time.Time{} })
306-}
307-
308-type proxyCommandTestClock struct{ timers chan *proxyCommandTestTimer }
309-
310-func newProxyCommandTestClock() *proxyCommandTestClock {
311- return &proxyCommandTestClock{timers: make(chan *proxyCommandTestTimer, 8)}
312-}
313-
314-func (*proxyCommandTestClock) Now() time.Time { return time.Time{} }
315-func (c *proxyCommandTestClock) NewTimer(delay time.Duration) ports.Timer {
316- if delay != proxyAttachedCommandTimeout {
317- return stubTimer{}
318- }
319- timer := newProxyCommandTestTimer()
320- c.timers <- timer
321- return timer
322-}
323-
324-func requireProxyCommandRequest(t *testing.T, link *proxyTestTransport) ports.CommandRequest {
325- t.Helper()
326- frame := awaitTestValue(t, link.sent, "proxy command was not sent")
327- require.Equal(t, ports.MsgCommand, frame.Type)
328- request, err := ports.UnmarshalCommandRequest(frame.Payload)
329- require.NoError(t, err)
330- return request
331-}
332-
333-func commandResultFrame(result ports.CommandResult) ports.Frame {
334- return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}
335-}
336-
337-func TestProxyAttachedCommandCorrelatesInterleavedWrongAndLateResults(t *testing.T) {
338- d, proxy, _, link, _ := newProxyInputHarness(t)
339- clock := newProxyCommandTestClock()
340-
341- firstDone := make(chan ports.CommandResult, 1)
342- firstErr := make(chan error, 1)
343- go func() {
344- result, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil)
345- firstDone <- result
346- firstErr <- err
347- }()
348- first := requireProxyCommandRequest(t, link)
349- require.Equal(t, uint64(1), first.RequestID)
350- require.True(t, first.Attached)
351- _ = awaitTestValue(t, clock.timers, "first command did not arm timeout")
352-
353- generation := proxy.linkGeneration
354- result, err := d.handleLinkFrame(proxy, generation, proxyHandshakeSnapshot())
355- require.NoError(t, err)
356- require.Equal(t, proxyLinkResume, result)
357- require.Equal(t, ports.MsgAck, awaitTestValue(t, link.sent, "interleaved output was not acknowledged").Type)
358- result, err = d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})})
359- require.NoError(t, err)
360- require.Equal(t, proxyLinkResume, result)
361- result, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: 999, OK: true}))
362- require.NoError(t, err)
363- require.Equal(t, proxyLinkResume, result)
364- select {
365- case <-firstDone:
366- t.Fatal("wrong request ID completed command")
367- default:
368- }
369- require.NoError(t, proxy.sendInput([]byte("x")))
370- requireProxyInputFrame(t, link, 1, []byte("x"))
371-
372- _, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: first.RequestID, OK: true}))
373- require.NoError(t, err)
374- require.True(t, (<-firstDone).OK)
375- require.NoError(t, <-firstErr)
376-
377- secondDone := make(chan ports.CommandResult, 1)
378- go func() {
379- result, _ := proxy.sendCommand(context.Background(), clock, "previous-tab", nil)
380- secondDone <- result
381- }()
382- second := requireProxyCommandRequest(t, link)
383- require.Equal(t, uint64(2), second.RequestID)
384- _ = awaitTestValue(t, clock.timers, "second command did not arm timeout")
385- _, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: first.RequestID, OK: true}))
386- require.NoError(t, err)
387- select {
388- case <-secondDone:
389- t.Fatal("late request ID completed replacement command")
390- default:
391- }
392- _, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: second.RequestID, Code: ports.ErrInternal, Text: "remote failed"}))
393- require.NoError(t, err)
394- remoteFailure := <-secondDone
395- require.False(t, remoteFailure.OK)
396- require.Equal(t, "remote failed", remoteFailure.Text)
397-}
398-
399-func TestProxyAttachedCommandSharesSerializedSender(t *testing.T) {
400- d, proxy, _, link, _ := newProxyInputHarness(t)
401- clock := newProxyCommandTestClock()
402- gate := make(chan struct{})
403- entered := make(chan struct{}, 2)
404- link.sendGate = gate
405- link.sendEntered = entered
406-
407- commandDone := make(chan error, 1)
408- go func() {
409- _, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil)
410- commandDone <- err
411- }()
412- awaitTestCompletion(t, entered, "command sender did not enter transport")
413- inputDone := make(chan error, 1)
414- go func() { inputDone <- proxy.sendInput([]byte("x")) }()
415- select {
416- case <-entered:
417- t.Fatal("input entered Transport.Send while command send held the shared sender")
418- default:
419- }
420- close(gate)
421- request := requireProxyCommandRequest(t, link)
422- require.NoError(t, awaitTestValue(t, inputDone, "input send did not finish"))
423- requireProxyInputFrame(t, link, 1, []byte("x"))
424- _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
425- _, err := d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))
426- require.NoError(t, err)
427- require.NoError(t, awaitTestValue(t, commandDone, "command did not finish"))
428- require.False(t, link.concurrent.Load(), "transport observed concurrent Send calls")
429-}
430-
431-func TestProxyAttachedCommandAllowsOnlyOneOutstandingInteractiveRequest(t *testing.T) {
432- d, proxy, _, link, _ := newProxyInputHarness(t)
433- clock := newProxyCommandTestClock()
434- firstDone := make(chan struct{})
435- go func() {
436- _, _ = proxy.sendCommand(context.Background(), clock, "next-tab", nil)
437- close(firstDone)
438- }()
439- first := requireProxyCommandRequest(t, link)
440- _ = awaitTestValue(t, clock.timers, "first command did not arm timeout")
441-
442- secondDone := make(chan struct{})
443- go func() {
444- _, _ = proxy.sendCommand(context.Background(), clock, "previous-tab", nil)
445- close(secondDone)
446- }()
447- requireNoProxyFrame(t, link)
448- _, err := d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: first.RequestID, OK: true}))
449- require.NoError(t, err)
450- awaitTestCompletion(t, firstDone, "first command did not complete")
451- second := requireProxyCommandRequest(t, link)
452- _ = awaitTestValue(t, clock.timers, "second command did not arm timeout")
453- _, err = d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: second.RequestID, OK: true}))
454- require.NoError(t, err)
455- awaitTestCompletion(t, secondDone, "second command did not complete")
456-}
457-
458-func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T) {
459- t.Run("timeout", func(t *testing.T) {
460- _, proxy, _, link, _ := newProxyInputHarness(t)
461- clock := newProxyCommandTestClock()
462- done := make(chan error, 1)
463- go func() {
464- _, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil)
465- done <- err
466- }()
467- _ = requireProxyCommandRequest(t, link)
468- timer := awaitTestValue(t, clock.timers, "command did not arm timeout")
469- timer.Fire()
470- require.ErrorIs(t, awaitTestValue(t, done, "command did not time out"), errProxyCommandTimeout)
471- })
472-
473- t.Run("context cancellation", func(t *testing.T) {
474- _, proxy, _, link, _ := newProxyInputHarness(t)
475- clock := newProxyCommandTestClock()
476- ctx, cancel := context.WithCancel(context.Background())
477- done := make(chan error, 1)
478- go func() {
479- _, err := proxy.sendCommand(ctx, clock, "next-tab", nil)
480- done <- err
481- }()
482- _ = requireProxyCommandRequest(t, link)
483- _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
484- cancel()
485- require.ErrorIs(t, awaitTestValue(t, done, "command did not cancel"), context.Canceled)
486- })
487-
488- t.Run("link generation replacement", func(t *testing.T) {
489- d, proxy, _, link, _ := newProxyInputHarness(t)
490- clock := newProxyCommandTestClock()
491- done := make(chan error, 1)
492- go func() {
493- _, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil)
494- done <- err
495- }()
496- request := requireProxyCommandRequest(t, link)
497- _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
498- oldGeneration := proxy.linkGeneration
499- newGeneration, _ := proxy.installTransport(newProxyTestTransport())
500- require.Greater(t, newGeneration, oldGeneration)
501- require.Error(t, awaitTestValue(t, done, "replacement did not cancel command"))
502- _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))
503- require.NoError(t, err, "late old-generation result must be harmless")
504- })
505-}
506-
507-func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *testing.T) {
508- d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
509- second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
510- publishTiledPaneOwners(sess, second)
511- sess.mu.Lock()
512- sess.tabs = append(sess.tabs, second)
513- selectTestAttachmentTabLocked(sess, 0)
514- sess.mu.Unlock()
515- d.attachCoordinator(sess, ac, true)
516-
517- send := func(request ports.CommandRequest) ports.CommandResult {
518- payload, err := ports.MarshalCommandRequest(request)
519- require.NoError(t, err)
520- token := sess.attachmentToken(ac, ac.transport())
521- require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
522- frame := awaitFrame(t, sends, ports.MsgCommandResult)
523- result, err := ports.UnmarshalCommandResult(frame.Payload)
524- require.NoError(t, err)
525- return result
526- }
527-
528- result := send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 1, Attached: true, Slug: "next-tab"})
529- require.True(t, result.OK)
530- sess.mu.Lock()
531- require.Equal(t, 1, testAttachmentTabIndexLocked(sess))
532- sess.mu.Unlock()
533-
534- result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 2, Attached: true, Slug: "previous-tab", TargetSession: "other"})
535- require.False(t, result.OK)
536- sess.mu.Lock()
537- require.Equal(t, 1, testAttachmentTabIndexLocked(sess), "target override must not execute against any session")
538- sess.mu.Unlock()
539-
540- result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 3, Attached: true, Slug: "rename-tab"})
541- require.True(t, result.OK)
542- require.True(t, ac.overlays.promptActive(), "attached interactive command must be able to open the remote prompt")
543-}
544-
545-func TestProxyAttachedCommandRemoteErrorCreatesLocalNotice(t *testing.T) {
546- d, proxy, ac, link, handler := newProxyInputHarness(t)
547- clock := newProxyCommandTestClock()
548- d.clock = clock
549- handler.enterPalette()
550- done := make(chan struct{})
551- go func() {
552- d.handlePaletteInput(ac, []byte("NXT\r"), handler.connectionToken.effect)
553- close(done)
554- }()
555- request := requireProxyCommandRequest(t, link)
556- _ = awaitTestValue(t, clock.timers, "palette command did not arm timeout")
557- _, err := d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, Code: ports.ErrInternal, Text: "remote failed"}))
558- require.NoError(t, err)
559- awaitTestCompletion(t, done, "palette command did not finish")
560- notice, ok := d.notices.latest()
561- require.True(t, ok)
562- require.Equal(t, domain.NoticeSessionUnavailable, notice.Code)
563- require.Contains(t, notice.Message, "remote failed")
564-}
565-
566-func TestProxyAttachedCommandMalformedAndUnavailableResultsAreSafe(t *testing.T) {
567- d, proxy, _, _, _ := newProxyInputHarness(t)
568- result, err := d.handleLinkFrame(proxy, proxy.linkGeneration, ports.Frame{Type: ports.MsgCommandResult, Payload: []byte{1}})
569- require.Equal(t, proxyLinkStop, result)
570- require.Error(t, err)
571-
572- proxy.mu.Lock()
573- proxy.transport = nil
574- proxy.mu.Unlock()
575- _, err = proxy.sendCommand(context.Background(), stubClock{}, "next-tab", nil)
576- require.True(t, errors.Is(err, errProxyCommandUnavailable))
577-}
578-
579-func TestProxyOwnershipMakesCompatibleRemoteRowsSelectable(t *testing.T) {
580- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
581- for _, status := range []remoteHostStatus{remoteHostCached, remoteHostRefreshing, remoteHostFresh, remoteHostUnreachable} {
582- view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running", Tabs: 2}, status, time.Unix(1, 0))
583- require.True(t, view.RemoteAttachReady, "compatible status %d must be enabled after ownership is complete", status)
584- model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
585- target, selectable := model.Selected()
586- require.True(t, selectable)
587- require.Equal(t, key, *target.RemoteKey)
588- }
589- mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostVersionMismatch, time.Time{})
590- require.False(t, mismatch.RemoteAttachReady)
591- _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
592- require.False(t, selectable)
593-}
594-
595-func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *testing.T) {
596- d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
597- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
598- transport := newProxyTestTransport()
599- transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
600- factory := portsmocks.NewMockRemoteDialerFactory(t)
601- dialer := portsmocks.NewMockDialer(t)
602- var daemonLockAvailable, sourceLockAvailable bool
603- factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).
604- Run(func(string, string, ports.RemoteTransportMode, *slog.Logger) {
605- // If picker selection retained either architecture lock while dialing,
606- // this synchronous callback would deadlock.
607- d.mu.Lock()
608- daemonLockAvailable = true
609- d.mu.Unlock()
610- source.mu.Lock()
611- sourceLockAvailable = true
612- source.mu.Unlock()
613- }).Return(dialer, nil).Once()
614- dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
615- d.remoteDialerFactory = factory
616- d.remoteTransportMode = ports.RemoteTransportUDP
617- d.attachCoordinator(source, ac, true)
618-
619- token := source.attachmentToken(ac, ac.transport())
620- effect, admitted := ac.beginAttachmentEffect(token)
621- require.True(t, admitted)
622- token.effect = effect
623- err := d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
624- require.NoError(t, err)
625- require.True(t, daemonLockAvailable)
626- require.True(t, sourceLockAvailable)
627- proxy, ok := ac.currentAttachmentSession().(*proxySession)
628- require.True(t, ok)
629- require.Equal(t, key, proxy.key)
630- t.Cleanup(func() { stopProxy(t, proxy) })
631-
632- wrong := domain.RemoteSessionKey{Host: "arch", Name: "other"}
633- currentToken := attachmentToken(proxy, ac, ac.transport())
634- currentEffect, admitted := ac.beginAttachmentEffect(currentToken)
635- require.True(t, admitted)
636- currentToken.effect = currentEffect
637- err = d.switchToTargetForAttachment(currentToken, picker.Target{Session: key.ID(), RemoteKey: &wrong, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
638- require.ErrorIs(t, err, errAttachmentTransition)
639- require.Same(t, proxy, ac.currentAttachmentSession(), "mismatched structured key must not redirect attachment")
640-
641- staleToken := attachmentToken(proxy, ac, ac.transport())
642- staleEffect, admitted := ac.beginAttachmentEffect(staleToken)
643- require.True(t, admitted)
644- staleToken.effect = staleEffect
645- ac.connectionGeneration.Add(1) // model a concurrent role replacement after selection
646- err = d.switchToTargetForAttachment(staleToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
647- require.ErrorIs(t, err, errAttachmentTransition)
648- require.Same(t, proxy, ac.currentAttachmentSession(), "stale initiating role must not republish attachment")
649-}
650-
651-func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) {
652- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
653- d.attachCoordinator(local, ac, true)
654- key := domain.RemoteSessionKey{Host: "arch", Name: "enter"}
655- transport := newProxyTestTransport()
656- transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
657- d.remoteDialerFactory = newProxyConstructionFactory(transport)
658- d.remoteTransportMode = ports.RemoteTransportUDP
659- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
660- Host: key.Host, FetchedAt: time.Unix(1, 0),
661- Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},
662- }})
663-
664- model := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
665- d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
666- target, selectable := model.Selected()
667- require.True(t, selectable)
668- require.Equal(t, key, *target.RemoteKey)
669-
670- token := local.attachmentToken(ac, ac.transport())
671- effect, admitted := ac.beginAttachmentEffect(token)
672- require.True(t, admitted)
673- d.handlePickerInput(ac, []byte("\r"), effect)
674- proxy, ok := ac.currentAttachmentSession().(*proxySession)
675- require.True(t, ok, "Enter must route the row instead of only marking it selectable")
676- require.Equal(t, key, proxy.key)
677- t.Cleanup(func() { stopProxy(t, proxy) })
678-}
679-
680-func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
681- d, proxy, ac, _, handler := newProxyInputHarness(t)
682- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
683- local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
684- publishTiledPaneOwners(local, tb)
685- d.mu.Lock()
686- require.True(t, d.registerSessionLocked(local))
687- d.mu.Unlock()
688-
689- model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
690- d.publishPicker(proxy, ac, model, pickerNavigate, moveSourceLocator{})
691- target, selectable := model.Selected()
692- require.True(t, selectable)
693- require.Equal(t, local.id, target.Session)
694- require.Equal(t, local.name, target.Name)
695- require.False(t, target.Stopped)
696-
697- require.NoError(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
698- require.Same(t, local, ac.currentSession())
699- require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker")
700-
701- d.remoteDialerFactory = portsmocks.NewMockRemoteDialerFactory(t)
702- localToken := attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
703- require.NoError(t, d.backSessionForAttachment(localToken))
704- require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")
705-
706- proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
707- require.NoError(t, d.switchToTargetForAttachment(proxyToken, target, sessionHandoffGuard{}, "picker-select"))
708- require.Same(t, local, ac.currentSession())
709- d.backSession(local, ac)
710- require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity")
711-}
712-
713-func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) {
714- d, proxy, ac, _, handler := newProxyInputHarness(t)
715- err := d.switchToTargetForAttachment(handler.connectionToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select")
716- require.Error(t, err)
717- require.Same(t, proxy, ac.currentAttachmentSession())
718-}
719-
720-func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {
721- d, proxy, ac, _, handler := newProxyInputHarness(t)
722- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
723- local := &session{sessionCore: sessionCore{id: "local", name: "local", createdAt: 1}, tabs: []*tab{tb}}
724- publishTiledPaneOwners(local, tb)
725- d.mu.Lock()
726- require.True(t, d.registerSessionLocked(local))
727- d.mu.Unlock()
728-
729- model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
730- target, selectable := model.Selected()
731- require.True(t, selectable)
732- d.afterAttachmentEffectsFrozen = func() {
733- d.afterAttachmentEffectsFrozen = nil
734- local.mu.Lock()
735- local.name = "replacement"
736- local.createdAt++
737- local.mu.Unlock()
738- }
739-
740- require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
741- require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")
742-}
743-
744-func TestRemotePickerRejectsReplacedLocalIncarnation(t *testing.T) {
745- d, proxy, ac, _, handler := newProxyInputHarness(t)
746- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
747- tb.stableID = "local-tab"
748- local := &session{sessionCore: sessionCore{id: "local", name: "local", ephemeral: true, incarnation: domain.IncarnationID{1}}, tabs: []*tab{tb}}
749- publishTiledPaneOwners(local, tb)
750- d.mu.Lock()
751- require.True(t, d.registerSessionLocked(local))
752- d.mu.Unlock()
753-
754- model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, Incarnation: local.incarnation, TabID: domain.TabStableID(tb.stableID)})
755- target, selectable := model.Selected()
756- require.True(t, selectable)
757- d.afterAttachmentEffectsFrozen = func() {
758- d.afterAttachmentEffectsFrozen = nil
759- local.mu.Lock()
760- local.incarnation = domain.IncarnationID{2}
761- local.mu.Unlock()
762- }
763-
764- require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
765- require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker incarnation must not redirect the attachment")
766-}
767-
768-func TestRemotePickerRejectsReplacedLocalTab(t *testing.T) {
769- d, proxy, ac, _, handler := newProxyInputHarness(t)
770- first := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
771- first.stableID = "first"
772- second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
773- second.stableID = "second"
774- local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{first, second}}
775- publishTiledPaneOwners(local, first)
776- publishTiledPaneOwners(local, second)
777- d.mu.Lock()
778- require.True(t, d.registerSessionLocked(local))
779- d.mu.Unlock()
780-
781- model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(second.stableID)})
782- target, selectable := model.Selected()
783- require.True(t, selectable)
784- require.Equal(t, domain.TabStableID(second.stableID), target.TabID)
785- d.afterAttachmentEffectsFrozen = func() {
786- d.afterAttachmentEffectsFrozen = nil
787- local.mu.Lock()
788- local.tabs[0], local.tabs[1] = local.tabs[1], local.tabs[0]
789- local.mu.Unlock()
790- }
791-
792- require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
793- require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker tab must not redirect the attachment")
794-}
795-
796-func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
797- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
798- d.attachCoordinator(local, ac, true)
799- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
800- remote := newProxyTestTransport()
801- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
802- d.remoteDialerFactory = newProxyConstructionFactory(remote)
803- d.remoteTransportMode = ports.RemoteTransportUDP
804-
805- localToken := attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
806- require.NoError(t, d.switchToTargetForAttachment(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
807- proxy, ok := ac.currentAttachmentSession().(*proxySession)
808- require.True(t, ok)
809- t.Cleanup(func() { stopProxy(t, proxy) })
810-
811- proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
812- require.NoError(t, d.switchToTargetForAttachment(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
813- require.Same(t, local, ac.currentSession())
814-
815- model := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
816- d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
817- target, selectable := model.Selected()
818- require.True(t, selectable)
819- require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key")
820- localToken = attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
821- require.NoError(t, d.switchToTargetForAttachment(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
822- require.Same(t, proxy, ac.currentAttachmentSession())
823- require.False(t, ac.overlays.pickerActive())
824-}
825-
826-func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) {
827- d := newTestDaemon(t, nil, stubClock{})
828- client := newProxyTestTransport()
829- ac := &attachedClient{}
830- ac.replaceTransport(client)
831- first := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
832- second := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
833- ac.setSession(first)
834-
835- const unstableSnapshots = 100
836- attempts := 0
837- d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
838- attempts++
839- if attempts >= unstableSnapshots {
840- return
841- }
842- if snapshot == first {
843- ac.setSession(second)
844- } else {
845- ac.setSession(first)
846- }
847- }
848-
849- _, _, ok := d.currentAttachmentConnection(ac, client)
850- require.False(t, ok)
851- require.Less(t, attempts, unstableSnapshots)
852-}
853-
854-func TestConnectionLoopStopsWhenReceiveErrorCleanupMakesNoProgress(t *testing.T) {
855- d := newTestDaemon(t, nil, stubClock{})
856- client := newProxyTestTransport()
857- ac := &attachedClient{}
858- ac.replaceTransport(client)
859- local := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
860- ac.setSession(local)
861-
862- cleanupAttempts := 0
863- d.beforeClientGoneDetach = func() {
864- cleanupAttempts++
865- local.mu.Lock()
866- local.registerAttachmentLocked(&attachedClient{})
867- local.mu.Unlock()
868- }
869- snapshots := 0
870- d.afterConnectionSessionSnapshot = func(attachmentSession) {
871- snapshots++
872- if snapshots == 100 {
873- ac.setSession(nil)
874- }
875- }
876- client.recv <- proxyRecv{err: io.EOF}
877-
878- d.runConnLoop(ac)
879-
880- require.Equal(t, 1, cleanupAttempts)
881- require.Equal(t, 1, snapshots)
882-}
883-
884-func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
885- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
886- client := newProxyTestTransport()
887- ac.replaceTransport(client)
888- d.attachCoordinator(local, ac, true)
889-
890- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
891- remote := newProxyTestTransport()
892- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
893- d.remoteDialerFactory = newProxyConstructionFactory(remote)
894- d.remoteTransportMode = ports.RemoteTransportUDP
895- token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
896- snapshots := make(chan attachmentSession, 1)
897- transitionErr := make(chan error, 1)
898- d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
899- d.afterConnectionSessionSnapshot = nil
900- snapshots <- snapshot
901- transitionErr <- d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
902- }
903-
904- loopDone := make(chan struct{})
905- go func() {
906- d.runConnLoop(ac)
907- close(loopDone)
908- }()
909- client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}}
910- require.Same(t, local, awaitTestValue(t, snapshots, "connection role was not snapshotted"))
911- require.NoError(t, awaitTestValue(t, transitionErr, "handoff did not run during role resolution"))
912- proxy, ok := ac.currentAttachmentSession().(*proxySession)
913- require.True(t, ok)
914- t.Cleanup(func() { stopProxy(t, proxy) })
915- _ = awaitFrame(t, client.sent, ports.MsgPong)
916-
917- client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
918- awaitTestCompletion(t, loopDone, "connection loop did not continue after retrying role resolution")
919-}
920-
921-func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) {
922- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
923- client := newProxyTestTransport()
924- ac.replaceTransport(client)
925- d.attachCoordinator(local, ac, true)
926-
927- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
928- remote := newProxyTestTransport()
929- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
930- d.remoteDialerFactory = newProxyConstructionFactory(remote)
931- d.remoteTransportMode = ports.RemoteTransportUDP
932-
933- loopDone := make(chan struct{})
934- go func() {
935- d.runConnLoop(ac)
936- close(loopDone)
937- }()
938- require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
939-
940- token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
941- require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
942- proxy, ok := ac.currentAttachmentSession().(*proxySession)
943- require.True(t, ok)
944- t.Cleanup(func() { stopProxy(t, proxy) })
945-
946- client.recv <- proxyRecv{err: io.EOF}
947- awaitTestCompletion(t, loopDone, "connection loop did not return after receive failure")
948- require.Nil(t, ac.currentAttachmentSession(), "receive failure cleaned the stale local role instead of the current proxy role")
949-}
950-
951-func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {
952- d, _, ac, _, handler := newProxyInputHarness(t)
953- client, ok := ac.transport().(*proxyTestTransport)
954- require.True(t, ok)
955- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
956- local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
957- publishTiledPaneOwners(local, tb)
958- d.mu.Lock()
959- require.True(t, d.registerSessionLocked(local))
960- d.mu.Unlock()
961-
962- loopDone := make(chan struct{})
963- go func() {
964- d.runConnLoop(ac)
965- close(loopDone)
966- }()
967- require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
968-
969- require.NoError(t, d.switchToTargetForAttachment(handler.connectionToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
970- require.Same(t, local, ac.currentSession())
971- client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
972- awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session")
973- require.Nil(t, ac.currentAttachmentSession())
974-}
975-
976-func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
977- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
978- client := newProxyTestTransport()
979- ac.replaceTransport(client)
980- d.attachCoordinator(local, ac, true)
981-
982- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
983- remote := newProxyTestTransport()
984- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
985- d.remoteDialerFactory = newProxyConstructionFactory(remote)
986- d.remoteTransportMode = ports.RemoteTransportUDP
987-
988- loopDone := make(chan struct{})
989- go func() {
990- d.runConnLoop(ac)
991- close(loopDone)
992- }()
993- require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
994-
995- token := local.attachmentToken(ac, client)
996- effect, admitted := ac.beginAttachmentEffect(token)
997- require.True(t, admitted)
998- token.effect = effect
999- require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
1000- proxy, ok := ac.currentAttachmentSession().(*proxySession)
1001- require.True(t, ok)
1002- t.Cleanup(func() { stopProxy(t, proxy) })
1003-
1004- client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
1005- awaitTestCompletion(t, loopDone, "connection loop did not handle input after the proxy handoff")
1006- require.Nil(t, ac.currentAttachmentSession(), "connection loop abandoned the client after the proxy handoff")
1007-}
D internal/usecase/daemon/proxy_kill.go +0 -276
@@ -1,276 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "io"
8- "time"
9-
10- "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/usecase/picker"
12-)
13-
14-const proxyKillTimeout = 10 * time.Second
15-
16-var (
17- errProxyKillTimeout = errors.New("proxy session: remote kill timed out")
18- errProxyKillGeneration = errors.New("proxy session: remote kill lifecycle changed")
19- errProxyKillConfirmation = errors.New("type the full remote session label exactly")
20-)
21-
22-type proxyKillIOResult struct {
23- frame ports.Frame
24- err error
25-}
26-
27-// enterRemoteKillConfirmation turns a remote picker deletion into an exact,
28-// typed lifecycle capability. The proxy pointer is resolved when the prompt is
29-// opened, while its generation is deliberately captured only after the exact
30-// label is submitted.
31-func (d *Daemon) enterRemoteKillConfirmation(owner attachmentSession, ac *attachedClient, target picker.Target) bool {
32- if d == nil || owner == nil || ac == nil || target.RemoteKey == nil || target.Session != target.RemoteKey.ID() {
33- return false
34- }
35- key := *target.RemoteKey
36- d.mu.Lock()
37- proxy, ok := d.sessions[target.Session].(*proxySession)
38- ok = ok && proxy.key == key
39- d.mu.Unlock()
40- if !ok {
41- return false
42- }
43- title := " Type " + key.Display() + " to kill "
44- proxy.mu.Lock()
45- unreachable := proxy.expired || proxy.linkState == ports.LinkStateOffline || proxy.linkState == ports.LinkStateDead
46- proxy.mu.Unlock()
47- if unreachable {
48- title = " Type " + key.Display() + " to kill (remote unreachable) "
49- }
50- d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentConnectionToken) error {
51- if value != key.Display() {
52- return errProxyKillConfirmation
53- }
54- if token.ac != nil {
55- if !token.attachmentCurrent() {
56- return errAttachmentTransition
57- }
58- token.effect.bindActionEnd(d, "remote-picker-delete")
59- token.effect.End()
60- }
61- d.mu.Lock()
62- if d.sessions[proxy.id] != proxy {
63- d.mu.Unlock()
64- return errProxyKillGeneration
65- }
66- proxy.mu.Lock()
67- generation := proxy.generation
68- proxy.mu.Unlock()
69- d.mu.Unlock()
70- ctx := d.serveCtx
71- if ctx == nil {
72- ctx = context.Background()
73- }
74- return d.killRemoteProxy(ctx, proxy, generation)
75- })
76- return true
77-}
78-
79-// killRemoteProxy uses a fresh control transport and accepts only EOF as the
80-// remote daemon's authoritative kill acknowledgement. No live or cached state
81-// is changed before that acknowledgement and exact generation publication.
82-func (d *Daemon) killRemoteProxy(ctx context.Context, proxy *proxySession, generation uint64) error {
83- if d == nil || proxy == nil || d.remoteDialerFactory == nil || d.clock == nil {
84- return errors.New("proxy session: remote kill is unavailable")
85- }
86- if ctx == nil {
87- ctx = context.Background()
88- }
89- if err := ctx.Err(); err != nil {
90- return err
91- }
92-
93- d.mu.Lock()
94- current := d.sessions[proxy.id] == proxy
95- if current {
96- proxy.mu.Lock()
97- current = proxy.generation == generation
98- proxy.mu.Unlock()
99- }
100- d.mu.Unlock()
101- if !current {
102- return errProxyKillGeneration
103- }
104-
105- timer := d.clock.NewTimer(proxyKillTimeout)
106- if timer == nil {
107- return errProxyKillTimeout
108- }
109- defer timer.Stop()
110- boundedCtx, cancel := context.WithCancel(ctx)
111- defer cancel()
112- timedOut := make(chan struct{})
113- stopTimeout := make(chan struct{})
114- defer close(stopTimeout)
115- go func() {
116- select {
117- case <-timer.C():
118- close(timedOut)
119- cancel()
120- case <-stopTimeout:
121- }
122- }()
123-
124- dialer, err := d.remoteDialerFactory.DialerForRemote(proxy.key.Host, proxy.key.Name, d.remoteTransportMode, d.log)
125- if err != nil {
126- return fmt.Errorf("proxy session: select remote kill dialer: %w", err)
127- }
128- if dialer == nil {
129- return errors.New("proxy session: remote kill dialer is nil")
130- }
131- transport, err := dialer.Dial(boundedCtx)
132- if err != nil {
133- return proxyKillContextError(ctx, timedOut, fmt.Errorf("proxy session: remote kill dial: %w", err))
134- }
135- if transport == nil {
136- return errors.New("proxy session: remote kill transport is nil")
137- }
138- defer func() { _ = transport.Close() }()
139-
140- killFrame := ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: proxy.key.Name})}
141- sendDone := make(chan error, 1)
142- go func() { sendDone <- transport.Send(killFrame) }()
143- select {
144- case err := <-sendDone:
145- if err != nil {
146- return fmt.Errorf("proxy session: send remote kill: %w", err)
147- }
148- case <-boundedCtx.Done():
149- _ = transport.Close()
150- // Transport promises Close unblocks Send, but this boundary cannot let a
151- // broken adapter defeat the caller's bound. The buffered result lets the
152- // caller return; a non-cooperative adapter may retain this one transport
153- // goroutine until Send eventually exits. It owns no daemon locks or state.
154- return proxyKillContextError(ctx, timedOut, boundedCtx.Err())
155- }
156-
157- recvDone := make(chan proxyKillIOResult, 1)
158- go func() {
159- frame, err := transport.Recv()
160- recvDone <- proxyKillIOResult{frame: frame, err: err}
161- }()
162- var received proxyKillIOResult
163- select {
164- case received = <-recvDone:
165- case <-boundedCtx.Done():
166- _ = transport.Close()
167- // Apply the same bounded boundary defensively to Recv. A conforming
168- // transport exits on Close; a broken one may retain this transport-owned
169- // goroutine, but cannot delay or mutate the failed kill operation.
170- return proxyKillContextError(ctx, timedOut, boundedCtx.Err())
171- }
172- if !errors.Is(received.err, io.EOF) {
173- if received.err != nil {
174- return fmt.Errorf("proxy session: receive remote kill result: %w", received.err)
175- }
176- if received.frame.Type == ports.MsgError {
177- remoteErr, decodeErr := ports.UnmarshalErrorMsg(received.frame.Payload)
178- if decodeErr != nil {
179- return fmt.Errorf("proxy session: malformed remote kill error: %w", decodeErr)
180- }
181- return fmt.Errorf("proxy session: remote kill failed (%d): %s", remoteErr.Code, remoteErr.Text)
182- }
183- return fmt.Errorf("proxy session: unexpected remote kill reply type %d", received.frame.Type)
184- }
185- return d.publishRemoteProxyKill(proxy, generation)
186-}
187-
188-func proxyKillContextError(parent context.Context, timedOut <-chan struct{}, fallback error) error {
189- select {
190- case <-timedOut:
191- return errProxyKillTimeout
192- default:
193- }
194- if err := parent.Err(); err != nil {
195- return err
196- }
197- return fallback
198-}
199-
200-// publishRemoteProxyKill atomically removes only the acknowledged proxy
201-// generation and matching discovery row. Teardown and persistence are external
202-// operations and therefore happen after every architecture lock is released.
203-func (d *Daemon) publishRemoteProxyKill(proxy *proxySession, generation uint64) error {
204- d.mu.Lock()
205- if d.sessions[proxy.id] != proxy {
206- d.mu.Unlock()
207- return errProxyKillGeneration
208- }
209- proxy.sessionCore.mu.Lock()
210- proxy.mu.Lock()
211- if proxy.generation != generation {
212- proxy.mu.Unlock()
213- proxy.sessionCore.mu.Unlock()
214- d.mu.Unlock()
215- return errProxyKillGeneration
216- }
217- proxy.generation++
218- proxy.expired = true
219- warm := proxy.warm
220- proxy.warm = nil
221- cancel := proxy.cancel
222- transport := proxy.transport
223- coordinator := proxy.coordinator.Load()
224- proxy.mu.Unlock()
225- if !d.unregisterSessionLocked(proxy) {
226- proxy.sessionCore.mu.Unlock()
227- d.mu.Unlock()
228- return errProxyKillGeneration
229- }
230- proxy.sessionCore.mu.Unlock()
231-
232- cacheChanged := false
233- d.remoteCatalog.mu.Lock()
234- if entry, ok := d.remoteCatalog.cache[proxy.key.Host]; ok {
235- sessions := make([]ports.RemoteCatalogSession, 0, len(entry.Sessions))
236- for _, session := range entry.Sessions {
237- if session.Name == proxy.key.Name {
238- cacheChanged = true
239- continue
240- }
241- sessions = append(sessions, session)
242- }
243- if cacheChanged {
244- entry.Sessions = sessions
245- d.remoteCatalog.cache[proxy.key.Host] = entry
246- }
247- }
248- d.remoteCatalog.mu.Unlock()
249- empty := len(d.sessions) == 0
250- if empty {
251- d.closing = true
252- }
253- d.mu.Unlock()
254-
255- warm.stop()
256- if cancel != nil {
257- cancel()
258- }
259- if transport != nil {
260- _ = transport.Close()
261- }
262- if coordinator != nil {
263- coordinator.beginSessionTeardown().finish()
264- coordinator.waitForTimerWorkers()
265- }
266- proxy.finish()
267- if cacheChanged {
268- d.persistRemoteCatalogCache()
269- }
270- if empty {
271- d.doneOnce.Do(func() { close(d.done) })
272- } else {
273- d.refreshRemoteOpenPickers()
274- }
275- return nil
276-}
D internal/usecase/daemon/proxy_lifecycle.go +0 -344
@@ -1,344 +0,0 @@
1-package daemon
2-
3-import (
4- "sync"
5- "time"
6-
7- "github.com/bnema/vev/internal/ports"
8-)
9-
10-// Proxy lifecycle owns the dormant-expiry timer and the link-state publication
11-// of one remote attachment. It extends the package lock ordering documented at
12-// the top of client.go: every function in this file acquires
13-// d.mu -> p.sessionCore.mu -> p.mu, in that order and never any other, and
14-// holds them only to validate and publish state. Clock, Timer, transport,
15-// coordinator, and repaint work always runs after all three are released.
16-
17-const proxyWarmDuration = 5 * time.Minute
18-
19-// proxyWarmTimer is an exact lifecycle capability. Both pointer identity and
20-// generation must still match before its callback may remove a registry entry.
21-type proxyWarmTimer struct {
22- generation uint64
23- timer ports.Timer
24- cancel chan struct{}
25- done chan struct{}
26- cancelOnce sync.Once
27-}
28-
29-func (t *proxyWarmTimer) stop() {
30- if t == nil {
31- return
32- }
33- if t.timer != nil {
34- t.timer.Stop()
35- }
36- t.cancelOnce.Do(func() { close(t.cancel) })
37-}
38-
39-// proxyAttachmentTransitionCommitted applies lifecycle policy only after the
40-// attachment publication has released every architecture lock. Each helper
41-// performs its own exact pointer/client revalidation before changing a timer.
42-func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveAttachment bool) {
43- if d == nil || preserveAttachment || source == target {
44- return
45- }
46- if proxy, ok := source.(*proxySession); ok {
47- d.armProxyWarm(proxy)
48- }
49- if proxy, ok := target.(*proxySession); ok {
50- if d.cancelProxyWarmForAttachment(proxy, ac) {
51- d.touchMRU(proxy)
52- }
53- }
54-}
55-
56-// cancelProxyWarmForAttachment marks an exact published attachment as active.
57-// Timer operations occur only after d.mu, sessionCore.mu, and proxy.mu have all
58-// been released.
59-func (d *Daemon) cancelProxyWarmForAttachment(p *proxySession, ac *attachedClient) bool {
60- if d == nil || p == nil || ac == nil {
61- return false
62- }
63- d.mu.Lock()
64- if d.sessions[p.id] != p {
65- d.mu.Unlock()
66- return false
67- }
68- p.sessionCore.mu.Lock()
69- if !attachmentRegisteredLocked(attachmentSession(p), ac) {
70- p.sessionCore.mu.Unlock()
71- d.mu.Unlock()
72- return false
73- }
74- p.mu.Lock()
75- p.generation++
76- old := p.warm
77- p.warm = nil
78- p.mu.Unlock()
79- p.sessionCore.mu.Unlock()
80- d.mu.Unlock()
81- old.stop()
82- return true
83-}
84-
85-// armProxyWarm reserves and publishes a fresh five-minute timer only while the
86-// exact proxy remains registered and headless. Clock and Timer methods are
87-// external operations and therefore run outside architecture locks.
88-//
89-// The lifecycle generation is bumped and the incumbent token released only in
90-// phase 2, atomically with publishing the replacement. A failed revalidation
91-// therefore leaves the already-armed token both installed and current, so the
92-// proxy can never be left registered without a path out of the live registry.
93-func (d *Daemon) armProxyWarm(p *proxySession) bool {
94- if d == nil || p == nil || d.clock == nil {
95- return false
96- }
97- d.mu.Lock()
98- if d.sessions[p.id] != p || d.closing {
99- d.mu.Unlock()
100- return false
101- }
102- p.sessionCore.mu.Lock()
103- if len(p.attachments) != 0 {
104- p.sessionCore.mu.Unlock()
105- d.mu.Unlock()
106- return false
107- }
108- p.mu.Lock()
109- generation := p.generation
110- old := p.warm
111- p.mu.Unlock()
112- p.sessionCore.mu.Unlock()
113- d.mu.Unlock()
114-
115- timer := d.clock.NewTimer(proxyWarmDuration)
116- if timer == nil {
117- return false
118- }
119- token := &proxyWarmTimer{
120- timer: timer,
121- cancel: make(chan struct{}),
122- done: make(chan struct{}),
123- }
124-
125- d.mu.Lock()
126- valid := d.sessions[p.id] == p && !d.closing
127- if valid {
128- p.sessionCore.mu.Lock()
129- valid = len(p.attachments) == 0
130- if valid {
131- p.mu.Lock()
132- valid = p.generation == generation && p.warm == old
133- if valid {
134- p.generation++
135- token.generation = p.generation
136- p.warm = token
137- }
138- p.mu.Unlock()
139- }
140- p.sessionCore.mu.Unlock()
141- }
142- d.mu.Unlock()
143- if !valid {
144- timer.Stop()
145- close(token.done)
146- return false
147- }
148- // The incumbent token is retired only once its successor owns the exact
149- // lifecycle generation, never before.
150- old.stop()
151-
152- go func() {
153- defer close(token.done)
154- select {
155- case <-timer.C():
156- d.expireWarmProxy(p, token)
157- case <-token.cancel:
158- }
159- }()
160- return true
161-}
162-
163-// expireWarmProxy removes only the exact dormant proxy pointer and lifecycle
164-// generation. The canonical d.mu -> core.mu -> proxy.mu order is held only for
165-// validation/publication; cancellation, coordinator teardown, transport close,
166-// picker refresh, and daemon completion happen afterward.
167-func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {
168- if d == nil || p == nil || token == nil {
169- return false
170- }
171- d.mu.Lock()
172- if d.sessions[p.id] != p {
173- d.mu.Unlock()
174- return false
175- }
176- p.sessionCore.mu.Lock()
177- if len(p.attachments) != 0 {
178- p.sessionCore.mu.Unlock()
179- d.mu.Unlock()
180- return false
181- }
182- p.mu.Lock()
183- if p.warm != token || p.generation != token.generation {
184- p.mu.Unlock()
185- p.sessionCore.mu.Unlock()
186- d.mu.Unlock()
187- return false
188- }
189- // Removal from the registry is what commits this expiry, so it is attempted
190- // before any terminal lifecycle state is published. unregisterSessionLocked
191- // only reads d.sessions, which this call already owns; it acquires no lock.
192- if !d.unregisterSessionLocked(p) {
193- p.mu.Unlock()
194- p.sessionCore.mu.Unlock()
195- d.mu.Unlock()
196- return false
197- }
198- p.warm = nil
199- p.generation++
200- p.expired = true
201- cancel := p.cancel
202- transport := p.transport
203- coordinator := p.coordinator.Load()
204- p.mu.Unlock()
205- p.sessionCore.mu.Unlock()
206- empty := len(d.sessions) == 0
207- if empty {
208- d.closing = true
209- }
210- d.mu.Unlock()
211-
212- if cancel != nil {
213- cancel()
214- }
215- if transport != nil {
216- _ = transport.Close()
217- }
218- if coordinator != nil {
219- coordinator.beginSessionTeardown().finish()
220- coordinator.waitForTimerWorkers()
221- }
222- p.finish()
223- if empty {
224- d.doneOnce.Do(func() { close(d.done) })
225- } else {
226- d.refreshRemoteOpenPickers()
227- }
228- return true
229-}
230-
231-// withProxyLocked evaluates one lifecycle predicate under the canonical
232-// d.mu -> p.sessionCore.mu -> p.mu order, but only while p is still the exact
233-// registered session. update owns every state mutation it wants published and
234-// reports whether it applied. Repaint is deliberately after all architecture
235-// locks have been released.
236-func (d *Daemon) withProxyLocked(p *proxySession, update func() bool) bool {
237- if d == nil || p == nil {
238- return false
239- }
240- d.mu.Lock()
241- if d.sessions[p.id] != p {
242- d.mu.Unlock()
243- return false
244- }
245- p.sessionCore.mu.Lock()
246- p.mu.Lock()
247- current := update()
248- attachments := append([]*attachedClient(nil), p.snapshotAttachmentsLocked()...)
249- p.mu.Unlock()
250- p.sessionCore.mu.Unlock()
251- d.mu.Unlock()
252- if current {
253- for _, ac := range attachments {
254- d.invalidateRender(p, ac, false, "proxy_lifecycle.go")
255- }
256- }
257- return current
258-}
259-
260-// updateProxyLinkState publishes one transport callback only if the exact proxy,
261-// link generation, and transport remain current.
262-func (d *Daemon) updateProxyLinkState(p *proxySession, generation uint64, transport ports.Transport, state ports.LinkState) bool {
263- if d == nil || p == nil || transport == nil {
264- return false
265- }
266- return d.withProxyLocked(p, func() bool {
267- current := !p.expired && p.linkGeneration == generation && p.transport == transport
268- if current {
269- p.linkState = state
270- }
271- return current
272- })
273-}
274-
275-// updateProxyDisconnectedState publishes a state after the exact generation's
276-// transport has retired. It is used while resume dialing is pending or failed.
277-func (d *Daemon) updateProxyDisconnectedState(p *proxySession, generation uint64, state ports.LinkState) bool {
278- if d == nil || p == nil {
279- return false
280- }
281- return d.withProxyLocked(p, func() bool {
282- current := !p.expired && p.linkGeneration == generation && p.transport == nil
283- if current {
284- p.linkState = state
285- }
286- return current
287- })
288-}
289-
290-func (d *Daemon) repaintProxyLifecycle(p *proxySession) {
291- d.withProxyLocked(p, func() bool {
292- return !p.expired && p.transport != nil
293- })
294-}
295-
296-// markProxyReplaced makes ReasonReplaced terminal for this exact remote link.
297-// Once published, no later state callback or resume attempt can revive it. An
298-// already-armed dormant expiry remains the lifecycle capability for this exact
299-// proxy and generation; replacement must not invalidate or clear that only path
300-// out of the live registry. A dormant proxy without a timer is armed afterward,
301-// outside every architecture lock.
302-func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport ports.Transport) bool {
303- if d == nil || p == nil || transport == nil {
304- return false
305- }
306- // Arming and repainting are mutually exclusive: needsWarm requires a headless
307- // proxy, and the helper only repaints an attached one. Deferring the arm until
308- // after the helper's repaint therefore preserves the published order.
309- var needsWarm bool
310- current := d.withProxyLocked(p, func() bool {
311- current := p.linkGeneration == generation && p.transport == transport
312- if current {
313- p.expired = true
314- p.linkState = ports.LinkStateDead
315- p.generation++
316- if p.warm != nil {
317- // Rekey the already-published exact timer instead of stopping it. This
318- // preserves its deadline while invalidating older lifecycle observers.
319- p.warm.generation = p.generation
320- }
321- }
322- needsWarm = current && len(p.attachments) == 0 && p.warm == nil
323- return current
324- })
325- if needsWarm {
326- d.armProxyWarm(p)
327- }
328- return current
329-}
330-
331-func (p *proxySession) lifecycleDisplayName() string {
332- if p == nil {
333- return ""
334- }
335- p.mu.Lock()
336- defer p.mu.Unlock()
337- if p.expired {
338- return p.name + " [expired]"
339- }
340- if p.linkState == ports.LinkStateConnected {
341- return p.name
342- }
343- return p.name + " [" + p.linkState.String() + "]"
344-}
D internal/usecase/daemon/proxy_lifecycle_test.go +0 -1624
@@ -1,1624 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "io"
8- "log/slog"
9- "sync"
10- "testing"
11- "time"
12-
13- "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/ports"
15- "github.com/bnema/vev/internal/usecase/picker"
16- "github.com/stretchr/testify/require"
17-)
18-
19-type proxyLifecycleClock struct {
20- mu sync.RWMutex
21- now time.Time
22- timers chan *proxyLifecycleTimer
23-}
24-
25-func newProxyLifecycleClock() *proxyLifecycleClock {
26- return &proxyLifecycleClock{
27- now: time.Unix(1_800_000_000, 0),
28- timers: make(chan *proxyLifecycleTimer, 16),
29- }
30-}
31-
32-func (c *proxyLifecycleClock) Now() time.Time {
33- c.mu.RLock()
34- defer c.mu.RUnlock()
35- return c.now
36-}
37-
38-func (c *proxyLifecycleClock) Advance(delay time.Duration) {
39- c.mu.Lock()
40- c.now = c.now.Add(delay)
41- c.mu.Unlock()
42-}
43-
44-func (c *proxyLifecycleClock) NewTimer(delay time.Duration) ports.Timer {
45- timer := &proxyLifecycleTimer{clock: c, delay: delay, ticks: make(chan time.Time, 1)}
46- select {
47- case c.timers <- timer:
48- default:
49- }
50- return timer
51-}
52-
53-type proxyLifecycleTimer struct {
54- mu sync.Mutex
55- clock *proxyLifecycleClock
56- delay time.Duration
57- ticks chan time.Time
58- stopped bool
59-}
60-
61-func (t *proxyLifecycleTimer) C() <-chan time.Time { return t.ticks }
62-func (t *proxyLifecycleTimer) Reset(delay time.Duration) bool {
63- t.mu.Lock()
64- wasActive := !t.stopped
65- t.delay = delay
66- t.stopped = false
67- t.mu.Unlock()
68- return wasActive
69-}
70-func (t *proxyLifecycleTimer) Stop() bool {
71- t.mu.Lock()
72- wasActive := !t.stopped
73- t.stopped = true
74- t.mu.Unlock()
75- return wasActive
76-}
77-func (t *proxyLifecycleTimer) fire() {
78- t.clock.Advance(t.delay)
79- select {
80- case t.ticks <- t.clock.Now():
81- default:
82- }
83-}
84-
85-func TestProxyLifecycleClockTimerRegistrationDoesNotBlock(t *testing.T) {
86- clock := newProxyLifecycleClock()
87- for range cap(clock.timers) {
88- clock.NewTimer(time.Minute)
89- }
90-
91- registered := make(chan struct{})
92- go func() {
93- clock.NewTimer(time.Minute)
94- close(registered)
95- }()
96- awaitTestCompletion(t, registered, "timer registration blocked on observer capacity")
97- for range cap(clock.timers) {
98- _ = awaitTestValue(t, clock.timers, "registered timer was not observable")
99- }
100-}
101-
102-type proxyLifecycleTransport struct {
103- d *Daemon
104- proxy *proxySession
105- closed chan struct{}
106- closeOne sync.Once
107- unlocked bool
108-}
109-
110-func (t *proxyLifecycleTransport) Send(ports.Frame) error { return nil }
111-func (t *proxyLifecycleTransport) Recv() (ports.Frame, error) {
112- <-t.closed
113- return ports.Frame{}, nil
114-}
115-func (t *proxyLifecycleTransport) Close() error {
116- t.closeOne.Do(func() {
117- daemonUnlocked := t.d.mu.TryLock()
118- if daemonUnlocked {
119- t.d.mu.Unlock()
120- }
121- coreUnlocked := t.proxy.sessionCore.mu.TryLock()
122- if coreUnlocked {
123- t.proxy.sessionCore.mu.Unlock()
124- }
125- proxyUnlocked := t.proxy.mu.TryLock()
126- if proxyUnlocked {
127- t.proxy.mu.Unlock()
128- }
129- t.unlocked = daemonUnlocked && coreUnlocked && proxyUnlocked
130- close(t.closed)
131- })
132- return nil
133-}
134-
135-func newRegisteredLifecycleProxy(t *testing.T, clock ports.Clock) (*Daemon, *proxySession, *proxyLifecycleTransport) {
136- t.Helper()
137- d := newTestDaemon(t, nil, clock)
138- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize)
139- require.NoError(t, err)
140- transport := &proxyLifecycleTransport{d: d, proxy: proxy, closed: make(chan struct{})}
141- proxy.transport = transport
142- proxy.linkGeneration = 1
143- d.mu.Lock()
144- require.True(t, d.registerSessionLocked(proxy))
145- d.mu.Unlock()
146- return d, proxy, transport
147-}
148-
149-func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
150- clock := newProxyLifecycleClock()
151- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
152- d.clock = clock
153- localCoordinator := d.attachCoordinator(local, ac, true)
154- localToken := attachmentToken(local, ac, ac.transport())
155- localToken.lease = localCoordinator.attachmentLease(ac)
156-
157- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ac.size)
158- require.NoError(t, err)
159- d.mu.Lock()
160- require.True(t, d.registerSessionLocked(proxy))
161- d.mu.Unlock()
162-
163- toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
164- source: local, target: proxy, next: ac,
165-
166- expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
167- action: "lifecycle test", ready: true,
168- })
169- require.NoError(t, err)
170- require.NotZero(t, proxy.mruAt.Load(), "proxy activation must participate in unified MRU ordering")
171- proxy.mu.Lock()
172- require.Nil(t, proxy.warm, "attachment must leave no warm timer published")
173- proxy.mu.Unlock()
174-
175- _, err = d.transitionAttachment(attachmentTransitionRequest{
176- source: proxy, target: local, next: ac,
177-
178- expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
179- action: "lifecycle test", ready: true,
180- })
181- require.NoError(t, err)
182- timer := awaitTestValue(t, clock.timers, "proxy timer was not registered")
183- require.Equal(t, proxyWarmDuration, timer.delay)
184- proxy.mu.Lock()
185- require.NotNil(t, proxy.warm, "switch-away must arm the warm lifecycle")
186- proxy.mu.Unlock()
187-}
188-
189-func TestProxyWarmLifecycleUsesExactFiveMinuteGeneration(t *testing.T) {
190- clock := newProxyLifecycleClock()
191- d, proxy, transport := newRegisteredLifecycleProxy(t, clock)
192- d.remoteCatalog.mu.Lock()
193- d.remoteCatalog.cache[proxy.key.Host] = ports.RemoteCatalogCacheEntry{Host: proxy.key.Host, FetchedAt: clock.Now()}
194- d.remoteCatalog.mu.Unlock()
195-
196- d.armProxyWarm(proxy)
197- timer := awaitTestValue(t, clock.timers, "proxy timer was not registered")
198- require.Equal(t, 5*time.Minute, timer.delay)
199- proxy.mu.Lock()
200- token := proxy.warm
201- generation := proxy.generation
202- proxy.mu.Unlock()
203- require.NotNil(t, token)
204-
205- timer.fire()
206- awaitTestCompletion(t, token.done, "proxy warm lifecycle did not complete")
207-
208- d.mu.Lock()
209- require.NotContains(t, d.sessions, proxy.id)
210- require.True(t, d.closing, "the last warm proxy expiry must begin daemon shutdown")
211- d.mu.Unlock()
212- require.True(t, transport.unlocked, "transport close must run after daemon/core/proxy locks are released")
213- proxy.mu.Lock()
214- require.Greater(t, proxy.generation, generation)
215- proxy.mu.Unlock()
216- d.remoteCatalog.mu.Lock()
217- require.Contains(t, d.remoteCatalog.cache, proxy.key.Host, "warm expiry must preserve discovery cache")
218- d.remoteCatalog.mu.Unlock()
219-}
220-
221-func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
222- clock := newProxyLifecycleClock()
223- d, proxy, _ := newRegisteredLifecycleProxy(t, clock)
224-
225- d.armProxyWarm(proxy)
226- firstTimer := awaitTestValue(t, clock.timers, "first proxy timer was not registered")
227- proxy.mu.Lock()
228- firstToken := proxy.warm
229- proxy.mu.Unlock()
230- require.NotNil(t, firstToken)
231-
232- ac := &attachedClient{}
233- proxy.sessionCore.mu.Lock()
234- proxy.registerAttachmentLocked(ac)
235- proxy.sessionCore.mu.Unlock()
236- d.cancelProxyWarmForAttachment(proxy, ac)
237- awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete")
238- firstTimer.fire()
239-
240- d.mu.Lock()
241- require.Same(t, proxy, d.sessions[proxy.id])
242- d.mu.Unlock()
243-
244- proxy.sessionCore.mu.Lock()
245- for _, attachment := range proxy.snapshotAttachmentsLocked() {
246- proxy.unregisterAttachmentLocked(attachment)
247- }
248- proxy.sessionCore.mu.Unlock()
249- d.armProxyWarm(proxy)
250- secondTimer := awaitTestValue(t, clock.timers, "second proxy timer was not registered")
251- proxy.mu.Lock()
252- secondToken := proxy.warm
253- proxy.mu.Unlock()
254- require.NotNil(t, secondToken)
255- require.NotSame(t, firstToken, secondToken)
256-
257- secondTimer.fire()
258- awaitTestCompletion(t, secondToken.done, "second proxy warm lifecycle did not complete")
259- d.mu.Lock()
260- require.NotContains(t, d.sessions, proxy.id)
261- d.mu.Unlock()
262-}
263-
264-func TestProxyWarmTimerRejectsReplacementPointer(t *testing.T) {
265- clock := newProxyLifecycleClock()
266- d, proxy, transport := newRegisteredLifecycleProxy(t, clock)
267- d.armProxyWarm(proxy)
268- timer := awaitTestValue(t, clock.timers, "proxy timer was not registered")
269- proxy.mu.Lock()
270- token := proxy.warm
271- proxy.mu.Unlock()
272-
273- replacement, err := newProxySession(proxy.key, defaultSize)
274- require.NoError(t, err)
275- d.mu.Lock()
276- d.sessions[proxy.id] = replacement
277- d.mu.Unlock()
278-
279- timer.fire()
280- awaitTestCompletion(t, token.done, "proxy warm lifecycle did not complete")
281- d.mu.Lock()
282- require.Same(t, replacement, d.sessions[proxy.id])
283- d.mu.Unlock()
284- select {
285- case <-transport.closed:
286- t.Fatal("stale pointer timer closed the old transport")
287- default:
288- }
289-}
290-
291-type proxyResumeTestFactory struct {
292- mu sync.Mutex
293- transports []ports.Transport
294- callCount int
295- calls chan int
296-}
297-
298-func newProxyResumeTestFactory(transports ...ports.Transport) *proxyResumeTestFactory {
299- return &proxyResumeTestFactory{transports: transports, calls: make(chan int, proxyResumeMaxAttempts+2)}
300-}
301-
302-func (f *proxyResumeTestFactory) DialerForRemote(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error) {
303- f.mu.Lock()
304- f.callCount++
305- callNumber := f.callCount
306- var selected ports.Transport
307- for i, transport := range f.transports {
308- if transport != nil {
309- selected = transport
310- f.transports[i] = nil
311- break
312- }
313- }
314- f.mu.Unlock()
315- f.calls <- callNumber
316- if selected == nil {
317- return nil, errors.New("resume dial unavailable")
318- }
319- return proxyConstructionDialer{transport: selected}, nil
320-}
321-
322-func newProxyResumeHarness(t *testing.T, clock *proxyLifecycleClock, factory ports.RemoteDialerFactory) (*Daemon, *proxySession, *proxyTestTransport) {
323- t.Helper()
324- d := newTestDaemon(t, nil, clock)
325- d.remoteDialerFactory = factory
326- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize)
327- require.NoError(t, err)
328- initial := newProxyTestTransport()
329- proxy.mu.Lock()
330- proxy.transport = initial
331- proxy.linkGeneration = 1
332- proxy.mu.Unlock()
333- d.mu.Lock()
334- require.True(t, d.registerSessionLocked(proxy))
335- d.mu.Unlock()
336- return d, proxy, initial
337-}
338-
339-func TestProxyResumeBackoffIsFakeClockBoundedAndParksOffline(t *testing.T) {
340- clock := newProxyLifecycleClock()
341- factory := newProxyResumeTestFactory()
342- d, proxy, initial := newProxyResumeHarness(t, clock, factory)
343- initial.recv <- proxyRecv{err: io.ErrUnexpectedEOF}
344-
345- go d.runProxyLink(context.Background(), proxy)
346- for attempt := range proxyResumeMaxAttempts {
347- timer := awaitTestValue(t, clock.timers, "proxy resume did not arm fake-clock backoff")
348- require.Equal(t, proxyResumeBackoff(attempt), timer.delay)
349- timer.fire()
350- require.Equal(t, attempt+1, awaitTestValue(t, factory.calls, "proxy resume dial was not attempted"))
351- handshakeTimer := awaitTestValue(t, clock.timers, "proxy resume handshake did not arm its timeout")
352- require.Equal(t, proxyHandshakeTimeout, handshakeTimer.delay)
353- }
354- awaitTestCompletion(t, proxy.done, "proxy resume did not stop after its attempt cap")
355-
356- proxy.mu.Lock()
357- require.Nil(t, proxy.transport)
358- require.Equal(t, ports.LinkStateOffline, proxy.linkState)
359- proxy.mu.Unlock()
360- d.mu.Lock()
361- require.Same(t, proxy, d.sessions[proxy.id], "exhaustion must park the proxy instead of unregistering it")
362- d.mu.Unlock()
363- select {
364- case timer := <-clock.timers:
365- t.Fatalf("attempt cap armed an extra retry: %v", timer.delay)
366- default:
367- }
368-}
369-
370-func TestProxyResumeCancellationDuringBackoffDoesNotRedial(t *testing.T) {
371- clock := newProxyLifecycleClock()
372- factory := newProxyResumeTestFactory()
373- d, proxy, initial := newProxyResumeHarness(t, clock, factory)
374- initial.recv <- proxyRecv{err: io.EOF}
375- ctx, cancel := context.WithCancel(context.Background())
376- go d.runProxyLink(ctx, proxy)
377- _ = awaitTestValue(t, clock.timers, "proxy resume did not arm backoff")
378- cancel()
379- awaitTestCompletion(t, proxy.done, "canceled proxy resume did not stop")
380- select {
381- case <-factory.calls:
382- t.Fatal("canceled backoff dialed a replacement link")
383- default:
384- }
385-}
386-
387-func TestProxyResumeBackoffResetsAfterStableConnection(t *testing.T) {
388- clock := newProxyLifecycleClock()
389- resumed := newProxyTestTransport()
390- factory := newProxyResumeTestFactory(resumed)
391- d, proxy, initial := newProxyResumeHarness(t, clock, factory)
392- initial.recv <- proxyRecv{err: io.EOF}
393- ctx, cancel := context.WithCancel(context.Background())
394- defer cancel()
395- go d.runProxyLink(ctx, proxy)
396-
397- first := awaitTestValue(t, clock.timers, "initial resume did not arm backoff")
398- require.Equal(t, proxyResumeInitialBackoff, first.delay)
399- first.fire()
400- _ = awaitTestValue(t, factory.calls, "stable resume did not dial")
401- handshakeTimer := awaitTestValue(t, clock.timers, "stable resume handshake did not arm its timeout")
402- require.Equal(t, proxyHandshakeTimeout, handshakeTimer.delay)
403- _ = requireProxyHello(t, resumed)
404- resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume)}
405- require.Eventually(t, func() bool {
406- proxy.mu.Lock()
407- defer proxy.mu.Unlock()
408- return proxy.transport == resumed && proxy.resumeToken == 2
409- }, time.Second, time.Millisecond)
410- resumed.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}}
411- _ = awaitFrame(t, resumed.sent, ports.MsgPong)
412-
413- clock.Advance(proxyResumeStableDuration)
414- resumed.recv <- proxyRecv{err: io.EOF}
415- second := awaitTestValue(t, clock.timers, "stable link loss did not arm another backoff")
416- require.Equal(t, proxyResumeInitialBackoff, second.delay, "stable connection must reset exponential backoff")
417- cancel()
418- awaitTestCompletion(t, proxy.done, "stable-reset proxy did not stop after cancellation")
419-}
420-
421-func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) {
422- clock := newProxyLifecycleClock()
423- d, proxy, transport := newRegisteredLifecycleProxy(t, clock)
424-
425- tests := []struct {
426- state ports.LinkState
427- want string
428- }{
429- {ports.LinkStateConnected, proxy.name},
430- {ports.LinkStateDegraded, proxy.name + " [degraded]"},
431- {ports.LinkStateProbing, proxy.name + " [probing]"},
432- {ports.LinkStateOffline, proxy.name + " [offline]"},
433- {ports.LinkStateDead, proxy.name + " [dead]"},
434- }
435- for _, test := range tests {
436- require.True(t, d.updateProxyLinkState(proxy, 1, transport, test.state))
437- require.Equal(t, test.want, proxy.statusSegments(false).session)
438- }
439- require.False(t, d.updateProxyLinkState(proxy, 2, transport, ports.LinkStateConnected))
440- require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session)
441-}
442-
443-func TestIncomingDirectAttachTerminatesRemoteAttachment(t *testing.T) {
444- d := newTestDaemon(t, nil, newProxyLifecycleClock())
445- sess := &session{sessionCore: sessionCore{id: "work", name: "work"}}
446- oldTransport := newProxyTestTransport()
447- old := &attachedClient{tr: oldTransport, output: newOutputStateStream()}
448- old.initOverlays()
449- old.setSession(sess)
450- sess.registerAttachment(old)
451- d.mu.Lock()
452- require.True(t, d.registerSessionLocked(sess))
453- d.mu.Unlock()
454- d.attachCoordinator(sess, old, true)
455-
456- newTransport := newProxyTestTransport()
457- next := &attachedClient{tr: newTransport, output: newOutputStateStream()}
458- next.initOverlays()
459- result, err := d.transitionAttachment(attachmentTransitionRequest{
460- target: sess, next: next,
461-
462- expectedTransport: next.transportSnapshot(), ready: true,
463- })
464- require.NoError(t, err)
465- require.NotNil(t, result.published.ac)
466- require.Contains(t, sess.snapshotAttachments(), next)
467- require.Same(t, sess, old.currentAttachmentSession())
468-
469- d.deferAttachmentTransitionCleanups(result)
470- d.waitNotifies()
471- select {
472- case <-oldTransport.sent:
473- t.Fatal("publishing an attachment displaced the existing connection")
474- default:
475- }
476- select {
477- case <-oldTransport.closed:
478- t.Fatal("publishing an attachment closed the existing connection")
479- default:
480- }
481-}
482-
483-func TestProxyReplacedIsTerminalExpired(t *testing.T) {
484- clock := newProxyLifecycleClock()
485- d, proxy, transport := newRegisteredLifecycleProxy(t, clock)
486-
487- require.True(t, d.markProxyReplaced(proxy, 1, transport))
488- proxy.mu.Lock()
489- require.True(t, proxy.expired)
490- proxy.mu.Unlock()
491- require.Equal(t, proxy.name+" [expired]", proxy.statusSegments(false).session)
492- require.False(t, d.updateProxyLinkState(proxy, 1, transport, ports.LinkStateConnected), "replacement expiry must reject later link recovery")
493-}
494-
495-func TestRemoteProxyPickerUsesExplicitExpiredLifecycleState(t *testing.T) {
496- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
497- spoofed := remoteProxyPickerView(key, sessionView{name: key.Display() + " [expired]", tabCount: 1})
498- require.Equal(t, key.Display(), spoofed.Name)
499- require.Equal(t, picker.RemoteFresh, spoofed.RemoteAvailability)
500- require.True(t, spoofed.RemoteAttachReady)
501-
502- expired := remoteProxyPickerView(key, sessionView{name: "unrelated presentation", tabCount: 1, expired: true})
503- require.Equal(t, key.Display()+" [expired]", expired.Name)
504- require.Equal(t, picker.RemoteStale, expired.RemoteAvailability)
505- require.False(t, expired.RemoteAttachReady)
506-}
507-
508-func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T) {
509- clock := newProxyLifecycleClock()
510- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
511- d.clock = clock
512- coordinator := d.attachCoordinator(local, ac, true)
513- localToken := attachmentToken(local, ac, ac.transport())
514- localToken.lease = coordinator.attachmentLease(ac)
515-
516- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
517- proxy, err := newProxySession(key, ac.size)
518- require.NoError(t, err)
519- transport := &proxyLifecycleTransport{d: d, proxy: proxy, closed: make(chan struct{})}
520- proxy.transport = transport
521- proxy.linkGeneration = 1
522- factory := newProxyConstructionFactory(newProxyTestTransport())
523- d.remoteDialerFactory = factory
524- d.mu.Lock()
525- require.True(t, d.registerSessionLocked(proxy))
526- d.mu.Unlock()
527-
528- toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
529- source: local, target: proxy, next: ac,
530-
531- expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
532- action: "replaced picker regression", ready: true,
533- })
534- require.NoError(t, err)
535-
536- result, err := d.handleLinkFrame(proxy, proxy.linkGeneration, ports.Frame{
537- Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced}),
538- })
539- require.NoError(t, err)
540- require.Equal(t, proxyLinkReplaced, result)
541- require.True(t, d.markProxyReplaced(proxy, proxy.linkGeneration, transport))
542-
543- _, err = d.transitionAttachment(attachmentTransitionRequest{
544- source: proxy, target: local, next: ac,
545-
546- expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
547- action: "replaced picker regression", ready: true,
548- })
549- require.NoError(t, err)
550- require.Same(t, local, ac.currentAttachmentSession())
551- proxy.mu.Lock()
552- warm := proxy.warm
553- proxy.mu.Unlock()
554- require.NotNil(t, warm, "switch-away did not retain warm expiry")
555- warmTimer, ok := warm.timer.(*proxyLifecycleTimer)
556- require.True(t, ok)
557- require.Equal(t, proxyWarmDuration, warmTimer.delay)
558-
559- views, _ := d.pickerViews(local, nil)
560- var expiredView picker.SessionView
561- for _, view := range views {
562- if view.RemoteKey != nil && *view.RemoteKey == key {
563- expiredView = view
564- break
565- }
566- }
567- require.Equal(t, key.ID(), expiredView.ID, "exact registered proxy was absent from unified picker")
568- require.Equal(t, key.Display()+" [expired]", expiredView.Name)
569- require.Equal(t, picker.RemoteStale, expiredView.RemoteAvailability)
570- require.Equal(t, "expired", expiredView.RemoteDetail)
571- require.False(t, expiredView.RemoteAttachReady)
572- require.Equal(t, key.Display()+" [expired]", proxy.lifecycleDisplayName())
573-
574- d.enterPicker(local, ac)
575- forced := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{
576- Session: key.ID(), RemoteKey: &key,
577- })
578- ac.overlays.pickerMu.Lock()
579- ac.overlays.picker = forced
580- cursor, cursorOK := forced.Cursor()
581- _, selectable := forced.Selected()
582- ac.overlays.pickerMu.Unlock()
583- require.True(t, cursorOK)
584- require.Equal(t, key, *cursor.RemoteKey)
585- require.False(t, selectable, "terminal proxy row must not be activatable")
586- d.handlePickerInput(ac, []byte("\r"))
587- require.False(t, ac.overlays.pickerActive())
588- require.Same(t, local, ac.currentAttachmentSession(), "picker activation reattached the terminal proxy")
589-
590- token := attachmentToken(local, ac, ac.transport())
591- effect, admitted := ac.beginAttachmentEffect(token)
592- require.True(t, admitted)
593- token.effect = effect
594- err = d.switchToTargetForAttachment(token, cursor, sessionHandoffGuard{}, "replaced picker regression")
595- require.Error(t, err)
596- require.Same(t, local, ac.currentAttachmentSession(), "defensive activation reused the terminal proxy")
597-
598- reopened, err := d.openProxySession(context.Background(), key, ac.size)
599- require.Error(t, err)
600- require.Nil(t, reopened)
601- require.Equal(t, 0, factory.callCount(), "terminal proxy triggered an automatic IntentAttach/redial")
602- d.mu.Lock()
603- require.Same(t, proxy, d.sessions[key.ID()], "terminal proxy registration changed before warm expiry")
604- d.mu.Unlock()
605-
606- proxy.mu.Lock()
607- require.Same(t, warm, proxy.warm)
608- proxy.mu.Unlock()
609- warmTimer.fire()
610- awaitTestCompletion(t, warm.done, "terminal proxy warm expiry did not complete")
611-}
612-
613-func TestDormantProxyReplacedRetainsAutomaticWarmExpiry(t *testing.T) {
614- clock := newProxyLifecycleClock()
615- d, proxy, _ := newRegisteredLifecycleProxy(t, clock)
616- link := newProxyKillControlTransport(d, proxy)
617- proxy.mu.Lock()
618- proxy.transport = link
619- proxy.mu.Unlock()
620-
621- require.True(t, d.armProxyWarm(proxy))
622- timer := awaitTestValue(t, clock.timers, "dormant proxy did not arm its warm deadline")
623- proxy.mu.Lock()
624- token := proxy.warm
625- generation := proxy.generation
626- proxy.mu.Unlock()
627- require.NotNil(t, token)
628- require.Equal(t, proxyWarmDuration, timer.delay)
629-
630- link.recv <- proxyKillRecv{frame: ports.Frame{
631- Type: ports.MsgDetached,
632- Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced}),
633- }}
634- go d.runProxyLink(context.Background(), proxy)
635- awaitTestCompletion(t, proxy.done, "replacement did not terminate the proxy link")
636-
637- proxy.mu.Lock()
638- require.True(t, proxy.expired)
639- require.Same(t, token, proxy.warm, "terminal replacement cleared the only expiry capability")
640- require.Greater(t, proxy.generation, generation, "terminal replacement did not invalidate stale lifecycle observers")
641- require.Equal(t, proxy.generation, token.generation, "preserved expiry capability was not rekeyed to the terminal generation")
642- proxy.mu.Unlock()
643- select {
644- case extra := <-clock.timers:
645- t.Fatalf("replacement unexpectedly rearmed the original deadline: %v", extra.delay)
646- default:
647- }
648-
649- timer.fire()
650- awaitTestCompletion(t, token.done, "original warm deadline did not complete")
651- d.mu.Lock()
652- require.NotContains(t, d.sessions, proxy.id, "exact replaced proxy was not automatically unregistered")
653- d.mu.Unlock()
654-}
655-
656-type proxyKillRecv struct {
657- frame ports.Frame
658- err error
659-}
660-
661-type proxyKillControlTransport struct {
662- d *Daemon
663- proxy *proxySession
664- sent chan ports.Frame
665- recv chan proxyKillRecv
666- recvLocksChecked chan struct{}
667- closed chan struct{}
668- close sync.Once
669- recvCheck sync.Once
670- sendErr error
671-}
672-
673-func newProxyKillControlTransport(d *Daemon, proxy *proxySession) *proxyKillControlTransport {
674- return &proxyKillControlTransport{
675- d: d, proxy: proxy, sent: make(chan ports.Frame, 2), recv: make(chan proxyKillRecv, 1),
676- recvLocksChecked: make(chan struct{}), closed: make(chan struct{}),
677- }
678-}
679-
680-func (t *proxyKillControlTransport) locksAvailable() bool {
681- daemonUnlocked := t.d.mu.TryLock()
682- if daemonUnlocked {
683- t.d.mu.Unlock()
684- }
685- coreUnlocked := t.proxy.sessionCore.mu.TryLock()
686- if coreUnlocked {
687- t.proxy.sessionCore.mu.Unlock()
688- }
689- proxyUnlocked := t.proxy.mu.TryLock()
690- if proxyUnlocked {
691- t.proxy.mu.Unlock()
692- }
693- catalogUnlocked := t.d.remoteCatalog.mu.TryLock()
694- if catalogUnlocked {
695- t.d.remoteCatalog.mu.Unlock()
696- }
697- return daemonUnlocked && coreUnlocked && proxyUnlocked && catalogUnlocked
698-}
699-
700-func (t *proxyKillControlTransport) Send(frame ports.Frame) error {
701- if !t.locksAvailable() {
702- return errors.New("architecture lock held during control send")
703- }
704- t.sent <- frame
705- return t.sendErr
706-}
707-
708-func (t *proxyKillControlTransport) Recv() (ports.Frame, error) {
709- if !t.locksAvailable() {
710- return ports.Frame{}, errors.New("architecture lock held during control receive")
711- }
712- t.recvCheck.Do(func() { close(t.recvLocksChecked) })
713- select {
714- case result := <-t.recv:
715- return result.frame, result.err
716- case <-t.closed:
717- return ports.Frame{}, io.EOF
718- }
719-}
720-
721-func (t *proxyKillControlTransport) Close() error {
722- if !t.locksAvailable() {
723- return errors.New("architecture lock held during control close")
724- }
725- t.close.Do(func() { close(t.closed) })
726- return nil
727-}
728-
729-type proxyKillBlockedSendTransport struct {
730- *proxyKillControlTransport
731- started chan struct{}
732- release chan struct{}
733- finished chan struct{}
734- start sync.Once
735- finish sync.Once
736-}
737-
738-func (t *proxyKillBlockedSendTransport) Send(ports.Frame) error {
739- if !t.locksAvailable() {
740- return errors.New("architecture lock held during blocked control send")
741- }
742- t.start.Do(func() { close(t.started) })
743- <-t.release
744- t.finish.Do(func() { close(t.finished) })
745- return nil
746-}
747-
748-type proxyKillDialer struct {
749- d *Daemon
750- proxy *proxySession
751- transport ports.Transport
752- err error
753-}
754-
755-func (d proxyKillDialer) Dial(ctx context.Context) (ports.Transport, error) {
756- if ctx == nil {
757- return nil, errors.New("nil kill context")
758- }
759- checker := newProxyKillControlTransport(d.d, d.proxy)
760- if !checker.locksAvailable() {
761- return nil, errors.New("architecture lock held during control dial")
762- }
763- return d.transport, d.err
764-}
765-
766-type proxyKillDialerFactory struct {
767- d *Daemon
768- proxy *proxySession
769- dialer ports.Dialer
770- err error
771- targets chan domain.RemoteSessionKey
772-}
773-
774-func (f *proxyKillDialerFactory) DialerForRemote(target, session string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) {
775- checker := newProxyKillControlTransport(f.d, f.proxy)
776- if !checker.locksAvailable() {
777- return nil, errors.New("architecture lock held during dialer selection")
778- }
779- if f.targets != nil {
780- f.targets <- domain.RemoteSessionKey{Host: target, Name: session}
781- }
782- return f.dialer, f.err
783-}
784-
785-type proxyKillCache struct {
786- d *Daemon
787- proxy *proxySession
788- stores chan []ports.RemoteCatalogCacheEntry
789-}
790-
791-func (*proxyKillCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { return nil, nil }
792-func (c *proxyKillCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
793- checker := newProxyKillControlTransport(c.d, c.proxy)
794- if !checker.locksAvailable() {
795- return errors.New("architecture lock held during cache persistence")
796- }
797- copyEntries := make([]ports.RemoteCatalogCacheEntry, len(entries))
798- for i, entry := range entries {
799- copyEntries[i] = cloneRemoteCatalogEntry(entry)
800- }
801- c.stores <- copyEntries
802- return nil
803-}
804-
805-func newProxyKillHarness(t *testing.T) (*Daemon, *proxySession, *proxyLifecycleClock, *proxyKillControlTransport, *proxyKillCache) {
806- t.Helper()
807- clock := newProxyLifecycleClock()
808- d, proxy, _ := newRegisteredLifecycleProxy(t, clock)
809- control := newProxyKillControlTransport(d, proxy)
810- factory := &proxyKillDialerFactory{d: d, proxy: proxy, targets: make(chan domain.RemoteSessionKey, 1)}
811- factory.dialer = proxyKillDialer{d: d, proxy: proxy, transport: control}
812- d.remoteDialerFactory = factory
813- d.remoteTransportMode = ports.RemoteTransportUDP
814- cache := &proxyKillCache{d: d, proxy: proxy, stores: make(chan []ports.RemoteCatalogCacheEntry, 2)}
815- d.remoteCatalogCache = cache
816- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
817- Host: proxy.key.Host, FetchedAt: clock.Now(), Sessions: []ports.RemoteCatalogSession{
818- {Name: proxy.key.Name, State: "running"}, {Name: "other", State: "running"},
819- },
820- }})
821- return d, proxy, clock, control, cache
822-}
823-
824-func proxyKillGeneration(p *proxySession) uint64 {
825- p.mu.Lock()
826- defer p.mu.Unlock()
827- return p.generation
828-}
829-
830-func requireProxyKillState(t *testing.T, d *Daemon, proxy *proxySession, present bool) {
831- t.Helper()
832- d.mu.Lock()
833- require.Equal(t, present, d.sessions[proxy.id] == proxy)
834- d.mu.Unlock()
835- d.remoteCatalog.mu.Lock()
836- entry := d.remoteCatalog.cache[proxy.key.Host]
837- d.remoteCatalog.mu.Unlock()
838- found := false
839- for _, session := range entry.Sessions {
840- found = found || session.Name == proxy.key.Name
841- }
842- require.Equal(t, present, found)
843-}
844-
845-func TestProxyKillEOFSuccessRemovesExactLiveAndCacheWithoutLockedIO(t *testing.T) {
846- d, proxy, _, control, cache := newProxyKillHarness(t)
847- proxy.mu.Lock()
848- liveTransport, ok := proxy.transport.(*proxyLifecycleTransport)
849- liveGeneration := proxy.generation
850- proxy.mu.Unlock()
851- require.True(t, ok)
852- control.recv <- proxyKillRecv{err: fmt.Errorf("ssh stream closed: %w", io.EOF)}
853-
854- require.NoError(t, d.killRemoteProxy(context.Background(), proxy, liveGeneration))
855- frame := awaitTestValue(t, control.sent, "remote control kill was not sent")
856- require.Equal(t, ports.MsgKill, frame.Type)
857- kill, err := ports.UnmarshalKill(frame.Payload)
858- require.NoError(t, err)
859- require.Equal(t, ports.Kill{Name: proxy.key.Name}, kill)
860- requireProxyKillState(t, d, proxy, false)
861- awaitTestCompletion(t, liveTransport.closed, "successful remote kill did not close the exact live transport generation")
862- require.True(t, liveTransport.unlocked, "exact live transport close ran under an architecture lock")
863- proxy.mu.Lock()
864- require.Greater(t, proxy.generation, liveGeneration)
865- require.Same(t, liveTransport, proxy.transport, "teardown must not substitute a different link generation before closing it")
866- proxy.mu.Unlock()
867- stored := awaitTestValue(t, cache.stores, "successful remote kill did not persist cache removal")
868- require.Len(t, stored, 1)
869- require.Equal(t, []ports.RemoteCatalogSession{{Name: "other", State: "running"}}, stored[0].Sessions)
870-}
871-
872-func TestProxyKillFailuresPreserveLiveAndCache(t *testing.T) {
873- remoteFailure := ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrInternal, Text: "no"})}
874- tests := []struct {
875- name string
876- setup func(*Daemon, *proxySession, *proxyKillControlTransport)
877- }{
878- {name: "dialer selection", setup: func(d *Daemon, proxy *proxySession, _ *proxyKillControlTransport) {
879- d.remoteDialerFactory = &proxyKillDialerFactory{d: d, proxy: proxy, err: errors.New("factory")}
880- }},
881- {name: "dial", setup: func(d *Daemon, proxy *proxySession, _ *proxyKillControlTransport) {
882- d.remoteDialerFactory = &proxyKillDialerFactory{d: d, proxy: proxy, dialer: proxyKillDialer{d: d, proxy: proxy, err: errors.New("dial")}}
883- }},
884- {name: "send", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) {
885- control.sendErr = errors.New("send")
886- }},
887- {name: "receive error", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) {
888- control.recv <- proxyKillRecv{err: errors.New("truncated control reply")}
889- }},
890- {name: "remote error", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) {
891- control.recv <- proxyKillRecv{frame: remoteFailure}
892- }},
893- {name: "unexpected reply", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) {
894- control.recv <- proxyKillRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
895- }},
896- }
897- for _, test := range tests {
898- t.Run(test.name, func(t *testing.T) {
899- d, proxy, _, control, cache := newProxyKillHarness(t)
900- test.setup(d, proxy, control)
901- require.Error(t, d.killRemoteProxy(context.Background(), proxy, proxyKillGeneration(proxy)))
902- requireProxyKillState(t, d, proxy, true)
903- select {
904- case <-cache.stores:
905- t.Fatal("failed remote kill persisted a cache mutation")
906- default:
907- }
908- })
909- }
910-}
911-
912-func TestProxyKillTimeoutPreservesState(t *testing.T) {
913- d, proxy, clock, _, cache := newProxyKillHarness(t)
914- done := make(chan error, 1)
915- go func() { done <- d.killRemoteProxy(context.Background(), proxy, proxyKillGeneration(proxy)) }()
916- timer := awaitTestValue(t, clock.timers, "remote kill did not arm a bounded timeout")
917- require.Equal(t, proxyKillTimeout, timer.delay)
918- timer.fire()
919- require.ErrorIs(t, awaitTestValue(t, done, "remote kill timeout did not unblock receive"), errProxyKillTimeout)
920- requireProxyKillState(t, d, proxy, true)
921- select {
922- case <-cache.stores:
923- t.Fatal("timed out remote kill persisted a cache mutation")
924- default:
925- }
926-}
927-
928-func TestProxyKillBlockedSendReturnsAtBoundWithoutMutatingState(t *testing.T) {
929- d, proxy, clock, control, cache := newProxyKillHarness(t)
930- blocked := &proxyKillBlockedSendTransport{
931- proxyKillControlTransport: control,
932- started: make(chan struct{}), release: make(chan struct{}), finished: make(chan struct{}),
933- }
934- defer func() {
935- close(blocked.release)
936- awaitTestCompletion(t, blocked.finished, "abandoned send did not finish after test release")
937- }()
938- d.remoteDialerFactory = &proxyKillDialerFactory{
939- d: d, proxy: proxy,
940- dialer: proxyKillDialer{d: d, proxy: proxy, transport: blocked},
941- }
942-
943- d.mu.Lock()
944- beforeLive := d.sessions[proxy.id]
945- beforeClosing := d.closing
946- d.mu.Unlock()
947- proxy.mu.Lock()
948- beforeGeneration, beforeExpired, beforeWarm, beforeTransport := proxy.generation, proxy.expired, proxy.warm, proxy.transport
949- proxy.mu.Unlock()
950- d.remoteCatalog.mu.Lock()
951- beforeCache := cloneRemoteCatalogEntry(d.remoteCatalog.cache[proxy.key.Host])
952- d.remoteCatalog.mu.Unlock()
953-
954- done := make(chan error, 1)
955- go func() { done <- d.killRemoteProxy(context.Background(), proxy, beforeGeneration) }()
956- awaitTestCompletion(t, blocked.started, "remote kill did not enter adversarial Send")
957- timer := awaitTestValue(t, clock.timers, "remote kill did not arm a bounded timeout")
958- require.Equal(t, proxyKillTimeout, timer.delay)
959- timer.fire()
960- require.ErrorIs(t, awaitTestValue(t, done, "remote kill waited for Send after its bound"), errProxyKillTimeout)
961-
962- d.mu.Lock()
963- require.Same(t, beforeLive, d.sessions[proxy.id])
964- require.Equal(t, beforeClosing, d.closing)
965- d.mu.Unlock()
966- proxy.mu.Lock()
967- require.Equal(t, beforeGeneration, proxy.generation)
968- require.Equal(t, beforeExpired, proxy.expired)
969- require.Same(t, beforeWarm, proxy.warm)
970- require.Same(t, beforeTransport, proxy.transport)
971- proxy.mu.Unlock()
972- d.remoteCatalog.mu.Lock()
973- require.Equal(t, beforeCache, d.remoteCatalog.cache[proxy.key.Host])
974- d.remoteCatalog.mu.Unlock()
975- select {
976- case <-cache.stores:
977- t.Fatal("blocked remote kill persisted a cache mutation")
978- default:
979- }
980- select {
981- case <-blocked.finished:
982- t.Fatal("adversarial Send unexpectedly returned when transport closed")
983- default:
984- }
985-}
986-
987-func TestProxyKillStaleSuccessPreservesReplacementGeneration(t *testing.T) {
988- tests := []struct {
989- name string
990- win func(*Daemon, *proxySession)
991- }{
992- {name: "reattach generation", win: func(d *Daemon, proxy *proxySession) {
993- d.mu.Lock()
994- proxy.sessionCore.mu.Lock()
995- proxy.mu.Lock()
996- proxy.generation++
997- proxy.mu.Unlock()
998- proxy.sessionCore.mu.Unlock()
999- d.mu.Unlock()
1000- }},
1001- {name: "replacement pointer", win: func(d *Daemon, proxy *proxySession) {
1002- replacement, err := newProxySession(proxy.key, defaultSize)
1003- require.NoError(t, err)
1004- d.mu.Lock()
1005- d.sessions[proxy.id] = replacement
1006- d.mu.Unlock()
1007- }},
1008- }
1009- for _, test := range tests {
1010- t.Run(test.name, func(t *testing.T) {
1011- d, proxy, _, control, cache := newProxyKillHarness(t)
1012- generation := proxyKillGeneration(proxy)
1013- done := make(chan error, 1)
1014- go func() { done <- d.killRemoteProxy(context.Background(), proxy, generation) }()
1015- _ = awaitTestValue(t, control.sent, "remote kill did not reach the control transport")
1016- awaitTestCompletion(t, control.recvLocksChecked, "remote kill did not verify unlocked architecture before generation replacement")
1017- test.win(d, proxy)
1018- control.recv <- proxyKillRecv{err: io.EOF}
1019- require.ErrorIs(t, awaitTestValue(t, done, "stale remote kill did not finish"), errProxyKillGeneration)
1020- d.remoteCatalog.mu.Lock()
1021- entry := d.remoteCatalog.cache[proxy.key.Host]
1022- d.remoteCatalog.mu.Unlock()
1023- require.Len(t, entry.Sessions, 2, "stale success removed cached discovery")
1024- select {
1025- case <-cache.stores:
1026- t.Fatal("stale remote success persisted a cache mutation")
1027- default:
1028- }
1029- })
1030- }
1031-}
1032-
1033-func TestProxyKillCurrentLinkDetachedCanRaceControlEOF(t *testing.T) {
1034- d, proxy, _, control, _ := newProxyKillHarness(t)
1035- generation := proxyKillGeneration(proxy)
1036- done := make(chan error, 1)
1037- go func() { done <- d.killRemoteProxy(context.Background(), proxy, generation) }()
1038- _ = awaitTestValue(t, control.sent, "remote kill did not reach the control transport")
1039- result, err := d.handleLinkFrame(proxy, proxy.linkGeneration, ports.Frame{
1040- Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}),
1041- })
1042- require.NoError(t, err)
1043- require.Equal(t, proxyLinkStop, result)
1044- control.recv <- proxyKillRecv{err: io.EOF}
1045- require.NoError(t, awaitTestValue(t, done, "remote kill did not accept EOF after current-link detach"))
1046- requireProxyKillState(t, d, proxy, false)
1047-}
1048-
1049-func TestProxyKillConfirmationRequiresExactRemoteDisplay(t *testing.T) {
1050- p, release := newBlockingPTY(t)
1051- defer release()
1052- d, local, ac, _ := newManualSessionWithPTYs(t, p)
1053- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize)
1054- require.NoError(t, err)
1055- d.mu.Lock()
1056- require.True(t, d.registerSessionLocked(proxy))
1057- d.mu.Unlock()
1058-
1059- target := picker.Target{Session: proxy.id, RemoteKey: &proxy.key, TabIndex: -1}
1060- require.True(t, d.enterRemoteKillConfirmation(local, ac, target))
1061- ac.overlays.promptMu.Lock()
1062- require.Equal(t, " Type work@arch to kill ", ac.overlays.prompt.Title())
1063- submit := ac.overlays.promptTransitionSubmit
1064- ac.overlays.promptMu.Unlock()
1065- require.Error(t, submit("work", attachmentConnectionToken{}))
1066- d.mu.Lock()
1067- require.Same(t, proxy, d.sessions[proxy.id])
1068- d.mu.Unlock()
1069-}
1070-
1071-func TestProxyKillConfirmationWorksWhileAttachedToProxyAndCancelsWithoutIO(t *testing.T) {
1072- d, proxy, ac, _, _, _, _ := newProxyRoleDetachFixture(t)
1073- target := picker.Target{Session: proxy.id, RemoteKey: &proxy.key, TabIndex: -1}
1074- require.True(t, d.enterRemoteKillConfirmation(proxy, ac, target))
1075- d.handlePromptInput(ac, []byte("\x03"))
1076- require.False(t, ac.overlays.promptActive())
1077- d.mu.Lock()
1078- require.Same(t, proxy, d.sessions[proxy.id])
1079- d.mu.Unlock()
1080-}
1081-
1082-type proxyConstructionDialer struct{ transport ports.Transport }
1083-
1084-func (d proxyConstructionDialer) Dial(context.Context) (ports.Transport, error) {
1085- return d.transport, nil
1086-}
1087-
1088-type proxyConstructionFactory struct {
1089- mu sync.Mutex
1090- transports []ports.Transport
1091- calls chan int
1092- secondAttempt chan struct{}
1093- releaseSecond chan struct{}
1094- secondCallOnce sync.Once
1095-}
1096-
1097-func newProxyConstructionFactory(transports ...ports.Transport) *proxyConstructionFactory {
1098- return &proxyConstructionFactory{
1099- transports: transports, calls: make(chan int, len(transports)+1),
1100- secondAttempt: make(chan struct{}), releaseSecond: make(chan struct{}),
1101- }
1102-}
1103-
1104-func (f *proxyConstructionFactory) DialerForRemote(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error) {
1105- f.mu.Lock()
1106- call := len(f.transports)
1107- for i := range f.transports {
1108- if f.transports[i] != nil {
1109- call = i
1110- break
1111- }
1112- }
1113- if call == len(f.transports) {
1114- f.mu.Unlock()
1115- return nil, errors.New("unexpected extra proxy construction")
1116- }
1117- transport := f.transports[call]
1118- f.transports[call] = nil
1119- f.mu.Unlock()
1120- f.calls <- call + 1
1121- if call == 1 {
1122- f.secondCallOnce.Do(func() { close(f.secondAttempt) })
1123- <-f.releaseSecond
1124- }
1125- return proxyConstructionDialer{transport: transport}, nil
1126-}
1127-
1128-func (f *proxyConstructionFactory) callCount() int {
1129- f.mu.Lock()
1130- defer f.mu.Unlock()
1131- count := 0
1132- for _, transport := range f.transports {
1133- if transport == nil {
1134- count++
1135- }
1136- }
1137- return count
1138-}
1139-
1140-type proxyConstructionWaitContext struct {
1141- context.Context
1142- observed chan struct{}
1143- once sync.Once
1144-}
1145-
1146-func (c *proxyConstructionWaitContext) Done() <-chan struct{} {
1147- c.once.Do(func() { close(c.observed) })
1148- return c.Context.Done()
1149-}
1150-
1151-type proxyOpenResult struct {
1152- proxy *proxySession
1153- err error
1154-}
1155-
1156-func openProxyForTest(d *Daemon, ctx context.Context, key domain.RemoteSessionKey) <-chan proxyOpenResult {
1157- result := make(chan proxyOpenResult, 1)
1158- go func() {
1159- proxy, err := d.openProxySession(ctx, key, defaultSize)
1160- result <- proxyOpenResult{proxy: proxy, err: err}
1161- }()
1162- return result
1163-}
1164-
1165-func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
1166- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1167- first := newProxyTestTransport()
1168- second := newProxyTestTransport()
1169- second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityResume)}
1170- factory := newProxyConstructionFactory(first, second)
1171- d := newProxyTestDaemon(t, factory, nil)
1172-
1173- leader := openProxyForTest(d, context.Background(), key)
1174- require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
1175- _ = requireProxyHello(t, first)
1176-
1177- waitCtx, cancelWait := context.WithCancel(t.Context())
1178- defer cancelWait()
1179- observed := &proxyConstructionWaitContext{Context: waitCtx, observed: make(chan struct{})}
1180- waiter := openProxyForTest(d, observed, key)
1181-
1182- secondStarted := false
1183- select {
1184- case <-observed.observed:
1185- // The waiter reached the cancellable construction reservation.
1186- case <-factory.secondAttempt:
1187- // This is the adversarial pre-fix path: the later remote IntentAttach
1188- // would replace the leader before its otherwise-valid reply arrives.
1189- secondStarted = true
1190- close(factory.releaseSecond)
1191- _ = requireProxyHello(t, second)
1192- }
1193-
1194- first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
1195- leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
1196- waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish")
1197- require.NoError(t, leaderResult.err)
1198- require.NoError(t, waiterResult.err)
1199- require.False(t, secondStarted, "a second same-key IntentAttach handshake started")
1200- require.Equal(t, 1, factory.callCount())
1201- require.Same(t, leaderResult.proxy, waiterResult.proxy, "all callers must receive the exact registered winner")
1202- d.mu.Lock()
1203- require.Same(t, leaderResult.proxy, d.sessions[key.ID()])
1204- d.mu.Unlock()
1205- stopProxy(t, leaderResult.proxy)
1206-}
1207-
1208-func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
1209- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1210- first := newProxyTestTransport()
1211- second := newProxyTestTransport()
1212- factory := newProxyConstructionFactory(first, second)
1213- d := newProxyTestDaemon(t, factory, nil)
1214-
1215- leader := openProxyForTest(d, context.Background(), key)
1216- require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
1217- _ = requireProxyHello(t, first)
1218-
1219- base, cancel := context.WithCancel(t.Context())
1220- observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})}
1221- waiter := openProxyForTest(d, observed, key)
1222- select {
1223- case <-observed.observed:
1224- case <-factory.secondAttempt:
1225- close(factory.releaseSecond)
1226- t.Fatal("cancelable waiter started another same-key handshake")
1227- }
1228- cancel()
1229- waiterResult := awaitTestValue(t, waiter, "canceled waiter did not finish")
1230- require.ErrorIs(t, waiterResult.err, context.Canceled)
1231- require.Nil(t, waiterResult.proxy)
1232- require.Equal(t, 1, factory.callCount())
1233-
1234- first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
1235- leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
1236- require.NoError(t, leaderResult.err)
1237- stopProxy(t, leaderResult.proxy)
1238-}
1239-
1240-type warmHookClock struct {
1241- timers chan *signalTimer
1242-
1243- mu sync.Mutex
1244- hook func()
1245-}
1246-
1247-func (c *warmHookClock) Now() time.Time { return time.Time{} }
1248-
1249-func (c *warmHookClock) NewTimer(d time.Duration) ports.Timer {
1250- timer := &signalTimer{ch: make(chan time.Time, 1), duration: d}
1251- c.mu.Lock()
1252- hook := c.hook
1253- c.hook = nil
1254- c.mu.Unlock()
1255- if hook != nil {
1256- hook()
1257- }
1258- if c.timers != nil {
1259- c.timers <- timer
1260- }
1261- return timer
1262-}
1263-
1264-func (c *warmHookClock) setHook(hook func()) {
1265- c.mu.Lock()
1266- c.hook = hook
1267- c.mu.Unlock()
1268-}
1269-
1270-func registerLifecycleProxy(t *testing.T, d *Daemon, key domain.RemoteSessionKey) *proxySession {
1271- t.Helper()
1272- p, err := newProxySession(key, domain.Size{Cols: 80, Rows: 24})
1273- require.NoError(t, err)
1274- d.mu.Lock()
1275- registered := d.registerSessionLocked(p)
1276- d.mu.Unlock()
1277- require.True(t, registered, "lifecycle fixture proxy was not published")
1278- return p
1279-}
1280-
1281-func newProxyLifecycleFixture(t *testing.T) (*Daemon, *proxySession, *warmHookClock) {
1282- t.Helper()
1283- clock := &warmHookClock{timers: make(chan *signalTimer, 4)}
1284- d := newProxyTestDaemon(t, nil, clock)
1285- return d, registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "work"}), clock
1286-}
1287-
1288-func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) {
1289- p.mu.Lock()
1290- defer p.mu.Unlock()
1291- return p.warm, p.generation
1292-}
1293-
1294-func setProxyLifecycleClient(p *proxySession, ac *attachedClient) {
1295- p.sessionCore.mu.Lock()
1296- p.registerAttachmentLocked(ac)
1297- p.sessionCore.mu.Unlock()
1298-}
1299-
1300-func proxyRegistered(d *Daemon, p *proxySession) bool {
1301- d.mu.Lock()
1302- defer d.mu.Unlock()
1303- return d.sessions[p.id] == p
1304-}
1305-
1306-func proxyExpired(p *proxySession) bool {
1307- p.mu.Lock()
1308- defer p.mu.Unlock()
1309- return p.expired
1310-}
1311-
1312-func armWarmTimer(t *testing.T, d *Daemon, p *proxySession, clock *warmHookClock) (*proxyWarmTimer, *signalTimer) {
1313- t.Helper()
1314- require.True(t, d.armProxyWarm(p), "warm timer was not published")
1315- timer := awaitTestValue(t, clock.timers, "warm timer was never created")
1316- token, _ := proxyWarmToken(p)
1317- require.NotNil(t, token, "published arm left no warm token")
1318- return token, timer
1319-}
1320-
1321-// TestArmProxyWarmKeepsExpiryPathWhenPublicationFails pins the two-phase arm:
1322-// a re-arm that loses its revalidation must leave the incumbent timer both
1323-// installed and current, otherwise a registered proxy is left with no path out
1324-// of the live registry.
1325-func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
1326- tests := []struct {
1327- name string
1328- race func(*Daemon, *proxySession) func()
1329- }{
1330- {
1331- name: "fresh attachment publishes during arm",
1332- race: func(_ *Daemon, p *proxySession) func() {
1333- ac := &attachedClient{}
1334- p.sessionCore.mu.Lock()
1335- p.sessionCore.registerAttachmentLocked(ac)
1336- p.sessionCore.mu.Unlock()
1337- return func() {
1338- p.sessionCore.mu.Lock()
1339- p.sessionCore.unregisterAttachmentLocked(ac)
1340- p.sessionCore.mu.Unlock()
1341- }
1342- },
1343- },
1344- {
1345- name: "daemon shutdown publishes during arm",
1346- race: func(d *Daemon, _ *proxySession) func() {
1347- d.mu.Lock()
1348- d.closing = true
1349- d.mu.Unlock()
1350- return func() {
1351- d.mu.Lock()
1352- d.closing = false
1353- d.mu.Unlock()
1354- }
1355- },
1356- },
1357- }
1358- for _, tt := range tests {
1359- t.Run(tt.name, func(t *testing.T) {
1360- d, p, clock := newProxyLifecycleFixture(t)
1361- token, timer := armWarmTimer(t, d, p, clock)
1362-
1363- var undo func()
1364- clock.setHook(func() { undo = tt.race(d, p) })
1365- require.False(t, d.armProxyWarm(p), "raced re-arm must not publish a replacement")
1366- awaitTestValue(t, clock.timers, "raced re-arm never reached its clock")
1367-
1368- retained, generation := proxyWarmToken(p)
1369- require.NotNil(t, retained, "failed re-arm stripped the proxy of its only expiry path")
1370- require.Same(t, token, retained, "failed re-arm replaced the armed expiry timer")
1371- require.Equal(t, generation, retained.generation, "retained timer no longer owns the lifecycle generation")
1372- require.True(t, proxyRegistered(d, p), "a failed re-arm must not unregister the proxy")
1373-
1374- require.NotNil(t, undo, "publication race did not run")
1375- undo()
1376- timer.ch <- time.Time{}
1377- awaitTestCompletion(t, token.done, "retained warm timer never ran to completion")
1378- require.False(t, proxyRegistered(d, p), "retained warm timer did not expire the dormant proxy")
1379- require.True(t, proxyExpired(p), "expiry did not publish terminal lifecycle state")
1380- awaitTestCompletion(t, d.done, "last proxy expiry did not complete the daemon")
1381- })
1382- }
1383-}
1384-
1385-func TestDetachProxyIfCurrentTransportArmsExpiredProxy(t *testing.T) {
1386- d, p, clock := newProxyLifecycleFixture(t)
1387- transport := newProxyTestTransport()
1388- t.Cleanup(func() { _ = transport.Close() })
1389- ac := &attachedClient{tr: transport, output: newOutputStateStream()}
1390- ac.setSession(p)
1391- setProxyLifecycleClient(p, ac)
1392- p.mu.Lock()
1393- p.expired = true
1394- p.mu.Unlock()
1395-
1396- require.True(t, d.detachProxyIfCurrentTransport(p, ac, ac.transportSnapshot()))
1397- awaitTestValue(t, clock.timers, "expired proxy detach did not arm a warm timer")
1398- token, generation := proxyWarmToken(p)
1399- require.NotNil(t, token, "expired headless proxy has no expiry path")
1400- require.Equal(t, generation, token.generation)
1401- require.Nil(t, ac.currentAttachmentSession())
1402-
1403- token.stop()
1404- awaitTestCompletion(t, token.done, "warm timer did not stop during cleanup")
1405-}
1406-
1407-func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
1408- t.Run("rekeys an already armed timer", func(t *testing.T) {
1409- d, p, clock := newProxyLifecycleFixture(t)
1410- transport := newProxyTestTransport()
1411- t.Cleanup(func() { _ = transport.Close() })
1412- generation, _ := p.installTransport(transport)
1413- token, timer := armWarmTimer(t, d, p, clock)
1414-
1415- require.True(t, d.markProxyReplaced(p, generation, transport))
1416- require.Empty(t, clock.timers, "replacement armed a second timer instead of rekeying")
1417-
1418- rekeyed, lifecycle := proxyWarmToken(p)
1419- require.Same(t, token, rekeyed, "replacement replaced the armed timer")
1420- require.Equal(t, lifecycle, rekeyed.generation, "replacement did not rekey the armed timer")
1421- require.True(t, proxyExpired(p))
1422- p.mu.Lock()
1423- state := p.linkState
1424- p.mu.Unlock()
1425- require.Equal(t, ports.LinkStateDead, state)
1426-
1427- timer.ch <- time.Time{}
1428- awaitTestCompletion(t, token.done, "rekeyed warm timer never ran to completion")
1429- require.False(t, proxyRegistered(d, p), "rekeyed warm timer did not expire the proxy")
1430- })
1431-
1432- t.Run("arms an expiry for a headless proxy", func(t *testing.T) {
1433- d, p, clock := newProxyLifecycleFixture(t)
1434- transport := newProxyTestTransport()
1435- t.Cleanup(func() { _ = transport.Close() })
1436- generation, _ := p.installTransport(transport)
1437-
1438- require.True(t, d.markProxyReplaced(p, generation, transport))
1439- timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")
1440- token, lifecycle := proxyWarmToken(p)
1441- require.NotNil(t, token, "replaced headless proxy has no expiry path")
1442- require.Equal(t, lifecycle, token.generation)
1443-
1444- timer.ch <- time.Time{}
1445- awaitTestCompletion(t, token.done, "warm timer never ran to completion")
1446- require.False(t, proxyRegistered(d, p))
1447- })
1448-
1449- t.Run("stale link generation publishes nothing", func(t *testing.T) {
1450- d, p, _ := newProxyLifecycleFixture(t)
1451- transport := newProxyTestTransport()
1452- t.Cleanup(func() { _ = transport.Close() })
1453- generation, _ := p.installTransport(transport)
1454-
1455- require.False(t, d.markProxyReplaced(p, generation-1, transport))
1456- require.False(t, proxyExpired(p))
1457- warm, _ := proxyWarmToken(p)
1458- require.Nil(t, warm, "a rejected replacement must not arm an expiry")
1459- })
1460-}
1461-
1462-func TestExpireWarmProxyCompletesDaemonOnlyOnLastSession(t *testing.T) {
1463- t.Run("last session completes the daemon", func(t *testing.T) {
1464- d, p, clock := newProxyLifecycleFixture(t)
1465- token, timer := armWarmTimer(t, d, p, clock)
1466-
1467- timer.ch <- time.Time{}
1468- awaitTestCompletion(t, token.done, "warm timer never ran to completion")
1469- awaitTestCompletion(t, p.done, "expiry did not finish the proxy")
1470- awaitTestCompletion(t, d.done, "last session expiry did not complete the daemon")
1471- require.False(t, proxyRegistered(d, p))
1472- require.True(t, proxyExpired(p))
1473- })
1474-
1475- t.Run("surviving session keeps the daemon running", func(t *testing.T) {
1476- d, p, clock := newProxyLifecycleFixture(t)
1477- survivor := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "other"})
1478- token, timer := armWarmTimer(t, d, p, clock)
1479-
1480- timer.ch <- time.Time{}
1481- awaitTestCompletion(t, token.done, "warm timer never ran to completion")
1482- require.False(t, proxyRegistered(d, p))
1483- require.True(t, proxyRegistered(d, survivor))
1484- select {
1485- case <-d.done:
1486- t.Fatal("daemon completed while a session was still registered")
1487- default:
1488- }
1489- })
1490-}
1491-
1492-// TestExpireWarmProxyIgnoresSupersededToken proves the expiry callback is exact:
1493-// a token that lost its lifecycle generation must publish no terminal state and
1494-// must not unregister the proxy.
1495-func TestExpireWarmProxyIgnoresSupersededToken(t *testing.T) {
1496- d, p, clock := newProxyLifecycleFixture(t)
1497- stale, _ := armWarmTimer(t, d, p, clock)
1498- current, currentTimer := armWarmTimer(t, d, p, clock)
1499- require.NotSame(t, stale, current, "re-arm did not publish a replacement timer")
1500- awaitTestCompletion(t, stale.done, "superseded warm timer was not canceled")
1501-
1502- require.False(t, d.expireWarmProxy(p, stale), "a superseded token expired the proxy")
1503- require.True(t, proxyRegistered(d, p))
1504- require.False(t, proxyExpired(p))
1505- installed, generation := proxyWarmToken(p)
1506- require.Same(t, current, installed)
1507- require.Equal(t, generation, installed.generation)
1508-
1509- currentTimer.ch <- time.Time{}
1510- awaitTestCompletion(t, current.done, "current warm timer never ran to completion")
1511- require.False(t, proxyRegistered(d, p))
1512-}
1513-
1514-func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedClient, *closeTrackingTransport, *warmHookClock, attachmentConnectionToken, *renderCoordinator) {
1515- t.Helper()
1516- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1517- clock := &warmHookClock{timers: make(chan *signalTimer, 4)}
1518- d.clock = clock
1519- clientTransport := &closeTrackingTransport{}
1520- ac.replaceTransport(clientTransport)
1521- localCoordinator := d.attachCoordinator(local, ac, true)
1522- localToken := attachmentToken(local, ac, clientTransport)
1523- localToken.lease = localCoordinator.attachmentLease(ac)
1524- proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"})
1525- transition, err := d.transitionAttachment(attachmentTransitionRequest{
1526- source: local, target: proxy, next: ac,
1527-
1528- expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
1529- action: "proxy detach fixture", ready: true,
1530- })
1531- require.NoError(t, err)
1532- return d, proxy, ac, clientTransport, clock, transition.published, proxy.coordinator.Load()
1533-}
1534-
1535-func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {
1536- tests := []struct {
1537- name string
1538- detach func(*Daemon, attachmentConnectionToken, ports.Transport) bool
1539- }{
1540- {
1541- name: "explicit",
1542- detach: func(d *Daemon, token attachmentConnectionToken, _ ports.Transport) bool {
1543- effect, admitted := token.ac.beginAttachmentEffect(token)
1544- require.True(t, admitted)
1545- token.effect = effect
1546- return d.clientGoneForAttachment(token, true)
1547- },
1548- },
1549- {
1550- name: "send error",
1551- detach: func(d *Daemon, token attachmentConnectionToken, failed ports.Transport) bool {
1552- d.detachOnAttachmentSendError(token, failed)
1553- return token.ac.currentAttachmentSession() == nil
1554- },
1555- },
1556- }
1557- for _, tt := range tests {
1558- t.Run(tt.name, func(t *testing.T) {
1559- d, proxy, ac, tr, clock, token, coordinator := newProxyRoleDetachFixture(t)
1560- require.True(t, tt.detach(d, token, tr))
1561- require.Nil(t, ac.currentAttachmentSession())
1562- require.True(t, tr.Closed())
1563- require.Nil(t, coordinator.attachmentLease(ac), "coordinator must retire the detached client")
1564- var warmTimers []*signalTimer
1565- for len(warmTimers) == 0 {
1566- timer := awaitTestValue(t, clock.timers, "proxy detach did not arm a warm timer")
1567- if timer.duration == proxyWarmDuration {
1568- warmTimers = append(warmTimers, timer)
1569- }
1570- }
1571- select {
1572- case timer := <-clock.timers:
1573- if timer.duration == proxyWarmDuration {
1574- warmTimers = append(warmTimers, timer)
1575- }
1576- default:
1577- }
1578- require.Len(t, warmTimers, 1, "proxy detach must arm exactly one warm timer")
1579- warm, _ := proxyWarmToken(proxy)
1580- require.NotNil(t, warm)
1581- warm.stop()
1582- awaitTestCompletion(t, warm.done, "warm timer did not stop")
1583- })
1584- }
1585-}
1586-
1587-func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
1588- tests := []struct {
1589- name string
1590- stale func(*attachedClient, *attachmentConnectionToken) *closeTrackingTransport
1591- }{
1592- {
1593- name: "transport",
1594- stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport {
1595- current := &closeTrackingTransport{}
1596- ac.replaceTransport(current)
1597- return current
1598- },
1599- },
1600- {
1601- name: "role generation",
1602- stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport {
1603- ac.connectionGeneration.Add(1)
1604- return nil
1605- },
1606- },
1607- }
1608- for _, tt := range tests {
1609- t.Run(tt.name, func(t *testing.T) {
1610- d, proxy, ac, failed, clock, token, _ := newProxyRoleDetachFixture(t)
1611- current := tt.stale(ac, &token)
1612- d.detachOnAttachmentSendError(token, failed)
1613- require.Same(t, proxy, ac.currentAttachmentSession())
1614- if current != nil {
1615- require.False(t, current.Closed(), "stale transport error closed the rebound client")
1616- }
1617- select {
1618- case <-clock.timers:
1619- t.Fatal("stale proxy detach armed a warm timer")
1620- default:
1621- }
1622- })
1623- }
1624-}
D internal/usecase/daemon/proxy_link.go +0 -674
@@ -1,674 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "io"
8- "time"
9-
10- "github.com/bnema/vev/internal/ports"
11-)
12-
13-const (
14- proxyHandshakeTimeout = 15 * time.Second
15- proxyAttachedCommandTimeout = 10 * time.Second
16- proxyResumeInitialBackoff = 250 * time.Millisecond
17- proxyResumeMaxBackoff = 4 * time.Second
18- proxyResumeMaxAttempts = 5
19- proxyResumeStableDuration = 30 * time.Second
20-)
21-
22-var (
23- errProxyHandshakeTimeout = errors.New("proxy session: remote handshake timed out")
24- errProxyCommandTimeout = errors.New("proxy session: attached command timed out")
25- errProxyCommandUnavailable = errors.New("proxy session: attached command link is unavailable")
26- errProxyCommandGeneration = errors.New("proxy session: attached command link was replaced")
27- // errProxyLinkSend marks a reply that could not be written to the remote.
28- // The received frame was well formed, so the link is resumable rather than
29- // terminally broken.
30- errProxyLinkSend = errors.New("proxy session: link reply send failed")
31-)
32-
33-type proxyCommandOutcome struct {
34- result ports.CommandResult
35- err error
36-}
37-
38-type proxyCommandPending struct {
39- generation uint64
40- outcome chan proxyCommandOutcome
41-}
42-
43-type proxyRecvResult struct {
44- frame ports.Frame
45- err error
46-}
47-
48-type proxyLinkResult uint8
49-
50-const (
51- proxyLinkStop proxyLinkResult = iota
52- proxyLinkResume
53- proxyLinkReplaced
54-)
55-
56-// dialProxyHandshake installs a candidate transport, sends an ordinary Hello
57-// through the session's sole sender, and requires a matching Welcome. The
58-// candidate is never registry-visible.
59-func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, intent uint8) (err error) {
60- if parent == nil {
61- //nolint:contextcheck // nil is intentionally supported for direct internal callers; Background is the root fallback.
62- parent = context.Background()
63- }
64- ctx, timedOut, stop := d.proxyHandshakeContext(parent)
65- defer stop()
66- defer func() {
67- if err != nil {
68- err = proxyHandshakeContextError(parent, timedOut, err)
69- }
70- }()
71- if err := ctx.Err(); err != nil {
72- return err
73- }
74- dialer, err := d.remoteDialerFactory.DialerForRemote(p.key.Host, p.key.Name, d.remoteTransportMode, d.log)
75- if err != nil {
76- return fmt.Errorf("proxy session: select remote dialer: %w", err)
77- }
78- if dialer == nil {
79- return errors.New("proxy session: remote dialer factory returned nil dialer")
80- }
81- if err := ctx.Err(); err != nil {
82- return err
83- }
84- transport, err := dialer.Dial(ctx)
85- if err != nil {
86- return fmt.Errorf("proxy session: dial: %w", err)
87- }
88- if transport == nil {
89- return errors.New("proxy session: remote dialer returned nil transport")
90- }
91- if err := ctx.Err(); err != nil {
92- _ = transport.Close()
93- return err
94- }
95-
96- p.mu.Lock()
97- resumeToken := p.resumeToken
98- clientID := p.clientID
99- size := p.contentSize
100- p.mu.Unlock()
101- resume := intent == ports.IntentResume
102- if !resume {
103- resumeToken = 0
104- }
105- generation, previous := p.installTransport(transport)
106- if previous != nil && previous != transport {
107- _ = previous.Close()
108- }
109- failed := true
110- defer func() {
111- if failed {
112- p.retireTransport(generation, transport)
113- _ = transport.Close()
114- }
115- }()
116- stopTransport := watchProxyHandshakeTransport(ctx, transport)
117- defer stopTransport()
118- hello := ports.Hello{
119- Version: ports.ProtocolVersion,
120- Intent: intent,
121- ClientID: clientID,
122- ResumeToken: resumeToken,
123- Name: p.key.Name,
124- Size: size,
125- MaxOutputInFlight: proxyOutputWindow(transport),
126- }
127- if err := p.sendGeneration(generation, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
128- return fmt.Errorf("proxy session: send hello: %w", err)
129- }
130-
131- welcome, err := recvProxyHandshake(ctx, transport, p.key.Name)
132- if err != nil {
133- return err
134- }
135- if err := ctx.Err(); err != nil {
136- return err
137- }
138-
139- p.mu.Lock()
140- if p.linkGeneration != generation || p.transport != transport {
141- p.mu.Unlock()
142- return errors.New("proxy session: link replaced during handshake")
143- }
144- p.resumeToken = welcome.ResumeToken
145- p.linkState = ports.LinkStateConnected
146- p.expired = false
147- p.mu.Unlock()
148- failed = false
149- return nil
150-}
151-
152-func proxyOutputWindow(transport ports.Transport) uint8 {
153- if _, ok := transport.(ports.DatagramTransport); ok {
154- return 1
155- }
156- return maxUnackedOutputStates
157-}
158-
159-func (d *Daemon) proxyHandshakeContext(parent context.Context) (context.Context, <-chan struct{}, func()) {
160- ctx, cancel := context.WithCancel(parent)
161- timedOut := make(chan struct{})
162- clock := ports.Clock(systemClock{})
163- if d != nil && d.clock != nil {
164- clock = d.clock
165- }
166- timer := clock.NewTimer(proxyHandshakeTimeout)
167- if timer == nil {
168- timer = systemClock{}.NewTimer(proxyHandshakeTimeout)
169- }
170- stop := make(chan struct{})
171- done := make(chan struct{})
172- go func() {
173- defer close(done)
174- select {
175- case <-timer.C():
176- close(timedOut)
177- cancel()
178- case <-stop:
179- timer.Stop()
180- }
181- }()
182- return ctx, timedOut, func() {
183- close(stop)
184- <-done
185- cancel()
186- }
187-}
188-
189-func watchProxyHandshakeTransport(ctx context.Context, transport ports.Transport) func() {
190- stop := make(chan struct{})
191- done := make(chan struct{})
192- go func() {
193- defer close(done)
194- select {
195- case <-ctx.Done():
196- _ = transport.Close()
197- case <-stop:
198- }
199- }()
200- return func() {
201- close(stop)
202- <-done
203- }
204-}
205-
206-func proxyHandshakeContextError(parent context.Context, timedOut <-chan struct{}, fallback error) error {
207- select {
208- case <-timedOut:
209- return errProxyHandshakeTimeout
210- default:
211- }
212- if err := parent.Err(); err != nil {
213- return err
214- }
215- return fallback
216-}
217-
218-func recvProxyHandshake(ctx context.Context, transport ports.Transport, sessionName string) (ports.Welcome, error) {
219- frame, err := recvProxyHandshakeFrame(ctx, transport)
220- if err != nil {
221- return ports.Welcome{}, fmt.Errorf("proxy session: receive welcome: %w", err)
222- }
223- welcome, err := validateProxyWelcome(frame, sessionName)
224- if err != nil {
225- return ports.Welcome{}, err
226- }
227- return welcome, nil
228-}
229-
230-func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) {
231- if frame.Type == ports.MsgError {
232- remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
233- if err != nil {
234- return ports.Welcome{}, fmt.Errorf("proxy session: malformed remote error: %w", err)
235- }
236- return ports.Welcome{}, fmt.Errorf("proxy session: remote rejected handshake (%d): %s", remoteErr.Code, remoteErr.Text)
237- }
238- if frame.Type != ports.MsgWelcome {
239- return ports.Welcome{}, fmt.Errorf("proxy session: first server frame is %d, want Welcome", frame.Type)
240- }
241- welcome, err := ports.UnmarshalWelcome(frame.Payload)
242- if err != nil {
243- return ports.Welcome{}, fmt.Errorf("proxy session: decode welcome: %w", err)
244- }
245- if welcome.SessionID == "" || welcome.SessionName != sessionName {
246- return ports.Welcome{}, fmt.Errorf("proxy session: welcome identity mismatch: got %q", welcome.SessionName)
247- }
248- return welcome, nil
249-}
250-
251-func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
252- result := make(chan proxyRecvResult, 1)
253- go func() {
254- frame, err := transport.Recv()
255- result <- proxyRecvResult{frame: frame, err: err}
256- }()
257- select {
258- case received := <-result:
259- return received.frame, received.err
260- case <-ctx.Done():
261- _ = transport.Close()
262- received := <-result // Transport.Close is required to unblock Recv.
263- if received.err != nil && !errors.Is(received.err, io.EOF) {
264- return ports.Frame{}, errors.Join(ctx.Err(), received.err)
265- }
266- return ports.Frame{}, ctx.Err()
267- }
268-}
269-
270-func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) {
271- // Link replacement participates in the same sender serialization as every
272- // frame. A request can therefore only be published wholly before the
273- // generation changes or wholly after it.
274- p.sendMu.Lock()
275- defer p.sendMu.Unlock()
276- p.mu.Lock()
277- previous := p.transport
278- p.linkGeneration++
279- generation := p.linkGeneration
280- // Each transport must establish its ordinary output dependency with a
281- // dependency-free full frame before incremental bytes are forwarded.
282- p.resetOutputStateLocked()
283- p.transport = transport
284- // Authentication is complete after the remote Welcome; ordinary output
285- // establishes the dependency chain asynchronously.
286- p.linkState = ports.LinkStateProbing
287- p.mu.Unlock()
288- p.failPendingCommandsLocked(errProxyCommandGeneration)
289- return generation, previous
290-}
291-
292-func (p *proxySession) retireTransport(generation uint64, transport ports.Transport) {
293- p.sendMu.Lock()
294- defer p.sendMu.Unlock()
295- p.mu.Lock()
296- current := p.linkGeneration == generation && p.transport == transport
297- if current {
298- p.transport = nil
299- }
300- p.mu.Unlock()
301- if current {
302- p.failPendingCommandsLocked(errProxyCommandUnavailable)
303- }
304-}
305-
306-func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error {
307- p.sendMu.Lock()
308- defer p.sendMu.Unlock()
309- p.mu.Lock()
310- if p.linkGeneration != generation || p.transport == nil {
311- p.mu.Unlock()
312- return errors.New("proxy session: stale or unavailable link")
313- }
314- transport := p.transport
315- p.mu.Unlock()
316- return transport.Send(frame)
317-}
318-
319-// sendCommand sends one correlated attached palette command and waits for its
320-// exact result. commandMu serializes interactive requests while sendMu remains
321-// available to all other link traffic during the wait.
322-func (p *proxySession) sendCommand(ctx context.Context, clock ports.Clock, slug string, args []string) (ports.CommandResult, error) {
323- if p == nil || clock == nil {
324- return ports.CommandResult{}, errProxyCommandUnavailable
325- }
326- if ctx == nil {
327- ctx = context.Background()
328- }
329- if err := ctx.Err(); err != nil {
330- return ports.CommandResult{}, err
331- }
332- p.commandMu.Lock()
333- defer p.commandMu.Unlock()
334- if err := ctx.Err(); err != nil {
335- return ports.CommandResult{}, err
336- }
337-
338- p.sendMu.Lock()
339- p.mu.Lock()
340- generation, transport := p.linkGeneration, p.transport
341- p.mu.Unlock()
342- if transport == nil {
343- p.sendMu.Unlock()
344- return ports.CommandResult{}, errProxyCommandUnavailable
345- }
346- p.commandNext++
347- if p.commandNext == 0 {
348- p.commandNext++
349- }
350- requestID := p.commandNext
351- payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
352- Version: ports.ProtocolVersion, RequestID: requestID, Attached: true,
353- Slug: slug, Args: append([]string(nil), args...),
354- })
355- if err != nil {
356- p.sendMu.Unlock()
357- return ports.CommandResult{}, err
358- }
359- outcome := make(chan proxyCommandOutcome, 1)
360- p.commandPending[requestID] = proxyCommandPending{generation: generation, outcome: outcome}
361- if err := transport.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
362- delete(p.commandPending, requestID)
363- p.sendMu.Unlock()
364- return ports.CommandResult{}, err
365- }
366- p.sendMu.Unlock()
367-
368- timer := clock.NewTimer(proxyAttachedCommandTimeout)
369- if timer == nil {
370- p.removePendingCommand(requestID, generation)
371- return ports.CommandResult{}, errProxyCommandTimeout
372- }
373- defer timer.Stop()
374- select {
375- case completed := <-outcome:
376- return completed.result, completed.err
377- case <-ctx.Done():
378- p.removePendingCommand(requestID, generation)
379- return ports.CommandResult{}, ctx.Err()
380- case <-timer.C():
381- p.removePendingCommand(requestID, generation)
382- return ports.CommandResult{}, errProxyCommandTimeout
383- }
384-}
385-
386-func (p *proxySession) removePendingCommand(requestID, generation uint64) {
387- p.sendMu.Lock()
388- pending, ok := p.commandPending[requestID]
389- if ok && pending.generation == generation {
390- delete(p.commandPending, requestID)
391- }
392- p.sendMu.Unlock()
393-}
394-
395-// completeCommandResult ignores malformed correlation state, unknown/late IDs,
396-// and results received by a replacement generation.
397-func (p *proxySession) completeCommandResult(generation uint64, result ports.CommandResult) {
398- if result.RequestID == 0 {
399- return
400- }
401- p.sendMu.Lock()
402- pending, ok := p.commandPending[result.RequestID]
403- if !ok || pending.generation != generation {
404- p.sendMu.Unlock()
405- return
406- }
407- delete(p.commandPending, result.RequestID)
408- p.sendMu.Unlock()
409- pending.outcome <- proxyCommandOutcome{result: result}
410-}
411-
412-// failPendingCommandsLocked requires sendMu and never blocks.
413-func (p *proxySession) failPendingCommandsLocked(err error) {
414- for id, pending := range p.commandPending {
415- delete(p.commandPending, id)
416- pending.outcome <- proxyCommandOutcome{err: err}
417- }
418-}
419-
420-func (d *Daemon) runProxyLink(ctx context.Context, p *proxySession) {
421- defer p.finish()
422- connectedAt := d.clock.Now()
423- resumeAttempts := 0
424- for {
425- p.mu.Lock()
426- generation := p.linkGeneration
427- transport := p.transport
428- p.mu.Unlock()
429- if transport == nil {
430- return
431- }
432-
433- result := d.runProxyTransport(ctx, p, generation, transport)
434- if result == proxyLinkReplaced {
435- // ReasonReplaced is terminal for the exact transport incarnation. Publish
436- // expiry before retirement clears the transport identity used by the
437- // callback fence; the switch below deliberately never redials it.
438- d.markProxyReplaced(p, generation, transport)
439- }
440- p.retireTransport(generation, transport)
441- switch result {
442- case proxyLinkReplaced, proxyLinkStop:
443- return
444- case proxyLinkResume:
445- if ctx.Err() != nil {
446- return
447- }
448- if d.clock.Now().Sub(connectedAt) >= proxyResumeStableDuration {
449- resumeAttempts = 0
450- }
451- d.updateProxyDisconnectedState(p, generation, ports.LinkStateOffline)
452-
453- for {
454- if resumeAttempts >= proxyResumeMaxAttempts {
455- d.log.Warn("proxy resume attempts exhausted", "host", p.key.Host, "session", p.key.Name, "attempts", resumeAttempts)
456- return
457- }
458- delay := proxyResumeBackoff(resumeAttempts)
459- if !waitProxyResume(ctx, d.clock, delay) {
460- return
461- }
462- resumeAttempts++
463- if err := d.dialProxyHandshake(ctx, p, ports.IntentResume); err != nil {
464- p.mu.Lock()
465- failedGeneration := p.linkGeneration
466- p.mu.Unlock()
467- d.updateProxyDisconnectedState(p, failedGeneration, ports.LinkStateOffline)
468- if ctx.Err() != nil {
469- return
470- }
471- d.log.Warn("proxy resume failed", "host", p.key.Host, "session", p.key.Name, "attempt", resumeAttempts, "err", err)
472- continue
473- }
474- connectedAt = d.clock.Now()
475- d.repaintProxyLifecycle(p)
476- break
477- }
478- }
479- }
480-}
481-
482-func proxyResumeBackoff(attempt int) time.Duration {
483- delay := proxyResumeInitialBackoff
484- for range attempt {
485- if delay >= proxyResumeMaxBackoff/2 {
486- return proxyResumeMaxBackoff
487- }
488- delay *= 2
489- }
490- return min(delay, proxyResumeMaxBackoff)
491-}
492-
493-func waitProxyResume(ctx context.Context, clock ports.Clock, delay time.Duration) bool {
494- if err := ctx.Err(); err != nil {
495- return false
496- }
497- timer := clock.NewTimer(delay)
498- if timer == nil {
499- return false
500- }
501- defer timer.Stop()
502- select {
503- case <-ctx.Done():
504- return false
505- case <-timer.C():
506- return true
507- }
508-}
509-
510-func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generation uint64, transport ports.Transport) proxyLinkResult {
511- recvCtx, cancelRecv := context.WithCancel(ctx)
512- recv := make(chan proxyRecvResult, 1)
513- recvDone := make(chan struct{})
514- go func() {
515- defer close(recvDone)
516- for {
517- frame, err := transport.Recv()
518- select {
519- case recv <- proxyRecvResult{frame: frame, err: err}:
520- case <-recvCtx.Done():
521- return
522- }
523- if err != nil {
524- return
525- }
526- }
527- }()
528- defer func() {
529- cancelRecv()
530- _ = transport.Close()
531- <-recvDone
532- }()
533-
534- var events <-chan ports.LinkEvent
535- if reporter, ok := transport.(ports.LinkStateReporter); ok {
536- events = reporter.LinkEvents()
537- }
538- for {
539- select {
540- case <-ctx.Done():
541- return proxyLinkStop
542- case event, ok := <-events:
543- if !ok {
544- events = nil
545- continue
546- }
547- if !d.updateProxyLinkState(p, generation, transport, event.State) {
548- return proxyLinkStop
549- }
550- if event.State == ports.LinkStateOffline || event.State == ports.LinkStateDead {
551- _ = transport.Close()
552- return proxyLinkResume
553- }
554- case received := <-recv:
555- if received.err != nil {
556- if ctx.Err() != nil {
557- return proxyLinkStop
558- }
559- return proxyLinkResume
560- }
561- result, err := d.handleLinkFrame(p, generation, received.frame)
562- if err != nil {
563- // A failed reply says nothing about the remote's framing, so it
564- // retires this transport instead of the whole proxy link.
565- if errors.Is(err, errProxyLinkSend) {
566- d.log.Warn("proxy link reply failed", "host", p.key.Host, "session", p.key.Name, "err", err)
567- return proxyLinkResume
568- }
569- d.log.Warn("invalid proxy link frame", "host", p.key.Host, "session", p.key.Name, "err", err)
570- return proxyLinkStop
571- }
572- if result != proxyLinkResume {
573- return result
574- }
575- }
576- }
577-}
578-
579-// handleProxySideEffect forwards a stateless remote effect to every live local
580-// attachment. Connection admission and transport incarnation checks make a stale
581-// handoff a harmless drop rather than a send to the client's next session.
582-func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error {
583- if p == nil || !p.currentLinkGeneration(generation) {
584- return nil
585- }
586- attachments := snapshotAttachmentSession(p)
587- var sendErr error
588- for _, ac := range attachments {
589- if ac == nil || ac.output == nil {
590- continue
591- }
592- expected := ac.transportSnapshot()
593- if expected.transport == nil {
594- continue
595- }
596- token := attachmentToken(p, ac, expected.transport)
597- if token.sess != p {
598- continue
599- }
600- ticket, admitted := ac.beginAttachmentEffect(token)
601- if !admitted {
602- continue
603- }
604- if !p.currentLinkGeneration(generation) {
605- ticket.End()
606- return nil
607- }
608- err := ac.sendExpectedSideEffectForAttachment(expected, out.Data, ac.echoAck.Load(), ticket)
609- ticket.End()
610- if err == nil || errors.Is(err, errAttachmentTransition) {
611- continue
612- }
613- // Try every live attachment before retiring the remote link for a failed
614- // client send; one blocked or dead peer must not starve the others.
615- if sendErr == nil {
616- sendErr = err
617- }
618- }
619- if sendErr != nil {
620- return fmt.Errorf("proxy session: side-effect send: %w: %w", errProxyLinkSend, sendErr)
621- }
622- return nil
623-}
624-
625-// handleLinkFrame returns proxyLinkResume to mean "continue this transport";
626-// the other values are terminal actions.
627-func (d *Daemon) handleLinkFrame(p *proxySession, generation uint64, frame ports.Frame) (proxyLinkResult, error) {
628- switch frame.Type {
629- case ports.MsgOutput:
630- out, err := ports.UnmarshalOutput(frame.Payload)
631- if err != nil {
632- return proxyLinkStop, err
633- }
634- if err := d.handleProxyOutput(p, generation, out); err != nil {
635- return proxyLinkStop, err
636- }
637- return proxyLinkResume, nil
638- case ports.MsgCommandResult:
639- result, err := ports.UnmarshalCommandResult(frame.Payload)
640- if err != nil {
641- return proxyLinkStop, err
642- }
643- p.completeCommandResult(generation, result)
644- return proxyLinkResume, nil
645- case ports.MsgPong:
646- _, err := ports.UnmarshalPong(frame.Payload)
647- return proxyLinkResume, err
648- case ports.MsgPing:
649- if _, err := ports.UnmarshalPing(frame.Payload); err != nil {
650- return proxyLinkStop, err
651- }
652- if err := p.sendGeneration(generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}); err != nil {
653- return proxyLinkStop, fmt.Errorf("proxy pong reply: %w: %w", errProxyLinkSend, err)
654- }
655- return proxyLinkResume, nil
656- case ports.MsgDetached:
657- detached, err := ports.UnmarshalDetached(frame.Payload)
658- if err != nil {
659- return proxyLinkStop, err
660- }
661- if detached.Reason == ports.ReasonReplaced {
662- return proxyLinkReplaced, nil
663- }
664- return proxyLinkStop, nil
665- case ports.MsgError:
666- remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
667- if err != nil {
668- return proxyLinkStop, err
669- }
670- return proxyLinkStop, fmt.Errorf("remote proxy error (%d): %s", remoteErr.Code, remoteErr.Text)
671- default:
672- return proxyLinkStop, fmt.Errorf("unexpected proxy server frame type %d", frame.Type)
673- }
674-}
D internal/usecase/daemon/proxy_output.go +0 -109
@@ -1,109 +0,0 @@
1-package daemon
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/vev/internal/ports"
7-)
8-
9-// applyOutputForGeneration validates the remote ordinary output dependency
10-// chain without retaining a second terminal or renderer state. Bytes are
11-// forwarded only after a full frame establishes the current remote stream.
12-func (p *proxySession) applyOutputForGeneration(generation uint64, out ports.Output) (ack uint64, requestReset, changed bool) {
13- p.mu.Lock()
14- defer p.mu.Unlock()
15- if p.linkGeneration != generation {
16- return 0, false, false
17- }
18- invalid := func() (uint64, bool, bool) {
19- if p.outputResetRequested {
20- return 0, false, false
21- }
22- p.outputResetRequested = true
23- return 0, true, false
24- }
25- if out.Epoch == 0 {
26- return invalid()
27- }
28- if out.New == 0 {
29- // New == 0 is an ordinary byte side effect, not a state transition.
30- // Match the thin client: it is accepted before the first state-bearing
31- // frame, and thereafter only within the current epoch.
32- if out.Base != 0 || out.Full || (p.outputReady && out.Epoch != p.outputEpoch) {
33- return invalid()
34- }
35- return 0, false, len(out.Data) != 0
36- }
37- if !p.outputReady {
38- if out.Base != 0 || !out.Full {
39- return invalid()
40- }
41- p.outputEpoch = out.Epoch
42- p.outputState = out.New
43- p.outputReady = true
44- p.outputResetRequested = false
45- return out.New, false, len(out.Data) != 0
46- }
47- if out.Epoch < p.outputEpoch {
48- return invalid()
49- }
50- if out.Epoch == p.outputEpoch {
51- if p.outputResetRequested || out.Full || out.Base != p.outputState || out.New != out.Base+1 {
52- return invalid()
53- }
54- p.outputState = out.New
55- return out.New, false, len(out.Data) != 0
56- }
57- if out.Base != 0 || !out.Full {
58- return invalid()
59- }
60- p.outputEpoch = out.Epoch
61- p.outputState = out.New
62- p.outputResetRequested = false
63- return out.New, false, len(out.Data) != 0
64-}
65-
66-// handleProxyOutput forwards ordinary remote terminal bytes to the local
67-// attachment and acknowledges a state-bearing frame only after forwarding.
68-func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports.Output) error {
69- ack, requestReset, changed := p.applyOutputForGeneration(generation, out)
70- if requestReset {
71- if err := p.sendGeneration(generation, ports.Frame{
72- Type: ports.MsgOutputResetRequest,
73- Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
74- }); err != nil {
75- return fmt.Errorf("proxy output reset request: %w: %w", errProxyLinkSend, err)
76- }
77- }
78- if changed {
79- if err := d.handleProxySideEffect(p, generation, out); err != nil {
80- return err
81- }
82- }
83- if ack != 0 {
84- payload, err := ports.MarshalAck(ports.Ack{Epoch: out.Epoch, State: ack})
85- if err != nil {
86- return fmt.Errorf("proxy output ACK: %w", err)
87- }
88- if err := p.sendGeneration(generation, ports.Frame{
89- Type: ports.MsgAck,
90- Payload: payload,
91- }); err != nil {
92- return fmt.Errorf("proxy output ACK: %w: %w", errProxyLinkSend, err)
93- }
94- }
95- return nil
96-}
97-
98-func (p *proxySession) resetOutputStateLocked() {
99- p.outputEpoch = 0
100- p.outputState = 0
101- p.outputReady = false
102- p.outputResetRequested = false
103-}
104-
105-func (p *proxySession) currentLinkGeneration(generation uint64) bool {
106- p.mu.Lock()
107- defer p.mu.Unlock()
108- return p.linkGeneration == generation
109-}
D internal/usecase/daemon/proxy_output_test.go +0 -166
@@ -1,166 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
8- "github.com/stretchr/testify/require"
9-)
10-
11-func newProxyOutputAttachment(t *testing.T, proxy *proxySession, id byte) (*attachedClient, *proxyTestTransport) {
12- t.Helper()
13- transport := newProxyTestTransport()
14- ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: proxy.contentSize}
15- ac.output.attachment = ac
16- ac.clientID[0] = id
17- ac.setSession(proxy)
18- proxy.sessionCore.mu.Lock()
19- require.True(t, proxy.registerAttachmentLocked(ac))
20- proxy.sessionCore.mu.Unlock()
21- return ac, transport
22-}
23-
24-func TestProxyOutputRejectsStaleStateAndRequestsOneReset(t *testing.T) {
25- tests := []struct {
26- name string
27- state ports.Output
28- stale ports.Output
29- }{
30- {
31- name: "duplicate full",
32- state: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true},
33- stale: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true, Data: []byte("duplicate")},
34- },
35- {
36- name: "older epoch",
37- state: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true},
38- stale: ports.Output{Epoch: 2, Base: 0, New: 5, Full: true, Data: []byte("older")},
39- },
40- {
41- name: "base-zero non-full",
42- state: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true},
43- stale: ports.Output{Epoch: 3, Base: 0, New: 5, Data: []byte("not full")},
44- },
45- }
46- for _, tt := range tests {
47- t.Run(tt.name, func(t *testing.T) {
48- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
49- require.NoError(t, err)
50- proxy.linkGeneration = 1
51- ack, reset, changed := proxy.applyOutputForGeneration(1, tt.state)
52- require.Equal(t, uint64(4), ack)
53- require.False(t, reset)
54- require.Equal(t, len(tt.state.Data) != 0, changed)
55-
56- ack, reset, changed = proxy.applyOutputForGeneration(1, tt.stale)
57- require.Zero(t, ack)
58- require.True(t, reset)
59- require.False(t, changed)
60- ack, reset, changed = proxy.applyOutputForGeneration(1, tt.stale)
61- require.Zero(t, ack)
62- require.False(t, reset)
63- require.False(t, changed)
64- })
65- }
66-}
67-
68-func TestProxyOutputFansOutToEveryLiveAttachment(t *testing.T) {
69- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
70- require.NoError(t, err)
71- remote := newProxyTestTransport()
72- proxy.transport = remote
73- proxy.linkGeneration = 1
74- d := &Daemon{}
75-
76- attachments := make([]*proxyTestTransport, 0, 2)
77- for id := byte(1); id <= 2; id++ {
78- _, clientTransport := newProxyOutputAttachment(t, proxy, id)
79- attachments = append(attachments, clientTransport)
80- }
81-
82- result, err := d.handleLinkFrame(proxy, 1, ports.Frame{
83- Type: ports.MsgOutput,
84- Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}),
85- })
86- require.NoError(t, err)
87- require.Equal(t, proxyLinkResume, result)
88- for _, transport := range attachments {
89- frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive output")
90- require.Equal(t, ports.MsgOutput, frame.Type)
91- out, err := ports.UnmarshalOutput(frame.Payload)
92- require.NoError(t, err)
93- require.Zero(t, out.New, "local proxy forwarding must not create a second ACK path")
94- require.Equal(t, []byte("full"), out.Data)
95- }
96- ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive output acknowledgement")
97- require.Equal(t, ports.MsgAck, ackFrame.Type)
98-}
99-
100-func TestProxyOutputAttachAfterStreamReceivesCurrentFullOutput(t *testing.T) {
101- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
102- require.NoError(t, err)
103- remote := newProxyTestTransport()
104- proxy.transport = remote
105- proxy.linkGeneration = 1
106- d := &Daemon{}
107-
108- _, firstTransport := newProxyOutputAttachment(t, proxy, 1)
109-
110- handle := func(output ports.Output) {
111- t.Helper()
112- result, err := d.handleLinkFrame(proxy, 1, ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(output)})
113- require.NoError(t, err)
114- require.Equal(t, proxyLinkResume, result)
115- }
116- handle(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("before attach")})
117- _ = awaitTestValue(t, firstTransport.sent, "first proxy attachment did not receive output")
118- _ = awaitTestValue(t, remote.sent, "proxy remote did not receive first acknowledgement")
119-
120- _, secondTransport := newProxyOutputAttachment(t, proxy, 2)
121-
122- handle(ports.Output{Epoch: 4, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("current full")})
123- for _, transport := range []*proxyTestTransport{firstTransport, secondTransport} {
124- frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive current output")
125- require.Equal(t, ports.MsgOutput, frame.Type)
126- out, err := ports.UnmarshalOutput(frame.Payload)
127- require.NoError(t, err)
128- require.Zero(t, out.New)
129- require.False(t, out.Full, "local proxy side effects must remain stateless")
130- require.Equal(t, []byte("current full"), out.Data)
131- }
132- ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive current acknowledgement")
133- require.Equal(t, ports.MsgAck, ackFrame.Type)
134-}
135-
136-func TestProxyOutputInvalidStateNeverForwardsBytes(t *testing.T) {
137- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
138- require.NoError(t, err)
139- remote := newProxyTestTransport()
140- proxy.transport = remote
141- proxy.linkGeneration = 1
142- _, clientTransport := newProxyOutputAttachment(t, proxy, 1)
143- d := &Daemon{}
144-
145- _, err = d.handleLinkFrame(proxy, 1, ports.Frame{
146- Type: ports.MsgOutput,
147- Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}),
148- })
149- require.NoError(t, err)
150- _ = awaitTestValue(t, clientTransport.sent, "proxy attachment did not receive initial output")
151- _ = awaitTestValue(t, remote.sent, "proxy remote did not receive initial acknowledgement")
152-
153- result, err := d.handleLinkFrame(proxy, 1, ports.Frame{
154- Type: ports.MsgOutput,
155- Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("stale")}),
156- })
157- require.NoError(t, err)
158- require.Equal(t, proxyLinkResume, result)
159- select {
160- case frame := <-clientTransport.sent:
161- t.Fatalf("stale proxy output forwarded as %v", frame.Type)
162- default:
163- }
164- reset := awaitTestValue(t, remote.sent, "proxy remote did not receive reset request")
165- require.Equal(t, ports.MsgOutputResetRequest, reset.Type)
166-}
D internal/usecase/daemon/proxy_session.go +0 -399
@@ -1,399 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "crypto/rand"
6- "errors"
7- "fmt"
8- "io"
9- "sync"
10- "time"
11-
12- "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14-)
15-
16-// proxySession is the local attachment identity for one remote session key. mu is a leaf lock: callers may take it below d.mu or sessionCore
17-// locks, but must not acquire another architecture lock or perform transport I/O
18-// while holding it. All Transport.Send calls are owned by sendMu.
19-type proxySession struct {
20- sessionCore
21- key domain.RemoteSessionKey
22-
23- mu sync.Mutex
24- generation uint64
25- linkGeneration uint64
26- transport ports.Transport
27- resumeToken uint64
28- outputEpoch uint64
29- outputState uint64
30- outputReady bool
31- outputResetRequested bool
32- meta ports.SessionMeta
33- attentionAt []time.Time
34- linkState ports.LinkState
35- expired bool
36- warm *proxyWarmTimer
37- cancel context.CancelFunc
38- done chan struct{}
39- doneOnce sync.Once
40- clientID [16]byte
41- contentSize domain.Size
42-
43- sendMu sync.Mutex
44- inputNext uint64
45- // commandMu permits at most one interactive palette request to be
46- // outstanding without blocking input, ACK, resize, or liveness sends.
47- commandMu sync.Mutex
48- commandNext uint64
49- commandPending map[uint64]proxyCommandPending
50-}
51-
52-func (p *proxySession) core() *sessionCore {
53- if p == nil {
54- return nil
55- }
56- return &p.sessionCore
57-}
58-
59-func (p *proxySession) isProxy() bool { return true }
60-
61-func (p *proxySession) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) {
62- return nil, false
63-}
64-
65-func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
66- if p == nil || !size.Valid() {
67- return false, false
68- }
69- payload, err := ports.MarshalResize(ports.Resize{Size: size})
70- if err != nil {
71- return false, false
72- }
73- p.mu.Lock()
74- if p.contentSize == size {
75- available := p.transport != nil
76- p.mu.Unlock()
77- return false, available
78- }
79- p.contentSize = size
80- generation := p.linkGeneration
81- p.mu.Unlock()
82- return true, p.sendGeneration(generation, ports.Frame{
83- Type: ports.MsgResize,
84- Payload: payload,
85- }) == nil
86-}
87-
88-func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta {
89- meta.Tabs = append([]ports.SessionTabMeta(nil), meta.Tabs...)
90- return meta
91-}
92-
93-// replaceSessionMetaLocked atomically replaces the relayed snapshot while
94-// preserving the first-observed timestamp of tabs that remain attentive.
95-// Callers must validate meta and hold p.mu.
96-// applyProxySessionMeta validates and atomically applies relayed metadata only
97-// for the exact current link generation. A remote session rename cannot mutate
98-// the immutable local identity, so it expires that exact proxy instead.
99-func (p *proxySession) snapshotView(opts viewOptions) sessionView {
100- if p == nil {
101- return sessionView{}
102- }
103- p.mu.Lock()
104- meta := cloneSessionMeta(p.meta)
105- attentionAt := append([]time.Time(nil), p.attentionAt...)
106- name := p.name
107- expired := p.expired
108- if expired {
109- name += " [expired]"
110- }
111- p.mu.Unlock()
112-
113- view := sessionView{
114- id: p.id,
115- name: name,
116- expired: expired,
117- createdAt: p.createdAt,
118- mruAt: p.mruAt.Load(),
119- defaultTab: 0,
120- tabCount: len(meta.Tabs),
121- cannotAcceptMoves: true,
122- }
123- p.sessionCore.mu.Lock()
124- view.attached = len(p.attachments) != 0
125- p.sessionCore.mu.Unlock()
126- if opts.tabDetails {
127- view.tabs = make([]tabView, len(meta.Tabs))
128- for i, tab := range meta.Tabs {
129- view.tabs[i] = tabView{name: tab.Name, attention: tab.Attention}
130- if i < len(attentionAt) {
131- view.tabs[i].attentionAt = attentionAt[i]
132- }
133- view.hasAttention = view.hasAttention || tab.Attention
134- }
135- } else {
136- for _, tab := range meta.Tabs {
137- view.hasAttention = view.hasAttention || tab.Attention
138- }
139- }
140- return view
141-}
142-
143-func (p *proxySession) statusSegments(_ bool) statusSnapshot {
144- if p == nil {
145- return statusSnapshot{}
146- }
147- name := p.lifecycleDisplayName()
148- p.mu.Lock()
149- meta := cloneSessionMeta(p.meta)
150- p.mu.Unlock()
151- snapshot := statusSnapshot{session: name, tabs: make([]statusTab, len(meta.Tabs))}
152- for i, tab := range meta.Tabs {
153- snapshot.tabs[i] = statusTab{
154- name: tab.Name,
155- active: uint16(i) == meta.Active,
156- attention: tab.Attention,
157- }
158- }
159- return snapshot
160-}
161-
162-func (p *proxySession) validTargetTabLocked(tabIndex int) bool {
163- if tabIndex < 0 {
164- return true
165- }
166- p.mu.Lock()
167- defer p.mu.Unlock()
168- return tabIndex < len(p.meta.Tabs)
169-}
170-
171-func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySession, error) {
172- if err := key.Validate(); err != nil {
173- return nil, fmt.Errorf("proxy session key: %w", err)
174- }
175- if !size.Valid() {
176- return nil, fmt.Errorf("proxy session size: invalid size %dx%d", size.Cols, size.Rows)
177- }
178- var clientID [16]byte
179- if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil {
180- return nil, fmt.Errorf("generate proxy client id: %w", err)
181- }
182- p := &proxySession{
183- sessionCore: sessionCore{
184- id: key.ID(),
185- name: key.Display(),
186- caps: sessionCapabilities{cannotAcceptMoves: true, cannotYieldMoves: true},
187- },
188- key: key,
189- generation: 1,
190- linkState: ports.LinkStateConnected,
191- done: make(chan struct{}),
192- clientID: clientID,
193- contentSize: size,
194- commandPending: make(map[uint64]proxyCommandPending),
195- }
196- return p, nil
197-}
198-
199-type proxyConstruction struct {
200- done chan struct{}
201- err error
202-}
203-
204-var errProxySessionExpired = errors.New("proxy session: remote attachment has expired")
205-
206-// openProxySession returns the exact already-published proxy for key, or elects
207-// one constructor for that remote key. Waiters never dial: they wait
208-// cancellably, then revalidate the exact registry winner under d.mu. The
209-// reservation itself is held only under d.mu; dial and handshake I/O run after
210-// releasing every daemon, core, and proxy architecture lock.
211-func (d *Daemon) openProxySession(ctx context.Context, key domain.RemoteSessionKey, size domain.Size) (*proxySession, error) {
212- if d == nil {
213- return nil, errors.New("proxy session: nil daemon")
214- }
215- if ctx == nil {
216- ctx = context.Background()
217- }
218- if err := key.Validate(); err != nil {
219- return nil, fmt.Errorf("proxy session: %w", err)
220- }
221- if d.remoteDialerFactory == nil {
222- return nil, errors.New("proxy session: remote dialer is not configured")
223- }
224-
225- for {
226- d.mu.Lock()
227- existing, found, err := d.proxyForKeyLocked(key)
228- if err != nil {
229- d.mu.Unlock()
230- return nil, err
231- }
232- if found {
233- d.mu.Unlock()
234- return existing, nil
235- }
236- if d.closing {
237- d.mu.Unlock()
238- return nil, errors.New("proxy session: daemon is shutting down")
239- }
240- if construction := d.proxyConstructions[key]; construction != nil {
241- done := construction.done
242- d.mu.Unlock()
243- select {
244- case <-ctx.Done():
245- return nil, ctx.Err()
246- case <-done:
247- }
248- if err := ctx.Err(); err != nil {
249- return nil, err
250- }
251-
252- // Publication and construction completion are ordered under d.mu.
253- // Revalidate rather than trusting a stale candidate pointer.
254- d.mu.Lock()
255- winner, registered, collisionErr := d.proxyForKeyLocked(key)
256- constructionErr := construction.err
257- d.mu.Unlock()
258- if collisionErr != nil {
259- return nil, collisionErr
260- }
261- if registered {
262- return winner, nil
263- }
264- if constructionErr != nil &&
265- !errors.Is(constructionErr, context.Canceled) &&
266- !errors.Is(constructionErr, context.DeadlineExceeded) {
267- return nil, constructionErr
268- }
269- // A canceled owner does not poison independent waiters. Re-elect
270- // under d.mu if this caller still wants the proxy.
271- continue
272- }
273-
274- construction := &proxyConstruction{done: make(chan struct{})}
275- if d.proxyConstructions == nil {
276- d.proxyConstructions = make(map[domain.RemoteSessionKey]*proxyConstruction)
277- }
278- d.proxyConstructions[key] = construction
279- d.mu.Unlock()
280-
281- proxy, constructionErr := d.constructProxySession(ctx, key, size)
282- d.mu.Lock()
283- construction.err = constructionErr
284- if d.proxyConstructions[key] == construction {
285- delete(d.proxyConstructions, key)
286- close(construction.done)
287- }
288- d.mu.Unlock()
289- return proxy, constructionErr
290- }
291-}
292-
293-// proxyForKeyLocked validates the opaque session-ID collision while d.mu is
294-// held. A true result is always the exact currently registered proxy.
295-func (d *Daemon) proxyForKeyLocked(key domain.RemoteSessionKey) (*proxySession, bool, error) {
296- existing := d.sessions[key.ID()]
297- if existing == nil {
298- return nil, false, nil
299- }
300- proxy, ok := existing.(*proxySession)
301- if !ok || proxy.key != key {
302- return nil, false, errors.New("proxy session: remote identity collision")
303- }
304- proxy.mu.Lock()
305- expired := proxy.expired
306- proxy.mu.Unlock()
307- if expired {
308- return nil, false, errProxySessionExpired
309- }
310- return proxy, true, nil
311-}
312-
313-// constructProxySession owns one reserved key. The caller has released d.mu
314-// before entering this function, and no architecture lock spans handshake I/O.
315-func (d *Daemon) constructProxySession(ctx context.Context, key domain.RemoteSessionKey, size domain.Size) (*proxySession, error) {
316- proxy, err := newProxySession(key, size)
317- if err != nil {
318- return nil, err
319- }
320- root := context.Background()
321- if d.serveCtx != nil {
322- root = d.serveCtx
323- }
324- linkCtx, cancel := context.WithCancel(root)
325- proxy.cancel = cancel
326- published := false
327- defer func() {
328- if !published {
329- proxy.disposeUnpublished()
330- }
331- }()
332-
333- if err := d.dialProxyHandshake(ctx, proxy, ports.IntentAttach); err != nil {
334- return nil, err
335- }
336- if err := ctx.Err(); err != nil {
337- return nil, err
338- }
339-
340- d.mu.Lock()
341- if err := ctx.Err(); err != nil {
342- d.mu.Unlock()
343- return nil, err
344- }
345- if existing, found, err := d.proxyForKeyLocked(key); err != nil {
346- d.mu.Unlock()
347- return nil, err
348- } else if found {
349- d.mu.Unlock()
350- return existing, nil
351- }
352- if d.closing || !d.registerSessionLocked(proxy) {
353- d.mu.Unlock()
354- return nil, errors.New("proxy session: daemon rejected publication")
355- }
356- published = true
357- d.mu.Unlock()
358-
359- go d.runProxyLink(linkCtx, proxy)
360- return proxy, nil
361-}
362-
363-func (p *proxySession) finish() {
364- if p != nil {
365- p.doneOnce.Do(func() { close(p.done) })
366- }
367-}
368-
369-func (p *proxySession) disposeUnpublished() {
370- if p == nil {
371- return
372- }
373- p.stop()
374- p.finish()
375-}
376-
377-func (p *proxySession) stop() {
378- if p == nil {
379- return
380- }
381- if p.cancel != nil {
382- p.cancel()
383- }
384- p.closeCurrentTransport()
385-}
386-
387-func (p *proxySession) closeCurrentTransport() {
388- if p == nil {
389- return
390- }
391- p.mu.Lock()
392- transport := p.transport
393- p.mu.Unlock()
394- if transport != nil {
395- _ = transport.Close()
396- }
397-}
398-
399-var _ attachmentSession = (*proxySession)(nil)
D internal/usecase/daemon/proxy_test_helpers_test.go +0 -127
@@ -1,127 +0,0 @@
1-package daemon
2-
3-import (
4- "io"
5- "log/slog"
6- "sync"
7- "sync/atomic"
8- "testing"
9- "time"
10-
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
13- "github.com/stretchr/testify/require"
14-)
15-
16-type proxyRecv struct {
17- frame ports.Frame
18- err error
19-}
20-
21-type proxyTestTransport struct {
22- recv chan proxyRecv
23- closed chan struct{}
24- sent chan ports.Frame
25- once sync.Once
26-
27- sendFails atomic.Bool
28- sendGate <-chan struct{}
29- sendEntered chan struct{}
30- receiving atomic.Int32
31- concurrent atomic.Bool
32- sending atomic.Int32
33-}
34-
35-func newProxyTestTransport() *proxyTestTransport {
36- return &proxyTestTransport{recv: make(chan proxyRecv, 16), closed: make(chan struct{}), sent: make(chan ports.Frame, 32)}
37-}
38-
39-func (t *proxyTestTransport) Send(frame ports.Frame) error {
40- if t.sending.Add(1) != 1 {
41- t.concurrent.Store(true)
42- }
43- defer t.sending.Add(-1)
44- if t.sendFails.Load() {
45- return io.ErrClosedPipe
46- }
47- if t.sendGate != nil {
48- if t.sendEntered != nil {
49- select {
50- case t.sendEntered <- struct{}{}:
51- default:
52- }
53- }
54- select {
55- case <-t.sendGate:
56- case <-t.closed:
57- return io.ErrClosedPipe
58- }
59- }
60- select {
61- case t.sent <- frame:
62- return nil
63- case <-t.closed:
64- return io.ErrClosedPipe
65- }
66-}
67-
68-func (t *proxyTestTransport) Recv() (ports.Frame, error) {
69- t.receiving.Add(1)
70- defer t.receiving.Add(-1)
71- select {
72- case item := <-t.recv:
73- return item.frame, item.err
74- case <-t.closed:
75- return ports.Frame{}, io.EOF
76- }
77-}
78-
79-func (t *proxyTestTransport) Close() error {
80- t.once.Do(func() { close(t.closed) })
81- return nil
82-}
83-
84-func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame {
85- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
86- SessionID: "remote-id", SessionName: name, ResumeToken: token, Capabilities: capabilities,
87- })}
88-}
89-
90-func proxyHandshakeSnapshot() ports.Frame {
91- return ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
92- Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("remote initial\n"),
93- })}
94-}
95-
96-func newProxyTestDaemon(t *testing.T, factory ports.RemoteDialerFactory, clk ports.Clock) *Daemon {
97- t.Helper()
98- if clk == nil {
99- clk = stubClock{}
100- }
101- return New(nil, clk, slog.New(slog.NewTextHandler(io.Discard, nil)),
102- WithRemoteDiscovery(nil, nil, nil, factory, ports.RemoteTransportUDP))
103-}
104-
105-func requireProxyHello(t *testing.T, tr *proxyTestTransport) ports.Hello {
106- t.Helper()
107- select {
108- case frame := <-tr.sent:
109- require.Equal(t, ports.MsgHello, frame.Type)
110- hello, err := ports.UnmarshalHello(frame.Payload)
111- require.NoError(t, err)
112- return hello
113- case <-time.After(time.Second):
114- t.Fatal("timed out waiting for proxy Hello")
115- return ports.Hello{}
116- }
117-}
118-
119-func stopProxy(t *testing.T, p *proxySession) {
120- t.Helper()
121- p.stop()
122- select {
123- case <-p.done:
124- case <-time.After(time.Second):
125- t.Fatal("timed out waiting for proxy link to stop")
126- }
127-}
M internal/usecase/daemon/remote_picker.go +0 -27
@@ -139,33 +139,6 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi
139139 }
140140 }
141141
142-func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker.SessionView {
143- view := snap.pickerView()
144- // Proxy construction uses this same ID and label. Restating them from the
145- // structured key makes the cache-to-live row upgrade independent of mutable
146- // presentation or relayed metadata. Lifecycle is carried separately from
147- // presentation so a display-name change can never spoof expiry.
148- view.ID = key.ID()
149- view.Name = key.Display()
150- if snap.expired {
151- view.Name += " [expired]"
152- }
153- view.TargetName = ""
154- view.ExpectedCreatedAt = nil
155- view.RemoteKey = &key
156- view.RemoteAvailability = picker.RemoteFresh
157- view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))
158- view.ConnectOnly = true
159- view.RemoteAttachReady = true
160- if snap.expired {
161- view.RemoteAvailability = picker.RemoteStale
162- view.RemoteDetail = "expired"
163- view.RemoteAttachReady = false
164- }
165- view.CannotAcceptMoves = true
166- return view
167-}
168-
169142 func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
170143 for i := range entries {
171144 sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
M internal/usecase/daemon/remote_picker_test.go +72 -151
@@ -14,7 +14,6 @@ import (
1414
1515 "github.com/bnema/vev/internal/domain"
1616 "github.com/bnema/vev/internal/ports"
17- portsmocks "github.com/bnema/vev/internal/ports/mocks"
1817 "github.com/bnema/vev/internal/usecase/picker"
1918 )
2019
@@ -56,91 +55,6 @@ func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
5655 return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
5756 }
5857
59-func TestRemotePickerMerge(t *testing.T) {
60- store := portsmocks.NewMockRemoteHostStore(t)
61- store.EXPECT().Hosts().Return([]string{"pinned"}, []string{"learned"}, nil).Once()
62- d := newRemotePickerDaemon(store)
63- local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{{}}}
64- local.mruAt.Store(6)
65- key := domain.RemoteSessionKey{Host: "pinned", Name: "work"}
66- proxy := &proxySession{
67- sessionCore: sessionCore{id: key.ID(), name: key.Display()},
68- key: key,
69- meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{
70- {Index: 0, Name: "shell"},
71- }},
72- }
73- proxy.mruAt.Store(5)
74- d.sessions[local.id] = local
75- d.sessions[proxy.id] = proxy
76- d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 2, lastUsedSeq: 9}
77- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
78- {Host: "learned", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "alpha", State: "running", Tabs: 3}}},
79- {Host: "pinned", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running", Tabs: 9}, {Name: "alpha", State: "running", Tabs: 2}}},
80- })
81- d.remoteCatalog.mu.Lock()
82- d.remoteCatalog.status["pinned"] = remoteHostFresh
83- d.remoteCatalog.status["learned"] = remoteHostFresh
84- d.remoteCatalog.mu.Unlock()
85-
86- views, _ := d.pickerViews(local, nil)
87-
88- require.Equal(t, []string{"local", "work@pinned", "alpha@pinned", "alpha@learned", "stopped"}, pickerViewNames(views))
89- require.False(t, views[0].Stopped)
90- require.Equal(t, key, *views[1].RemoteKey)
91- require.Equal(t, picker.RemoteFresh, views[1].RemoteAvailability)
92- require.True(t, views[1].ConnectOnly)
93- require.Equal(t, "2 tabs", views[2].RemoteDetail)
94- require.Equal(t, picker.RemoteFresh, views[2].RemoteAvailability)
95- require.Equal(t, picker.RemoteFresh, views[3].RemoteAvailability)
96- require.True(t, views[4].Stopped)
97-}
98-
99-func TestRemotePickerDedupeUsesExactKeyAndKeepsLiveID(t *testing.T) {
100- d := newRemotePickerDaemon(nil)
101- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
102- proxy := &proxySession{
103- sessionCore: sessionCore{id: key.ID(), name: key.Display()},
104- key: key,
105- meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{
106- {Index: 0, Name: "live tab"},
107- }},
108- }
109- d.sessions[key.ID()] = proxy
110- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
111- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{
112- {Name: "work", State: "running", Tabs: 7},
113- },
114- }})
115-
116- views, _ := d.pickerViews(nil, nil)
117-
118- require.Len(t, views, 1)
119- require.Equal(t, key.ID(), views[0].ID)
120- require.Equal(t, key, *views[0].RemoteKey)
121- require.Equal(t, []picker.TabEntry{{Name: "live tab"}}, views[0].Tabs, "live proxy snapshot wins over cached discovery")
122- require.Equal(t, picker.RemoteFresh, views[0].RemoteAvailability)
123-}
124-
125-func TestRemotePickerSnapshotsDoNotRetainProxyLock(t *testing.T) {
126- d := newRemotePickerDaemon(nil)
127- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
128- proxy := &proxySession{
129- sessionCore: sessionCore{id: key.ID(), name: key.Display()},
130- key: key,
131- meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}},
132- }
133- d.sessions[key.ID()] = proxy
134-
135- views, _ := d.pickerViews(nil, nil)
136-
137- require.Len(t, views, 1)
138- require.True(t, proxy.mu.TryLock(), "picker construction must release the proxy leaf lock")
139- proxy.mu.Unlock()
140- require.True(t, d.remoteCatalog.mu.TryLock(), "picker construction must release the remote catalog lock")
141- d.remoteCatalog.mu.Unlock()
142-}
143-
14458 func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
14559 tests := []struct {
14660 name string
@@ -219,33 +133,6 @@ func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) {
219133 }
220134 }
221135
222-func TestRemotePickerGroupedSortTreatsProxiesAsLiveSessions(t *testing.T) {
223- d := newRemotePickerDaemon(nil)
224- d.pickerSort.Store(uint32(pickerSortGrouped))
225- ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
226- ephemeral.mruAt.Store(9)
227- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
228- proxy := &proxySession{sessionCore: sessionCore{id: key.ID(), name: key.Display()}, key: key}
229- proxy.mruAt.Store(1)
230- named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
231- named.mruAt.Store(2)
232- d.sessions[ephemeral.id] = ephemeral
233- d.sessions[proxy.id] = proxy
234- d.sessions[named.id] = named
235-
236- views, _ := d.pickerViews(nil, nil)
237-
238- require.Equal(t, []string{"named", "work@arch", "1"}, pickerViewNames(views))
239-}
240-
241-func pickerViewNames(views []picker.SessionView) []string {
242- names := make([]string, len(views))
243- for i, view := range views {
244- names[i] = view.Name
245- }
246- return names
247-}
248-
249136 type remoteRefreshHostStore struct {
250137 mu sync.Mutex
251138 hosts []string
@@ -532,6 +419,78 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
532419 d.remoteCatalog.mu.Unlock()
533420 }
534421
422+func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
423+ d := newRemotePickerDaemon(nil)
424+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
425+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
426+ }})
427+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
428+ token := sess.attachmentToken(ac, ac.transport())
429+ effect, admitted := ac.beginAttachmentEffect(token)
430+ require.True(t, admitted)
431+ token.effect = effect
432+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
433+ target := picker.Target{Session: key.ID(), RemoteKey: &key}
434+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
435+
436+ frame := receiveRemotePicker(t, sends, "attach target")
437+ require.Equal(t, ports.MsgAttachTarget, frame.Type)
438+ got, err := ports.UnmarshalAttachTarget(frame.Payload)
439+ require.NoError(t, err)
440+ require.Equal(t, ports.AttachTarget{Endpoint: "arch", Session: "work", Intent: ports.IntentAttach}, got)
441+ require.Nil(t, ac.currentAttachmentSession())
442+ d.mu.Lock()
443+ require.NotContains(t, d.sessions, key.ID(), "remote picker handoff must not create a local session shadow")
444+ d.mu.Unlock()
445+}
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+
535494 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
536495 t.Helper()
537496 tr, sends := newCapturingTransport(t)
@@ -744,44 +703,6 @@ func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
744703 ac.overlays.pickerMu.Unlock()
745704 }
746705
747-func TestRemoteRefreshPreservesRemoteCursorAcrossCacheToProxyUpgrade(t *testing.T) {
748- d := newRemotePickerDaemon(nil)
749- ownerSession, owner, _ := addRemoteRefreshPickerOwner(t, d, "local")
750- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
751- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
752- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},
753- }})
754- owner.overlays.pickerMu.Lock()
755- owner.overlays.picker = d.newPickerModel(ownerSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
756- owner.overlays.pickerIntent = pickerNavigate
757- before, ok := owner.overlays.picker.Cursor()
758- _, beforeSelectable := owner.overlays.picker.Selected()
759- owner.overlays.pickerMu.Unlock()
760- require.True(t, ok)
761- require.Equal(t, &key, before.RemoteKey)
762- require.True(t, beforeSelectable, "ownership activation makes a compatible cached row selectable")
763-
764- proxy := &proxySession{
765- sessionCore: sessionCore{id: key.ID(), name: key.Display()},
766- key: key,
767- meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{{Index: 0, Name: "live"}}},
768- }
769- d.mu.Lock()
770- d.sessions[key.ID()] = proxy
771- d.mu.Unlock()
772-
773- d.refreshPickerOpts(owner, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
774-
775- owner.overlays.pickerMu.Lock()
776- after, ok := owner.overlays.picker.Cursor()
777- _, selectable := owner.overlays.picker.Selected()
778- owner.overlays.pickerMu.Unlock()
779- require.True(t, ok)
780- require.Equal(t, before.Session, after.Session)
781- require.Equal(t, before.RemoteKey, after.RemoteKey)
782- require.True(t, selectable, "completed ownership must keep the upgraded proxy row selectable")
783-}
784-
785706 func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
786707 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
787708 d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(500, 0))
M internal/usecase/daemon/render.go +2 -3
@@ -291,9 +291,8 @@ func (b *runtimeMarkBatch) flush() {
291291 }
292292
293293 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {
294- // Local sessions retain their tab/PTY preparation below. Proxy sessions
295- // enter the same attachment pipeline but supply their VT snapshot through
296- // attachmentSession.captureRenderState.
294+ // Session-owned PTY preparation remains local; attachment rendering is
295+ // captured through attachmentSession.captureRenderState.
297296 sess, local := localSession(entry)
298297 marks := d.newRuntimeMarkBatch()
299298 if lease != nil {
M internal/usecase/daemon/render_coordinator_test.go +30 -7
@@ -23,25 +23,30 @@ import (
2323 // coordinatorMockClock uses generated mocks while retaining deterministic timer
2424 // channels and recorded deadlines for coordinator scheduling assertions.
2525 type coordinatorMockClock struct {
26- clock *portsmocks.MockClock
27- timers chan *coordinatorMockTimer
26+ clock *portsmocks.MockClock
27+ timers chan *coordinatorMockTimer
28+ handshakeTimers chan *coordinatorMockTimer
2829 }
2930
3031 type coordinatorMockTimer struct {
3132 mock *portsmocks.MockTimer
3233 ch chan time.Time
3334 duration time.Duration
35+ stopped chan struct{}
36+ stopOnce sync.Once
3437 }
3538
3639 func newCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
3740 t.Helper()
3841 clk := &coordinatorMockClock{
39- clock: portsmocks.NewMockClock(t),
40- timers: make(chan *coordinatorMockTimer, capacity),
42+ clock: portsmocks.NewMockClock(t),
43+ timers: make(chan *coordinatorMockTimer, capacity),
44+ handshakeTimers: make(chan *coordinatorMockTimer, 1),
4145 }
4246 clk.clock.EXPECT().Now().Return(time.Time{}).Maybe()
4347 clk.clock.EXPECT().NewTimer(mock.MatchedBy(func(d time.Duration) bool {
44- return d == urgentRenderDeadline ||
48+ return d == ports.HandshakeTimeout ||
49+ d == urgentRenderDeadline ||
4550 (d >= minOutputRenderDeadline && d <= maxOutputRenderDeadline) ||
4651 d == maxSyncUpdateDuration ||
4752 d == time.Second ||
@@ -50,14 +55,21 @@ func newCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
5055 timer := &coordinatorMockTimer{
5156 mock: portsmocks.NewMockTimer(t),
5257 duration: d,
58+ stopped: make(chan struct{}),
5359 }
5460 timer.ch = make(chan time.Time, 1)
5561 // A normal deadline reads C both in its worker and in the inert-clock
5662 // guard; a watchdog reads it in its worker. The generated expectation
5763 // makes every channel access observable without a hand-written Timer.
5864 timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch))
59- timer.mock.EXPECT().Stop().Maybe().Return(true)
60- clk.timers <- timer
65+ timer.mock.EXPECT().Stop().Run(func() {
66+ timer.stopOnce.Do(func() { close(timer.stopped) })
67+ }).Maybe().Return(true)
68+ if d == ports.HandshakeTimeout {
69+ clk.handshakeTimers <- timer
70+ } else {
71+ clk.timers <- timer
72+ }
6173 return timer.mock
6274 }).Maybe()
6375 return clk
@@ -219,6 +231,17 @@ func captureResizeCallbackDone(t *testing.T, rc *renderCoordinator) <-chan struc
219231 return rc.resizeLane.token.done
220232 }
221233
234+func awaitHandshakeTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
235+ t.Helper()
236+ select {
237+ case tm := <-clk.handshakeTimers:
238+ return tm
239+ default:
240+ t.Fatal("handshake did not synchronously arm a fake-clock timer")
241+ return nil
242+ }
243+}
244+
222245 func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
223246 t.Helper()
224247 select {
M internal/usecase/daemon/render_pipeline.go +2 -5
@@ -491,9 +491,8 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
491491 }
492492 d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
493493 }
494- // Notices are session-scoped, but the repaint is not: a proxy attachment
495- // has no local session to report through and still needs its chrome
496- // redrawn after a failed transaction.
494+ // Notices are session-scoped, while the attachment repaint still needs
495+ // to redraw its chrome after a failed transaction.
497496 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
498497 return true
499498 }
@@ -566,8 +565,6 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
566565 if marks.attachmentEffect == nil {
567566 if sess, ok := localSession(entry); ok {
568567 d.detachOnSendError(sess, ac, sendTr)
569- } else if proxy, ok := entry.(*proxySession); ok {
570- d.detachProxyOnSendError(proxy, ac, sendTr)
571568 }
572569 } else {
573570 // Capture the exact admitted capability, including its coordinator
M internal/usecase/daemon/resume_test.go +2 -0
@@ -258,6 +258,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
258258 }()
259259
260260 <-tr.welcomeEntered
261+ handshakeTimer := awaitHandshakeTimer(t, clock)
261262 welcome := <-tr.sends
262263 require.Equal(t, ports.MsgWelcome, welcome.Type)
263264 sess.mu.Lock()
@@ -283,6 +284,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
283284 require.Zero(t, first.Base)
284285 tr.finish()
285286 <-done
287+ awaitTestCompletion(t, handshakeTimer.stopped, "handshake timer was not canceled after publication")
286288 requireNoCoordinatorOutputFrame(t, tr.sends)
287289 }
288290
M internal/usecase/daemon/session.go +0 -38
@@ -170,8 +170,6 @@ func (d *Daemon) touchMRU(entry attachmentSession) {
170170 break
171171 }
172172 }
173- // Proxy recency is live derived state only. Local named sessions retain the
174- // existing catalogue metadata publication semantics.
175173 sess, ok := localSession(entry)
176174 if !ok {
177175 return
@@ -761,39 +759,6 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
761759 return current
762760 }
763761
764-// detachProxyIfCurrentTransport is detachIfCurrentTransport for a proxy
765-// attachment. It fences on the same published identity — exact session pointer,
766-// exact attachment, exact transport incarnation — but reads the attachment
767-// through the shared sessionCore rather than a local session.
768-func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClient, expected transportSnapshot) bool {
769- if p == nil || ac == nil {
770- return false
771- }
772- frozen := freezeAttachmentEffectGates(ac)
773- defer frozen.unfreeze()
774- if d.afterDetachAttachmentEffectsFrozen != nil {
775- d.afterDetachAttachmentEffectsFrozen()
776- }
777- d.notices.routingMu.Lock()
778- p.sessionCore.mu.Lock()
779- current := attachmentRegisteredLocked(attachmentSession(p), ac) && ac.currentAttachmentSession() == attachmentSession(p)
780- if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
781- current = false
782- }
783- if current {
784- unregisterAttachmentSessionLocked(attachmentSession(p), ac)
785- ac.connectionGeneration.Add(1)
786- ac.setSession(nil)
787- ac.invalidateFrozenAttachmentCapability()
788- }
789- p.sessionCore.mu.Unlock()
790- d.notices.routingMu.Unlock()
791- if current {
792- d.armProxyWarm(p)
793- }
794- return current
795-}
796-
797762 // detachIfAttachmentCurrent invalidates only the exact attachment generation and transport
798763 // whose admitted send failed. A queued stale error cannot detach a later
799764 // attachment publication or a rebound transport on the same attachment object.
@@ -812,9 +777,6 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
812777 if sess, ok := localSession(token.sess); ok {
813778 return d.detachIfCurrentTransport(sess, token.ac, token.transport)
814779 }
815- if proxy, ok := token.sess.(*proxySession); ok {
816- return d.detachProxyIfCurrentTransport(proxy, token.ac, token.transport)
817- }
818780 return false
819781 }
820782 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
M internal/usecase/daemon/session_back.go +0 -4
@@ -17,10 +17,6 @@ func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error
1717 return nil
1818 }
1919 pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}
20- if proxy, ok := target.(*proxySession); ok {
21- key := proxy.key
22- pickerTarget.RemoteKey = &key
23- }
2420 return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session")
2521 }
2622
M internal/usecase/daemon/session_capabilities.go +2 -4
@@ -3,8 +3,7 @@ package daemon
33 import "errors"
44
55 // errSessionCannotYieldMoves rejects move-picker entry for sessions whose
6-// tabs and panes cannot leave the session (no local session sets this today;
7-// proxy sessions will).
6+// tabs and panes cannot leave the session.
87 var errSessionCannotYieldMoves = errors.New("session does not support moving tabs or panes")
98
109 // sessionCapabilities declares what cross-session operations a session
@@ -14,8 +13,7 @@ var errSessionCannotYieldMoves = errors.New("session does not support moving tab
1413 //
1514 // Set once at construction, never mutated: reading needs no lock.
1615 type sessionCapabilities struct {
17- cannotAcceptMoves bool
18- cannotYieldMoves bool
16+ cannotYieldMoves bool
1917 }
2018
2119 func (c sessionCapabilities) yieldsMoves() bool { return !c.cannotYieldMoves }
M internal/usecase/daemon/session_capabilities_test.go +1 -20
@@ -10,30 +10,11 @@ import (
1010 func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) {
1111 s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha"}}
1212 caps := s.capabilities()
13- if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() {
13+ if caps.cannotYieldMoves || !caps.yieldsMoves() {
1414 t.Fatalf("zero-value capabilities = %+v, want fully capable", caps)
1515 }
1616 }
1717
18-func TestSnapshotViewCarriesCannotAcceptMoves(t *testing.T) {
19- tests := []struct {
20- name string
21- caps sessionCapabilities
22- want bool
23- }{
24- {name: "default local", caps: sessionCapabilities{}, want: false},
25- {name: "restricted proxy", caps: sessionCapabilities{cannotAcceptMoves: true}, want: true},
26- }
27- for _, tt := range tests {
28- t.Run(tt.name, func(t *testing.T) {
29- s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps}}
30- if got := s.snapshotView(viewOptions{}).cannotAcceptMoves; got != tt.want {
31- t.Fatalf("snapshotView().cannotAcceptMoves = %v, want %v", got, tt.want)
32- }
33- })
34- }
35-}
36-
3718 func TestEnterPickerForIntentRejectsNonYieldingSource(t *testing.T) {
3819 d, sess, ac, _ := newManualSessionWithPTYs(t)
3920 sess.caps = sessionCapabilities{cannotYieldMoves: true}
M internal/usecase/daemon/session_core.go +0 -3
@@ -15,7 +15,6 @@ type attachmentSession interface {
1515 statusSegments(includeTerminalTitle bool) statusSnapshot
1616 captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool)
1717 validTargetTabLocked(tabIndex int) bool
18- isProxy() bool
1918 }
2019
2120 // sessionCore is the state shared by every attachment-capable session. It is
@@ -47,8 +46,6 @@ func (s *session) core() *sessionCore {
4746 return &s.sessionCore
4847 }
4948
50-func (s *session) isProxy() bool { return false }
51-
5249 func (s *session) captureRenderState(ac *attachedClient, req renderCaptureRequest) (*capturedRenderState, bool) {
5350 return captureLocalRenderState(s, ac, req)
5451 }
M internal/usecase/daemon/session_core_test.go +0 -1
@@ -31,7 +31,6 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
3131 core := sess.core()
3232 require.Same(t, &sess.sessionCore, core)
3333 require.Same(t, sess, mustLocalSession(t, attachmentSession(sess)))
34- require.False(t, sess.isProxy())
3534 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")
3635
3736 core.mu.Lock()
M internal/usecase/daemon/session_view.go +28 -33
@@ -25,23 +25,20 @@ type tabView struct {
2525
2626 // sessionView is an immutable, value-only description of a session. It
2727 // deliberately retains no live pointers, mirroring recentSession
28-// (recent_sessions.go). Listing paths (picker, MRU bar, palette,
29-// list-sessions) read local session state through snapshotView; proxy rows in
30-// later phases still come from registry/discovery, not this type alone.
28+// (recent_sessions.go). Listing paths (picker, MRU bar, palette, and
29+// list-sessions) read local session state through snapshotView.
3130 type sessionView struct {
32- id domain.SessionID
33- incarnation domain.IncarnationID
34- name string
35- ephemeral bool
36- createdAt int64
37- defaultTab int
38- mruAt uint64
39- attached bool
40- tabCount int
41- hasAttention bool
42- tabs []tabView
43- cannotAcceptMoves bool
44- expired bool
31+ id domain.SessionID
32+ incarnation domain.IncarnationID
33+ name string
34+ ephemeral bool
35+ createdAt int64
36+ defaultTab int
37+ mruAt uint64
38+ attached bool
39+ tabCount int
40+ hasAttention bool
41+ tabs []tabView
4542 }
4643
4744 // snapshotView reads session fields under s.mu and samples the independently
@@ -57,16 +54,15 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
5754 // default. Interactive callers pass an attachment and resolve its stable
5855 // view separately.
5956 view := sessionView{
60- id: s.id,
61- incarnation: s.incarnation,
62- name: s.name,
63- ephemeral: s.ephemeral,
64- createdAt: s.createdAt,
65- defaultTab: 0,
66- mruAt: s.mruAt.Load(),
67- attached: len(s.attachments) != 0,
68- tabCount: len(s.tabs),
69- cannotAcceptMoves: s.capabilities().cannotAcceptMoves,
57+ id: s.id,
58+ incarnation: s.incarnation,
59+ name: s.name,
60+ ephemeral: s.ephemeral,
61+ createdAt: s.createdAt,
62+ defaultTab: 0,
63+ mruAt: s.mruAt.Load(),
64+ attached: len(s.attachments) != 0,
65+ tabCount: len(s.tabs),
7066 }
7167 if opts.tabDetails {
7268 view.tabs = make([]tabView, 0, len(s.tabs))
@@ -100,13 +96,12 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
10096 // equivalent to the inline construction previously in pickerViews.
10197 func (view sessionView) pickerView() picker.SessionView {
10298 out := picker.SessionView{
103- ID: view.id,
104- Incarnation: view.incarnation,
105- Name: view.name,
106- TargetName: view.name,
107- Active: view.defaultTab,
108- Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
109- CannotAcceptMoves: view.cannotAcceptMoves,
99+ ID: view.id,
100+ Incarnation: view.incarnation,
101+ Name: view.name,
102+ TargetName: view.name,
103+ Active: view.defaultTab,
104+ Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
110105 }
111106 if !view.ephemeral {
112107 createdAt := view.createdAt
M internal/usecase/daemon/status_test.go +0 -1
@@ -35,7 +35,6 @@ func (*coreNilAttachment) captureRenderState(*attachedClient, renderCaptureReque
3535 return nil, false
3636 }
3737 func (*coreNilAttachment) validTargetTabLocked(int) bool { return false }
38-func (*coreNilAttachment) isProxy() bool { return false }
3938
4039 // stubClock returns timers whose channel never fires, so a scheduler under it
4140 // blocks in its debounce loop until the session context is cancelled. Used by
M internal/usecase/picker/model.go +1 -1
@@ -43,7 +43,7 @@ type SessionView struct {
4343 RemoteAttachReady bool
4444 // CannotAcceptMoves reports whether this session cannot receive a moved tab
4545 // or pane. False for ordinary local (and stopped) sessions; true for
46- // restricted proxy sessions.
46+ // restricted remote rows.
4747 CannotAcceptMoves bool
4848 }
4949

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