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/
75 - Named sessions survive headless and persist across daemon restarts.75 - Named sessions survive headless and persist across daemon restarts.
76 - The daemon starts on first use and exits when the last session ends.76 - The daemon starts on first use and exits when the last session ends.
77 - Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.77 - Local attach sends `Hello`, receives `Welcome`, then pumps `Input`/`Resize` and `Output` frames.
78-- Remote attach runs `ssh -- host vev _stdio [session]` and proxies the same protocol over stdio.78+- Remote attach runs `ssh -- host vev _stdio` and proxies the same protocol over stdio; session selection is carried in `Hello`.
79 - Only one client may attach to a session; a new attach replaces the old client.79 - Only one client may attach to a session; a new attach replaces the old client.
M cmd/vev-perf-harness/evidence_test.go +1 -1
@@ -44,7 +44,7 @@ func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) {
44 if err := os.WriteFile(m.TracePath, nil, 0o600); err != nil {44 if err := os.WriteFile(m.TracePath, nil, 0o600); err != nil {
45 t.Fatal(err)45 t.Fatal(err)
46 }46 }
47- p, err := l.preparePeer(m, roleCommand{Args: []string{"_udp-proxy", "work"}, Transport: tc.tr})47+ p, err := l.preparePeer(m, roleCommand{Args: []string{"_udp-proxy"}, Transport: tc.tr})
48 if err != nil {48 if err != nil {
49 t.Fatal(err)49 t.Fatal(err)
50 }50 }
M cmd/vev-perf-harness/hangup_test.go +2 -3
@@ -185,9 +185,8 @@ func TestClientGracefulShutdownDrainsTracedStdioDescendant(t *testing.T) {
185 case "daemon":185 case "daemon":
186 command = roleCommand{Args: []string{"--daemon"}}186 command = roleCommand{Args: []string{"--daemon"}}
187 case "ssh_stdio_peer":187 case "ssh_stdio_peer":
188- // An empty remote session requests an ephemeral session, avoiding a188+ // The transport-neutral _stdio mode requests an ephemeral session.
189- // separate bootstrap client while still executing the traced _stdio mode.189+ command = roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}}
190- command = roleCommand{Args: []string{"_stdio", ""}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}}
191 case "client":190 case "client":
192 command = roleCommand{Args: []string{"attach", "harness@127.0.0.1"}}191 command = roleCommand{Args: []string{"attach", "harness@127.0.0.1"}}
193 }192 }
M cmd/vev-perf-harness/launcher.go +12 -12
@@ -301,7 +301,7 @@ func (l *cliLauncher) releaseRuntime(runDir string) error {
301 301
302 // preparePeer installs a per-run ssh command seam. The ordinary public client302 // preparePeer installs a per-run ssh command seam. The ordinary public client
303 // still executes its documented remote attach command; its ssh child invokes303 // still executes its documented remote attach command; its ssh child invokes
304-// only the parsed public _stdio or _udp-proxy command. This makes the peer304+// only the parsed public _stdio or _udp-proxy entrypoint. This makes the peer
305 // carrying client traffic the declared, exclusively traced role rather than a305 // carrying client traffic the declared, exclusively traced role rather than a
306 // separately launched but unused process.306 // separately launched but unused process.
307 func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedProcess, error) {307 func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedProcess, error) {
@@ -332,7 +332,7 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP
332 if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {332 if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
333 return nil, fail(err)333 return nil, fail(err)
334 }334 }
335- if len(role.Args) != 2 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") {335+ if len(role.Args) != 1 || (role.Args[0] != "_stdio" && role.Args[0] != "_udp-proxy") {
336 return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args))336 return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args))
337 }337 }
338 route := peerRoute{mapping: m, command: role}338 route := peerRoute{mapping: m, command: role}
@@ -373,27 +373,27 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP
373 set -eu373 set -eu
374 case "$*" in374 case "$*" in
375 *"_stdio"*)375 *"_stdio"*)
376- exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[12]q VEV_PERF_RUN=%[13]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio %[6]q376+ exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[11]q VEV_PERF_RUN=%[12]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio
377 ;;377 ;;
378 *"_udp-bootstrap"*)378 *"_udp-bootstrap"*)
379- rm -f %[7]q %[8]q %[9]q379+ rm -f %[6]q %[7]q %[8]q
380- env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[12]q VEV_PERF_RUN=%[13]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _udp-proxy %[6]q >%[7]q 2>%[8]q &380+ env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[11]q VEV_PERF_RUN=%[12]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _udp-proxy >%[6]q 2>%[7]q &
381 peer=$!381 peer=$!
382- printf '%%s\n' "$peer" > %[11]q382+ printf '%%s\n' "$peer" > %[10]q
383 i=0383 i=0
384- while [ ! -s %[7]q ] && [ "$i" -lt 1000 ]; do sleep 0.01; i=$((i+1)); done384+ while [ ! -s %[6]q ] && [ "$i" -lt 1000 ]; do sleep 0.01; i=$((i+1)); done
385- test -s %[7]q385+ test -s %[6]q
386- line=$(head -n 1 %[7]q)386+ line=$(head -n 1 %[6]q)
387- printf '%%s\n' "$line" > %[9]q387+ printf '%%s\n' "$line" > %[8]q
388 set -- $line388 set -- $line
389 test "$1" = VEV-UDP389 test "$1" = VEV-UDP
390 test -n "$3"390 test -n "$3"
391- printf 'VEV-UDP %%s %%s\n' %[10]d "$3"391+ printf 'VEV-UDP %%s %%s\n' %[9]d "$3"
392 exit 0392 exit 0
393 ;;393 ;;
394 *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;394 *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;
395 esac395 esac
396-`, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, role.Args[1], ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run)396+`, m.TracePath, m.ProcessID, runtimeDir, filepath.Join(runDir, "state"), l.bin, ready, stderr, target, netemPort, route.pidPath, m.Scenario, m.Run)
397 if err := os.WriteFile(shim, []byte(body), 0o700); err != nil {397 if err := os.WriteFile(shim, []byte(body), 0o700); err != nil {
398 return nil, closeNetem(err)398 return nil, closeNetem(err)
399 }399 }
M cmd/vev-perf-harness/launcher_test.go +2 -2
@@ -614,8 +614,8 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
614 }{614 }{
615 {"daemon", []string{"--daemon"}},615 {"daemon", []string{"--daemon"}},
616 {"client", []string{"new", "perf-s-001"}},616 {"client", []string{"new", "perf-s-001"}},
617- {"ssh_stdio_peer", []string{"_stdio", "perf-s-001"}},617+ {"ssh_stdio_peer", []string{"_stdio"}},
618- {"udp_peer", []string{"_udp-proxy", "perf-s-001"}},618+ {"udp_peer", []string{"_udp-proxy"}},
619 } {619 } {
620 t.Run(tc.role, func(t *testing.T) {620 t.Run(tc.role, func(t *testing.T) {
621 got := roleArgs(scenario{ID: "s", Transport: "local"}, processMapping{Role: tc.role, Run: 1})621 got := roleArgs(scenario{ID: "s", Transport: "local"}, processMapping{Role: tc.role, Run: 1})
M cmd/vev-perf-harness/runner.go +2 -2
@@ -396,9 +396,9 @@ func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand
396 return roleCommand{Args: remote}396 return roleCommand{Args: remote}
397 }397 }
398 case "ssh_stdio_peer":398 case "ssh_stdio_peer":
399- return roleCommand{Args: []string{"_stdio", session}, Transport: transport{ID: s.Transport, Kind: "ssh_stdio"}}399+ return roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: s.Transport, Kind: "ssh_stdio"}}
400 case "udp_peer":400 case "udp_peer":
401- return roleCommand{Args: []string{"_udp-proxy", session}, Transport: selected}401+ return roleCommand{Args: []string{"_udp-proxy"}, Transport: selected}
402 }402 }
403 return roleCommand{}403 return roleCommand{}
404 }404 }
M cmd/vev-perf-harness/runner_test.go +2 -2
@@ -256,10 +256,10 @@ func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) {
256 if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1"}) {256 if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1"}) {
257 t.Fatalf("client did not use ephemeral public remote attach: %q", client.Args)257 t.Fatalf("client did not use ephemeral public remote attach: %q", client.Args)
258 }258 }
259- if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio", "perf-route-002"}) {259+ if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio"}) {
260 t.Fatalf("ssh peer command=%q", peer.Args)260 t.Fatalf("ssh peer command=%q", peer.Args)
261 }261 }
262- if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy", "perf-route-002"}) {262+ if tc.peer == "udp_peer" && !equalStrings(peer.Args, []string{"_udp-proxy"}) {
263 t.Fatalf("udp peer command=%q", peer.Args)263 t.Fatalf("udp peer command=%q", peer.Args)
264 }264 }
265 if peer.Transport.RTTMS != tc.wantRTT || peer.Transport.LossPercent != tc.wantLoss {265 if peer.Transport.RTTMS != tc.wantRTT || peer.Transport.LossPercent != tc.wantLoss {
M internal/adapters/dgram/proxy.go +1 -1
@@ -50,8 +50,8 @@ type proxyCopyDirection struct {
50 dst ports.Transport50 dst ports.Transport
51 recvKind proxyCopyErrKind51 recvKind proxyCopyErrKind
52 sendKind proxyCopyErrKind52 sendKind proxyCopyErrKind
53- retryRecoverable bool
54 transform func(ports.Frame) ports.Frame53 transform func(ports.Frame) ports.Frame
54+ retryRecoverable bool
55 }55 }
56 56
57 func (p ProxyRuntime) Run(ctx context.Context) error {57 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
39 func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }39 func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }
40 40
41 var (41 var (
42- startUDPBootstrap = func(ctx context.Context, target, session string, stderr io.Writer) bootstrapProcess {42+ startUDPBootstrap = func(ctx context.Context, target string, stderr io.Writer) bootstrapProcess {
43- spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", session)43+ spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", "")
44 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)44 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
45 cmd.Stderr = stderr45 cmd.Stderr = stderr
46 return execBootstrapProcess{cmd: cmd}46 return execBootstrapProcess{cmd: cmd}
@@ -98,19 +98,18 @@ func (b *limitedBuffer) String() string { return string(b.buf) }
98 // attach. SSH stdio is selected only through the explicit stdio transport mode.98 // attach. SSH stdio is selected only through the explicit stdio transport mode.
99 type RemoteDialer struct {99 type RemoteDialer struct {
100 Target string100 Target string
101- Session string
102 BootstrapTimeout time.Duration101 BootstrapTimeout time.Duration
103 ProbeTimeout time.Duration102 ProbeTimeout time.Duration
104 Log *slog.Logger103 Log *slog.Logger
105 RuntimeObserver ports.SerializedRuntimeObserver104 RuntimeObserver ports.SerializedRuntimeObserver
106 }105 }
107 106
108-func NewRemoteDialer(target, session string) RemoteDialer {107+func NewRemoteDialer(target, _ string) RemoteDialer {
109- return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout}108+ return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout}
110 }109 }
111 110
112-func NewRemoteDialerWithLogger(target, session string, log *slog.Logger) RemoteDialer {111+func NewRemoteDialerWithLogger(target, _ string, log *slog.Logger) RemoteDialer {
113- return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout, Log: log}112+ return RemoteDialer{Target: target, ProbeTimeout: defaultProbeTimeout, Log: log}
114 }113 }
115 114
116 func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {115 func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
@@ -129,7 +128,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
129 defer bootstrapCancel()128 defer bootstrapCancel()
130 129
131 var stderr limitedBuffer130 var stderr limitedBuffer
132- proc := startUDPBootstrap(bootstrapCtx, d.Target, d.Session, &stderr)131+ proc := startUDPBootstrap(bootstrapCtx, d.Target, &stderr)
133 stdout, err := proc.StdoutPipe()132 stdout, err := proc.StdoutPipe()
134 if err != nil {133 if err != nil {
135 return nil, udpUnavailable("bootstrap stdout", err, &stderr)134 return nil, udpUnavailable("bootstrap stdout", err, &stderr)
M internal/adapters/dgram/ssh_dialer_test.go +5 -5
@@ -33,7 +33,7 @@ func (p *fakeBootstrapProcess) Start() error { return p.st
33 func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }33 func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }
34 func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr }34 func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr }
35 35
36-func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string, io.Writer) bootstrapProcess) {36+func withBootstrapStarter(t *testing.T, fn func(context.Context, string, io.Writer) bootstrapProcess) {
37 t.Helper()37 t.Helper()
38 old := startUDPBootstrap38 old := startUDPBootstrap
39 startUDPBootstrap = fn39 startUDPBootstrap = fn
@@ -71,7 +71,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
71 if tt.name == "bootstrap wait failure" {71 if tt.name == "bootstrap wait failure" {
72 proc.waitErr = errors.New("exit status 255")72 proc.waitErr = errors.New("exit status 255")
73 }73 }
74- withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc })74+ withBootstrapStarter(t, func(context.Context, string, io.Writer) bootstrapProcess { return proc })
75 if tt.listen != nil {75 if tt.listen != nil {
76 withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen })76 withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen })
77 }77 }
@@ -120,9 +120,9 @@ func TestListenUDPPacketUsesDualStackWildcard(t *testing.T) {
120 func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {120 func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
121 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))121 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
122 proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}122 proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}
123- withBootstrapStarter(t, func(_ context.Context, target, session string, _ io.Writer) bootstrapProcess {123+ withBootstrapStarter(t, func(_ context.Context, target string, _ io.Writer) bootstrapProcess {
124- if target != "127.0.0.1" || session != "work" {124+ if target != "127.0.0.1" {
125- t.Fatalf("bootstrap target/session = %q/%q", target, session)125+ t.Fatalf("bootstrap target = %q", target)
126 }126 }
127 return proc127 return proc
128 })128 })
M internal/adapters/remote/remote_factory.go +3 -4
@@ -31,14 +31,14 @@ func (f DialerFactory) DialerForRemote(target string, session string, mode ports
31 if log != nil {31 if log != nil {
32 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)32 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
33 }33 }
34- dialer := dgram.NewRemoteDialerWithLogger(target, session, log)34+ dialer := dgram.NewRemoteDialerWithLogger(target, "", log)
35 dialer.RuntimeObserver = f.observer35 dialer.RuntimeObserver = f.observer
36 return dialer, nil36 return dialer, nil
37 case ports.RemoteTransportStdio:37 case ports.RemoteTransportStdio:
38 if log != nil {38 if log != nil {
39 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)39 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
40 }40 }
41- return stdioDialer{target: target, session: session, log: log, observer: f.observer}, nil41+ return stdioDialer{target: target, log: log, observer: f.observer}, nil
42 default:42 default:
43 return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)43 return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
44 }44 }
@@ -46,11 +46,10 @@ func (f DialerFactory) DialerForRemote(target string, session string, mode ports
46 46
47 type stdioDialer struct {47 type stdioDialer struct {
48 target string48 target string
49- session string
50 log *slog.Logger49 log *slog.Logger
51 observer ports.SerializedRuntimeObserver50 observer ports.SerializedRuntimeObserver
52 }51 }
53 52
54 func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {53 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))
56 }55 }
M internal/adapters/remote/remote_factory_test.go +5 -5
@@ -32,16 +32,16 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
32 if !ok {32 if !ok {
33 t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{})33 t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{})
34 }34 }
35- if got.Target != "remote.example" || got.Session != "work" {35+ if got.Target != "remote.example" {
36- t.Fatalf("dialer = %+v, want target/session copied", got)36+ t.Fatalf("dialer target = %q, want %q", got.Target, "remote.example")
37 }37 }
38 case stdioDialer:38 case stdioDialer:
39 got, ok := dialer.(stdioDialer)39 got, ok := dialer.(stdioDialer)
40 if !ok {40 if !ok {
41 t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{})41 t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{})
42 }42 }
43- if got.target != "remote.example" || got.session != "work" {43+ if got.target != "remote.example" {
44- t.Fatalf("dialer = %+v, want target/session copied", got)44+ t.Fatalf("dialer target = %q, want %q", got.target, "remote.example")
45 }45 }
46 }46 }
47 })47 })
@@ -64,7 +64,7 @@ func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {
64 }64 }
65 65
66 func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) {66 func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) {
67- dialer := stdioDialer{target: "remote.example", session: "work"}67+ dialer := stdioDialer{target: "remote.example"}
68 ctx, cancel := context.WithCancel(context.Background())68 ctx, cancel := context.WithCancel(context.Background())
69 cancel()69 cancel()
70 70
M internal/adapters/sshstdio/transport.go +35 -27
@@ -70,6 +70,8 @@ type transport struct {
70 operationCount int70 operationCount int
71 closing bool71 closing bool
72 operationsDone chan struct{}72 operationsDone chan struct{}
73+ closeOnce sync.Once
74+ closeErr error
73 75
74 mu sync.Mutex76 mu sync.Mutex
75 readBuf []byte77 readBuf []byte
@@ -160,22 +162,32 @@ func (t *transport) mapEOFError(err error) error {
160 }162 }
161 163
162 func (t *transport) Close() error {164 func (t *transport) Close() error {
163- done := t.beginShutdown()165+ t.closeOnce.Do(func() {
164- // Closing the reader first releases a Recv blocked in io.ReadFull even if166+ done := t.beginShutdown()
165- // the subprocess shutdown callback reports an error. This lets Recv emit167+ // Close owned streams before waiting for in-flight operations. The
166- // its failed end mark before the process observer is closed.168+ // writer is what releases a Send blocked on a child stdin pipe; the
167- var readErr error169+ // reader releases a Recv blocked in io.ReadFull.
168- if r, ok := t.r.(io.Closer); ok {170+ var writeErr, readErr error
169- readErr = r.Close()171+ if w, ok := t.w.(io.Closer); ok {
170- }172+ writeErr = w.Close()
171- closeErr := t.close()173+ }
172- if done != nil {174+ if r, ok := t.r.(io.Closer); ok {
173- <-done175+ readErr = r.Close()
174- }176+ }
175- if closeErr != nil {177+ closeErr := t.close()
176- return closeErr178+ if done != nil {
177- }179+ <-done
178- return readErr180+ }
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
179 }191 }
180 192
181 func (t *transport) beginObservedOperation() bool {193 func (t *transport) beginObservedOperation() bool {
@@ -294,18 +306,14 @@ type CommandSpec struct {
294 // BuildCommand constructs the local ssh subprocess argv without invoking a306 // BuildCommand constructs the local ssh subprocess argv without invoking a
295 // local shell. OpenSSH sends the remote command as one string for the remote307 // local shell. OpenSSH sends the remote command as one string for the remote
296 // user's shell to interpret, so every remote argv word is POSIX single-quoted.308 // user's shell to interpret, so every remote argv word is POSIX single-quoted.
297-func BuildCommand(target, session string) CommandSpec {309+func BuildCommand(target, _ string) CommandSpec {
298- return BuildCommandForMode(target, "_stdio", session)310+ return BuildCommandForMode(target, "_stdio", "")
299 }311 }
300 312
301 // BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev313 // BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev
302 // remote mode such as _stdio or _udp-bootstrap.314 // remote mode such as _stdio or _udp-bootstrap.
303-func BuildCommandForMode(target, mode, session string) CommandSpec {315+func BuildCommandForMode(target, mode, _ string) CommandSpec {
304- remote := []string{"vev", mode}316+ return BuildCommandForRemoteCommand(target, "vev", mode)
305- if session != "" {
306- remote = append(remote, session)
307- }
308- return BuildCommandForRemoteCommand(target, remote...)
309 }317 }
310 318
311 // BuildCommandForRemoteCommand constructs ssh argv for an arbitrary remote319 // BuildCommandForRemoteCommand constructs ssh argv for an arbitrary remote
@@ -324,9 +332,9 @@ func shellQuote(s string) string {
324 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"332 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
325 }333 }
326 334
327-// Dial starts ssh target vev _stdio [session] and returns a Transport over the335+// Dial starts ssh target vev _stdio and returns a Transport over the child
328-// child process' stdio. The subprocess is started with exec.Command argv, never336+// process' stdio. Session selection is carried in Hello. The subprocess is
329-// through a shell.337+// started with exec.Command argv, never through a shell.
330 func Dial(target, session string) (ports.Transport, error) {338 func Dial(target, session string) (ports.Transport, error) {
331 return DialContext(context.Background(), target, session)339 return DialContext(context.Background(), target, session)
332 }340 }
M internal/adapters/sshstdio/transport_test.go +43 -6
@@ -9,6 +9,7 @@ import (
9 "log/slog"9 "log/slog"
10 "os/exec"10 "os/exec"
11 "strings"11 "strings"
12+ "sync"
12 "testing"13 "testing"
13 "time"14 "time"
14 15
@@ -17,8 +18,9 @@ import (
17 )18 )
18 19
19 func TestBuildCommandUsesExecArgs(t *testing.T) {20 func TestBuildCommandUsesExecArgs(t *testing.T) {
20- // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio' [session]`.21+ // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio'`.
21- // Terminal color capability is carried in vev's Hello message, not in ssh env.22+ // Session selection and terminal color capability are carried in vev's Hello
23+ // message, not in ssh env.
22 tests := []struct {24 tests := []struct {
23 name string25 name string
24 target string26 target string
@@ -26,9 +28,8 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
26 want []string28 want []string
27 }{29 }{
28 {name: "no session", target: "user@example.com", want: []string{"--", "user@example.com", "'vev' '_stdio'"}},30 {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 /'"}},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'"}},
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'"}},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'"}},
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'"}},
32 }33 }
33 34
34 for _, tt := range tests {35 for _, tt := range tests {
@@ -51,7 +52,7 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
51 52
52 func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {53 func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {
53 got := BuildCommandForMode("user@example.com", "_udp-bootstrap", "work")54 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'"}
55 if got.Path != "ssh" {56 if got.Path != "ssh" {
56 t.Fatalf("Path = %q, want ssh", got.Path)57 t.Fatalf("Path = %q, want ssh", got.Path)
57 }58 }
@@ -107,6 +108,42 @@ func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) {
107 }108 }
108 }109 }
109 110
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+
110 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {147 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
111 clientRead, serverWrite := io.Pipe()148 clientRead, serverWrite := io.Pipe()
112 serverRead, clientWrite := io.Pipe()149 serverRead, clientWrite := io.Pipe()
M internal/app/cmd.go +40 -15
@@ -10,9 +10,11 @@ import (
10 "strings"10 "strings"
11 "text/tabwriter"11 "text/tabwriter"
12 12
13+ "github.com/bnema/vev/internal/adapters/clock"
13 "github.com/bnema/vev/internal/adapters/ipc"14 "github.com/bnema/vev/internal/adapters/ipc"
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 commandusecase "github.com/bnema/vev/internal/usecase/command"16 commandusecase "github.com/bnema/vev/internal/usecase/command"
17+ "github.com/bnema/vev/internal/usecase/daemon"
16 )18 )
17 19
18 type cmdInvocation struct {20 type cmdInvocation struct {
@@ -126,10 +128,11 @@ type cmdDeps struct {
126 stdout io.Writer128 stdout io.Writer
127 getenv func(string) string129 getenv func(string) string
128 dial func(context.Context, string) (ports.Transport, error)130 dial func(context.Context, string) (ports.Transport, error)
131+ clock ports.Clock
129 }132 }
130 133
131 func runCmd(ctx context.Context, invocation cmdInvocation) error {134 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()})
133 }136 }
134 137
135 func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {138 func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
@@ -157,11 +160,9 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
157 return usagef("--self requires running inside a vev pane")160 return usagef("--self requires running inside a vev pane")
158 }161 }
159 162
160- payload, err := ports.MarshalCommandRequest(request)163+ if _, err := ports.MarshalCommandRequest(request); errors.Is(err, ports.ErrTooManyCommandArgs) {
161- if errors.Is(err, ports.ErrTooManyCommandArgs) {
162 return usagef("too many command arguments")164 return usagef("too many command arguments")
163- }165+ } else if err != nil {
164- if err != nil {
165 return fmt.Errorf("encoding command: %w", err)166 return fmt.Errorf("encoding command: %w", err)
166 }167 }
167 168
@@ -171,20 +172,44 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
171 }172 }
172 defer func() { _ = transport.Close() }()173 defer func() { _ = transport.Close() }()
173 174
174- frame := ports.Frame{Type: ports.MsgCommand, Payload: payload}175+ tracker := daemon.NewCommandRequestTracker()
175- if err := transport.Send(frame); err != nil {176+ const generation = uint64(1)
176- return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}177+ requestID, outcome := tracker.Publish(generation)
177- }178+ request.RequestID = requestID
178- reply, err := transport.Recv()179+ payload, err := ports.MarshalCommandRequest(request)
179 if err != nil {180 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)
181 }183 }
182- if reply.Type != ports.MsgCommandResult {184+ if err := transport.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
183- return fmt.Errorf("unexpected command reply type %d", reply.Type)185+ tracker.Remove(requestID, generation)
186+ return &exitCoded{code: 3, err: fmt.Errorf("sending command: %w", err)}
184 }187 }
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)
186 if err != nil {211 if err != nil {
187- return fmt.Errorf("decoding command reply: %w", err)212+ return &exitCoded{code: 3, err: err}
188 }213 }
189 if !result.OK {214 if !result.OK {
190 if result.Code == ports.ErrInvalidCommandArgs {215 if result.Code == ports.ErrInvalidCommandArgs {
M internal/app/cmd_test.go +73 -5
@@ -9,8 +9,12 @@ import (
9 "strconv"9 "strconv"
10 "strings"10 "strings"
11 "testing"11 "testing"
12+ "time"
12 13
13 "github.com/bnema/vev/internal/ports"14 "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"
14 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
15 )19 )
16 20
@@ -287,6 +291,62 @@ func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) {
287 }291 }
288 }292 }
289 293
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+
290 func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {350 func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
291 dials := 0351 dials := 0
292 dialErr := errors.New("unreachable")352 dialErr := errors.New("unreachable")
@@ -349,11 +409,13 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
349 }409 }
350 410
351 type cmdTestTransport struct {411 type cmdTestTransport struct {
352- sent []ports.Frame412+ sent []ports.Frame
353- recv ports.Frame413+ recv ports.Frame
354- recvErr error414+ recvErr error
355- recvCalls int415+ recvCalls int
356- closeCalls int416+ closeCalls int
417+ recvStarted chan struct{}
418+ recvGate <-chan struct{}
357 }419 }
358 420
359 func (t *cmdTestTransport) Send(frame ports.Frame) error {421 func (t *cmdTestTransport) Send(frame ports.Frame) error {
@@ -363,6 +425,12 @@ func (t *cmdTestTransport) Send(frame ports.Frame) error {
363 425
364 func (t *cmdTestTransport) Recv() (ports.Frame, error) {426 func (t *cmdTestTransport) Recv() (ports.Frame, error) {
365 t.recvCalls++427 t.recvCalls++
428+ if t.recvStarted != nil {
429+ close(t.recvStarted)
430+ }
431+ if t.recvGate != nil {
432+ <-t.recvGate
433+ }
366 return t.recv, t.recvErr434 return t.recv, t.recvErr
367 }435 }
368 436
M internal/app/run.go +128 -194
@@ -47,7 +47,6 @@ import (
47 "github.com/bnema/vev/internal/platform"47 "github.com/bnema/vev/internal/platform"
48 "github.com/bnema/vev/internal/ports"48 "github.com/bnema/vev/internal/ports"
49 "github.com/bnema/vev/internal/usecase/client"49 "github.com/bnema/vev/internal/usecase/client"
50- "github.com/bnema/vev/internal/usecase/confirm"
51 "github.com/bnema/vev/internal/usecase/daemon"50 "github.com/bnema/vev/internal/usecase/daemon"
52 "github.com/bnema/vev/internal/usecase/recovery"51 "github.com/bnema/vev/internal/usecase/recovery"
53 pdgram "github.com/bnema/vev/pkg/dgram"52 pdgram "github.com/bnema/vev/pkg/dgram"
@@ -162,32 +161,20 @@ func parseArgs(args []string) (command, error) {
162 case "--daemon-launcher":161 case "--daemon-launcher":
163 return command{kind: kindDaemonLauncher}, nil162 return command{kind: kindDaemonLauncher}, nil
164 case "_stdio":163 case "_stdio":
165- if len(args) > 2 {164+ if len(args) != 1 {
166- return command{}, usagef("`_stdio` accepts at most one session name")165+ return command{}, usagef("`_stdio` does not accept a session name")
167- }
168- cmd := command{kind: kindStdio}
169- if len(args) == 2 {
170- cmd.name = args[1]
171 }166 }
172- return cmd, nil167+ return command{kind: kindStdio}, nil
173 case "_udp-bootstrap":168 case "_udp-bootstrap":
174- if len(args) > 2 {169+ if len(args) != 1 {
175- return command{}, usagef("`_udp-bootstrap` accepts at most one session name")170+ return command{}, usagef("`_udp-bootstrap` does not accept a session name")
176 }171 }
177- cmd := command{kind: kindUDPBootstrap}172+ return command{kind: kindUDPBootstrap}, nil
178- if len(args) == 2 {
179- cmd.name = args[1]
180- }
181- return cmd, nil
182 case "_udp-proxy":173 case "_udp-proxy":
183- if len(args) > 2 {174+ if len(args) != 1 {
184- return command{}, usagef("`_udp-proxy` accepts at most one session name")175+ return command{}, usagef("`_udp-proxy` does not accept a session name")
185 }176 }
186- cmd := command{kind: kindUDPProxy}177+ return command{kind: kindUDPProxy}, nil
187- if len(args) == 2 {
188- cmd.name = args[1]
189- }
190- return cmd, nil
191 case "new":178 case "new":
192 if len(args) < 2 || args[1] == "" {179 if len(args) < 2 || args[1] == "" {
193 return command{}, usagef("`new` requires a session name")180 return command{}, usagef("`new` requires a session name")
@@ -208,6 +195,14 @@ func parseArgs(args []string) (command, error) {
208 }195 }
209 cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]}196 cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]}
210 if target, session, ok := parseRemoteAttachTarget(args[1]); ok {197 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+ }
211 cmd.remoteTarget = target206 cmd.remoteTarget = target
212 cmd.name = session207 cmd.name = session
213 if session == "" {208 if session == "" {
@@ -271,9 +266,9 @@ func dispatch(ctx context.Context, cmd command) error {
271 case kindStdio:266 case kindStdio:
272 return runStdio(ctx)267 return runStdio(ctx)
273 case kindUDPBootstrap:268 case kindUDPBootstrap:
274- return runUDPBootstrap(ctx, cmd.name)269+ return runUDPBootstrap(ctx)
275 case kindUDPProxy:270 case kindUDPProxy:
276- return runUDPProxy(ctx, cmd.name, os.Stdout)271+ return runUDPProxy(ctx, os.Stdout)
277 case kindList:272 case kindList:
278 return runList(ctx, cmd)273 return runList(ctx, cmd)
279 case kindHost:274 case kindHost:
@@ -788,6 +783,16 @@ func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error)
788 }783 }
789 }784 }
790 785
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+
791 // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from796 // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from
792 // the same validated transport selection used by direct remote attach.797 // the same validated transport selection used by direct remote attach.
793 func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRuntimeObserver, transport string) (daemon.Option, error) {798 func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRuntimeObserver, transport string) (daemon.Option, error) {
@@ -804,6 +809,12 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti
804 ), nil809 ), nil
805 }810 }
806 811
812+type attachHandoffKey struct {
813+ endpoint string
814+ session string
815+ intent uint8
816+}
817+
807 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {818 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
808 if activeSession != "" {819 if activeSession != "" {
809 if remoteTarget == "" && intent == ports.IntentNew {820 if remoteTarget == "" && intent == ports.IntentNew {
@@ -816,100 +827,85 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
816 if runClient == nil {827 if runClient == nil {
817 runClient = runClientWithDeps828 runClient = runClientWithDeps
818 }829 }
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{})
819 if remoteTarget != "" {837 if remoteTarget != "" {
820- mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)838+ if modeErr != nil {
821- if err != nil {839+ return modeErr
822- return err
823 }840 }
824- factory := deps.remoteDialerFactory
825 if factory == nil {841 if factory == nil {
826 factory = defaultRemoteDialerFactory()842 factory = defaultRemoteDialerFactory()
827 }843 }
828- if log != nil {844+ seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
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 }845 }
845 846
846- localDialer := deps.localDialer847+ for {
847- if localDialer == nil {848+ var err error
848- localDialer = defaultLocalDialer849+ if remoteTarget != "" {
849- }850+ if log != nil {
850- return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{851+ log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
851- confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),852+ }
852- attach: func(ctx context.Context, intent uint8, name string) error {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 {
853 if log != nil {868 if log != nil {
854 log.Info("attaching to local session", "intent", intent, "name", name)869 log.Info("attaching to local session", "intent", intent, "name", name)
855 }870 }
856- return runClient(ctx, client.Dependencies{871+ err = runClient(ctx, client.Dependencies{
857 Dialer: localDialer(),872 Dialer: localDialer(),
858 Terminal: term.New(),873 Terminal: term.New(),
859 Clock: clock.New(),874 Clock: clock.New(),
860 Logger: log,875 Logger: log,
861 RuntimeObserver: deps.runtimeObserver,876 RuntimeObserver: deps.runtimeObserver,
877+ Remote: false,
862 }, client.AttachRequest{Intent: intent, SessionName: name})878 }, client.AttachRequest{Intent: intent, SessionName: name})
863- },879+ }
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- }
887 880
888- ok, promptErr := deps.confirmer.Confirm("Your vev version differs from the running daemon; kill it and restart?")881+ var handoff *client.AttachTargetError
889- if promptErr != nil {882+ if !errors.As(err, &handoff) {
890- return fmt.Errorf("vev: reading confirmation: %w", promptErr)883+ return err
891- }884+ }
892- if !ok {885+ if handoff == nil {
893- return err886+ return fmt.Errorf("vev: invalid remote attach handoff")
894- }887+ }
895- if killErr := deps.killDaemon(ctx); killErr != nil {888+ if err := validateRemoteAttachHandoff(handoff.Target); err != nil {
896- return killErr889+ return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
897- }890+ }
898- if deps.settleAfterKill != nil {891+ next := attachHandoffKey{endpoint: handoff.Target.Endpoint, session: handoff.Target.Session, intent: handoff.Target.Intent}
899- if settleErr := deps.settleAfterKill(ctx); settleErr != nil {892+ if _, seen := seenHandoffs[next]; seen {
900- return settleErr893+ 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()
901 }904 }
902 }905 }
903- return deps.attach(ctx, intent, name)
904 }906 }
905 907
906-func isDaemonVersionDrift(err error) bool {908+const daemonStopTimeout = 2 * time.Second
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-}
913 909
914 type localDaemonDialer struct {910 type localDaemonDialer struct {
915 dir string911 dir string
@@ -989,17 +985,6 @@ func createDetachedLocalSession(ctx context.Context, name string) error {
989 }985 }
990 }986 }
991 987
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-
1003 func requestDaemonStop(ctx context.Context) error {988 func requestDaemonStop(ctx context.Context) error {
1004 ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout)989 ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout)
1005 defer cancel()990 defer cancel()
@@ -1052,8 +1037,27 @@ func runStdio(ctx context.Context) (retErr error) {
1052 return err1037 return err
1053 }1038 }
1054 defer func() { _ = transport.Close() }()1039 defer func() { _ = transport.Close() }()
1055- stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer))1040+
1056- return runStdioProxy(ctx, stdio, transport, os.Environ(), log)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)
1057 }1061 }
1058 1062
1059 var (1063 var (
@@ -1065,12 +1069,12 @@ var (
1065 1069
1066 // runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness1070 // runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness
1067 // line, and exits so SSH stdio can close without owning the UDP proxy lifetime.1071 // line, and exits so SSH stdio can close without owning the UDP proxy lifetime.
1068-func runUDPBootstrap(ctx context.Context, session string) error {1072+func runUDPBootstrap(ctx context.Context) error {
1069 // Always start a fresh proxy for a bootstrap request. A client attach begins1073 // Always start a fresh proxy for a bootstrap request. A client attach begins
1070 // with MsgHello, which must reach the daemon handshake path; reusing an1074 // with MsgHello, which must reach the daemon handshake path; reusing an
1071 // already-running proxy would forward that Hello into the daemon connection's1075 // already-running proxy would forward that Hello into the daemon connection's
1072- // post-handshake runConnLoop, where it is intentionally ignored. The new1076+ // post-handshake runConnLoop, where it is intentionally ignored. Every
1073- // proxy publishes itself and retires any older registry entry once ready.1077+ // bootstrap starts a fresh byte-only carriage.
1074 r, w, err := os.Pipe()1078 r, w, err := os.Pipe()
1075 if err != nil {1079 if err != nil {
1076 return err1080 return err
@@ -1083,9 +1087,6 @@ func runUDPBootstrap(ctx context.Context, session string) error {
1083 return err1087 return err
1084 }1088 }
1085 args := []string{"_udp-proxy"}1089 args := []string{"_udp-proxy"}
1086- if session != "" {
1087- args = append(args, session)
1088- }
1089 devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)1090 devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
1090 if err != nil {1091 if err != nil {
1091 _ = w.Close()1092 _ = w.Close()
@@ -1136,13 +1137,13 @@ func runUDPBootstrap(ctx context.Context, session string) error {
1136 }1137 }
1137 1138
1138 // runUDPProxy is the detached long-lived remote-side datagram proxy.1139 // 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) {
1140 log, logCloser, err := configureLogging(logging.Stdio, false)1141 log, logCloser, err := configureLogging(logging.Stdio, false)
1141 if err != nil {1142 if err != nil {
1142 return err1143 return err
1143 }1144 }
1144 defer func() { _ = logCloser.Close() }()1145 defer func() { _ = logCloser.Close() }()
1145- log.Debug("udp proxy starting", "session", session)1146+ log.Debug("udp carriage starting")
1146 1147
1147 observer, observerCloser, err := newPerformanceTrace(clock.New())1148 observer, observerCloser, err := newPerformanceTrace(clock.New())
1148 if err != nil {1149 if err != nil {
@@ -1173,13 +1174,9 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
1173 if !ok {1174 if !ok {
1174 return errors.New("vev: udp proxy did not get a UDP address")1175 return errors.New("vev: udp proxy did not get a UDP address")
1175 }1176 }
1176- registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies"))
1177-
1178 var (1177 var (
1179- dg *dgram.Transport1178+ dg *dgram.Transport
1180- record dgram.ProxyRecord1179+ setupErr error
1181- published bool
1182- setupErr error
1183 )1180 )
1184 pdgram.SecretDo(func() {1181 pdgram.SecretDo(func() {
1185 key := make([]byte, pdgram.KeySize)1182 key := make([]byte, pdgram.KeySize)
@@ -1191,12 +1188,6 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
1191 if setupErr != nil {1188 if setupErr != nil {
1192 return1189 return
1193 }1190 }
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-
1200 readiness := append([]byte("VEV-UDP "), strconv.Itoa(addr.Port)...)1191 readiness := append([]byte("VEV-UDP "), strconv.Itoa(addr.Port)...)
1201 readiness = append(readiness, ' ')1192 readiness = append(readiness, ' ')
1202 readiness = base64.StdEncoding.AppendEncode(readiness, key)1193 readiness = base64.StdEncoding.AppendEncode(readiness, key)
@@ -1210,15 +1201,10 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
1210 if dg != nil {1201 if dg != nil {
1211 _ = dg.Close()1202 _ = dg.Close()
1212 }1203 }
1213- if published {
1214- _ = registry.RemoveOwned(record)
1215- }
1216 return setupErr1204 return setupErr
1217 }1205 }
1218 defer func() { _ = dg.Close() }()1206 defer func() { _ = dg.Close() }()
1219- defer func() { _ = registry.RemoveOwned(record) }()1207+ return runUDPProxyRuntime(ctx, dg, daemonTr, log)
1220-
1221- return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log)
1222 }1208 }
1223 1209
1224 const udpProxyIdleTTL = 15 * time.Minute1210 const udpProxyIdleTTL = 15 * time.Minute
@@ -1309,66 +1295,14 @@ func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, erro
1309 return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)1295 return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
1310 }1296 }
1311 1297
1312-// firstHelloTransport applies proxy-owned values to exactly the first Hello1298+// runStdioProxy forwards the framed transport without interpreting protocol
1313-// crossing a remote boundary. It intentionally leaves all later frames and1299+// messages. Session selection remains in the Hello sent by the thin client.
1314-// malformed Hellos untouched so proxying preserves the client stream.1300+func runStdioProxy(ctx context.Context, client, daemon ports.Transport, log *slog.Logger) error {
1315-type firstHelloTransport struct {1301+ return proxyTransports(ctx, client, daemon, log)
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)
1361 }1302 }
1362 1303
1363-// runUDPProxyRuntime applies the remote host environment and selected session1304+func runUDPProxyRuntime(ctx context.Context, client, daemon ports.Transport, log *slog.Logger) error {
1364-// before the datagram stream reaches the daemon.1305+ return dgram.ProxyRuntime{Client: client, Daemon: daemon, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)
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)
1372 }1306 }
1373 1307
1374 func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger) error {1308 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 (
23 "github.com/bnema/vev/internal/ports"23 "github.com/bnema/vev/internal/ports"
24 portsmocks "github.com/bnema/vev/internal/ports/mocks"24 portsmocks "github.com/bnema/vev/internal/ports/mocks"
25 "github.com/bnema/vev/internal/usecase/client"25 "github.com/bnema/vev/internal/usecase/client"
26- "github.com/bnema/vev/internal/usecase/confirm"
27 "github.com/bnema/vev/internal/usecase/daemon"26 "github.com/bnema/vev/internal/usecase/daemon"
28 "github.com/bnema/vev/pkg/kv"27 "github.com/bnema/vev/pkg/kv"
29 "github.com/bnema/vev/pkg/safedir"28 "github.com/bnema/vev/pkg/safedir"
@@ -411,139 +410,6 @@ func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
411 // scriptRecv makes a transport yield frames in order, then wait for done410 // scriptRecv makes a transport yield frames in order, then wait for done
412 // before returning EOF. The shared done channel coordinates proxy readers411 // before returning EOF. The shared done channel coordinates proxy readers
413 // without relying on scheduling or sleeps.412 // 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-
547 func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {413 func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
548 require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)414 require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
549 }415 }
@@ -579,9 +445,12 @@ func TestParseArgs(t *testing.T) {
579 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},445 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
580 {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},446 {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
581 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},447 {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"},448+ {name: "attach remote host 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"},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"},
584- {name: "attach remote with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", 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},
585 {name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true},454 {name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true},
586 {name: "attach without name", args: []string{"attach"}, wantErr: true},455 {name: "attach without name", args: []string{"attach"}, wantErr: true},
587 {name: "ls", args: []string{"ls"}, wantKind: kindList},456 {name: "ls", args: []string{"ls"}, wantKind: kindList},
@@ -613,12 +482,13 @@ func TestParseArgs(t *testing.T) {
613 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},482 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
614 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},483 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
615 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},484 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
616- {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},485+ {name: "stdio rejects session", args: []string{"_stdio", "work"}, wantErr: true},
617- {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},486+ {name: "stdio rejects extra args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
618- {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},487+ {name: "udp bootstrap", args: []string{"_udp-bootstrap"}, wantKind: kindUDPBootstrap},
619- {name: "udp bootstrap", args: []string{"_udp-bootstrap", "work"}, wantKind: kindUDPBootstrap, wantName: "work"},488+ {name: "udp bootstrap rejects session", args: []string{"_udp-bootstrap", "work"}, wantErr: true},
620- {name: "udp proxy", args: []string{"_udp-proxy", "work"}, wantKind: kindUDPProxy, wantName: "work"},489+ {name: "udp proxy", args: []string{"_udp-proxy"}, wantKind: kindUDPProxy},
621- {name: "udp proxy too many args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},490+ {name: "udp proxy rejects session", args: []string{"_udp-proxy", "work"}, wantErr: true},
491+ {name: "udp proxy rejects extra args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
622 {name: "help", args: []string{"--help"}, wantKind: kindHelp},492 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
623 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},493 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
624 {name: "version", args: []string{"--version"}, wantKind: kindVersion},494 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
@@ -633,13 +503,13 @@ func TestParseArgs(t *testing.T) {
633 t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)503 t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)
634 }504 }
635 if tt.nonUsageErr {505 if tt.nonUsageErr {
636- var ue *usageError506+ _, usage := errors.AsType[*usageError](err)
637- if errors.As(err, &ue) {507+ if usage {
638 t.Fatalf("parseArgs(%q) error = *usageError, want non-usage error", tt.args)508 t.Fatalf("parseArgs(%q) error = *usageError, want non-usage error", tt.args)
639 }509 }
640 } else {510 } else {
641- var ue *usageError511+ _, usage := errors.AsType[*usageError](err)
642- if !errors.As(err, &ue) {512+ if !usage {
643 t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)513 t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)
644 }514 }
645 }515 }
@@ -689,6 +559,31 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
689 }559 }
690 }560 }
691 561
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+
692 func TestListShowsBroken(t *testing.T) {587 func TestListShowsBroken(t *testing.T) {
693 var out bytes.Buffer588 var out bytes.Buffer
694 printSessions(&out, []ports.SessionInfo{589 printSessions(&out, []ports.SessionInfo{
@@ -859,131 +754,6 @@ func captureStdout(t *testing.T, fn func()) string {
859 return string(out)754 return string(out)
860 }755 }
861 756
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-
987 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {757 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
988 called := false758 called := false
989 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{759 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
@@ -1073,7 +843,10 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
1073 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)843 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
1074 }844 }
1075 gotDialer = nd.name845 gotDialer = nd.name
1076- gotRemote = request.Remote846+ gotRemote = deps.Remote
847+ if request.Remote {
848+ t.Fatal("remote carriage metadata must not be in the attach request")
849+ }
1077 gotClipboard = deps.Clipboard850 gotClipboard = deps.Clipboard
1078 if request.Intent != ports.IntentAttach || request.SessionName != "work" {851 if request.Intent != ports.IntentAttach || request.SessionName != "work" {
1079 t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)852 t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)
@@ -1097,6 +870,92 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
1097 }870 }
1098 }871 }
1099 872
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+
1100 func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {959 func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
1101 factory := portsmocks.NewMockRemoteDialerFactory(t)960 factory := portsmocks.NewMockRemoteDialerFactory(t)
1102 runClientCalled := false961 runClientCalled := false
@@ -1153,7 +1012,10 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
1153 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)1012 t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
1154 }1013 }
1155 gotDialer = nd.name1014 gotDialer = nd.name
1156- gotRemote = request.Remote1015+ gotRemote = deps.Remote
1016+ if request.Remote {
1017+ t.Fatal("local carriage metadata must not be in the attach request")
1018+ }
1157 gotClipboard = deps.Clipboard1019 gotClipboard = deps.Clipboard
1158 if request.Intent != ports.IntentEphemeral || request.SessionName != "" {1020 if request.Intent != ports.IntentEphemeral || request.SessionName != "" {
1159 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)1021 t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)
@@ -1189,7 +1051,7 @@ func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
1189 t.Cleanup(func() { udpProxyCommand = oldCommand })1051 t.Cleanup(func() { udpProxyCommand = oldCommand })
1190 1052
1191 got := captureStdout(t, func() {1053 got := captureStdout(t, func() {
1192- if err := runUDPBootstrap(context.Background(), "work"); err != nil {1054+ if err := runUDPBootstrap(context.Background()); err != nil {
1193 t.Fatalf("runUDPBootstrap() error = %v", err)1055 t.Fatalf("runUDPBootstrap() error = %v", err)
1194 }1056 }
1195 })1057 })
@@ -1214,7 +1076,7 @@ func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) {
1214 }1076 }
1215 t.Cleanup(func() { udpProxyCommand = oldCommand })1077 t.Cleanup(func() { udpProxyCommand = oldCommand })
1216 1078
1217- err := runUDPBootstrap(context.Background(), "work")1079+ err := runUDPBootstrap(context.Background())
1218 if !errors.Is(err, io.EOF) {1080 if !errors.Is(err, io.EOF) {
1219 t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)1081 t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)
1220 }1082 }
@@ -1230,7 +1092,7 @@ func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
1230 }1092 }
1231 t.Cleanup(func() { udpProxyCommand = oldCommand })1093 t.Cleanup(func() { udpProxyCommand = oldCommand })
1232 1094
1233- err := runUDPBootstrap(context.Background(), "work")1095+ err := runUDPBootstrap(context.Background())
1234 if !errors.Is(err, context.DeadlineExceeded) {1096 if !errors.Is(err, context.DeadlineExceeded) {
1235 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)1097 t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
1236 }1098 }
M internal/ports/frame.go +6 -0
@@ -1,5 +1,11 @@
1 package ports1 package ports
2 2
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+
3 // ProtocolVersion is the current vev IPC wire protocol version.9 // ProtocolVersion is the current vev IPC wire protocol version.
4 const ProtocolVersion uint16 = 2310 const ProtocolVersion uint16 = 23
5 11
M internal/usecase/client/client.go +147 -81
@@ -129,9 +129,9 @@ const stdinBufSize = 4096
129 // goroutine, decoupling the input/resize pumps from transport back-pressure.129 // goroutine, decoupling the input/resize pumps from transport back-pressure.
130 const sendQueueDepth = 64130 const sendQueueDepth = 64
131 131
132-// preWelcomeTimeout bounds each blocking operation before the daemon has132+// preWelcomeTimeout remains the focused-test name for the shared handshake
133-// accepted the attach. Mosh uses the same 15-second startup budget.133+// budget, which now also covers connect and initial publication.
134-const preWelcomeTimeout = 15 * time.Second134+const preWelcomeTimeout = ports.HandshakeTimeout
135 135
136 // remoteHostLearnerShutdownTimeout bounds how long Run waits for async remote136 // remoteHostLearnerShutdownTimeout bounds how long Run waits for async remote
137 // host learning after terminal restoration. Learning is best-effort; a stalled137 // host learning after terminal restoration. Learning is best-effort; a stalled
@@ -155,9 +155,13 @@ type Dependencies struct {
155 Logger *slog.Logger155 Logger *slog.Logger
156 RuntimeObserver ports.SerializedRuntimeObserver156 RuntimeObserver ports.SerializedRuntimeObserver
157 RemoteHostLearner ports.RemoteHostLearner157 RemoteHostLearner ports.RemoteHostLearner
158+ // Remote selects client-side carriage presentation only; it never enters
159+ // the daemon-facing session request.
160+ Remote bool
158 }161 }
159 162
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.
161 type AttachRequest struct {165 type AttachRequest struct {
162 Intent uint8166 Intent uint8
163 SessionName string167 SessionName string
@@ -173,6 +177,7 @@ type Runner struct {
173 logger *slog.Logger177 logger *slog.Logger
174 runtimeObserver ports.SerializedRuntimeObserver178 runtimeObserver ports.SerializedRuntimeObserver
175 remoteHostLearner ports.RemoteHostLearner179 remoteHostLearner ports.RemoteHostLearner
180+ remote bool
176 181
177 inputMu sync.Mutex182 inputMu sync.Mutex
178 input *terminalInputPump183 input *terminalInputPump
@@ -192,6 +197,7 @@ func NewRunner(deps Dependencies) *Runner {
192 logger: log,197 logger: log,
193 runtimeObserver: deps.RuntimeObserver,198 runtimeObserver: deps.RuntimeObserver,
194 remoteHostLearner: deps.RemoteHostLearner,199 remoteHostLearner: deps.RemoteHostLearner,
200+ remote: deps.Remote,
195 }201 }
196 }202 }
197 203
@@ -282,9 +288,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
282 }288 }
283 }289 }
284 }()290 }()
291+ remote := request.Remote || r.remote
285 reconnect := &reconnectUI{292 reconnect := &reconnectUI{
286 term: r.term,293 term: r.term,
287- remote: request.Remote,294+ remote: remote,
288 rawEntered: &rawEntered,295 rawEntered: &rawEntered,
289 stage: reconnectStageOfflineRetrying,296 stage: reconnectStageOfflineRetrying,
290 }297 }
@@ -306,8 +313,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
306 }313 }
307 314
308 for {315 for {
309- transport, err := r.dialer.Dial(ctx)316+ handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
317+ transport, err := boundedDial(handshakeCtx, r.dialer)
310 if err != nil {318 if err != nil {
319+ err = handshakeContextError(ctx, timedOut, err)
320+ finishHandshake()
311 if resumeToken == 0 || ctx.Err() != nil {321 if resumeToken == 0 || ctx.Err() != nil {
312 if clearErr := reconnect.clear(); clearErr != nil {322 if clearErr := reconnect.clear(); clearErr != nil {
313 return errors.Join(err, clearErr)323 return errors.Join(err, clearErr)
@@ -333,32 +343,57 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
333 continue343 continue
334 }344 }
335 ms.dialed = true345 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+ }
336 361
337 var linkEvents <-chan ports.LinkEvent362 var linkEvents <-chan ports.LinkEvent
338- if reporter, ok := transport.(ports.LinkStateReporter); ok {363+ if reporter, ok := connection.Transport().(ports.LinkStateReporter); ok {
339 linkEvents = reporter.LinkEvents()364 linkEvents = reporter.LinkEvents()
340 }365 }
341 result := (&attachAttempt{366 result := (&attachAttempt{
342- runner: r,367+ runner: r,
343- transport: transport,368+ connection: connection,
344- request: attemptRequest,369+ remote: remote,
345- resumeToken: resumeToken,370+ handshakeCtx: handshakeCtx,
346- clientID: processClientID,371+ handshakeTimedOut: timedOut,
347- milestones: &ms,372+ finishHandshake: finishHandshake,
348- themeState: themeState,373+ stopHandshakeTransport: stopHandshakeTransport,
349- enterRaw: enterRaw,374+ request: attemptRequest,
350- reconnect: reconnect,375+ resumeToken: resumeToken,
351- linkEvents: linkEvents,376+ clientID: processClientID,
352- rememberRemoteHost: rememberRemoteHost,377+ milestones: &ms,
378+ themeState: themeState,
379+ enterRaw: enterRaw,
380+ reconnect: reconnect,
381+ linkEvents: linkEvents,
382+ rememberRemoteHost: rememberRemoteHost,
353 terminalInput: func() *terminalInputPump {383 terminalInput: func() *terminalInputPump {
354 return input384 return input
355 },385 },
356 }).run(ctx)386 }).run(ctx)
387+ stopHandshakeTransport()
388+ finishHandshake()
357 if result.welcomed {389 if result.welcomed {
358 backoff = defaultReconnectBackoff.initial390 backoff = defaultReconnectBackoff.initial
359 }391 }
360 if !result.transportClosed {392 if !result.transportClosed {
361- _ = transport.Close()393+ _ = connection.Close()
394+ }
395+ if result.target != nil {
396+ return &AttachTargetError{Target: *result.target}
362 }397 }
363 if result.resumeToken != 0 {398 if result.resumeToken != 0 {
364 resumeToken = result.resumeToken399 resumeToken = result.resumeToken
@@ -514,18 +549,24 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
514 }549 }
515 550
516 type attachAttempt struct {551 type attachAttempt struct {
517- runner *Runner552+ runner *Runner
518- transport ports.Transport553+ connection *SessionConnection
519- request AttachRequest554+ remote bool
520- resumeToken uint64555+ transport ports.Transport
521- clientID [16]byte556+ handshakeCtx context.Context
522- milestones *milestones557+ handshakeTimedOut <-chan struct{}
523- themeState *terminalThemeState558+ finishHandshake func()
524- enterRaw func() error559+ stopHandshakeTransport func()
525- reconnect *reconnectUI560+ request AttachRequest
526- linkEvents <-chan ports.LinkEvent561+ resumeToken uint64
527- rememberRemoteHost func()562+ clientID [16]byte
528- terminalInput func() *terminalInputPump563+ milestones *milestones
564+ themeState *terminalThemeState
565+ enterRaw func() error
566+ reconnect *reconnectUI
567+ linkEvents <-chan ports.LinkEvent
568+ rememberRemoteHost func()
569+ terminalInput func() *terminalInputPump
529 }570 }
530 571
531 type attachResult struct {572 type attachResult struct {
@@ -533,48 +574,21 @@ type attachResult struct {
533 sessionName string574 sessionName string
534 welcomed bool575 welcomed bool
535 transportClosed bool576 transportClosed bool
577+ target *ports.AttachTarget
536 err error578 err error
537 }579 }
538 580
539-// boundedPreWelcome runs one blocking handshake operation. Transport methods581+// AttachTargetError requests that the composition root replace the current
540-// do not accept a context, so cancellation and expiry close this attempt's582+// transport with the selected endpoint. The daemon never opens that endpoint.
541-// transport to interrupt the call, then wait for its goroutine to exit.583+type AttachTargetError struct {
542-func boundedPreWelcome(ctx context.Context, clk ports.Clock, transport ports.Transport, operation func() error) (bool, error) {584+ Target ports.AttachTarget
543- if err := ctx.Err(); err != nil {585+}
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() }()
552 586
553- select {587+func (e *AttachTargetError) Error() string {
554- case err := <-completed:588+ if e == nil {
555- // Prefer a cancellation/expiry observed concurrently with completion.589+ return "client: attach target handoff"
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
577 }590 }
591+ return fmt.Sprintf("client: attach target %q/%q", e.Target.Endpoint, e.Target.Session)
578 }592 }
579 593
580 // paletteSlot validates the scanner's signed slot before narrowing it for the594 // paletteSlot validates the scanner's signed slot before narrowing it for the
@@ -601,10 +615,15 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
601 615
602 func (a *attachAttempt) run(ctx context.Context) attachResult {616 func (a *attachAttempt) run(ctx context.Context) attachResult {
603 transport := a.transport617 transport := a.transport
618+ request := a.request
619+ if a.connection != nil {
620+ transport = a.connection.Transport()
621+ request = a.connection.AttachRequest()
622+ }
604 term := a.runner.term623 term := a.runner.term
605 clk := a.runner.clock624 clk := a.runner.clock
606- intent := a.request.Intent625+ intent := request.Intent
607- name := a.request.SessionName626+ name := request.SessionName
608 resumeToken := a.resumeToken627 resumeToken := a.resumeToken
609 clientID := a.clientID628 clientID := a.clientID
610 ms := a.milestones629 ms := a.milestones
@@ -612,10 +631,45 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
612 enterRaw := a.enterRaw631 enterRaw := a.enterRaw
613 reconnect := a.reconnect632 reconnect := a.reconnect
614 linkEvents := a.linkEvents633 linkEvents := a.linkEvents
615- remote := a.request.Remote634+ remote := a.remote || a.request.Remote
616 clipboard := a.runner.clipboard635 clipboard := a.runner.clipboard
617 log := a.runner.logger636 log := a.runner.logger
618 observer := a.runner.runtimeObserver637 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+ }
619 // 1. Handshake: send Hello with our size and TERM.673 // 1. Handshake: send Hello with our size and TERM.
620 size, err := term.Size()674 size, err := term.Size()
621 if err != nil {675 if err != nil {
@@ -642,22 +696,21 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
642 MaxOutputInFlight: requestedOutputWindow(transport),696 MaxOutputInFlight: requestedOutputWindow(transport),
643 Env: os.Environ(),697 Env: os.Environ(),
644 }698 }
645- if closed, err := boundedPreWelcome(ctx, clk, transport, func() error {699+ if err := sendHandshake(func() error {
646 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})700 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
647 }); err != nil {701 }); err != nil {
648- return attachResult{transportClosed: closed, err: fmt.Errorf("vev: sending hello: %w", err)}702+ return handshakeFailure("sending hello", err)
649 }703 }
650 ms.helloSent = true704 ms.helloSent = true
651 705
652 // 2. Await Welcome or a typed rejection.706 // 2. Await Welcome or a typed rejection.
653 var reply ports.Frame707 var reply ports.Frame
654- closed, err := boundedPreWelcome(ctx, clk, transport, func() error {708+ if err := boundedHandshakeOperation(handshakeCtx, transport, func() error {
655 var recvErr error709 var recvErr error
656 reply, recvErr = transport.Recv()710 reply, recvErr = transport.Recv()
657 return recvErr711 return recvErr
658- })712+ }); err != nil {
659- if err != nil {713+ return handshakeFailure("awaiting welcome", err)
660- return attachResult{transportClosed: closed, err: fmt.Errorf("vev: awaiting welcome: %w", err)}
661 }714 }
662 result := func(welcomed bool, err error) attachResult {715 result := func(welcomed bool, err error) attachResult {
663 return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err}716 return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err}
@@ -686,6 +739,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
686 default:739 default:
687 return result(false, fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type))740 return result(false, fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type))
688 }741 }
742+ if err := handshakeCtx.Err(); err != nil {
743+ return handshakeFailure("validating welcome", err)
744+ }
689 welcomedResult := func(err error) attachResult { return result(true, err) }745 welcomedResult := func(err error) attachResult { return result(true, err) }
690 746
691 // 3. Enter raw mode after Welcome; Run owns restoration.747 // 3. Enter raw mode after Welcome; Run owns restoration.
@@ -747,7 +803,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
747 // keeps the generation publication ordered before the first query and803 // keeps the generation publication ordered before the first query and
748 // avoids leaving a queued Theme behind an immediate detach.804 // avoids leaving a queued Theme behind an immediate detach.
749 if !senderStarted {805 if !senderStarted {
750- if err := transport.Send(frame); err != nil {806+ if err := sendHandshake(func() error { return transport.Send(frame) }); err != nil {
751 return fmt.Errorf("sending initial theme: %w", err)807 return fmt.Errorf("sending initial theme: %w", err)
752 }808 }
753 return nil809 return nil
@@ -836,7 +892,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
836 if reconnect.remote {892 if reconnect.remote {
837 // Before the asynchronous sender starts, preserve transport ordering by893 // Before the asynchronous sender starts, preserve transport ordering by
838 // requesting the reset synchronously after the initial Theme publication.894 // requesting the reset synchronously after the initial Theme publication.
839- closed, resetErr := boundedPreWelcome(ctx, clk, transport, func() error {895+ resetErr := sendHandshake(func() error {
840 payload, err := ports.MarshalResize(ports.Resize{Size: size})896 payload, err := ports.MarshalResize(ports.Resize{Size: size})
841 if err != nil {897 if err != nil {
842 return err898 return err
@@ -844,8 +900,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
844 return transport.Send(ports.Frame{Type: ports.MsgResize, Payload: payload})900 return transport.Send(ports.Frame{Type: ports.MsgResize, Payload: payload})
845 })901 })
846 if resetErr != nil {902 if resetErr != nil {
847- resetResult := welcomedResult(fmt.Errorf("requesting reconnect reconciliation: %w", resetErr))903+ resetErr = handshakeContextError(ctx, handshakeTimedOut, resetErr)
848- resetResult.transportClosed = closed904+ resetResult := welcomedResult(fmt.Errorf("vev: requesting reconnect reconciliation: %w", resetErr))
905+ resetResult.transportClosed = handshakeCtx.Err() != nil
849 return resetResult906 return resetResult
850 }907 }
851 awaitingReconnectReset = true908 awaitingReconnectReset = true
@@ -1042,7 +1099,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1042 if !ms.firstOutput {1099 if !ms.firstOutput {
1043 ms.firstOutput = true1100 ms.firstOutput = true
1044 log.Debug("received first output")1101 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))
1045 }1108 }
1109+ handoff := welcomedResult(nil)
1110+ handoff.target = &target
1111+ return handoff
1046 case ports.MsgDetached:1112 case ports.MsgDetached:
1047 d, derr := ports.UnmarshalDetached(r.frame.Payload)1113 d, derr := ports.UnmarshalDetached(r.frame.Payload)
1048 if derr != nil {1114 if derr != nil {
M internal/usecase/client/client_test.go +53 -19
@@ -182,7 +182,7 @@ func attachTestDependencies(transport ports.Transport, terminal ports.Terminal,
182 return testDependencies(transportDialer{transport: transport}, terminal, clock, nil, nil)182 return testDependencies(transportDialer{transport: transport}, terminal, clock, nil, nil)
183 }183 }
184 184
185-const testPreWelcomeTimeout = 15 * time.Second185+const testPreWelcomeTimeout = ports.HandshakeTimeout
186 186
187 func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-chan chan time.Time) {187 func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-chan chan time.Time) {
188 t.Helper()188 t.Helper()
@@ -199,6 +199,26 @@ func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-c
199 return clk, created199 return clk, created
200 }200 }
201 201
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+
202 func TestRunBoundsPreWelcomeOperations(t *testing.T) {222 func TestRunBoundsPreWelcomeOperations(t *testing.T) {
203 tests := []struct {223 tests := []struct {
204 name string224 name string
@@ -216,11 +236,7 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
216 t.Run(tt.name, func(t *testing.T) {236 t.Run(tt.name, func(t *testing.T) {
217 ctx, cancel := context.WithCancel(context.Background())237 ctx, cancel := context.WithCancel(context.Background())
218 defer cancel()238 defer cancel()
219- timerCount := 1239+ clk, createdTimers := newHandshakeClock(t, 1)
220- if tt.phase == "recv" {
221- timerCount = 2
222- }
223- clk, createdTimers := newHandshakeClock(t, timerCount)
224 240
225 term := portsmocks.NewMockTerminal(t)241 term := portsmocks.NewMockTerminal(t)
226 term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()242 term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
@@ -228,9 +244,10 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
228 244
229 started := make(chan struct{})245 started := make(chan struct{})
230 closed := make(chan struct{})246 closed := make(chan struct{})
247+ var closeOnce sync.Once
231 operationExited := make(chan struct{})248 operationExited := make(chan struct{})
232 tr := portsmocks.NewMockTransport(t)249 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()
234 251
235 block := func() error {252 block := func() error {
236 close(started)253 close(started)
@@ -260,21 +277,13 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
260 select {277 select {
261 case timerC = <-createdTimers:278 case timerC = <-createdTimers:
262 case <-time.After(time.Second):279 case <-time.After(time.Second):
263- t.Fatal("pre-Welcome timer was not created")280+ t.Fatal("handshake timer was not created")
264 }281 }
265 select {282 select {
266 case <-started:283 case <-started:
267 case <-time.After(time.Second):284 case <-time.After(time.Second):
268- t.Fatal("pre-Welcome operation did not start")285+ t.Fatal("handshake 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- }
276 }286 }
277-
278 if tt.end == "cancel" {287 if tt.end == "cancel" {
279 cancel()288 cancel()
280 } else {289 } else {
@@ -285,12 +294,12 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
285 case err := <-errCh:294 case err := <-errCh:
286 require.ErrorIs(t, err, tt.want)295 require.ErrorIs(t, err, tt.want)
287 case <-time.After(time.Second):296 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")
289 }298 }
290 select {299 select {
291 case <-operationExited:300 case <-operationExited:
292 case <-time.After(time.Second):301 case <-time.After(time.Second):
293- t.Fatal("pre-Welcome operation leaked after Run returned")302+ t.Fatal("handshake operation leaked after Run returned")
294 }303 }
295 tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme))304 tr.AssertNotCalled(t, "Send", isType(ports.MsgTheme))
296 })305 })
@@ -337,6 +346,31 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
337 }346 }
338 }347 }
339 348
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+
340 func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {374 func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
341 t.Setenv("VEV_CLIENT_ENV_TEST", "TOKEN=a=b=c")375 t.Setenv("VEV_CLIENT_ENV_TEST", "TOKEN=a=b=c")
342 376
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 (
20 20
21 // attachPaletteClock deliberately exposes each independently-created timer.21 // attachPaletteClock deliberately exposes each independently-created timer.
22 // Tests fire only the deadline they intend to exercise; no wall clock is used.22 // 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+}
24 27
25 type attachPaletteTimer struct {28 type attachPaletteTimer struct {
26 ch chan time.Time29 ch chan time.Time
27 stopped chan struct{}30 stopped chan struct{}
28 stopOnce sync.Once31 stopOnce sync.Once
32+ duration time.Duration
29 }33 }
30 34
31 func newAttachPaletteClock() *attachPaletteClock {35 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+ }
33 }40 }
34 41
35 func (*attachPaletteClock) Now() time.Time { return time.Time{} }42 func (*attachPaletteClock) Now() time.Time { return time.Time{} }
36-func (c *attachPaletteClock) NewTimer(time.Duration) ports.Timer {43+func (c *attachPaletteClock) NewTimer(delay time.Duration) ports.Timer {
37- timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{})}44+ timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{}), duration: delay}
38- c.timers <- timer45+ if delay == ports.HandshakeTimeout {
46+ c.handshakeTimers <- timer
47+ } else {
48+ c.timers <- timer
49+ }
39 return timer50 return timer
40 }51 }
41 func (t *attachPaletteTimer) C() <-chan time.Time { return t.ch }52 func (t *attachPaletteTimer) C() <-chan time.Time { return t.ch }
@@ -160,11 +171,12 @@ func (t *attachPaletteTransport) snapshots() []ports.Theme {
160 }171 }
161 172
162 type attachPaletteHarness struct {173 type attachPaletteHarness struct {
163- clock *attachPaletteClock174+ clock *attachPaletteClock
164- reader *attachPaletteReader175+ handshakeTimer *attachPaletteTimer
165- writer *attachPaletteWriter176+ reader *attachPaletteReader
166- transport *attachPaletteTransport177+ writer *attachPaletteWriter
167- done chan attachResult178+ transport *attachPaletteTransport
179+ done chan attachResult
168 }180 }
169 181
170 func startAttachPaletteHarness(state *terminalThemeState) *attachPaletteHarness {182 func startAttachPaletteHarness(state *terminalThemeState) *attachPaletteHarness {
@@ -196,13 +208,15 @@ func startAttachPaletteHarnessWithInputAndWriteError(state *terminalThemeState,
196 return input208 return input
197 },209 },
198 }210 }
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+ }
200 go func() { h.done <- attempt.run(context.Background()) }()218 go func() { h.done <- attempt.run(context.Background()) }()
201- // The handshake's two bounded-pre-welcome timers are unrelated to palette219+ h.handshakeTimer = <-clock.handshakeTimers
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
206 return h220 return h
207 }221 }
208 222
@@ -221,7 +235,13 @@ func (h *attachPaletteHarness) nextTimer(t *testing.T) *attachPaletteTimer {
221 func (h *attachPaletteHarness) detach(t *testing.T) {235 func (h *attachPaletteHarness) detach(t *testing.T) {
222 t.Helper()236 t.Helper()
223 h.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}237 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+ }
225 }245 }
226 246
227 func paletteReply(foreground, background string, slot uint8, color string) string {247 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) {
936 trigger: func(_ context.CancelFunc, timer *reconnectResetTimer) {936 trigger: func(_ context.CancelFunc, timer *reconnectResetTimer) {
937 timer.ch <- time.Time{}937 timer.ch <- time.Time{}
938 },938 },
939- wantErr: context.DeadlineExceeded,939+ wantErr: errHandshakeTimeout,
940 },940 },
941 {941 {
942 name: "cancellation",942 name: "cancellation",
@@ -961,15 +961,13 @@ func TestPreSenderReconnectResetIsBounded(t *testing.T) {
961 resultCh := make(chan attachResult, 1)961 resultCh := make(chan attachResult, 1)
962 go func() { resultCh <- attempt.run(ctx) }()962 go func() { resultCh <- attempt.run(ctx) }()
963 963
964- <-clock.preWelcome // Hello Send964+ handshakeTimer := <-clock.preWelcome
965- <-clock.preWelcome // Welcome Recv
966- resetTimer := <-clock.preWelcome
967 select {965 select {
968 case <-tr.entered:966 case <-tr.entered:
969 case <-time.After(time.Second):967 case <-time.After(time.Second):
970 t.Fatal("timed out waiting for synchronous reconnect reset Send")968 t.Fatal("timed out waiting for synchronous reconnect reset Send")
971 }969 }
972- tt.trigger(cancel, resetTimer)970+ tt.trigger(cancel, handshakeTimer)
973 971
974 result := requireAttachResult(t, resultCh)972 result := requireAttachResult(t, resultCh)
975 require.ErrorIs(t, result.err, tt.wantErr)973 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
168 if err != nil {168 if err != nil {
169 return result, err169 return result, err
170 }170 }
171- d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveAttachment)
172 return result, nil171 return result, nil
173 }172 }
174 173
M internal/usecase/daemon/client.go +22 -42
@@ -85,6 +85,13 @@ type attachedClient struct {
85 previousSession Guarded[attachmentSession]85 previousSession Guarded[attachmentSession]
86 linkMu sync.Mutex86 linkMu sync.Mutex
87 sendMu sync.Mutex87 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
88 // beforeAttachmentTokenValidation is a deterministic lifecycle-race seam95 // beforeAttachmentTokenValidation is a deterministic lifecycle-race seam
89 // used only by package tests.96 // used only by package tests.
90 beforeAttachmentTokenValidation func()97 beforeAttachmentTokenValidation func()
@@ -136,9 +143,7 @@ func (ac *attachedClient) initOverlays() {
136 143
137 func (ac *attachedClient) currentAttachmentSession() attachmentSession { return ac.sess.Get() }144 func (ac *attachedClient) currentAttachmentSession() attachmentSession { return ac.sess.Get() }
138 145
139-// currentSession is the explicit local-only narrowing used until proxy-aware146+// currentSession narrows the attachment to its owning local session.
140-// consumers are introduced. Local PTY/tab paths must treat non-local entries as
141-// unavailable rather than infer proxy behavior.
142 func (ac *attachedClient) currentSession() *session {147 func (ac *attachedClient) currentSession() *session {
143 sess, _ := localSession(ac.currentAttachmentSession())148 sess, _ := localSession(ac.currentAttachmentSession())
144 return sess149 return sess
@@ -344,31 +349,6 @@ func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportS
344 return err349 return err
345 }350 }
346 351
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-
372 // boundedSend sends f to ac with a deadline watchdog: if the send (including352 // boundedSend sends f to ac with a deadline watchdog: if the send (including
373 // waiting on sendMu behind a wedged paint) does not complete within353 // waiting on sendMu behind a wedged paint) does not complete within
374 // detachNotifyTimeout, the transport is force-closed, failing the in-flight354 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -608,8 +588,7 @@ func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordina
608 }588 }
609 589
610 // ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an590 // ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an
611-// exact attachment implementation. The caller holds entry.core().mu; proxy591+// exact attachment implementation. The caller holds entry.core().mu.
612-// VT state is deliberately not touched here.
613 func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSession) *renderCoordinator {592 func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSession) *renderCoordinator {
614 if rc := attachmentRenderCoordinator(entry); rc != nil {593 if rc := attachmentRenderCoordinator(entry); rc != nil {
615 return rc594 return rc
@@ -705,10 +684,6 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
705 token.ac.captureFrames = nil684 token.ac.captureFrames = nil
706 token.ac.sendMu.Unlock()685 token.ac.sendMu.Unlock()
707 }686 }
708- if _, ok := token.sess.(*proxySession); ok {
709- // Remote ordinary output arrives independently after the proxy Welcome.
710- return true
711- }
712 sess, ok := localSession(token.sess)687 sess, ok := localSession(token.sess)
713 if !ok {688 if !ok {
714 return false689 return false
@@ -819,19 +794,24 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
819 view.windowTop = 0794 view.windowTop = 0
820 view.revision++795 view.revision++
821 ac.publishView(view)796 ac.publishView(view)
797+ ac.rebaseOutput()
822 ac.pipelineCache = composeCacheInput{}798 ac.pipelineCache = composeCacheInput{}
823 ac.pipelineScratch = composeCacheInput{}799 ac.pipelineScratch = composeCacheInput{}
824 ac.sendMu.Unlock()800 ac.sendMu.Unlock()
825 801
826- entry := token.sess802+ sess, ok := localSession(token.sess)
827- // Rendering is attachment-scoped. Run it independently of the connection803+ if !ok {
828- // reader so a blocked transport cannot hold session dispatch or peers.804+ return false
829- if proxy, ok := entry.(*proxySession); ok {
830- _, _ = proxy.resize(size)
831- return true
832 }805 }
833- go d.paint(entry, ac, true, token.lease)806+ rc := sess.renderCoordinator()
834- return true807+ 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
835 }815 }
836 816
837 // handleClientNotice maps the closed client-event enum to daemon-owned notice817 // handleClientNotice maps the closed client-event enum to daemon-owned notice
M internal/usecase/daemon/client_frame_routing.go +3 -17
@@ -54,13 +54,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
54 if !ok {54 if !ok {
55 return55 return
56 }56 }
57- if proxy, ok := sess.(*proxySession); ok {57+ local, ok := localSession(sess)
58- d.detachProxyOnSendError(proxy, ac, tr)58+ if !ok {
59- } else if local, ok := localSession(sess); ok {
60- d.clientGone(local, ac, tr, false)
61- } else {
62 return59 return
63 }60 }
61+ d.clientGone(local, ac, tr, false)
64 current := ac.currentAttachmentSession()62 current := ac.currentAttachmentSession()
65 if current == sess || !ac.currentTransportIs(tr) || current == nil {63 if current == sess || !ac.currentTransportIs(tr) || current == nil {
66 return64 return
@@ -204,12 +202,6 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
204 result.Text = request.Slug + " is not scriptable"202 result.Text = request.Slug + " is not scriptable"
205 return result203 return result
206 }204 }
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-
213 // The frame's attachment token is the target capability. No registry/name lookup is205 // The frame's attachment token is the target capability. No registry/name lookup is
214 // performed, so the command cannot escape to another remote session.206 // performed, so the command cannot escape to another remote session.
215 err := sess.runMutation(func() error {207 err := sess.runMutation(func() error {
@@ -229,12 +221,6 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
229 return result221 return result
230 }222 }
231 223
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-
238 // resetOutput rebases only the exact proxied attachment and transport admitted224 // resetOutput rebases only the exact proxied attachment and transport admitted
239 // by the frame router. sendMu serializes the rebase with every output send;225 // by the frame router. sendMu serializes the rebase with every output send;
240 // transport revalidation under that lock rejects a link replaced while waiting.226 // 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
37 }37 }
38 token.effect.bindActionEnd(d, "detach")38 token.effect.bindActionEnd(d, "detach")
39 token.effect.End()39 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- }
48 sess, ok := localSession(token.sess)40 sess, ok := localSession(token.sess)
49 if !ok {41 if !ok {
50 return false42 return false
@@ -124,48 +116,6 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
124 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)116 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
125 }117 }
126 118
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-
169 func (d *Daemon) detachOnAttachmentSendError(token attachmentConnectionToken, failed ports.Transport) {119 func (d *Daemon) detachOnAttachmentSendError(token attachmentConnectionToken, failed ports.Transport) {
170 d.detachOnAttachmentSendErrorUntil(token, failed, nil)120 d.detachOnAttachmentSendErrorUntil(token, failed, nil)
171 }121 }
@@ -176,13 +126,6 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
176 if failed == nil || failed != token.transport.transport {126 if failed == nil || failed != token.transport.transport {
177 return127 return
178 }128 }
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- }
186 sess, ok := localSession(token.sess)129 sess, ok := localSession(token.sess)
187 if !ok {130 if !ok {
188 return131 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 (
5 "encoding/json"5 "encoding/json"
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "runtime"
8 "slices"9 "slices"
9 "sort"10 "sort"
10 "strings"11 "strings"
@@ -34,21 +35,57 @@ func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) error {
34 Text: "attached command relay is not enabled",35 Text: "attached command relay is not enabled",
35 })36 })
36 }37 }
37- result := d.dispatchCommand(d.serveCtx, request)38+
38- result.RequestID = request.RequestID39+ 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+ }
39 return d.sendCommandResult(tr, result)65 return d.sendCommandResult(tr, result)
40 }66 }
41 67
42 // sendCommandResult gives one-shot control responses the same observable68 // sendCommandResult gives one-shot control responses the same observable
43 // transport-failure behavior regardless of which validation path produced it.69 // transport-failure behavior regardless of which validation path produced it.
44 func (d *Daemon) sendCommandResult(tr ports.Transport, result ports.CommandResult) error {70 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 {
46 d.log.Warn("command response send failed", "err", err)72 d.log.Warn("command response send failed", "err", err)
47 return err73 return err
48 }74 }
49 return nil75 return nil
50 }76 }
51 77
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+
52 func frameCommandResult(result ports.CommandResult) ports.Frame {89 func frameCommandResult(result ports.CommandResult) ports.Frame {
53 return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}90 return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}
54 }91 }
@@ -82,7 +119,17 @@ func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandReque
82 return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)119 return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
83 }120 }
84 121
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+ }
86 defer sess.dispatchMu.Unlock()133 defer sess.dispatchMu.Unlock()
87 134
88 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)135 tb, pane, code, text := resolveControlTarget(sess, cmd.Target, request)
M internal/usecase/daemon/control_test.go +77 -0
@@ -124,6 +124,56 @@ func (s *actionRunnerSpy) Run(request daemonActionRequest) error {
124 return s.err124 return s.err
125 }125 }
126 126
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+
127 func TestHandleCommandAttachedRejectionReturnsSendFailure(t *testing.T) {177 func TestHandleCommandAttachedRejectionReturnsSendFailure(t *testing.T) {
128 d := newTestDaemon(t, nil, stubClock{})178 d := newTestDaemon(t, nil, stubClock{})
129 var logs bytes.Buffer179 var logs bytes.Buffer
@@ -1272,6 +1322,33 @@ func (f *controlPTYFactory) close() {
1272 }1322 }
1273 }1323 }
1274 1324
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+
1275 type commandSendErrorTransport struct {1352 type commandSendErrorTransport struct {
1276 err error1353 err error
1277 closed bool1354 closed bool
M internal/usecase/daemon/daemon.go +130 -45
@@ -49,8 +49,8 @@ const detachNotifyTimeout = time.Second
49 // not yet acked by the client) before paint defers rather than composing49 // not yet acked by the client) before paint defers rather than composing
50 // another diff. It bounds the daemon's paint rate to the client's ack rate, so50 // another diff. It bounds the daemon's paint rate to the client's ack rate, so
51 // heavy output degrades to lower fps on a slow link instead of overflowing the51 // 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 so52+// transport. It must stay well under the datagram carriage's 32-frame client
53-// the proxy's reliable queue never fills from painting alone.53+// window so its reliable queue never fills from painting alone.
54 const maxUnackedOutputStates = 854 const maxUnackedOutputStates = 8
55 55
56 // normalizeOutputWindow bounds the untrusted Hello value. Zero deliberately56 // normalizeOutputWindow bounds the untrusted Hello value. Zero deliberately
@@ -74,10 +74,7 @@ type Daemon struct {
74 stopped map[string]stoppedSession74 stopped map[string]stoppedSession
75 // creating reserves names while durable creation I/O runs without mu.75 // creating reserves names while durable creation I/O runs without mu.
76 creating map[string]struct{}76 creating map[string]struct{}
77- // proxyConstructions serializes remote IntentAttach handshakes by remote77+ nextID uint64
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
81 // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water78 // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
82 // mark. It is guarded by mu and prevents a wall-clock regression from79 // mark. It is guarded by mu and prevents a wall-clock regression from
83 // reusing a lifecycle identity.80 // reusing a lifecycle identity.
@@ -948,32 +945,50 @@ func (d *Daemon) sessionsSnapshotLocked() []*session {
948 }945 }
949 946
950 // handleConn reads the first frame off a fresh connection and routes it. A947 // 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 handler948+// context watcher closes the transport on serve-context cancel or handshake
952-// parked in Recv (mid-handshake or between input frames) unwinds on shutdown.949+// timeout so blocked transport operations unwind.
953 func (d *Daemon) handleConn(tr ports.Transport) {950 func (d *Daemon) handleConn(tr ports.Transport) {
954- stop := context.AfterFunc(d.hardCtx, func() { _ = tr.Close() })951+ handshakeCtx, timedOut, finishHandshake := d.newHandshakeContext(d.hardCtx)
955- defer stop()952+ stopTransport := watchHandshakeTransport(handshakeCtx, tr)
956-953+ defer finishHandshake()
957- first, err := tr.Recv()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+ })
958 if err != nil {962 if err != nil {
963+ err = handshakeContextError(d.hardCtx, timedOut, err)
959 d.log.Warn("connection closed before hello", "err", err)964 d.log.Warn("connection closed before hello", "err", err)
960 _ = tr.Close()965 _ = tr.Close()
961 return966 return
962 }967 }
963 switch first.Type {968 switch first.Type {
964 case ports.MsgList:969 case ports.MsgList:
970+ stopTransport()
971+ finishHandshake()
965 d.handleList(tr)972 d.handleList(tr)
966 case ports.MsgCommand:973 case ports.MsgCommand:
974+ stopTransport()
975+ finishHandshake()
967 if err := d.handleCommand(tr, first); err != nil {976 if err := d.handleCommand(tr, first); err != nil {
968 d.log.Warn("command handler failed", "err", err)977 d.log.Warn("command handler failed", "err", err)
969 }978 }
970 case ports.MsgKill:979 case ports.MsgKill:
980+ stopTransport()
981+ finishHandshake()
971 d.handleKill(tr, first)982 d.handleKill(tr, first)
972 case ports.MsgHello:983 case ports.MsgHello:
973- d.handleHello(tr, first)984+ d.handleHelloWithContext(handshakeCtx, timedOut, stopTransport, finishHandshake, tr, first)
974 default:985 default:
975 d.log.Warn("hello rejected", "err", "expected hello", "type", first.Type)986 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()
977 _ = tr.Close()992 _ = tr.Close()
978 }993 }
979 }994 }
@@ -1019,7 +1034,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
1019 d.mu.Unlock()1034 d.mu.Unlock()
1020 1035
1021 sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })1036 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))
1023 }1038 }
1024 1039
1025 // handleKill terminates the requested live session or stopped named session,1040 // handleKill terminates the requested live session or stopped named session,
@@ -1030,7 +1045,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
1030 1045
1031 k, err := ports.UnmarshalKill(f.Payload)1046 k, err := ports.UnmarshalKill(f.Payload)
1032 if err != nil {1047 if err != nil {
1033- _ = tr.Send(frameError(ports.ErrInternal, "malformed kill request"))1048+ _ = d.boundedControlSend(tr, frameError(ports.ErrInternal, "malformed kill request"))
1034 return1049 return
1035 }1050 }
1036 if k.All {1051 if k.All {
@@ -1047,7 +1062,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
1047 // deletion order as live and offline purges.1062 // deletion order as live and offline purges.
1048 if err := d.retryStoppedPurge(k.Name); err != nil {1063 if err := d.retryStoppedPurge(k.Name); err != nil {
1049 d.log.Warn("deleting stopped session failed", "err", err, "session", k.Name)1064 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"))
1051 }1066 }
1052 return1067 return
1053 }1068 }
@@ -1055,48 +1070,77 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
1055 d.mu.Unlock()1070 d.mu.Unlock()
1056 1071
1057 if target == nil {1072 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))
1059 return1074 return
1060 }1075 }
1061 if err := d.killSession(target, ports.ReasonSessionKilled, true); err != nil {1076 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"))
1063 }1078 }
1064 }1079 }
1065 1080
1066-// handleHello runs the attach handshake: version check, intent routing,1081+// handleHello runs the attach handshake for direct package callers. Accepted
1067-// Welcome, guaranteed first paint, then the per-connection input loop.1082+// connections use handleHelloWithContext so the deadline also covers the first
1083+// frame read in handleConn.
1068 func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {1084 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+ }
1069 h, err := ports.UnmarshalHello(f.Payload)1106 h, err := ports.UnmarshalHello(f.Payload)
1070 if err != nil {1107 if err != nil {
1071 if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion {1108 if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion {
1072 d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", version, "expected", ports.ProtocolVersion)1109 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"))
1074 } else {1111 } else {
1075 d.log.Warn("hello rejected", "err", err)1112 d.log.Warn("hello rejected", "err", err)
1076- _ = tr.Send(frameError(ports.ErrInternal, "malformed hello"))1113+ _ = sendHandshake(frameError(ports.ErrInternal, "malformed hello"))
1077 }1114 }
1078 _ = tr.Close()1115 _ = tr.Close()
1079 return1116 return
1080 }1117 }
1081 if h.Version != ports.ProtocolVersion {1118 if h.Version != ports.ProtocolVersion {
1082 d.log.Warn("hello rejected", "err", "protocol version mismatch", "version", h.Version, "expected", ports.ProtocolVersion, "intent", h.Intent, "session", h.Name)1119 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"))
1084 _ = tr.Close()1121 _ = tr.Close()
1085 return1122 return
1086 }1123 }
1087 1124
1088- sess, ac, rerr := d.route(h, tr)1125+ sess, ac, rerr := d.routeWithContext(handshakeCtx, h, tr)
1089 if rerr != nil {1126 if rerr != nil {
1090 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)1127 d.log.Warn("hello rejected", "err", rerr, "intent", h.Intent, "session", h.Name)
1091 if pe, ok := errors.AsType[*protoErr](rerr); ok {1128 if pe, ok := errors.AsType[*protoErr](rerr); ok {
1092- _ = tr.Send(frameError(pe.code, pe.text))1129+ _ = sendHandshake(frameError(pe.code, pe.text))
1093 } else {1130 } else {
1094- _ = tr.Send(frameError(ports.ErrInternal, rerr.Error()))1131+ _ = sendHandshake(frameError(ports.ErrInternal, rerr.Error()))
1095 }1132 }
1096 _ = tr.Close()1133 _ = tr.Close()
1097 return1134 return
1098 }1135 }
1099 1136
1137+ failAttachment := func() {
1138+ d.failHandshakeAttachment(sess, ac, tr)
1139+ }
1140+ if err := handshakeCtx.Err(); err != nil {
1141+ failAttachment()
1142+ return
1143+ }
1100 rc := sess.renderCoordinator()1144 rc := sess.renderCoordinator()
1101 lease := (*attachmentLease)(nil)1145 lease := (*attachmentLease)(nil)
1102 if rc != nil {1146 if rc != nil {
@@ -1115,18 +1159,16 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
1115 }1159 }
1116 return1160 return
1117 }1161 }
1118- if err := ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {1162+ if err := handshakeCtx.Err(); err != nil {
1119 welcomeTicket.End()1163 welcomeTicket.End()
1120- if !d.abortResumeClaim(ac) {1164+ failAttachment()
1121- d.clientGone(sess, ac, tr, false)
1122- }
1123 return1165 return
1124 }1166 }
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 {
1126 welcomeTicket.End()1170 welcomeTicket.End()
1127- if !d.abortResumeClaim(ac) {1171+ failAttachment()
1128- d.clientGone(sess, ac, tr, false)
1129- }
1130 return1172 return
1131 }1173 }
1132 // Release Welcome's effect before discovering post-handshake authority so a1174 // 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) {
1134 welcomeTicket.End()1176 welcomeTicket.End()
1135 postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr)1177 postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr)
1136 if !admitted {1178 if !admitted {
1137- d.clientGone(sess, ac, tr, false)1179+ failAttachment()
1138 return1180 return
1139 }1181 }
1140 postWelcomeLease := postWelcomeToken.lease1182 postWelcomeLease := postWelcomeToken.lease
1141 if postWelcomeToken.ac == nil {1183 if postWelcomeToken.ac == nil {
1142 postWelcomeTicket.End()1184 postWelcomeTicket.End()
1143- d.clientGone(sess, ac, tr, false)1185+ failAttachment()
1144 return1186 return
1145 }1187 }
1146 if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {1188 if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {
1147 postWelcomeTicket.End()1189 postWelcomeTicket.End()
1148 // The attachment was detached while Welcome was in flight; never let1190 // The attachment was detached while Welcome was in flight; never let
1149 // this stale handshake emit an Output frame.1191 // this stale handshake emit an Output frame.
1150- d.clientGone(sess, ac, tr, false)1192+ failAttachment()
1151 return1193 return
1152 }1194 }
1153 postWelcomeTicket.End()1195 postWelcomeTicket.End()
1154 paintToken := sess.attachmentToken(ac, tr)1196 paintToken := sess.attachmentToken(ac, tr)
1155- if !d.firstPaintForTransition(paintToken) {1197+ painted := make(chan bool, 1)
1156- d.clientGone(sess, ac, tr, false)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()
1157 return1207 return
1158 }1208 }
1209+ if !d.commitResumeClaim(ac) {
1210+ failAttachment()
1211+ return
1212+ }
1213+ stopHandshakeTransport()
1214+ finishHandshake()
1159 d.runConnLoop(ac)1215 d.runConnLoop(ac)
1160 _ = tr.Close()1216 _ = tr.Close()
1161 }1217 }
@@ -1253,8 +1309,23 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1253 }1309 }
1254 1310
1255 // route resolves a Hello to a session and a freshly attached client, creating1311 // 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.
1257 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {1314 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+ }
1258 sz := h.Size1329 sz := h.Size
1259 if !sz.Valid() {1330 if !sz.Valid() {
1260 return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}1331 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
1299 }1370 }
1300 }1371 }
1301 if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {1372 if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {
1302- ctx := d.serveCtx
1303- if ctx == nil {
1304- ctx = context.Background()
1305- }
1306 if err := d.waitForTargetRestore(ctx, h.Name); err != nil {1373 if err := d.waitForTargetRestore(ctx, h.Name); err != nil {
1307 return nil, nil, err1374 return nil, nil, err
1308 }1375 }
@@ -1320,6 +1387,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1320 case ports.IntentResume:1387 case ports.IntentResume:
1321 // Resume miss/expiry falls back to normal attach semantics.1388 // Resume miss/expiry falls back to normal attach semantics.
1322 sess := d.findByNameLocked(h.Name)1389 sess := d.findByNameLocked(h.Name)
1390+ created := false
1323 if sess == nil {1391 if sess == nil {
1324 stopped, ok := d.stopped[h.Name]1392 stopped, ok := d.stopped[h.Name]
1325 if !ok || stopped.purging {1393 if !ok || stopped.purging {
@@ -1333,8 +1401,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1333 d.mu.Unlock()1401 d.mu.Unlock()
1334 return nil, nil, err1402 return nil, nil, err
1335 }1403 }
1404+ created = true
1336 }1405 }
1337 ac, err := d.finishAttach(sess, tr, sz, term, h)1406 ac, err := d.finishAttach(sess, tr, sz, term, h)
1407+ if err == nil && created {
1408+ ac.routeCreatedSession = true
1409+ }
1338 return sess, ac, err1410 return sess, ac, err
1339 1411
1340 case ports.IntentEphemeral:1412 case ports.IntentEphemeral:
@@ -1345,6 +1417,10 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1345 return nil, nil, err1417 return nil, nil, err
1346 }1418 }
1347 ac, err := d.finishAttach(sess, tr, sz, term, h)1419 ac, err := d.finishAttach(sess, tr, sz, term, h)
1420+ if err == nil {
1421+ ac.routeCreatedSession = true
1422+ ac.routeSessionPurge = true
1423+ }
1348 return sess, ac, err1424 return sess, ac, err
1349 1425
1350 case ports.IntentNew:1426 case ports.IntentNew:
@@ -1366,10 +1442,15 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1366 return nil, nil, err1442 return nil, nil, err
1367 }1443 }
1368 ac, err := d.finishAttach(sess, tr, sz, term, h)1444 ac, err := d.finishAttach(sess, tr, sz, term, h)
1445+ if err == nil {
1446+ ac.routeCreatedSession = true
1447+ ac.routeSessionPurge = true
1448+ }
1369 return sess, ac, err1449 return sess, ac, err
1370 1450
1371 case ports.IntentAttach:1451 case ports.IntentAttach:
1372 sess := d.findByNameLocked(h.Name)1452 sess := d.findByNameLocked(h.Name)
1453+ created := false
1373 if sess == nil {1454 if sess == nil {
1374 stopped, ok := d.stopped[h.Name]1455 stopped, ok := d.stopped[h.Name]
1375 if !ok || stopped.purging {1456 if !ok || stopped.purging {
@@ -1383,8 +1464,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1383 d.mu.Unlock()1464 d.mu.Unlock()
1384 return nil, nil, err1465 return nil, nil, err
1385 }1466 }
1467+ created = true
1386 }1468 }
1387 ac, err := d.finishAttach(sess, tr, sz, term, h)1469 ac, err := d.finishAttach(sess, tr, sz, term, h)
1470+ if err == nil && created {
1471+ ac.routeCreatedSession = true
1472+ }
1388 return sess, ac, err1473 return sess, ac, err
1389 1474
1390 default:1475 default:
M internal/usecase/daemon/daemon_test.go +2 -0
@@ -664,6 +664,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
664 }()664 }()
665 665
666 awaitTestCompletion(t, tr.welcomeEntered, "timed out waiting for Welcome send")666 awaitTestCompletion(t, tr.welcomeEntered, "timed out waiting for Welcome send")
667+ handshakeTimer := awaitHandshakeTimer(t, clock)
667 welcome := awaitFrame(t, tr.sends, ports.MsgWelcome)668 welcome := awaitFrame(t, tr.sends, ports.MsgWelcome)
668 require.Equal(t, ports.MsgWelcome, welcome.Type)669 require.Equal(t, ports.MsgWelcome, welcome.Type)
669 sess := firstSession(d)670 sess := firstSession(d)
@@ -694,6 +695,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
694 require.Zero(t, first.Base, "the first post-Welcome frame is full")695 require.Zero(t, first.Base, "the first post-Welcome frame is full")
695 tr.finish()696 tr.finish()
696 awaitTestCompletion(t, done, "timed out waiting for Welcome handler completion")697 awaitTestCompletion(t, done, "timed out waiting for Welcome handler completion")
698+ awaitTestCompletion(t, handshakeTimer.stopped, "handshake timer was not canceled after publication")
697 requireNoCoordinatorOutputFrame(t, tr.sends)699 requireNoCoordinatorOutputFrame(t, tr.sends)
698 }700 }
699 701
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) {
43 func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) {43 func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) {
44 ac := token.ac44 ac := token.ac
45 ac.initOverlays()45 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- }
63 ac.mouseScan.Scan(data,46 ac.mouseScan.Scan(data,
64 func(ev mouse.Event) {47 func(ev mouse.Event) {
65 if token.attachmentEffectCurrent() {48 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
228 if err != nil {228 if err != nil {
229 if sess, ok := localSession(entry); ok {229 if sess, ok := localSession(entry); ok {
230 d.detachOnSendError(sess, ac, failed)230 d.detachOnSendError(sess, ac, failed)
231- } else if proxy, ok := entry.(*proxySession); ok {
232- d.detachProxyOnSendError(proxy, ac, failed)
233 } else {231 } else {
234 _ = ac.closeCapturedTransport(failed)232 _ = ac.closeCapturedTransport(failed)
235 }233 }
M internal/usecase/daemon/notices_overlay_test.go +0 -34
@@ -431,40 +431,6 @@ func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
431 require.Equal(t, "no notifications yet", history[0].Message)431 require.Equal(t, "no notifications yet", history[0].Message)
432 }432 }
433 433
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-
468 func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {434 func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {
469 p, release := newBlockingPTY(t)435 p, release := newBlockingPTY(t)
470 d, sess, ac, sends := newManualSessionWithPTYs(t, p)436 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/notify_test.go +2 -7
@@ -21,16 +21,11 @@ import (
21 func TestReportAttachmentErrorPreservesLocalOrNilScope(t *testing.T) {21 func TestReportAttachmentErrorPreservesLocalOrNilScope(t *testing.T) {
22 d := newTestDaemon(t, nil, stubClock{})22 d := newTestDaemon(t, nil, stubClock{})
23 local := &session{sessionCore: sessionCore{id: "local", name: "local"}}23 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-
27 d.reportAttachmentError(local, domain.UserWarn(domain.NoticeClipboard, "local failure", nil))24 d.reportAttachmentError(local, domain.UserWarn(domain.NoticeClipboard, "local failure", nil))
28- d.reportAttachmentError(proxy, domain.UserWarn(domain.NoticeClipboard, "proxy failure", nil))
29 25
30 history := d.notices.history()26 history := d.notices.history()
31- require.Len(t, history, 2, "each attachment error must report exactly once")27+ require.Len(t, history, 1)
32- require.Equal(t, local.id, history[1].SessionID)28+ require.Equal(t, local.id, history[0].SessionID)
33- require.Equal(t, domain.SessionID(""), history[0].SessionID)
34 }29 }
35 30
36 func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {31 func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {
M internal/usecase/daemon/palette.go +0 -90
@@ -1,7 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "context"
5 "errors"4 "errors"
6 "sort"5 "sort"
7 "time"6 "time"
@@ -21,34 +20,6 @@ const (
21 paletteRailWidth = 6420 paletteRailWidth = 64
22 )21 )
23 22
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-
52 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}}23 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}}
53 24
54 func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {25 func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
@@ -180,7 +151,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
180 return151 return
181 }152 }
182 sess, local := localSession(entry)153 sess, local := localSession(entry)
183- proxy, remote := entry.(*proxySession)
184 var cmd command.Command154 var cmd command.Command
185 var sessionTarget palette.Result155 var sessionTarget palette.Result
186 var hasSessionTarget bool156 var hasSessionTarget bool
@@ -347,66 +317,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
347 }317 }
348 return318 return
349 }319 }
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- }
410 if !local {320 if !local {
411 if ac.closeExecutedPalette(generation, rawQuery) {321 if ac.closeExecutedPalette(generation, rawQuery) {
412 d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, cmd.Name+" is unavailable for this attachment", nil)322 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) {
125 // only a log line. The new-tab spawn failure is wrapped as a domain.UserError125 // only a log line. The new-tab spawn failure is wrapped as a domain.UserError
126 // with NoticeTabSpawn (session.go createTab), so it surfaces under that code126 // with NoticeTabSpawn (session.go createTab), so it surfaces under that code
127 // rather than the NoticeInternal catch-all.127 // 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-
150 func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) {128 func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) {
151 p, release := newBlockingPTY(t)129 p, release := newBlockingPTY(t)
152 defer release()130 defer release()
M internal/usecase/daemon/performance_benchmark_test.go +0 -134
@@ -1,7 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "bytes"
5 "context"4 "context"
6 "errors"5 "errors"
7 "fmt"6 "fmt"
@@ -18,7 +17,6 @@ import (
18 "github.com/bnema/vev/internal/usecase/layout"17 "github.com/bnema/vev/internal/usecase/layout"
19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"18 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20 "github.com/bnema/vev/pkg/renderer"19 "github.com/bnema/vev/pkg/renderer"
21- "github.com/bnema/vev/pkg/vt"
22 )20 )
23 21
24 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {22 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
@@ -198,114 +196,6 @@ func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *tes
198 require.Equal(t, payload, transport.lastPayload(), "the counting transport must retain the payload slice header, not copy it")196 require.Equal(t, payload, transport.lastPayload(), "the counting transport must retain the payload slice header, not copy it")
199 }197 }
200 198
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-
309 func TestPerformanceFixtureCounters(t *testing.T) {199 func TestPerformanceFixtureCounters(t *testing.T) {
310 fixture := newPerformanceFixture(t, performanceConfig{})200 fixture := newPerformanceFixture(t, performanceConfig{})
311 201
@@ -469,30 +359,6 @@ var daemonSnapshotTopologies = []daemonHistoryTopology{
469 {name: "1tab-4panes", tabs: 1, panes: 4},359 {name: "1tab-4panes", tabs: 1, panes: 4},
470 }360 }
471 361
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-
496 func BenchmarkDaemonHistoryLivePaint(b *testing.B) {362 func BenchmarkDaemonHistoryLivePaint(b *testing.B) {
497 benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })363 benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })
498 }364 }
M internal/usecase/daemon/picker.go +36 -141
@@ -86,12 +86,8 @@ func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model
86 func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {86 func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {
87 d.mu.Lock()87 d.mu.Lock()
88 sessions := make([]attachmentSession, 0, len(d.sessions))88 sessions := make([]attachmentSession, 0, len(d.sessions))
89- proxies := make(map[domain.SessionID]*proxySession)89+ for _, entry := range d.sessions {
90- for id, entry := range d.sessions {
91 sessions = append(sessions, entry)90 sessions = append(sessions, entry)
92- if proxy, ok := entry.(*proxySession); ok {
93- proxies[id] = proxy
94- }
95 }91 }
96 stopped := make([]stoppedSession, 0, len(d.stopped))92 stopped := make([]stoppedSession, 0, len(d.stopped))
97 for _, s := range d.stopped {93 for _, s := range d.stopped {
@@ -114,8 +110,8 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
114 }110 }
115 // One snapshot per live session: sorting and view building read the same111 // One snapshot per live session: sorting and view building read the same
116 // capture, so comparators cannot observe a concurrent touchMRU or112 // capture, so comparators cannot observe a concurrent touchMRU or
117- // renameSession mid-sort. Proxy and remote-catalog locks are released before113+ // renameSession mid-sort. Remote-catalog locks are released before sorting
118- // sorting and picker-row construction.114+ // and picker-row construction.
119 live := make([]liveSnapshot, 0, len(sessions))115 live := make([]liveSnapshot, 0, len(sessions))
120 var current picker.SourceFilter116 var current picker.SourceFilter
121 for _, entry := range sessions {117 for _, entry := range sessions {
@@ -126,10 +122,7 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
126 }122 }
127 snap := entry.snapshotView(opts)123 snap := entry.snapshotView(opts)
128 if entry == cur {124 if entry == cur {
129- if proxy, ok := entry.(*proxySession); ok {125+ if _, ok := localSession(entry); ok && ac != nil {
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 {
133 view := ac.viewSnapshot()126 view := ac.viewSnapshot()
134 if view.tabID != "" {127 if view.tabID != "" {
135 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}128 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
175 }168 }
176 views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows)169 views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows)
177 for _, item := range live {170 for _, item := range live {
178- if proxy, ok := item.entry.(*proxySession); ok {
179- views = append(views, remoteProxyPickerView(proxy.key, item.view))
180- continue
181- }
182 views = append(views, item.view.pickerView())171 views = append(views, item.view.pickerView())
183 }172 }
184 for _, host := range catalog {173 for _, host := range catalog {
@@ -187,9 +176,6 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
187 if key.Validate() != nil {176 if key.Validate() != nil {
188 continue177 continue
189 }178 }
190- if proxy, ok := proxies[key.ID()]; ok && proxy.key == key {
191- continue
192- }
193 views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))179 views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))
194 }180 }
195 if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {181 if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
@@ -640,7 +626,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
640 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,626 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
641 })627 })
642 if err != nil {628 if err != nil {
643- // Losing the exact source attachment is a benign stale action, not a notice for629+ // Losing the exact source role is a benign stale action, not a notice for
644 // whichever attachment replaced the initiator.630 // whichever attachment replaced the initiator.
645 if !token.attachmentCurrent() {631 if !token.attachmentCurrent() {
646 return nil632 return nil
@@ -676,14 +662,11 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
676 if token.sess == nil || token.ac == nil || token.effect == nil {662 if token.sess == nil || token.ac == nil || token.effect == nil {
677 return nil663 return nil
678 }664 }
679- token.effect.bindActionEnd(d, action)
680- token.effect.End()
681 if target.RemoteKey != nil {665 if target.RemoteKey != nil {
682- return d.switchToRemoteTargetForAttachment(token, target, *target.RemoteKey, guard, action)666+ return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
683- }
684- if token.sess.isProxy() && !target.Stopped {
685- return d.switchActiveTargetForAttachmentGuarded(token, target, guard, action)
686 }667 }
668+ token.effect.bindActionEnd(d, action)
669+ token.effect.End()
687 sess, ok := localSession(token.sess)670 sess, ok := localSession(token.sess)
688 if !ok {671 if !ok {
689 return errAttachmentTransition672 return errAttachmentTransition
@@ -691,132 +674,45 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
691 return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action)674 return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action)
692 }675 }
693 676
694-// switchToRemoteTargetForAttachment performs dialing after the initiating effect has677+// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
695-// ended and without attachment-effect or architecture locks. transitionAttachment then678+// endpoint to the thin client. The daemon owns no remote session shadow: after
696-// freezes and revalidates the exact source attachment and exact structured-key proxy679+// the target is sent, the local attachment is detached and the client opens a
697-// before publishing ownership.680+// fresh connection to the owning daemon.
698-func (d *Daemon) switchToRemoteTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error {681+func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
699- if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" {682+ if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
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() {
722 return errAttachmentTransition683 return errAttachmentTransition
723 }684 }
724- proxy.mu.Lock()685+ if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(ports.AttachTarget{
725- candidateGeneration := proxy.generation686+ Endpoint: key.Host,
726- proxy.mu.Unlock()687+ Session: key.Name,
727-688+ Intent: ports.IntentAttach,
728- transition, err := d.transitionAttachment(attachmentTransitionRequest{689+ })}); err != nil {
729- source: token.sess, target: proxy, next: token.ac,690+ return err
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
739 }691 }
740 if guard.closePicker {692 if guard.closePicker {
741- fresh, admitted := token.ac.beginAttachmentEffect(transition.published)693+ d.closePicker(token.ac)
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)
755 }694 }
695+ d.clientGoneForAttachment(token, false)
756 return nil696 return nil
757 }697 }
758 698
759-// discardUnattachedProxyGeneration removes only a transition-owned candidate699+func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
760-// that never acquired a client. It mirrors normal proxy teardown fencing but700+ if d == nil {
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()
794 return false701 return false
795 }702 }
796- proxy.sessionCore.mu.Unlock()703+ d.remoteCatalog.mu.Lock()
797- empty := len(d.sessions) == 0704+ defer d.remoteCatalog.mu.Unlock()
798- if empty {705+ for _, entry := range d.remoteCatalog.cache {
799- d.closing = true706+ if entry.Host != key.Host {
800- }707+ continue
801- d.mu.Unlock()708+ }
802-709+ for _, session := range entry.Sessions {
803- if cancel != nil {710+ if session.Name == key.Name {
804- cancel()711+ return d.remoteCatalog.status[key.Host] != remoteHostVersionMismatch
805- }712+ }
806- if transport != nil {713+ }
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()
818 }714 }
819- return true715+ return false
820 }716 }
821 717
822 // switchToTargetGuarded is retained for daemon-internal and headless callers.718 // switchToTargetGuarded is retained for daemon-internal and headless callers.
@@ -1122,7 +1018,6 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attac
1122 return d.killPickerTarget(target)1018 return d.killPickerTarget(target)
1123 }1019 }
1124 if target.RemoteKey != nil {1020 if target.RemoteKey != nil {
1125- d.enterRemoteKillConfirmation(token.sess, token.ac, target)
1126 return nil1021 return nil
1127 }1022 }
1128 if target.Stopped {1023 if target.Stopped {
M internal/usecase/daemon/prompt.go +1 -2
@@ -47,8 +47,7 @@ func (d *Daemon) closePrompt(ac *attachedClient) {
47 func promptValidationError(err error) bool {47 func promptValidationError(err error) bool {
48 return errors.Is(err, domain.ErrInvalidSessionName) ||48 return errors.Is(err, domain.ErrInvalidSessionName) ||
49 errors.Is(err, errSessionNameRequired) ||49 errors.Is(err, errSessionNameRequired) ||
50- errors.Is(err, errSessionNameInUse) ||50+ errors.Is(err, errSessionNameInUse)
51- errors.Is(err, errProxyKillConfirmation)
52 }51 }
53 52
54 func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {53 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
139 }139 }
140 }140 }
141 141
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-
169 func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {142 func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
170 for i := range entries {143 for i := range entries {
171 sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {144 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 (
14 14
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17- portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 "github.com/bnema/vev/internal/usecase/picker"17 "github.com/bnema/vev/internal/usecase/picker"
19 )18 )
20 19
@@ -56,91 +55,6 @@ func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
56 return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))55 return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
57 }56 }
58 57
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-
144 func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {58 func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
145 tests := []struct {59 tests := []struct {
146 name string60 name string
@@ -219,33 +133,6 @@ func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) {
219 }133 }
220 }134 }
221 135
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-
249 type remoteRefreshHostStore struct {136 type remoteRefreshHostStore struct {
250 mu sync.Mutex137 mu sync.Mutex
251 hosts []string138 hosts []string
@@ -532,6 +419,78 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
532 d.remoteCatalog.mu.Unlock()419 d.remoteCatalog.mu.Unlock()
533 }420 }
534 421
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+
535 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {494 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
536 t.Helper()495 t.Helper()
537 tr, sends := newCapturingTransport(t)496 tr, sends := newCapturingTransport(t)
@@ -744,44 +703,6 @@ func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
744 ac.overlays.pickerMu.Unlock()703 ac.overlays.pickerMu.Unlock()
745 }704 }
746 705
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-
785 func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {706 func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
786 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}707 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
787 d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(500, 0))708 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() {
291 }291 }
292 292
293 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {293 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {
294- // Local sessions retain their tab/PTY preparation below. Proxy sessions294+ // Session-owned PTY preparation remains local; attachment rendering is
295- // enter the same attachment pipeline but supply their VT snapshot through295+ // captured through attachmentSession.captureRenderState.
296- // attachmentSession.captureRenderState.
297 sess, local := localSession(entry)296 sess, local := localSession(entry)
298 marks := d.newRuntimeMarkBatch()297 marks := d.newRuntimeMarkBatch()
299 if lease != nil {298 if lease != nil {
M internal/usecase/daemon/render_coordinator_test.go +30 -7
@@ -23,25 +23,30 @@ import (
23 // coordinatorMockClock uses generated mocks while retaining deterministic timer23 // coordinatorMockClock uses generated mocks while retaining deterministic timer
24 // channels and recorded deadlines for coordinator scheduling assertions.24 // channels and recorded deadlines for coordinator scheduling assertions.
25 type coordinatorMockClock struct {25 type coordinatorMockClock struct {
26- clock *portsmocks.MockClock26+ clock *portsmocks.MockClock
27- timers chan *coordinatorMockTimer27+ timers chan *coordinatorMockTimer
28+ handshakeTimers chan *coordinatorMockTimer
28 }29 }
29 30
30 type coordinatorMockTimer struct {31 type coordinatorMockTimer struct {
31 mock *portsmocks.MockTimer32 mock *portsmocks.MockTimer
32 ch chan time.Time33 ch chan time.Time
33 duration time.Duration34 duration time.Duration
35+ stopped chan struct{}
36+ stopOnce sync.Once
34 }37 }
35 38
36 func newCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {39 func newCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
37 t.Helper()40 t.Helper()
38 clk := &coordinatorMockClock{41 clk := &coordinatorMockClock{
39- clock: portsmocks.NewMockClock(t),42+ clock: portsmocks.NewMockClock(t),
40- timers: make(chan *coordinatorMockTimer, capacity),43+ timers: make(chan *coordinatorMockTimer, capacity),
44+ handshakeTimers: make(chan *coordinatorMockTimer, 1),
41 }45 }
42 clk.clock.EXPECT().Now().Return(time.Time{}).Maybe()46 clk.clock.EXPECT().Now().Return(time.Time{}).Maybe()
43 clk.clock.EXPECT().NewTimer(mock.MatchedBy(func(d time.Duration) bool {47 clk.clock.EXPECT().NewTimer(mock.MatchedBy(func(d time.Duration) bool {
44- return d == urgentRenderDeadline ||48+ return d == ports.HandshakeTimeout ||
49+ d == urgentRenderDeadline ||
45 (d >= minOutputRenderDeadline && d <= maxOutputRenderDeadline) ||50 (d >= minOutputRenderDeadline && d <= maxOutputRenderDeadline) ||
46 d == maxSyncUpdateDuration ||51 d == maxSyncUpdateDuration ||
47 d == time.Second ||52 d == time.Second ||
@@ -50,14 +55,21 @@ func newCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
50 timer := &coordinatorMockTimer{55 timer := &coordinatorMockTimer{
51 mock: portsmocks.NewMockTimer(t),56 mock: portsmocks.NewMockTimer(t),
52 duration: d,57 duration: d,
58+ stopped: make(chan struct{}),
53 }59 }
54 timer.ch = make(chan time.Time, 1)60 timer.ch = make(chan time.Time, 1)
55 // A normal deadline reads C both in its worker and in the inert-clock61 // A normal deadline reads C both in its worker and in the inert-clock
56 // guard; a watchdog reads it in its worker. The generated expectation62 // guard; a watchdog reads it in its worker. The generated expectation
57 // makes every channel access observable without a hand-written Timer.63 // makes every channel access observable without a hand-written Timer.
58 timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch))64 timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch))
59- timer.mock.EXPECT().Stop().Maybe().Return(true)65+ timer.mock.EXPECT().Stop().Run(func() {
60- clk.timers <- timer66+ 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+ }
61 return timer.mock73 return timer.mock
62 }).Maybe()74 }).Maybe()
63 return clk75 return clk
@@ -219,6 +231,17 @@ func captureResizeCallbackDone(t *testing.T, rc *renderCoordinator) <-chan struc
219 return rc.resizeLane.token.done231 return rc.resizeLane.token.done
220 }232 }
221 233
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+
222 func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {245 func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
223 t.Helper()246 t.Helper()
224 select {247 select {
M internal/usecase/daemon/render_pipeline.go +2 -5
@@ -491,9 +491,8 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
491 }491 }
492 d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))492 d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
493 }493 }
494- // Notices are session-scoped, but the repaint is not: a proxy attachment494+ // Notices are session-scoped, while the attachment repaint still needs
495- // has no local session to report through and still needs its chrome495+ // to redraw its chrome after a failed transaction.
496- // redrawn after a failed transaction.
497 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")496 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
498 return true497 return true
499 }498 }
@@ -566,8 +565,6 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
566 if marks.attachmentEffect == nil {565 if marks.attachmentEffect == nil {
567 if sess, ok := localSession(entry); ok {566 if sess, ok := localSession(entry); ok {
568 d.detachOnSendError(sess, ac, sendTr)567 d.detachOnSendError(sess, ac, sendTr)
569- } else if proxy, ok := entry.(*proxySession); ok {
570- d.detachProxyOnSendError(proxy, ac, sendTr)
571 }568 }
572 } else {569 } else {
573 // Capture the exact admitted capability, including its coordinator570 // 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) {
258 }()258 }()
259 259
260 <-tr.welcomeEntered260 <-tr.welcomeEntered
261+ handshakeTimer := awaitHandshakeTimer(t, clock)
261 welcome := <-tr.sends262 welcome := <-tr.sends
262 require.Equal(t, ports.MsgWelcome, welcome.Type)263 require.Equal(t, ports.MsgWelcome, welcome.Type)
263 sess.mu.Lock()264 sess.mu.Lock()
@@ -283,6 +284,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
283 require.Zero(t, first.Base)284 require.Zero(t, first.Base)
284 tr.finish()285 tr.finish()
285 <-done286 <-done
287+ awaitTestCompletion(t, handshakeTimer.stopped, "handshake timer was not canceled after publication")
286 requireNoCoordinatorOutputFrame(t, tr.sends)288 requireNoCoordinatorOutputFrame(t, tr.sends)
287 }289 }
288 290
M internal/usecase/daemon/session.go +0 -38
@@ -170,8 +170,6 @@ func (d *Daemon) touchMRU(entry attachmentSession) {
170 break170 break
171 }171 }
172 }172 }
173- // Proxy recency is live derived state only. Local named sessions retain the
174- // existing catalogue metadata publication semantics.
175 sess, ok := localSession(entry)173 sess, ok := localSession(entry)
176 if !ok {174 if !ok {
177 return175 return
@@ -761,39 +759,6 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
761 return current759 return current
762 }760 }
763 761
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-
797 // detachIfAttachmentCurrent invalidates only the exact attachment generation and transport762 // detachIfAttachmentCurrent invalidates only the exact attachment generation and transport
798 // whose admitted send failed. A queued stale error cannot detach a later763 // whose admitted send failed. A queued stale error cannot detach a later
799 // attachment publication or a rebound transport on the same attachment object.764 // attachment publication or a rebound transport on the same attachment object.
@@ -812,9 +777,6 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
812 if sess, ok := localSession(token.sess); ok {777 if sess, ok := localSession(token.sess); ok {
813 return d.detachIfCurrentTransport(sess, token.ac, token.transport)778 return d.detachIfCurrentTransport(sess, token.ac, token.transport)
814 }779 }
815- if proxy, ok := token.sess.(*proxySession); ok {
816- return d.detachProxyIfCurrentTransport(proxy, token.ac, token.transport)
817- }
818 return false780 return false
819 }781 }
820 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)782 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
17 return nil17 return nil
18 }18 }
19 pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}19 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- }
24 return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session")20 return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session")
25 }21 }
26 22
M internal/usecase/daemon/session_capabilities.go +2 -4
@@ -3,8 +3,7 @@ package daemon
3 import "errors"3 import "errors"
4 4
5 // errSessionCannotYieldMoves rejects move-picker entry for sessions whose5 // errSessionCannotYieldMoves rejects move-picker entry for sessions whose
6-// tabs and panes cannot leave the session (no local session sets this today;6+// tabs and panes cannot leave the session.
7-// proxy sessions will).
8 var errSessionCannotYieldMoves = errors.New("session does not support moving tabs or panes")7 var errSessionCannotYieldMoves = errors.New("session does not support moving tabs or panes")
9 8
10 // sessionCapabilities declares what cross-session operations a session9 // sessionCapabilities declares what cross-session operations a session
@@ -14,8 +13,7 @@ var errSessionCannotYieldMoves = errors.New("session does not support moving tab
14 //13 //
15 // Set once at construction, never mutated: reading needs no lock.14 // Set once at construction, never mutated: reading needs no lock.
16 type sessionCapabilities struct {15 type sessionCapabilities struct {
17- cannotAcceptMoves bool16+ cannotYieldMoves bool
18- cannotYieldMoves bool
19 }17 }
20 18
21 func (c sessionCapabilities) yieldsMoves() bool { return !c.cannotYieldMoves }19 func (c sessionCapabilities) yieldsMoves() bool { return !c.cannotYieldMoves }
M internal/usecase/daemon/session_capabilities_test.go +1 -20
@@ -10,30 +10,11 @@ import (
10 func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) {10 func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) {
11 s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha"}}11 s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha"}}
12 caps := s.capabilities()12 caps := s.capabilities()
13- if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() {13+ if caps.cannotYieldMoves || !caps.yieldsMoves() {
14 t.Fatalf("zero-value capabilities = %+v, want fully capable", caps)14 t.Fatalf("zero-value capabilities = %+v, want fully capable", caps)
15 }15 }
16 }16 }
17 17
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-
37 func TestEnterPickerForIntentRejectsNonYieldingSource(t *testing.T) {18 func TestEnterPickerForIntentRejectsNonYieldingSource(t *testing.T) {
38 d, sess, ac, _ := newManualSessionWithPTYs(t)19 d, sess, ac, _ := newManualSessionWithPTYs(t)
39 sess.caps = sessionCapabilities{cannotYieldMoves: true}20 sess.caps = sessionCapabilities{cannotYieldMoves: true}
M internal/usecase/daemon/session_core.go +0 -3
@@ -15,7 +15,6 @@ type attachmentSession interface {
15 statusSegments(includeTerminalTitle bool) statusSnapshot15 statusSegments(includeTerminalTitle bool) statusSnapshot
16 captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool)16 captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool)
17 validTargetTabLocked(tabIndex int) bool17 validTargetTabLocked(tabIndex int) bool
18- isProxy() bool
19 }18 }
20 19
21 // sessionCore is the state shared by every attachment-capable session. It is20 // sessionCore is the state shared by every attachment-capable session. It is
@@ -47,8 +46,6 @@ func (s *session) core() *sessionCore {
47 return &s.sessionCore46 return &s.sessionCore
48 }47 }
49 48
50-func (s *session) isProxy() bool { return false }
51-
52 func (s *session) captureRenderState(ac *attachedClient, req renderCaptureRequest) (*capturedRenderState, bool) {49 func (s *session) captureRenderState(ac *attachedClient, req renderCaptureRequest) (*capturedRenderState, bool) {
53 return captureLocalRenderState(s, ac, req)50 return captureLocalRenderState(s, ac, req)
54 }51 }
M internal/usecase/daemon/session_core_test.go +0 -1
@@ -31,7 +31,6 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
31 core := sess.core()31 core := sess.core()
32 require.Same(t, &sess.sessionCore, core)32 require.Same(t, &sess.sessionCore, core)
33 require.Same(t, sess, mustLocalSession(t, attachmentSession(sess)))33 require.Same(t, sess, mustLocalSession(t, attachmentSession(sess)))
34- require.False(t, sess.isProxy())
35 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")34 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")
36 35
37 core.mu.Lock()36 core.mu.Lock()
M internal/usecase/daemon/session_view.go +28 -33
@@ -25,23 +25,20 @@ type tabView struct {
25 25
26 // sessionView is an immutable, value-only description of a session. It26 // sessionView is an immutable, value-only description of a session. It
27 // deliberately retains no live pointers, mirroring recentSession27 // deliberately retains no live pointers, mirroring recentSession
28-// (recent_sessions.go). Listing paths (picker, MRU bar, palette,28+// (recent_sessions.go). Listing paths (picker, MRU bar, palette, and
29-// list-sessions) read local session state through snapshotView; proxy rows in29+// list-sessions) read local session state through snapshotView.
30-// later phases still come from registry/discovery, not this type alone.
31 type sessionView struct {30 type sessionView struct {
32- id domain.SessionID31+ id domain.SessionID
33- incarnation domain.IncarnationID32+ incarnation domain.IncarnationID
34- name string33+ name string
35- ephemeral bool34+ ephemeral bool
36- createdAt int6435+ createdAt int64
37- defaultTab int36+ defaultTab int
38- mruAt uint6437+ mruAt uint64
39- attached bool38+ attached bool
40- tabCount int39+ tabCount int
41- hasAttention bool40+ hasAttention bool
42- tabs []tabView41+ tabs []tabView
43- cannotAcceptMoves bool
44- expired bool
45 }42 }
46 43
47 // snapshotView reads session fields under s.mu and samples the independently44 // snapshotView reads session fields under s.mu and samples the independently
@@ -57,16 +54,15 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
57 // default. Interactive callers pass an attachment and resolve its stable54 // default. Interactive callers pass an attachment and resolve its stable
58 // view separately.55 // view separately.
59 view := sessionView{56 view := sessionView{
60- id: s.id,57+ id: s.id,
61- incarnation: s.incarnation,58+ incarnation: s.incarnation,
62- name: s.name,59+ name: s.name,
63- ephemeral: s.ephemeral,60+ ephemeral: s.ephemeral,
64- createdAt: s.createdAt,61+ createdAt: s.createdAt,
65- defaultTab: 0,62+ defaultTab: 0,
66- mruAt: s.mruAt.Load(),63+ mruAt: s.mruAt.Load(),
67- attached: len(s.attachments) != 0,64+ attached: len(s.attachments) != 0,
68- tabCount: len(s.tabs),65+ tabCount: len(s.tabs),
69- cannotAcceptMoves: s.capabilities().cannotAcceptMoves,
70 }66 }
71 if opts.tabDetails {67 if opts.tabDetails {
72 view.tabs = make([]tabView, 0, len(s.tabs))68 view.tabs = make([]tabView, 0, len(s.tabs))
@@ -100,13 +96,12 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
100 // equivalent to the inline construction previously in pickerViews.96 // equivalent to the inline construction previously in pickerViews.
101 func (view sessionView) pickerView() picker.SessionView {97 func (view sessionView) pickerView() picker.SessionView {
102 out := picker.SessionView{98 out := picker.SessionView{
103- ID: view.id,99+ ID: view.id,
104- Incarnation: view.incarnation,100+ Incarnation: view.incarnation,
105- Name: view.name,101+ Name: view.name,
106- TargetName: view.name,102+ TargetName: view.name,
107- Active: view.defaultTab,103+ Active: view.defaultTab,
108- Tabs: make([]picker.TabEntry, 0, len(view.tabs)),104+ Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
109- CannotAcceptMoves: view.cannotAcceptMoves,
110 }105 }
111 if !view.ephemeral {106 if !view.ephemeral {
112 createdAt := view.createdAt107 createdAt := view.createdAt
M internal/usecase/daemon/status_test.go +0 -1
@@ -35,7 +35,6 @@ func (*coreNilAttachment) captureRenderState(*attachedClient, renderCaptureReque
35 return nil, false35 return nil, false
36 }36 }
37 func (*coreNilAttachment) validTargetTabLocked(int) bool { return false }37 func (*coreNilAttachment) validTargetTabLocked(int) bool { return false }
38-func (*coreNilAttachment) isProxy() bool { return false }
39 38
40 // stubClock returns timers whose channel never fires, so a scheduler under it39 // stubClock returns timers whose channel never fires, so a scheduler under it
41 // blocks in its debounce loop until the session context is cancelled. Used by40 // 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 {
43 RemoteAttachReady bool43 RemoteAttachReady bool
44 // CannotAcceptMoves reports whether this session cannot receive a moved tab44 // CannotAcceptMoves reports whether this session cannot receive a moved tab
45 // or pane. False for ordinary local (and stopped) sessions; true for45 // or pane. False for ordinary local (and stopped) sessions; true for
46- // restricted proxy sessions.46+ // restricted remote rows.
47 CannotAcceptMoves bool47 CannotAcceptMoves bool
48 }48 }
49 49

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