detached da262ae

refactor(remote): remove semantic proxy sessions

commit
da262ae6af8719403bdfa6f0163e469d9dd11fb7
author
brice · · 2026-08-03 11:19 UTC
committer
brice · · 2026-08-04 07:16 UTC
parent
004db2e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
50 files · +244 -6378
M internal/adapters/dgram/proxy.go +1 -1
@@ -50,8 +50,8 @@ type proxyCopyDirection struct {
5050 dst ports.Transport
5151 recvKind proxyCopyErrKind
5252 sendKind proxyCopyErrKind
53- retryRecoverable bool
5453 transform func(ports.Frame) ports.Frame
54+ retryRecoverable bool
5555 }
5656
5757 func (p ProxyRuntime) Run(ctx context.Context) error {
D internal/adapters/dgram/proxy_registry.go +0 -158
@@ -1,158 +0,0 @@
1-package dgram
2-
3-import (
4- "crypto/sha256"
5- "encoding/base64"
6- "encoding/json"
7- "errors"
8- "os"
9- "path/filepath"
10- "syscall"
11- "time"
12-
13- "github.com/bnema/vev/pkg/safedir"
14-)
15-
16-type ProxyRecord struct {
17- Session string `json:"session"`
18- PID int `json:"pid"`
19- Port int `json:"port"`
20- // KeyFingerprint is a non-secret ownership tag (SHA-256 of the AEAD key).
21- // The raw key must never be persisted by vev.
22- KeyFingerprint string `json:"key_fingerprint"`
23- Created time.Time `json:"created"`
24-}
25-
26-// KeyFingerprint derives the non-secret registry ownership tag.
27-func KeyFingerprint(key []byte) string {
28- sum := sha256.Sum256(key)
29- return base64.StdEncoding.EncodeToString(sum[:])
30-}
31-
32-type ProxyRegistry struct{ dir string }
33-
34-func NewProxyRegistry(dir string) *ProxyRegistry { return &ProxyRegistry{dir: dir} }
35-
36-func (r *ProxyRegistry) Lookup(session string) (ProxyRecord, bool) {
37- var rec ProxyRecord
38- ok := false
39- _ = r.withLock(func() error {
40- var err error
41- rec, err = r.read(session)
42- if err != nil {
43- return nil
44- }
45- if !processAlive(rec.PID) {
46- _ = r.removeLocked(session)
47- return nil
48- }
49- if rec.PID != os.Getpid() {
50- return nil
51- }
52- ok = true
53- return nil
54- })
55- return rec, ok
56-}
57-
58-func (r *ProxyRegistry) Publish(rec ProxyRecord) error {
59- if rec.Created.IsZero() {
60- rec.Created = time.Now()
61- }
62- return r.withLock(func() error {
63- b, err := json.Marshal(rec)
64- if err != nil {
65- return err
66- }
67- tmp, err := os.CreateTemp(r.dir, ".udp-proxy-*.tmp")
68- if err != nil {
69- return err
70- }
71- tmpName := tmp.Name()
72- defer func() { _ = os.Remove(tmpName) }()
73- if _, err := tmp.Write(b); err != nil {
74- _ = tmp.Close()
75- return err
76- }
77- if err := tmp.Close(); err != nil {
78- return err
79- }
80- return os.Rename(tmpName, r.path(rec.Session))
81- })
82-}
83-
84-func (r *ProxyRegistry) Remove(session string) error {
85- return r.withLock(func() error { return r.removeLocked(session) })
86-}
87-
88-func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error {
89- return r.withLock(func() error {
90- cur, err := r.read(rec.Session)
91- if errors.Is(err, os.ErrNotExist) {
92- return nil
93- }
94- if err != nil {
95- return err
96- }
97- if cur.PID != rec.PID || cur.Port != rec.Port || cur.KeyFingerprint != rec.KeyFingerprint {
98- return nil
99- }
100- return r.removeLocked(rec.Session)
101- })
102-}
103-
104-func (r *ProxyRegistry) withLock(fn func() error) error {
105- if err := safedir.EnsurePrivate(r.dir); err != nil {
106- return err
107- }
108- lock, err := os.OpenFile(filepath.Join(r.dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o600)
109- if err != nil {
110- return err
111- }
112- defer func() { _ = lock.Close() }()
113- if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
114- return err
115- }
116- defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) }()
117- return fn()
118-}
119-
120-func (r *ProxyRegistry) removeLocked(session string) error {
121- err := os.Remove(r.path(session))
122- if errors.Is(err, os.ErrNotExist) {
123- return nil
124- }
125- return err
126-}
127-
128-func (r *ProxyRegistry) read(session string) (ProxyRecord, error) {
129- b, err := os.ReadFile(r.path(session))
130- if err != nil {
131- return ProxyRecord{}, err
132- }
133- var rec ProxyRecord
134- if err := json.Unmarshal(b, &rec); err != nil {
135- return ProxyRecord{}, err
136- }
137- return rec, nil
138-}
139-
140-func (r *ProxyRegistry) path(session string) string {
141- if session == "" {
142- return filepath.Join(r.dir, "udp-proxy-empty.json")
143- }
144- name := base64.RawURLEncoding.EncodeToString([]byte(session))
145- return filepath.Join(r.dir, "udp-proxy-"+name+".json")
146-}
147-
148-func processAlive(pid int) bool {
149- if pid <= 0 {
150- return false
151- }
152- p, err := os.FindProcess(pid)
153- if err != nil {
154- return false
155- }
156- err = p.Signal(syscall.Signal(0))
157- return err == nil || errors.Is(err, syscall.EPERM)
158-}
D internal/adapters/dgram/proxy_registry_test.go +0 -153
@@ -1,153 +0,0 @@
1-package dgram
2-
3-import (
4- "bytes"
5- "crypto/sha256"
6- "encoding/base64"
7- "encoding/json"
8- "os"
9- "path/filepath"
10- "testing"
11-)
12-
13-func privateDir(t *testing.T) string {
14- t.Helper()
15- return filepath.Join(t.TempDir(), "vev")
16-}
17-
18-func TestKeyFingerprint(t *testing.T) {
19- key := bytes.Repeat([]byte{0xA5}, 32)
20- sum := sha256.Sum256(key)
21- want := base64.StdEncoding.EncodeToString(sum[:])
22- if got := KeyFingerprint(key); got != want {
23- t.Fatalf("KeyFingerprint()=%q, want %q", got, want)
24- }
25- other := bytes.Repeat([]byte{0xA6}, 32)
26- if KeyFingerprint(key) == KeyFingerprint(other) {
27- t.Fatal("distinct keys produced identical fingerprints")
28- }
29-}
30-
31-func TestProxyRegistryFileHoldsNoKeyMaterial(t *testing.T) {
32- r := NewProxyRegistry(privateDir(t))
33- key := bytes.Repeat([]byte{0xA5}, 32)
34- fingerprint := KeyFingerprint(key)
35- rec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: fingerprint}
36- if err := r.Publish(rec); err != nil {
37- t.Fatal(err)
38- }
39-
40- data, err := os.ReadFile(r.path("work"))
41- if err != nil {
42- t.Fatal(err)
43- }
44- if bytes.Contains(data, key) || bytes.Contains(data, []byte(base64.StdEncoding.EncodeToString(key))) {
45- t.Fatalf("registry file contains raw key material: %s", data)
46- }
47- var fields map[string]json.RawMessage
48- if err := json.Unmarshal(data, &fields); err != nil {
49- t.Fatal(err)
50- }
51- if _, ok := fields["key"]; ok {
52- t.Fatalf("registry file contains raw key field: %s", data)
53- }
54- var got string
55- if err := json.Unmarshal(fields["key_fingerprint"], &got); err != nil {
56- t.Fatal(err)
57- }
58- if got != fingerprint {
59- t.Fatalf("key_fingerprint=%q, want %q", got, fingerprint)
60- }
61-}
62-
63-func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) {
64- t.Run("reuses live matching record", func(t *testing.T) {
65- r := NewProxyRegistry(privateDir(t))
66- want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}
67- if err := r.Publish(want); err != nil {
68- t.Fatal(err)
69- }
70- got, ok := r.Lookup("work")
71- if !ok {
72- t.Fatal("Lookup ok=false, want true")
73- }
74- if got.Port != want.Port || got.KeyFingerprint != want.KeyFingerprint || got.PID != want.PID {
75- t.Fatalf("Lookup()=%+v, want %+v", got, want)
76- }
77- })
78- t.Run("removes stale record", func(t *testing.T) {
79- r := NewProxyRegistry(privateDir(t))
80- if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}); err != nil {
81- t.Fatal(err)
82- }
83- if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key_fingerprint":"abc"}`), 0o600); err != nil {
84- t.Fatal(err)
85- }
86- if _, ok := r.Lookup("work"); ok {
87- t.Fatal("Lookup ok=true for stale pid")
88- }
89- if _, err := os.Stat(r.path("work")); !os.IsNotExist(err) {
90- t.Fatalf("stale record stat err=%v, want not exist", err)
91- }
92- })
93- t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) {
94- r := NewProxyRegistry(privateDir(t))
95- old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "old"}
96- if err := r.Publish(old); err != nil {
97- t.Fatal(err)
98- }
99- newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, KeyFingerprint: "new"}
100- if err := r.Publish(newRec); err != nil {
101- t.Fatal(err)
102- }
103- if err := r.RemoveOwned(old); err != nil {
104- t.Fatal(err)
105- }
106- got, ok := r.Lookup("work")
107- if !ok {
108- t.Fatal("new live record removed by stale owner")
109- }
110- if got.Port != newRec.Port || got.KeyFingerprint != newRec.KeyFingerprint {
111- t.Fatalf("Lookup()=%+v, want %+v", got, newRec)
112- }
113- if err := r.RemoveOwned(newRec); err != nil {
114- t.Fatal(err)
115- }
116- if _, ok := r.Lookup("work"); ok {
117- t.Fatal("owned remove left registry record")
118- }
119- })
120-}
121-
122-func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) {
123- r := NewProxyRegistry(privateDir(t))
124- tests := []struct {
125- a string
126- b string
127- }{
128- {a: "", b: "default"},
129- {a: "a/b", b: "a_b"},
130- {a: `a\b`, b: "a_b"},
131- }
132- for _, tt := range tests {
133- if r.path(tt.a) == r.path(tt.b) {
134- t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a))
135- }
136- first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, KeyFingerprint: "first"}
137- second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, KeyFingerprint: "second"}
138- if err := r.Publish(first); err != nil {
139- t.Fatal(err)
140- }
141- if err := r.Publish(second); err != nil {
142- t.Fatal(err)
143- }
144- gotFirst, ok := r.Lookup(tt.a)
145- if !ok || gotFirst.KeyFingerprint != first.KeyFingerprint {
146- t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok)
147- }
148- gotSecond, ok := r.Lookup(tt.b)
149- if !ok || gotSecond.KeyFingerprint != second.KeyFingerprint {
150- t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok)
151- }
152- }
153-}
M internal/adapters/dgram/ssh_dialer.go +2 -2
@@ -40,7 +40,7 @@ func (p execBootstrapProcess) Wait() error { return p.cmd
4040
4141 var (
4242 startUDPBootstrap = func(ctx context.Context, target, session string, stderr io.Writer) bootstrapProcess {
43- spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", session)
43+ spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", "")
4444 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
4545 cmd.Stderr = stderr
4646 return execBootstrapProcess{cmd: cmd}
@@ -129,7 +129,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
129129 defer bootstrapCancel()
130130
131131 var stderr limitedBuffer
132- proc := startUDPBootstrap(bootstrapCtx, d.Target, d.Session, &stderr)
132+ proc := startUDPBootstrap(bootstrapCtx, d.Target, "", &stderr)
133133 stdout, err := proc.StdoutPipe()
134134 if err != nil {
135135 return nil, udpUnavailable("bootstrap stdout", err, &stderr)
M internal/adapters/dgram/ssh_dialer_test.go +1 -1
@@ -121,7 +121,7 @@ func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
121121 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
122122 proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}
123123 withBootstrapStarter(t, func(_ context.Context, target, session string, _ io.Writer) bootstrapProcess {
124- if target != "127.0.0.1" || session != "work" {
124+ if target != "127.0.0.1" || session != "" {
125125 t.Fatalf("bootstrap target/session = %q/%q", target, session)
126126 }
127127 return proc
M internal/adapters/remote/remote_factory.go +3 -3
@@ -31,14 +31,14 @@ func (f DialerFactory) DialerForRemote(target string, session string, mode ports
3131 if log != nil {
3232 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
3333 }
34- dialer := dgram.NewRemoteDialerWithLogger(target, session, log)
34+ dialer := dgram.NewRemoteDialerWithLogger(target, "", log)
3535 dialer.RuntimeObserver = f.observer
3636 return dialer, nil
3737 case ports.RemoteTransportStdio:
3838 if log != nil {
3939 log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
4040 }
41- return stdioDialer{target: target, session: session, log: log, observer: f.observer}, nil
41+ return stdioDialer{target: target, log: log, observer: f.observer}, nil
4242 default:
4343 return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
4444 }
@@ -52,5 +52,5 @@ type stdioDialer struct {
5252 }
5353
5454 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))
55+ return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, "", d.log, sshstdio.WithRuntimeObserver(d.observer))
5656 }
M internal/adapters/remote/remote_factory_test.go +4 -4
@@ -32,16 +32,16 @@ func TestDialerFactorySelectsExplicitModes(t *testing.T) {
3232 if !ok {
3333 t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{})
3434 }
35- if got.Target != "remote.example" || got.Session != "work" {
36- t.Fatalf("dialer = %+v, want target/session copied", got)
35+ if got.Target != "remote.example" || got.Session != "" {
36+ t.Fatalf("dialer = %+v, want target and no session metadata", got)
3737 }
3838 case stdioDialer:
3939 got, ok := dialer.(stdioDialer)
4040 if !ok {
4141 t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{})
4242 }
43- if got.target != "remote.example" || got.session != "work" {
44- t.Fatalf("dialer = %+v, want target/session copied", got)
43+ if got.target != "remote.example" || got.session != "" {
44+ t.Fatalf("dialer = %+v, want target and no session metadata", got)
4545 }
4646 }
4747 })
M internal/adapters/sshstdio/transport.go +4 -8
@@ -294,18 +294,14 @@ type CommandSpec struct {
294294 // BuildCommand constructs the local ssh subprocess argv without invoking a
295295 // local shell. OpenSSH sends the remote command as one string for the remote
296296 // user's shell to interpret, so every remote argv word is POSIX single-quoted.
297-func BuildCommand(target, session string) CommandSpec {
298- return BuildCommandForMode(target, "_stdio", session)
297+func BuildCommand(target, _ string) CommandSpec {
298+ return BuildCommandForMode(target, "_stdio", "")
299299 }
300300
301301 // BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev
302302 // remote mode such as _stdio or _udp-bootstrap.
303-func BuildCommandForMode(target, mode, session string) CommandSpec {
304- remote := []string{"vev", mode}
305- if session != "" {
306- remote = append(remote, session)
307- }
308- return BuildCommandForRemoteCommand(target, remote...)
303+func BuildCommandForMode(target, mode, _ string) CommandSpec {
304+ return BuildCommandForRemoteCommand(target, "vev", mode)
309305 }
310306
311307 // BuildCommandForRemoteCommand constructs ssh argv for an arbitrary remote
M internal/adapters/sshstdio/transport_test.go +3 -4
@@ -26,9 +26,8 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
2626 want []string
2727 }{
2828 {name: "no session", target: "user@example.com", want: []string{"--", "user@example.com", "'vev' '_stdio'"}},
29- {name: "session shell metacharacters are quoted in remote command", target: "user@example.com", session: "work; rm -rf /", want: []string{"--", "user@example.com", "'vev' '_stdio' 'work; rm -rf /'"}},
30- {name: "single quote in session is posix escaped", target: "user@example.com", session: "it's fine", want: []string{"--", "user@example.com", "'vev' '_stdio' 'it'\\''s fine'"}},
31- {name: "target kept as single local arg after option terminator", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"--", "user@host; touch /tmp/pwn", "'vev' '_stdio' 'work'"}},
29+ {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: "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'"}},
3231 }
3332
3433 for _, tt := range tests {
@@ -51,7 +50,7 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
5150
5251 func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {
5352 got := BuildCommandForMode("user@example.com", "_udp-bootstrap", "work")
54- want := []string{"--", "user@example.com", "'vev' '_udp-bootstrap' 'work'"}
53+ want := []string{"--", "user@example.com", "'vev' '_udp-bootstrap'"}
5554 if got.Path != "ssh" {
5655 t.Fatalf("Path = %q, want ssh", got.Path)
5756 }
M internal/app/run.go +43 -101
@@ -833,23 +833,35 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
833833 if factory == nil {
834834 factory = defaultRemoteDialerFactory()
835835 }
836- if log != nil {
837- log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
838- }
839- dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log)
840- if err != nil {
841- return err
836+ for {
837+ if log != nil {
838+ log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
839+ }
840+ dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log)
841+ if err != nil {
842+ return err
843+ }
844+ err = runClient(ctx, client.Dependencies{
845+ Dialer: dialer,
846+ Terminal: term.New(),
847+ Clock: clock.New(),
848+ Clipboard: deps.clipboard,
849+ Logger: log,
850+ RuntimeObserver: deps.runtimeObserver,
851+ RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
852+ Remote: true,
853+ }, client.AttachRequest{Intent: intent, SessionName: name})
854+ var handoff *client.AttachTargetError
855+ if !errors.As(err, &handoff) {
856+ return err
857+ }
858+ if handoff == nil || ports.ValidateAttachTarget(handoff.Target) != nil {
859+ return fmt.Errorf("vev: invalid remote attach handoff")
860+ }
861+ remoteTarget = handoff.Target.Endpoint
862+ name = handoff.Target.Session
863+ intent = handoff.Target.Intent
842864 }
843- return runClient(ctx, client.Dependencies{
844- Dialer: dialer,
845- Terminal: term.New(),
846- Clock: clock.New(),
847- Clipboard: deps.clipboard,
848- Logger: log,
849- RuntimeObserver: deps.runtimeObserver,
850- RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
851- Remote: true,
852- }, client.AttachRequest{Intent: intent, SessionName: name})
853865 }
854866
855867 localDialer := deps.localDialer
@@ -1063,7 +1075,7 @@ func runStdio(ctx context.Context) (retErr error) {
10631075 }
10641076 defer func() { _ = transport.Close() }()
10651077 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer))
1066- return runStdioProxy(ctx, stdio, transport, os.Environ(), log)
1078+ return runStdioProxy(ctx, stdio, transport, log)
10671079 }
10681080
10691081 var (
@@ -1075,12 +1087,12 @@ var (
10751087
10761088 // runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness
10771089 // line, and exits so SSH stdio can close without owning the UDP proxy lifetime.
1078-func runUDPBootstrap(ctx context.Context, session string) error {
1090+func runUDPBootstrap(ctx context.Context, _ string) error {
10791091 // Always start a fresh proxy for a bootstrap request. A client attach begins
10801092 // with MsgHello, which must reach the daemon handshake path; reusing an
10811093 // already-running proxy would forward that Hello into the daemon connection's
1082- // post-handshake runConnLoop, where it is intentionally ignored. The new
1083- // proxy publishes itself and retires any older registry entry once ready.
1094+ // post-handshake runConnLoop, where it is intentionally ignored. Every
1095+ // bootstrap starts a fresh byte-only carriage.
10841096 r, w, err := os.Pipe()
10851097 if err != nil {
10861098 return err
@@ -1093,9 +1105,6 @@ func runUDPBootstrap(ctx context.Context, session string) error {
10931105 return err
10941106 }
10951107 args := []string{"_udp-proxy"}
1096- if session != "" {
1097- args = append(args, session)
1098- }
10991108 devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
11001109 if err != nil {
11011110 _ = w.Close()
@@ -1146,13 +1155,13 @@ func runUDPBootstrap(ctx context.Context, session string) error {
11461155 }
11471156
11481157 // runUDPProxy is the detached long-lived remote-side datagram proxy.
1149-func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr error) {
1158+func runUDPProxy(ctx context.Context, _ string, ready io.Writer) (retErr error) {
11501159 log, logCloser, err := configureLogging(logging.Stdio, false)
11511160 if err != nil {
11521161 return err
11531162 }
11541163 defer func() { _ = logCloser.Close() }()
1155- log.Debug("udp proxy starting", "session", session)
1164+ log.Debug("udp carriage starting")
11561165
11571166 observer, observerCloser, err := newPerformanceTrace(clock.New())
11581167 if err != nil {
@@ -1183,13 +1192,9 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
11831192 if !ok {
11841193 return errors.New("vev: udp proxy did not get a UDP address")
11851194 }
1186- registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies"))
1187-
11881195 var (
1189- dg *dgram.Transport
1190- record dgram.ProxyRecord
1191- published bool
1192- setupErr error
1196+ dg *dgram.Transport
1197+ setupErr error
11931198 )
11941199 pdgram.SecretDo(func() {
11951200 key := make([]byte, pdgram.KeySize)
@@ -1201,12 +1206,6 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
12011206 if setupErr != nil {
12021207 return
12031208 }
1204- record = dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, KeyFingerprint: dgram.KeyFingerprint(key)}
1205- if setupErr = registry.Publish(record); setupErr != nil {
1206- return
1207- }
1208- published = true
1209-
12101209 readiness := append([]byte("VEV-UDP "), strconv.Itoa(addr.Port)...)
12111210 readiness = append(readiness, ' ')
12121211 readiness = base64.StdEncoding.AppendEncode(readiness, key)
@@ -1220,15 +1219,10 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
12201219 if dg != nil {
12211220 _ = dg.Close()
12221221 }
1223- if published {
1224- _ = registry.RemoveOwned(record)
1225- }
12261222 return setupErr
12271223 }
12281224 defer func() { _ = dg.Close() }()
1229- defer func() { _ = registry.RemoveOwned(record) }()
1230-
1231- return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log)
1225+ return runUDPProxyRuntime(ctx, dg, daemonTr, log)
12321226 }
12331227
12341228 const udpProxyIdleTTL = 15 * time.Minute
@@ -1319,66 +1313,14 @@ func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, erro
13191313 return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
13201314 }
13211315
1322-// firstHelloTransport applies proxy-owned values to exactly the first Hello
1323-// crossing a remote boundary. It intentionally leaves all later frames and
1324-// malformed Hellos untouched so proxying preserves the client stream.
1325-type firstHelloTransport struct {
1326- ports.Transport
1327- mu sync.Mutex
1328- seen bool
1329- session string
1330- env []string
1331-}
1332-
1333-func newFirstHelloTransport(transport ports.Transport, session string, env []string) *firstHelloTransport {
1334- return &firstHelloTransport{
1335- Transport: transport,
1336- session: session,
1337- env: append([]string(nil), env...),
1338- }
1339-}
1340-
1341-func (t *firstHelloTransport) Recv() (ports.Frame, error) {
1342- f, err := t.Transport.Recv()
1343- if err != nil || f.Type != ports.MsgHello {
1344- return f, err
1345- }
1346-
1347- t.mu.Lock()
1348- defer t.mu.Unlock()
1349- if t.seen {
1350- return f, nil
1351- }
1352- t.seen = true
1353-
1354- h, err := ports.UnmarshalHello(f.Payload)
1355- if err != nil {
1356- return f, nil
1357- }
1358- h.Env = append([]string(nil), t.env...)
1359- if h.Name == "" {
1360- h.Name = t.session
1361- }
1362- f.Payload = ports.MarshalHello(h)
1363- return f, nil
1364-}
1365-
1366-// runStdioProxy applies the remote host environment before the stdio stream
1367-// reaches the daemon. Its explicit environment input keeps this host boundary
1368-// independently testable.
1369-func runStdioProxy(ctx context.Context, client, daemon ports.Transport, env []string, log *slog.Logger) error {
1370- return proxyTransports(ctx, newFirstHelloTransport(client, "", env), daemon, log)
1316+// runStdioProxy forwards the framed transport without interpreting protocol
1317+// messages. Session selection remains in the Hello sent by the thin client.
1318+func runStdioProxy(ctx context.Context, client, daemon ports.Transport, log *slog.Logger) error {
1319+ return proxyTransports(ctx, client, daemon, log)
13711320 }
13721321
1373-// runUDPProxyRuntime applies the remote host environment and selected session
1374-// before the datagram stream reaches the daemon.
1375-func runUDPProxyRuntime(ctx context.Context, session string, client, daemon ports.Transport, env []string, log *slog.Logger) error {
1376- return dgram.ProxyRuntime{
1377- Client: newFirstHelloTransport(client, session, env),
1378- Daemon: daemon,
1379- Log: log,
1380- IdleTTL: udpProxyIdleTTL,
1381- }.Run(ctx)
1322+func runUDPProxyRuntime(ctx context.Context, client, daemon ports.Transport, log *slog.Logger) error {
1323+ return dgram.ProxyRuntime{Client: client, Daemon: daemon, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)
13821324 }
13831325
13841326 func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger) error {
M internal/app/run_test.go +20 -133
@@ -411,139 +411,6 @@ func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
411411 // scriptRecv makes a transport yield frames in order, then wait for done
412412 // before returning EOF. The shared done channel coordinates proxy readers
413413 // 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-
547414 func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
548415 require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
549416 }
@@ -1128,6 +995,26 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
1128995 }
1129996 }
1130997
998+func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
999+ factory := portsmocks.NewMockRemoteDialerFactory(t)
1000+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
1001+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
1002+ var calls int
1003+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1004+ remoteDialerFactory: factory,
1005+ runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
1006+ calls++
1007+ require.NotNil(t, deps.Dialer)
1008+ if calls == 1 {
1009+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}
1010+ }
1011+ return nil
1012+ },
1013+ })
1014+ require.NoError(t, err)
1015+ require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")
1016+}
1017+
11311018 func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
11321019 factory := portsmocks.NewMockRemoteDialerFactory(t)
11331020 runClientCalled := false
M internal/usecase/client/client.go +25 -0
@@ -376,6 +376,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
376376 if !result.transportClosed {
377377 _ = connection.Close()
378378 }
379+ if result.target != nil {
380+ return &AttachTargetError{Target: *result.target}
381+ }
379382 if result.resumeToken != 0 {
380383 resumeToken = result.resumeToken
381384 }
@@ -551,9 +554,23 @@ type attachResult struct {
551554 sessionName string
552555 welcomed bool
553556 transportClosed bool
557+ target *ports.AttachTarget
554558 err error
555559 }
556560
561+// AttachTargetError requests that the composition root replace the current
562+// transport with the selected endpoint. The daemon never opens that endpoint.
563+type AttachTargetError struct {
564+ Target ports.AttachTarget
565+}
566+
567+func (e *AttachTargetError) Error() string {
568+ if e == nil {
569+ return "client: attach target handoff"
570+ }
571+ return fmt.Sprintf("client: attach target %q/%q", e.Target.Endpoint, e.Target.Session)
572+}
573+
557574 // boundedPreWelcome runs one blocking handshake operation. Transport methods
558575 // do not accept a context, so cancellation and expiry close this attempt's
559576 // transport to interrupt the call, then wait for its goroutine to exit.
@@ -1066,6 +1083,14 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10661083 ms.firstOutput = true
10671084 log.Debug("received first output")
10681085 }
1086+ case ports.MsgAttachTarget:
1087+ target, derr := ports.UnmarshalAttachTarget(r.frame.Payload)
1088+ if derr != nil {
1089+ return welcomedResult(fmt.Errorf("vev: decoding attach target: %w", derr))
1090+ }
1091+ handoff := welcomedResult(nil)
1092+ handoff.target = &target
1093+ return handoff
10691094 case ports.MsgDetached:
10701095 d, derr := ports.UnmarshalDetached(r.frame.Payload)
10711096 if derr != nil {
M internal/usecase/client/client_test.go +25 -0
@@ -337,6 +337,31 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
337337 }
338338 }
339339
340+func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
341+ var out bytes.Buffer
342+ var restoreCount atomic.Int32
343+ resizeCh := make(chan domain.Size)
344+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
345+ defer in.unblock()
346+
347+ target := ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}
348+ tr := portsmocks.NewMockTransport(t)
349+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
350+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
351+ unblock := scriptRecv(tr,
352+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "local"}))},
353+ recvItem{f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(target))},
354+ )
355+ defer unblock()
356+ tr.EXPECT().Close().Return(nil).Once()
357+
358+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"})
359+ var handoff *client.AttachTargetError
360+ require.ErrorAs(t, err, &handoff)
361+ require.Equal(t, target, handoff.Target)
362+ require.Equal(t, int32(1), restoreCount.Load(), "handoff must restore the terminal after closing the old connection")
363+}
364+
340365 func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
341366 t.Setenv("VEV_CLIENT_ENV_TEST", "TOKEN=a=b=c")
342367
M internal/usecase/daemon/attachment_transition.go +0 -1
@@ -168,7 +168,6 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
168168 if err != nil {
169169 return result, err
170170 }
171- d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveAttachment)
172171 return result, nil
173172 }
174173
M internal/usecase/daemon/client.go +3 -38
@@ -136,9 +136,7 @@ func (ac *attachedClient) initOverlays() {
136136
137137 func (ac *attachedClient) currentAttachmentSession() attachmentSession { return ac.sess.Get() }
138138
139-// currentSession is the explicit local-only narrowing used until proxy-aware
140-// consumers are introduced. Local PTY/tab paths must treat non-local entries as
141-// unavailable rather than infer proxy behavior.
139+// currentSession narrows the attachment to its owning local session.
142140 func (ac *attachedClient) currentSession() *session {
143141 sess, _ := localSession(ac.currentAttachmentSession())
144142 return sess
@@ -344,31 +342,6 @@ func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportS
344342 return err
345343 }
346344
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-
372345 // boundedSend sends f to ac with a deadline watchdog: if the send (including
373346 // waiting on sendMu behind a wedged paint) does not complete within
374347 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -608,8 +581,7 @@ func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordina
608581 }
609582
610583 // ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an
611-// exact attachment implementation. The caller holds entry.core().mu; proxy
612-// VT state is deliberately not touched here.
584+// exact attachment implementation. The caller holds entry.core().mu.
613585 func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSession) *renderCoordinator {
614586 if rc := attachmentRenderCoordinator(entry); rc != nil {
615587 return rc
@@ -705,10 +677,6 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
705677 token.ac.captureFrames = nil
706678 token.ac.sendMu.Unlock()
707679 }
708- if _, ok := token.sess.(*proxySession); ok {
709- // Remote ordinary output arrives independently after the proxy Welcome.
710- return true
711- }
712680 sess, ok := localSession(token.sess)
713681 if !ok {
714682 return false
@@ -819,6 +787,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
819787 view.windowTop = 0
820788 view.revision++
821789 ac.publishView(view)
790+ ac.rebaseOutput()
822791 ac.pipelineCache = composeCacheInput{}
823792 ac.pipelineScratch = composeCacheInput{}
824793 ac.sendMu.Unlock()
@@ -826,10 +795,6 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
826795 entry := token.sess
827796 // Rendering is attachment-scoped. Run it independently of the connection
828797 // reader so a blocked transport cannot hold session dispatch or peers.
829- if proxy, ok := entry.(*proxySession); ok {
830- _, _ = proxy.resize(size)
831- return true
832- }
833798 go d.paint(entry, ac, true, token.lease)
834799 return true
835800 }
M internal/usecase/daemon/client_frame_routing.go +3 -17
@@ -54,13 +54,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
5454 if !ok {
5555 return
5656 }
57- if proxy, ok := sess.(*proxySession); ok {
58- d.detachProxyOnSendError(proxy, ac, tr)
59- } else if local, ok := localSession(sess); ok {
60- d.clientGone(local, ac, tr, false)
61- } else {
57+ local, ok := localSession(sess)
58+ if !ok {
6259 return
6360 }
61+ d.clientGone(local, ac, tr, false)
6462 current := ac.currentAttachmentSession()
6563 if current == sess || !ac.currentTransportIs(tr) || current == nil {
6664 return
@@ -204,12 +202,6 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
204202 result.Text = request.Slug + " is not scriptable"
205203 return result
206204 }
207- if !proxyAttachedCommandOwnedRemotely(cmd.Slug) {
208- result.Code = ports.ErrNotScriptable
209- result.Text = request.Slug + " is owned by the local proxy daemon"
210- return result
211- }
212-
213205 // The frame's attachment token is the target capability. No registry/name lookup is
214206 // performed, so the command cannot escape to another remote session.
215207 err := sess.runMutation(func() error {
@@ -229,12 +221,6 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
229221 return result
230222 }
231223
232-// proxyAttachedCommandOwnedRemotely applies the palette's canonical ownership
233-// policy to command frames received by the remote daemon.
234-func proxyAttachedCommandOwnedRemotely(slug string) bool {
235- return proxyPaletteCommandOwnership(slug) == proxyPaletteRemote
236-}
237-
238224 // resetOutput rebases only the exact proxied attachment and transport admitted
239225 // by the frame router. sendMu serializes the rebase with every output send;
240226 // transport revalidation under that lock rejects a link replaced while waiting.
M internal/usecase/daemon/client_transport_errors.go +0 -57
@@ -37,14 +37,6 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
3737 }
3838 token.effect.bindActionEnd(d, "detach")
3939 token.effect.End()
40- if proxy, ok := token.sess.(*proxySession); ok {
41- if !d.detachIfAttachmentCurrent(token) {
42- return false
43- }
44- d.finishProxyClientGone(proxy, token.ac, token.transport.transport, explicit)
45- d.armProxyWarm(proxy)
46- return true
47- }
4840 sess, ok := localSession(token.sess)
4941 if !ok {
5042 return false
@@ -124,48 +116,6 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
124116 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
125117 }
126118
127-// detachProxyOnSendError is detachOnSendError for an attachment that has no
128-// local session behind it. Proxy attachments own no resumable parking
129-// lifecycle, so the remote link is retained as a warm headless session instead
130-// of being parked for the same client to resume.
131-func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, failed ports.Transport) {
132- if d == nil || p == nil || ac == nil {
133- return
134- }
135- expected := transportSnapshot{}
136- oldTr := failed
137- if failed != nil {
138- expected = ac.transportSnapshot()
139- if expected.transport != failed {
140- return
141- }
142- } else {
143- oldTr = ac.transport()
144- }
145- if !d.detachProxyIfCurrentTransport(p, ac, expected) {
146- return
147- }
148- d.finishProxyClientGone(p, ac, oldTr, false)
149-}
150-
151-// finishProxyClientGone retires external client ownership after the exact proxy
152-// attachment has been unpublished. Warm-timer publication remains with the caller so
153-// each detach path can arm once, after membership is empty.
154-func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) {
155- if rc := p.coordinator.Load(); rc != nil {
156- rc.noteDetach(ac)
157- }
158- if ac.overlays != nil {
159- d.closePicker(ac)
160- }
161- ac.clearPreviousSession()
162- if explicit {
163- d.boundedSend(ac, frameDetached(ports.ReasonDetach))
164- }
165- _ = ac.closeCapturedTransport(failed)
166- d.log.Warn("detached proxy client", "host", p.key.Host, "session", p.key.Name, "explicit", explicit)
167-}
168-
169119 func (d *Daemon) detachOnAttachmentSendError(token attachmentConnectionToken, failed ports.Transport) {
170120 d.detachOnAttachmentSendErrorUntil(token, failed, nil)
171121 }
@@ -176,13 +126,6 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
176126 if failed == nil || failed != token.transport.transport {
177127 return
178128 }
179- if proxy, ok := token.sess.(*proxySession); ok {
180- if d.detachIfAttachmentCurrentUntil(token, done) {
181- d.finishProxyClientGone(proxy, token.ac, failed, false)
182- d.armProxyWarm(proxy)
183- }
184- return
185- }
186129 sess, ok := localSession(token.sess)
187130 if !ok {
188131 return
M internal/usecase/daemon/daemon.go +3 -6
@@ -49,8 +49,8 @@ const detachNotifyTimeout = time.Second
4949 // not yet acked by the client) before paint defers rather than composing
5050 // another diff. It bounds the daemon's paint rate to the client's ack rate, so
5151 // heavy output degrades to lower fps on a slow link instead of overflowing the
52-// transport. It must stay well under the UDP proxy's 32-frame client window so
53-// the proxy's reliable queue never fills from painting alone.
52+// transport. It must stay well under the datagram carriage's 32-frame client
53+// window so its reliable queue never fills from painting alone.
5454 const maxUnackedOutputStates = 8
5555
5656 // normalizeOutputWindow bounds the untrusted Hello value. Zero deliberately
@@ -74,10 +74,7 @@ type Daemon struct {
7474 stopped map[string]stoppedSession
7575 // creating reserves names while durable creation I/O runs without mu.
7676 creating map[string]struct{}
77- // proxyConstructions serializes remote IntentAttach handshakes by remote
78- // key. Entries are guarded by mu and never retain it across dial or link I/O.
79- proxyConstructions map[domain.RemoteSessionKey]*proxyConstruction
80- nextID uint64
77+ nextID uint64
8178 // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
8279 // mark. It is guarded by mu and prevents a wall-clock regression from
8380 // reusing a lifecycle identity.
M internal/usecase/daemon/input.go +0 -17
@@ -43,23 +43,6 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
4343 func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) {
4444 ac := token.ac
4545 ac.initOverlays()
46- if proxy, ok := token.sess.(*proxySession); ok {
47- handler := proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: token}
48- ac.mouseScan.Scan(data,
49- func(ev mouse.Event) {
50- if token.attachmentEffectCurrent() && !proxyMouseOwnedLocally(ac.overlays) {
51- handler.Mouse(ev)
52- }
53- },
54- func(b []byte) {
55- if !token.attachmentEffectCurrent() || ac.overlays.HandleInput(d, b, token.effect) || !token.attachmentEffectCurrent() {
56- return
57- }
58- ac.keys.RouteWithHandler(b, handler)
59- },
60- )
61- return
62- }
6346 ac.mouseScan.Scan(data,
6447 func(ev mouse.Event) {
6548 if token.attachmentEffectCurrent() {
M internal/usecase/daemon/notices_overlay.go +0 -2
@@ -228,8 +228,6 @@ func (d *Daemon) yankNotice(entry attachmentSession, ac *attachedClient, n domai
228228 if err != nil {
229229 if sess, ok := localSession(entry); ok {
230230 d.detachOnSendError(sess, ac, failed)
231- } else if proxy, ok := entry.(*proxySession); ok {
232- d.detachProxyOnSendError(proxy, ac, failed)
233231 } else {
234232 _ = ac.closeCapturedTransport(failed)
235233 }
M internal/usecase/daemon/notices_overlay_test.go +0 -34
@@ -431,40 +431,6 @@ func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
431431 require.Equal(t, "no notifications yet", history[0].Message)
432432 }
433433
434-func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T) {
435- d, proxy, ac, _, handler := newProxyInputHarness(t)
436- handler.enterPalette()
437- d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect)
438-
439- history := d.notices.history()
440- require.Len(t, history, 1)
441- require.Equal(t, domain.SessionID(""), history[0].SessionID)
442- require.Same(t, proxy, ac.currentAttachmentSession())
443-}
444-
445-func TestProxyNoticeYankSendFailureDetachesAndArmsWarmExpiry(t *testing.T) {
446- d, proxy, ac, _, handler := newProxyInputHarness(t)
447- handler.connectionToken.effect.End()
448- client, ok := ac.transport().(*proxyTestTransport)
449- require.True(t, ok)
450- client.sendFails.Store(true)
451-
452- d.yankNotice(proxy, ac, domain.Notification{Code: domain.NoticeClipboard, Message: "copy", Time: time.Unix(1, 0)})
453-
454- require.Nil(t, ac.currentAttachmentSession())
455- select {
456- case <-client.closed:
457- default:
458- t.Fatal("proxy client transport was not closed")
459- }
460- proxy.mu.Lock()
461- warm := proxy.warm
462- proxy.mu.Unlock()
463- require.NotNil(t, warm, "proxy send failure must retain one warm expiry path")
464- warm.stop()
465- awaitTestCompletion(t, warm.done, "warm expiry did not stop during cleanup")
466-}
467-
468434 func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {
469435 p, release := newBlockingPTY(t)
470436 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/notify_test.go +2 -7
@@ -21,16 +21,11 @@ import (
2121 func TestReportAttachmentErrorPreservesLocalOrNilScope(t *testing.T) {
2222 d := newTestDaemon(t, nil, stubClock{})
2323 local := &session{sessionCore: sessionCore{id: "local", name: "local"}}
24- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize)
25- require.NoError(t, err)
26-
2724 d.reportAttachmentError(local, domain.UserWarn(domain.NoticeClipboard, "local failure", nil))
28- d.reportAttachmentError(proxy, domain.UserWarn(domain.NoticeClipboard, "proxy failure", nil))
2925
3026 history := d.notices.history()
31- require.Len(t, history, 2, "each attachment error must report exactly once")
32- require.Equal(t, local.id, history[1].SessionID)
33- require.Equal(t, domain.SessionID(""), history[0].SessionID)
27+ require.Len(t, history, 1)
28+ require.Equal(t, local.id, history[0].SessionID)
3429 }
3530
3631 func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {
M internal/usecase/daemon/palette.go +0 -90
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "context"
54 "errors"
65 "sort"
76 "time"
@@ -21,34 +20,6 @@ const (
2120 paletteRailWidth = 64
2221 )
2322
24-type proxyPaletteOwnership uint8
25-
26-const (
27- proxyPaletteRejected proxyPaletteOwnership = iota
28- proxyPaletteLocal
29- proxyPaletteRemote
30-)
31-
32-// proxyPaletteCommandOwnership is exhaustive by policy: an unknown future
33-// palette command is rejected until its proxy ownership is deliberately added.
34-func proxyPaletteCommandOwnership(slug string) proxyPaletteOwnership {
35- switch slug {
36- case "new-tab", "close-tab",
37- "split-right", "split-left", "split-up", "split-down",
38- "consume-or-expel-pane-left", "consume-or-expel-pane-right",
39- "stack-pane", "toggle-stack", "toggle-floating-pane", "close-pane",
40- "focus-pane-left", "focus-pane-right", "focus-pane-up", "focus-pane-down",
41- "resize-pane", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes",
42- "next-tab", "previous-tab", "rename-tab":
43- return proxyPaletteRemote
44- case "new-session", "back-session", "jump-recent-session", "session-picker",
45- "notifications", "yank-last-notification", "detach":
46- return proxyPaletteLocal
47- default:
48- return proxyPaletteRejected
49- }
50-}
51-
5223 var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
5324
5425 func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
@@ -180,7 +151,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
180151 return
181152 }
182153 sess, local := localSession(entry)
183- proxy, remote := entry.(*proxySession)
184154 var cmd command.Command
185155 var sessionTarget palette.Result
186156 var hasSessionTarget bool
@@ -347,66 +317,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
347317 }
348318 return
349319 }
350- ownership := proxyPaletteCommandOwnership(cmd.Slug)
351- if remote && ownership == proxyPaletteRemote {
352- if !ac.closeExecutedPalette(generation, rawQuery) {
353- return
354- }
355- ctx := d.serveCtx
356- if ctx == nil {
357- ctx = context.Background()
358- }
359- result, err := proxy.sendCommand(ctx, d.clock, cmd.Slug, args)
360- if err != nil {
361- d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, "remote command failed", err)
362- return
363- }
364- if !result.OK {
365- text := result.Text
366- if text == "" {
367- text = "remote command failed"
368- }
369- d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, text, nil)
370- return
371- }
372- d.recordPaletteUse(cmd.Code)
373- d.invalidateRender(proxy, ac, true, "palette.go")
374- return
375- }
376- if remote && ownership == proxyPaletteLocal {
377- // Every local action supersedes this exact remote palette generation.
378- // Close it before role handoff or opening another overlay so a delayed
379- // action cannot leave the old palette rendered over the new state.
380- if !ac.closeExecutedPalette(generation, rawQuery) {
381- return
382- }
383- var err error
384- switch cmd.Slug {
385- case "new-session", "yank-last-notification":
386- err = cmd.Run(paletteExec{d: d, ac: ac, effect: effect, attachment: proxy}, args)
387- if err == nil {
388- d.recordPaletteUse(cmd.Code)
389- }
390- case "back-session":
391- if effect != nil {
392- err = d.backSessionForAttachment(effect.connectionToken())
393- }
394- case "detach":
395- if effect != nil && !d.clientGoneForAttachment(effect.connectionToken(), true) {
396- err = errAttachmentTransition
397- }
398- case "session-picker":
399- d.enterPicker(proxy, ac)
400- case "notifications":
401- d.enterNotices(proxy, ac)
402- default:
403- err = errors.New(cmd.Name + " is unavailable for a remote session")
404- }
405- if err != nil && !errors.Is(err, errAttachmentTransition) {
406- d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, err.Error(), nil)
407- }
408- return
409- }
410320 if !local {
411321 if ac.closeExecutedPalette(generation, rawQuery) {
412322 d.notify(nil, domain.NoticeError, domain.NoticeSessionUnavailable, cmd.Name+" is unavailable for this attachment", nil)
M internal/usecase/daemon/palette_test.go +0 -22
@@ -125,28 +125,6 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
125125 // only a log line. The new-tab spawn failure is wrapped as a domain.UserError
126126 // with NoticeTabSpawn (session.go createTab), so it surfaces under that code
127127 // rather than the NoticeInternal catch-all.
128-func TestRemotePaletteClosesBeforeOpeningLocalOverlay(t *testing.T) {
129- tests := []struct {
130- name string
131- code string
132- active func(*overlayRuntime) bool
133- }{
134- {name: "session picker", code: "SSP", active: func(rt *overlayRuntime) bool { return rt.pickerActive() }},
135- {name: "notifications", code: "NTC", active: func(rt *overlayRuntime) bool { return rt.noticesActive() }},
136- }
137- for _, test := range tests {
138- t.Run(test.name, func(t *testing.T) {
139- d, _, ac, link, handler := newProxyInputHarness(t)
140- handler.enterPalette()
141- d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.connectionToken.effect)
142-
143- require.False(t, ac.overlays.paletteActive(), "stale remote palette remained active")
144- require.True(t, test.active(ac.overlays), "replacement local overlay was not opened")
145- requireNoProxyFrame(t, link)
146- })
147- }
148-}
149-
150128 func TestPaletteCommandFailureSurfacesAsNotice(t *testing.T) {
151129 p, release := newBlockingPTY(t)
152130 defer release()
M internal/usecase/daemon/performance_benchmark_test.go +0 -134
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "bytes"
54 "context"
65 "errors"
76 "fmt"
@@ -18,7 +17,6 @@ import (
1817 "github.com/bnema/vev/internal/usecase/layout"
1918 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2019 "github.com/bnema/vev/pkg/renderer"
21- "github.com/bnema/vev/pkg/vt"
2220 )
2321
2422 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
@@ -198,114 +196,6 @@ func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *tes
198196 require.Equal(t, payload, transport.lastPayload(), "the counting transport must retain the payload slice header, not copy it")
199197 }
200198
201-func TestProxyANSIBenchmarkFixtures(t *testing.T) {
202- fixtures := proxyANSIBenchmarkFixtures()
203- require.Len(t, fixtures, 4)
204- require.Equal(t, []string{
205- "absolute-position-one-cell",
206- "120-column-full-line",
207- "fragmented-truecolor-styled-line",
208- "full-width-40-row-scroll",
209- }, []string{fixtures[0].name, fixtures[1].name, fixtures[2].name, fixtures[3].name})
210- require.Equal(t, fixtures, proxyANSIBenchmarkFixtures(), "fixtures must be deterministic")
211- require.Equal(t, []byte("\x1b[20;60HX"), fixtures[0].data)
212- require.Equal(t, append([]byte("\x1b[1;1H"), bytes.Repeat([]byte("l"), 120)...), fixtures[1].data)
213- require.Equal(t, 12, bytes.Count(fixtures[2].data, []byte("\x1b[38;2;")))
214- require.Equal(t, 40, bytes.Count(fixtures[3].data, []byte("\r\n")))
215-
216- for _, fixture := range fixtures {
217- t.Run(fixture.name, func(t *testing.T) {
218- state := newProxyANSIApplyState(t)
219-
220- ack, reset, changed := state.apply(ports.Output{
221- Base: 0,
222- New: 1,
223- Data: fixture.data,
224- })
225-
226- require.Equal(t, uint64(1), ack)
227- require.False(t, reset)
228- require.True(t, changed)
229- require.Equal(t, domain.Size{Cols: 120, Rows: 40}, domain.Size{Cols: state.screen.Frame.Width, Rows: state.screen.Frame.Height})
230- })
231- }
232-}
233-
234-// proxyANSIApplyState is deliberately a test-only copy of the removed ANSI
235-// relay state: each operation copies transport bytes, feeds vt.Screen.Write,
236-// and advances a dependent state chain. It keeps this historical benchmark
237-// independent of structured snapshots and renderer-frame cloning.
238-type proxyANSIApplyState struct {
239- mu sync.Mutex
240- screen *vt.Screen
241- appliedState uint64
242- awaitingReset bool
243-}
244-
245-func newProxyANSIApplyState(t testing.TB) *proxyANSIApplyState {
246- t.Helper()
247- return &proxyANSIApplyState{screen: vt.NewScreen(120, 40)}
248-}
249-
250-func (s *proxyANSIApplyState) apply(out ports.Output) (ack uint64, requestReset, changed bool) {
251- data := append([]byte(nil), out.Data...)
252- s.mu.Lock()
253- defer s.mu.Unlock()
254- if out.New == 0 {
255- s.screen.Write(data)
256- return 0, false, len(data) != 0
257- }
258- if out.Base == 0 {
259- if !s.awaitingReset && out.New <= s.appliedState {
260- return 0, false, false
261- }
262- s.screen = vt.NewScreen(s.screen.Frame.Width, s.screen.Frame.Height)
263- s.screen.Write(data)
264- s.appliedState = out.New
265- s.awaitingReset = false
266- return out.New, false, true
267- }
268- if s.awaitingReset || out.Base != s.appliedState || out.New != out.Base+1 {
269- if !s.awaitingReset {
270- s.awaitingReset = true
271- requestReset = true
272- }
273- return 0, requestReset, false
274- }
275- s.screen.Write(data)
276- s.appliedState = out.New
277- return out.New, false, len(data) != 0
278-}
279-
280-type proxyANSIBenchmarkFixture struct {
281- name string
282- data []byte
283-}
284-
285-func proxyANSIBenchmarkFixtures() []proxyANSIBenchmarkFixture {
286- fullLine := append([]byte("\x1b[1;1H"), bytes.Repeat([]byte("l"), 120)...)
287-
288- var styledLine []byte
289- for fragment := range 12 {
290- styledLine = fmt.Appendf(styledLine, "\x1b[12;%dH\x1b[38;2;%d;%d;%dm", fragment*10+1, fragment*17, fragment*13, fragment*7)
291- styledLine = append(styledLine, bytes.Repeat([]byte{byte('a' + fragment)}, 10)...)
292- }
293- styledLine = append(styledLine, "\x1b[0m"...)
294-
295- scroll := make([]byte, 0, 40*122)
296- for row := range 40 {
297- scroll = append(scroll, performanceFullWidthRow(120, 0, row)...)
298- scroll = append(scroll, '\r', '\n')
299- }
300-
301- return []proxyANSIBenchmarkFixture{
302- {name: "absolute-position-one-cell", data: []byte("\x1b[20;60HX")},
303- {name: "120-column-full-line", data: fullLine},
304- {name: "fragmented-truecolor-styled-line", data: styledLine},
305- {name: "full-width-40-row-scroll", data: scroll},
306- }
307-}
308-
309199 func TestPerformanceFixtureCounters(t *testing.T) {
310200 fixture := newPerformanceFixture(t, performanceConfig{})
311201
@@ -469,30 +359,6 @@ var daemonSnapshotTopologies = []daemonHistoryTopology{
469359 {name: "1tab-4panes", tabs: 1, panes: 4},
470360 }
471361
472-// BenchmarkProxyANSIApply is a component diagnostic for proxy ANSI parsing and
473-// apply only; it is not a pipeline comparison with structured output.
474-func BenchmarkProxyANSIApply(b *testing.B) {
475- for _, fixture := range proxyANSIBenchmarkFixtures() {
476- b.Run(fixture.name, func(b *testing.B) {
477- state := newProxyANSIApplyState(b)
478- b.ReportAllocs()
479- b.SetBytes(int64(len(fixture.data)))
480- for b.Loop() {
481- next := state.appliedState + 1
482- ack, reset, changed := state.apply(ports.Output{
483- Base: state.appliedState,
484- New: next,
485- Data: fixture.data,
486- })
487- if ack != next || reset || !changed {
488- b.Fatalf("apply output = ack %d, reset %t, changed %t; want ack %d, reset false, changed true", ack, reset, changed, next)
489- }
490- }
491- b.ReportMetric(float64(len(fixture.data)), "ansibytes/op")
492- })
493- }
494-}
495-
496362 func BenchmarkDaemonHistoryLivePaint(b *testing.B) {
497363 benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })
498364 }
M internal/usecase/daemon/picker.go +39 -143
@@ -86,12 +86,8 @@ func (d *Daemon) publishPicker(sess attachmentSession, ac *attachedClient, model
8686 func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {
8787 d.mu.Lock()
8888 sessions := make([]attachmentSession, 0, len(d.sessions))
89- proxies := make(map[domain.SessionID]*proxySession)
90- for id, entry := range d.sessions {
89+ for _, entry := range d.sessions {
9190 sessions = append(sessions, entry)
92- if proxy, ok := entry.(*proxySession); ok {
93- proxies[id] = proxy
94- }
9591 }
9692 stopped := make([]stoppedSession, 0, len(d.stopped))
9793 for _, s := range d.stopped {
@@ -114,8 +110,8 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
114110 }
115111 // One snapshot per live session: sorting and view building read the same
116112 // capture, so comparators cannot observe a concurrent touchMRU or
117- // renameSession mid-sort. Proxy and remote-catalog locks are released before
118- // sorting and picker-row construction.
113+ // renameSession mid-sort. Remote-catalog locks are released before sorting
114+ // and picker-row construction.
119115 live := make([]liveSnapshot, 0, len(sessions))
120116 var current picker.SourceFilter
121117 for _, entry := range sessions {
@@ -126,10 +122,7 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
126122 }
127123 snap := entry.snapshotView(opts)
128124 if entry == cur {
129- if proxy, ok := entry.(*proxySession); ok {
130- key := proxy.key
131- current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, RemoteKey: &key}
132- } else if _, ok := localSession(entry); ok && ac != nil {
125+ if _, ok := localSession(entry); ok && ac != nil {
133126 view := ac.viewSnapshot()
134127 if view.tabID != "" {
135128 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}
@@ -175,10 +168,6 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
175168 }
176169 views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows)
177170 for _, item := range live {
178- if proxy, ok := item.entry.(*proxySession); ok {
179- views = append(views, remoteProxyPickerView(proxy.key, item.view))
180- continue
181- }
182171 views = append(views, item.view.pickerView())
183172 }
184173 for _, host := range catalog {
@@ -187,9 +176,6 @@ func (d *Daemon) pickerViews(cur attachmentSession, ac *attachedClient) ([]picke
187176 if key.Validate() != nil {
188177 continue
189178 }
190- if proxy, ok := proxies[key.ID()]; ok && proxy.key == key {
191- continue
192- }
193179 views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))
194180 }
195181 if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
@@ -640,7 +626,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
640626 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
641627 })
642628 if err != nil {
643- // Losing the exact source attachment is a benign stale action, not a notice for
629+ // Losing the exact source role is a benign stale action, not a notice for
644630 // whichever attachment replaced the initiator.
645631 if !token.attachmentCurrent() {
646632 return nil
@@ -676,14 +662,11 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
676662 if token.sess == nil || token.ac == nil || token.effect == nil {
677663 return nil
678664 }
679- token.effect.bindActionEnd(d, action)
680- token.effect.End()
681665 if target.RemoteKey != nil {
682- return d.switchToRemoteTargetForAttachment(token, target, *target.RemoteKey, guard, action)
683- }
684- if token.sess.isProxy() && !target.Stopped {
685- return d.switchActiveTargetForAttachmentGuarded(token, target, guard, action)
666+ return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
686667 }
668+ token.effect.bindActionEnd(d, action)
669+ token.effect.End()
687670 sess, ok := localSession(token.sess)
688671 if !ok {
689672 return errAttachmentTransition
@@ -691,132 +674,45 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
691674 return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action)
692675 }
693676
694-// switchToRemoteTargetForAttachment performs dialing after the initiating effect has
695-// ended and without attachment-effect or architecture locks. transitionAttachment then
696-// freezes and revalidates the exact source attachment and exact structured-key proxy
697-// before publishing ownership.
698-func (d *Daemon) switchToRemoteTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error {
699- if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" {
700- return errAttachmentTransition
701- }
702- ctx := d.serveCtx
703- if ctx == nil {
704- ctx = context.Background()
705- }
706- // Only the caller that observed neither a published proxy nor an in-flight
707- // constructor may own failure cleanup. Waiters and existing-proxy users must
708- // never tear down the shared link when their source transition goes stale.
709- d.mu.Lock()
710- _, alreadyPublished := d.sessions[key.ID()]
711- constructionInFlight := d.proxyConstructions[key] != nil
712- d.mu.Unlock()
713- ownsCandidate := !alreadyPublished && !constructionInFlight
714- proxy, err := d.openProxySession(ctx, key, token.ac.size)
715- if err != nil {
716- if !token.attachmentCurrent() {
717- return errAttachmentTransition
718- }
719- return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't connect to that remote session", err)
720- }
721- if proxy == nil || proxy.key != key || proxy.id != key.ID() {
722- return errAttachmentTransition
723- }
724- proxy.mu.Lock()
725- candidateGeneration := proxy.generation
726- proxy.mu.Unlock()
727-
728- transition, err := d.transitionAttachment(attachmentTransitionRequest{
729- source: token.sess, target: proxy, next: token.ac,
730-
731- expectedTransport: token.transport, sourceToken: &token, action: action,
732- preserveAttachment: proxy == token.sess, ready: true,
733- })
734- if err != nil {
735- if ownsCandidate {
736- d.discardUnattachedProxyGeneration(proxy, candidateGeneration)
737- }
677+// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
678+// endpoint to the thin client. The daemon owns no remote session shadow: after
679+// the target is sent, the local attachment is detached and the client opens a
680+// fresh connection to the owning daemon.
681+func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
682+ if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
738683 return errAttachmentTransition
739684 }
740685 if guard.closePicker {
741- fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
742- if admitted {
743- d.closePicker(token.ac)
744- fresh.End()
745- }
746- }
747- if proxy != token.sess {
748- token.ac.recordPreviousSession(token.sess)
686+ d.closePicker(token.ac)
749687 }
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)
688+ if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(ports.AttachTarget{
689+ Endpoint: key.Host,
690+ Session: key.Name,
691+ Intent: ports.IntentAttach,
692+ })}); err != nil {
693+ return err
755694 }
695+ d.clientGoneForAttachment(token, false)
756696 return nil
757697 }
758698
759-// discardUnattachedProxyGeneration removes only a transition-owned candidate
760-// that never acquired a client. It mirrors normal proxy teardown fencing but
761-// deliberately does not arm the five-minute warm lifetime for an attachment
762-// that was never published.
763-func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generation uint64) bool {
764- if d == nil || proxy == nil {
765- return false
766- }
767- d.mu.Lock()
768- if d.sessions[proxy.id] != proxy {
769- d.mu.Unlock()
699+func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
700+ if d == nil {
770701 return false
771702 }
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 false
795- }
796- proxy.sessionCore.mu.Unlock()
797- empty := len(d.sessions) == 0
798- if empty {
799- d.closing = true
800- }
801- d.mu.Unlock()
802-
803- if cancel != nil {
804- cancel()
805- }
806- if transport != nil {
807- _ = transport.Close()
808- }
809- if coordinator != nil {
810- coordinator.beginSessionTeardown().finish()
811- coordinator.waitForTimerWorkers()
812- }
813- proxy.finish()
814- if empty {
815- d.doneOnce.Do(func() { close(d.done) })
816- } else {
817- d.refreshRemoteOpenPickers()
703+ d.remoteCatalog.mu.Lock()
704+ defer d.remoteCatalog.mu.Unlock()
705+ for _, entry := range d.remoteCatalog.cache {
706+ if entry.Host != key.Host {
707+ continue
708+ }
709+ for _, session := range entry.Sessions {
710+ if session.Name == key.Name {
711+ return d.remoteCatalog.status[key.Host] != remoteHostVersionMismatch
712+ }
713+ }
818714 }
819- return true
715+ return false
820716 }
821717
822718 // switchToTargetGuarded is retained for daemon-internal and headless callers.
@@ -1085,14 +981,15 @@ func targetMatchesLifecycle(target picker.Target, name string, createdAt int64)
1085981
1086982 // stealClientForTarget is retained for direct-ID callers and tests. Named
1087983 // targets must use switchToTarget so resolution and commit share d.mu.
1088-func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) {
984+func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
1089985 d.mu.Lock()
1090986 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
1091987 d.mu.Unlock()
1092988 if !switched {
1093- return
989+ return nil
1094990 }
1095991 d.deferAttachmentTransitionCleanups(transition)
992+ return nil
1096993 }
1097994
1098995 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
@@ -1122,7 +1019,6 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attac
11221019 return d.killPickerTarget(target)
11231020 }
11241021 if target.RemoteKey != nil {
1125- d.enterRemoteKillConfirmation(token.sess, token.ac, target)
11261022 return nil
11271023 }
11281024 if target.Stopped {
M internal/usecase/daemon/prompt.go +1 -2
@@ -47,8 +47,7 @@ func (d *Daemon) closePrompt(ac *attachedClient) {
4747 func promptValidationError(err error) bool {
4848 return errors.Is(err, domain.ErrInvalidSessionName) ||
4949 errors.Is(err, errSessionNameRequired) ||
50- errors.Is(err, errSessionNameInUse) ||
51- errors.Is(err, errProxyKillConfirmation)
50+ errors.Is(err, errSessionNameInUse)
5251 }
5352
5453 func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
D internal/usecase/daemon/proxy_input.go +0 -223
@@ -1,223 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
8- "github.com/bnema/vev/internal/usecase/keys"
9- "github.com/bnema/vev/internal/usecase/mouse"
10- "github.com/bnema/vev/internal/usecase/palette"
11-)
12-
13-// sendInput is the sole proxy input sender. Sequence assignment and transport
14-// Send are serialized with every other proxy link write; raw is never rebuilt
15-// from an interpreted key action.
16-func (p *proxySession) sendInput(raw []byte) error {
17- if p == nil || len(raw) == 0 {
18- return nil
19- }
20- data := append([]byte(nil), raw...)
21- p.sendMu.Lock()
22- defer p.sendMu.Unlock()
23-
24- p.mu.Lock()
25- transport := p.transport
26- p.mu.Unlock()
27- if transport == nil {
28- return errors.New("proxy session: input link is unavailable")
29- }
30- p.inputNext++
31- return transport.Send(ports.Frame{
32- Type: ports.MsgInput,
33- Payload: ports.MarshalInput(ports.Input{
34- InputSeq: p.inputNext,
35- Data: data,
36- }),
37- })
38-}
39-
40-// proxyKeyHandler pins input ownership while a local client is attached to a
41-// proxy. Local UI actions never reach the link. Every remote-owned action is
42-// sent using the exact bytes supplied by keys.Router.
43-type proxyKeyHandler struct {
44- d *Daemon
45- proxy *proxySession
46- ac *attachedClient
47- connectionToken attachmentConnectionToken
48-}
49-
50-func (h proxyKeyHandler) acquireAttachmentEffect() (*attachmentEffectTicket, bool, bool) {
51- if h.proxy == nil || h.ac == nil {
52- return nil, false, false
53- }
54- if h.connectionToken.ac == nil {
55- return nil, false, false
56- }
57- if h.connectionToken.sess != h.proxy || h.connectionToken.ac != h.ac {
58- return nil, false, false
59- }
60- if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
61- return effect, false, true
62- }
63- effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
64- if h.d != nil && h.d.afterDelayedKeyEffectAttempt != nil {
65- h.d.afterDelayedKeyEffectAttempt(admitted)
66- }
67- if !admitted {
68- return nil, false, false
69- }
70- if h.d != nil && h.d.afterAttachmentEffectAdmitted != nil {
71- token := h.connectionToken
72- token.effect = effect
73- h.d.afterAttachmentEffectAdmitted(token)
74- }
75- return effect, true, true
76-}
77-
78-func (h proxyKeyHandler) sendOwned(raw []byte) {
79- if len(raw) == 0 {
80- return
81- }
82- if err := h.proxy.sendInput(raw); err != nil && h.d != nil {
83- h.d.log.Warn("proxy input send failed", "host", h.proxy.key.Host, "session", h.proxy.key.Name, "err", err)
84- }
85-}
86-
87-func (h proxyKeyHandler) send(raw []byte) {
88- effect, owned, ok := h.acquireAttachmentEffect()
89- if !ok {
90- return
91- }
92- if owned {
93- defer effect.End()
94- }
95- h.sendOwned(raw)
96-}
97-
98-func (h proxyKeyHandler) Forward(raw []byte) { h.send(raw) }
99-
100-func (h proxyKeyHandler) Mouse(event mouse.Event) { h.send(event.Raw) }
101-
102-func (h proxyKeyHandler) Action(action keys.Action, raw []byte) {
103- effect, owned, ok := h.acquireAttachmentEffect()
104- if !ok {
105- return
106- }
107- if owned {
108- defer effect.End()
109- }
110- h.connectionToken.effect = effect
111- switch action {
112- case keys.ActionOpenPalette:
113- h.enterPalette()
114- case keys.ActionJumpAttention:
115- if h.proxy.hasRemoteAttention() {
116- h.sendOwned(raw)
117- return
118- }
119- h.jumpLocalAttention()
120- case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
121- keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
122- keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9,
123- keys.ActionFocusPaneLeft, keys.ActionFocusPaneRight, keys.ActionFocusPaneUp, keys.ActionFocusPaneDown,
124- keys.ActionToggleFloatingPane,
125- keys.ActionGrowPaneWidth, keys.ActionShrinkPaneWidth,
126- keys.ActionGrowPaneHeight, keys.ActionShrinkPaneHeight,
127- keys.ActionEqualizePanes,
128- keys.ActionConsumeOrExpelPaneLeft, keys.ActionConsumeOrExpelPaneRight:
129- h.sendOwned(raw)
130- }
131-}
132-
133-func (p *proxySession) hasRemoteAttention() bool {
134- if p == nil {
135- return false
136- }
137- p.mu.Lock()
138- defer p.mu.Unlock()
139- for _, tab := range p.meta.Tabs {
140- if tab.Attention {
141- return true
142- }
143- }
144- return false
145-}
146-
147-func (h proxyKeyHandler) enterPalette() {
148- if h.d == nil || h.ac == nil || h.proxy == nil {
149- return
150- }
151- // paletteResults still accepts a local current session. Build its immutable
152- // snapshot without one and remove this proxy by its structured opaque ID.
153- recent := h.d.recentSessions(h.proxy)
154- commands := h.d.paletteCommands()
155- results := h.d.paletteResults(nil, commands)
156- filtered := results[:0]
157- for _, result := range results {
158- if id, ok := result.SessionID(); ok && id == h.proxy.id {
159- continue
160- }
161- filtered = append(filtered, result)
162- }
163- h.ac.overlays.paletteMu.Lock()
164- h.ac.overlays.paletteGeneration++
165- h.ac.overlays.palette = palette.New(filtered)
166- h.ac.overlays.paletteRecent = recent
167- h.ac.overlays.paletteHints = palette.ContextualHints{}
168- h.ac.overlays.paletteFeedback = ""
169- h.ac.overlays.palettePending = nil
170- h.ac.overlays.paletteMu.Unlock()
171- h.d.invalidateRender(h.proxy, h.ac, true, "proxy_input.go")
172-}
173-
174-// jumpLocalAttention handles only the selected-session part of attention
175-// navigation. A pending tab on the proxy remains remote-owned; without one,
176-// the local daemon may move the client to another local session.
177-func (h proxyKeyHandler) jumpLocalAttention() {
178- if h.d == nil || h.connectionToken.ac == nil || h.connectionToken.effect == nil {
179- return
180- }
181- target, ok := h.d.oldestOtherSessionAttention(nil)
182- if !ok {
183- return
184- }
185- h.d.mu.Lock()
186- targetSession, ok := localSession(h.d.sessions[target.sessionID])
187- h.d.mu.Unlock()
188- if !ok {
189- return
190- }
191-
192- token := h.connectionToken
193- token.effect.bindActionEnd(h.d, "proxy-jump-attention")
194- token.effect.End()
195- transition, err := h.d.transitionAttachment(attachmentTransitionRequest{
196- source: h.proxy, target: targetSession, next: h.ac,
197-
198- expectedTransport: token.transport, sourceToken: &token,
199- action: "proxy-jump-attention", activateTargetTab: true,
200- targetTabIndex: target.tabIndex, ready: true,
201- })
202- if err != nil {
203- return
204- }
205- h.d.touchMRU(targetSession)
206- h.ac.recordPreviousSession(h.proxy)
207- h.d.deferAttachmentTransitionCleanups(transition)
208- h.d.firstPaintForTransition(transition.published)
209-}
210-
211-func proxyMouseOwnedLocally(rt *overlayRuntime) bool {
212- // copyActive cannot be reached for proxy attachments through production input
213- // routing: copy mode is local-session-only and proxy keys never enter it.
214- return rt != nil && (rt.promptActive() || rt.paletteActive() || rt.pickerActive() ||
215- rt.noticesActive() || rt.resizeModeActive())
216-}
217-
218-func proxiedNavConfig(cfg domain.NavConfig) domain.NavConfig {
219- cfg.OverflowSessions = false
220- return cfg
221-}
222-
223-func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied }
D internal/usecase/daemon/proxy_input_test.go +0 -1007
@@ -1,1007 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "io"
7- "log/slog"
8- "sync"
9- "sync/atomic"
10- "testing"
11- "time"
12-
13- "github.com/stretchr/testify/mock"
14- "github.com/stretchr/testify/require"
15-
16- "github.com/bnema/vev/internal/domain"
17- "github.com/bnema/vev/internal/ports"
18- portsmocks "github.com/bnema/vev/internal/ports/mocks"
19- "github.com/bnema/vev/internal/usecase/command"
20- "github.com/bnema/vev/internal/usecase/keys"
21- "github.com/bnema/vev/internal/usecase/mouse"
22- "github.com/bnema/vev/internal/usecase/picker"
23-)
24-
25-func newProxyInputHarness(t *testing.T) (*Daemon, *proxySession, *attachedClient, *proxyTestTransport, proxyKeyHandler) {
26- t.Helper()
27- d := newTestDaemon(t, nil, stubClock{})
28- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
29- require.NoError(t, err)
30- link := newProxyTestTransport()
31- proxy.mu.Lock()
32- proxy.transport = link
33- proxy.linkGeneration = 1
34- proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}, {Index: 1, Name: "build"}}}
35- proxy.mu.Unlock()
36- ac := &attachedClient{size: domain.Size{Cols: 80, Rows: 24}, output: newOutputStateStream()}
37- ac.initOverlays()
38- token := attachmentEffectForTest(t, attachProxyInputRole(t, d, proxy, ac))
39- return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: token}
40-}
41-
42-func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *attachedClient) attachmentConnectionToken {
43- t.Helper()
44- clientTransport := newProxyTestTransport()
45- ac.replaceTransport(clientTransport)
46- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
47- d.mu.Lock()
48- require.True(t, d.registerSessionLocked(proxy))
49- d.mu.Unlock()
50- transition, err := d.transitionAttachment(attachmentTransitionRequest{
51- target: proxy, next: ac,
52-
53- expectedTransport: ac.transportSnapshot(), ready: true,
54- })
55- require.NoError(t, err)
56- d.deferAttachmentTransitionCleanups(transition)
57- return transition.published
58-}
59-
60-func attachmentEffectForTest(t *testing.T, token attachmentConnectionToken) attachmentConnectionToken {
61- t.Helper()
62- effect, admitted := token.ac.beginAttachmentEffect(token)
63- require.True(t, admitted)
64- token.effect = effect
65- t.Cleanup(effect.End)
66- return token
67-}
68-
69-func requireProxyInputFrame(t *testing.T, link *proxyTestTransport, wantSeq uint64, want []byte) {
70- t.Helper()
71- select {
72- case frame := <-link.sent:
73- require.Equal(t, ports.MsgInput, frame.Type)
74- input, err := ports.UnmarshalInput(frame.Payload)
75- require.NoError(t, err)
76- require.Equal(t, wantSeq, input.InputSeq)
77- require.Equal(t, want, input.Data)
78- default:
79- t.Fatal("missing proxy input frame")
80- }
81- select {
82- case frame := <-link.sent:
83- t.Fatalf("input was sent more than once: %#v", frame)
84- default:
85- }
86-}
87-
88-func requireNoProxyFrame(t *testing.T, link *proxyTestTransport) {
89- t.Helper()
90- select {
91- case frame := <-link.sent:
92- t.Fatalf("unexpected proxy frame: %#v", frame)
93- default:
94- }
95-}
96-
97-func TestProxyInputForwardsOrdinaryPasteMouseAndEscapeBytesExactlyOnce(t *testing.T) {
98- _, _, _, link, handler := newProxyInputHarness(t)
99- paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), []byte("a\x1bj\x00")...), ports.BracketedPasteCloseMarker...)
100- inputs := []struct {
101- name string
102- raw []byte
103- }{
104- {name: "ordinary keyboard", raw: []byte("hello")},
105- {name: "bracketed paste", raw: paste},
106- {name: "terminal escape", raw: []byte("\x1b[15~")},
107- }
108- for i, input := range inputs {
109- t.Run(input.name, func(t *testing.T) {
110- handler.Forward(input.raw)
111- requireProxyInputFrame(t, link, uint64(i+1), input.raw)
112- })
113- }
114-
115- mouseRaw := []byte("\x1b[<0;12;8M")
116- handler.Mouse(mouse.Event{Raw: mouseRaw})
117- requireProxyInputFrame(t, link, 4, mouseRaw)
118-}
119-
120-func TestProxyInputRemoteActionsForwardOriginalChordWithoutLocalExecution(t *testing.T) {
121- remoteActions := []keys.Action{
122- keys.ActionSwitchTab1, keys.ActionSwitchTab9,
123- keys.ActionFocusPaneLeft, keys.ActionFocusPaneRight, keys.ActionFocusPaneUp, keys.ActionFocusPaneDown,
124- keys.ActionToggleFloatingPane,
125- keys.ActionGrowPaneWidth, keys.ActionShrinkPaneWidth, keys.ActionGrowPaneHeight, keys.ActionShrinkPaneHeight,
126- keys.ActionEqualizePanes, keys.ActionConsumeOrExpelPaneLeft, keys.ActionConsumeOrExpelPaneRight,
127- }
128- for _, action := range remoteActions {
129- t.Run(action.Name(), func(t *testing.T) {
130- _, proxy, _, link, handler := newProxyInputHarness(t)
131- before := proxy.snapshotView(viewOptions{tabDetails: true})
132- raw := []byte{keys.ESC, byte('A' + action%26), 0x00}
133- handler.Action(action, raw)
134- requireProxyInputFrame(t, link, 1, raw)
135- require.Equal(t, before, proxy.snapshotView(viewOptions{tabDetails: true}), "remote action must not mutate local proxy metadata")
136- })
137- }
138-}
139-
140-func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) {
141- t.Run("router action bytes", func(t *testing.T) {
142- _, _, _, link, handler := newProxyInputHarness(t)
143- bindings := keys.DefaultBindings()
144- var published atomic.Pointer[keys.Bindings]
145- published.Store(bindings)
146- router := keys.NewRouter(stubClock{}, handler, &published)
147-
148- router.Route([]byte{keys.ESC, 'j'})
149- requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'})
150- })
151-
152- t.Run("retained escape reacquires its original role", func(t *testing.T) {
153- d, proxy, ac, link, initial := newProxyInputHarness(t)
154- base := initial.connectionToken
155- base.effect.End()
156- base.effect = nil
157-
158- firstEffect, admitted := ac.beginAttachmentEffect(base)
159- require.True(t, admitted)
160- first := base
161- first.effect = firstEffect
162- ac.keys.RouteWithHandler([]byte{keys.ESC}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: first})
163- firstEffect.End()
164-
165- secondEffect, admitted := ac.beginAttachmentEffect(base)
166- require.True(t, admitted)
167- second := base
168- second.effect = secondEffect
169- ac.keys.RouteWithHandler([]byte{'j'}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: second})
170- secondEffect.End()
171-
172- requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'})
173- })
174-}
175-
176-func TestProxyInputLocalPaletteNeverForwards(t *testing.T) {
177- d, _, ac, link, handler := newProxyInputHarness(t)
178- token := handler.connectionToken
179- handler.Action(keys.ActionOpenPalette, []byte{keys.ESC, ' '})
180- require.True(t, ac.overlays.paletteActive())
181- requireNoProxyFrame(t, link)
182-
183- // Keyboard and mouse navigation are consumed by the local overlay before
184- // either the proxy router or proxy mouse sender can observe them.
185- d.handleInputForAttachment(token, []byte("x"))
186- d.handleInputForAttachment(token, []byte("\x1b[<0;12;8M"))
187- requireNoProxyFrame(t, link)
188-}
189-
190-func TestProxyInputAttentionOwnership(t *testing.T) {
191- t.Run("pending remote tab attention forwards original chord", func(t *testing.T) {
192- _, proxy, _, link, handler := newProxyInputHarness(t)
193- proxy.mu.Lock()
194- proxy.meta.Tabs[1].Attention = true
195- proxy.mu.Unlock()
196- raw := []byte{keys.ESC, 'a'}
197- handler.Action(keys.ActionJumpAttention, raw)
198- requireProxyInputFrame(t, link, 1, raw)
199- })
200-
201- t.Run("without remote attention changes selected session locally", func(t *testing.T) {
202- d, _, ac, link, handler := newProxyInputHarness(t)
203- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
204- tb.attention = true
205- tb.attentionAt = time.Unix(1, 0)
206- local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
207- publishTiledPaneOwners(local, tb)
208- d.mu.Lock()
209- require.True(t, d.registerSessionLocked(local))
210- d.mu.Unlock()
211-
212- handler.Action(keys.ActionJumpAttention, []byte{keys.ESC, 'a'})
213-
214- require.Same(t, local, ac.currentSession())
215- requireNoProxyFrame(t, link)
216- })
217-}
218-
219-func TestProxyInputRemoteOwnershipGuards(t *testing.T) {
220- _, proxy, _, link, _ := newProxyInputHarness(t)
221- require.True(t, proxy.caps.cannotAcceptMoves, "a proxy must reject move destinations locally")
222- require.True(t, proxy.caps.cannotYieldMoves, "a proxy must reject move sources locally")
223- requireNoProxyFrame(t, link)
224-
225- cfg := domain.NavConfig{OverflowTabs: true, OverflowSessions: true}
226- require.Equal(t, domain.NavConfig{OverflowTabs: true}, proxiedNavConfig(cfg), "focus overflow must not cross remote-daemon sessions")
227- require.False(t, proxiedJumpSearchesOtherSessions(true), "proxied jump attention must stay in its attached session")
228- require.True(t, proxiedJumpSearchesOtherSessions(false))
229-}
230-
231-func TestProxyPaletteCommandOwnershipIsComplete(t *testing.T) {
232- expected := map[string]proxyPaletteOwnership{
233- "new-tab": proxyPaletteRemote, "new-session": proxyPaletteLocal, "close-tab": proxyPaletteRemote,
234- "split-right": proxyPaletteRemote, "split-left": proxyPaletteRemote, "split-up": proxyPaletteRemote, "split-down": proxyPaletteRemote,
235- "consume-or-expel-pane-left": proxyPaletteRemote, "consume-or-expel-pane-right": proxyPaletteRemote,
236- "stack-pane": proxyPaletteRemote, "toggle-stack": proxyPaletteRemote, "toggle-floating-pane": proxyPaletteRemote, "close-pane": proxyPaletteRemote,
237- "move-pane": proxyPaletteRejected, "move-tab": proxyPaletteRejected,
238- "focus-pane-left": proxyPaletteRemote, "focus-pane-right": proxyPaletteRemote, "focus-pane-up": proxyPaletteRemote, "focus-pane-down": proxyPaletteRemote,
239- "resize-pane": proxyPaletteRemote, "grow-pane-width": proxyPaletteRemote, "shrink-pane-width": proxyPaletteRemote,
240- "grow-pane-height": proxyPaletteRemote, "shrink-pane-height": proxyPaletteRemote, "equalize-panes": proxyPaletteRemote,
241- "next-tab": proxyPaletteRemote, "previous-tab": proxyPaletteRemote,
242- "back-session": proxyPaletteLocal, "jump-recent-session": proxyPaletteLocal, "session-picker": proxyPaletteLocal,
243- "notifications": proxyPaletteLocal, "yank-last-notification": proxyPaletteLocal,
244- "visual-mode": proxyPaletteRejected, "rename-session": proxyPaletteRejected, "rename-tab": proxyPaletteRemote,
245- "detach": proxyPaletteLocal,
246- }
247-
248- commands := command.PaletteRegistry()
249- require.Len(t, expected, len(commands), "ownership table must change whenever the palette registry changes")
250- for _, cmd := range commands {
251- want, covered := expected[cmd.Slug]
252- require.True(t, covered, "palette-visible command %q has no ownership assertion", cmd.Slug)
253- require.Equal(t, want, proxyPaletteCommandOwnership(cmd.Slug), cmd.Slug)
254- require.Equal(t, want == proxyPaletteRemote, proxyAttachedCommandOwnedRemotely(cmd.Slug), cmd.Slug)
255- }
256-}
257-
258-func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) {
259- t.Run("new session opens local transition prompt", func(t *testing.T) {
260- d, proxy, ac, link, handler := newProxyInputHarness(t)
261- handler.enterPalette()
262- d.handlePaletteInput(ac, []byte("CNS\r"), handler.connectionToken.effect)
263-
264- require.False(t, ac.overlays.paletteActive())
265- require.True(t, ac.overlays.promptActive())
266- require.Same(t, proxy, ac.currentAttachmentSession())
267- requireNoProxyFrame(t, link)
268- })
269-
270- t.Run("notification yank uses local notice center and clipboard", func(t *testing.T) {
271- d, proxy, ac, link, handler := newProxyInputHarness(t)
272- d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "local notice", Time: time.Unix(1, 0)})
273- handler.enterPalette()
274- client, ok := ac.transport().(*proxyTestTransport)
275- require.True(t, ok)
276- for len(client.sent) > 0 {
277- <-client.sent
278- }
279- d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect)
280-
281- require.False(t, ac.overlays.paletteActive())
282- require.Same(t, proxy, ac.currentAttachmentSession())
283- frame := awaitTestValue(t, client.sent, "local notification yank did not reach the client clipboard")
284- require.Equal(t, ports.MsgOutput, frame.Type)
285- output, err := ports.UnmarshalOutput(frame.Payload)
286- require.NoError(t, err)
287- require.Contains(t, string(output.Data), "\x1b]52;")
288- requireNoProxyFrame(t, link)
289- })
290-}
291-
292-type proxyCommandTestTimer struct {
293- ch chan time.Time
294- once sync.Once
295-}
296-
297-func newProxyCommandTestTimer() *proxyCommandTestTimer {
298- return &proxyCommandTestTimer{ch: make(chan time.Time, 1)}
299-}
300-
301-func (t *proxyCommandTestTimer) C() <-chan time.Time { return t.ch }
302-func (t *proxyCommandTestTimer) Reset(time.Duration) bool { return false }
303-func (t *proxyCommandTestTimer) Stop() bool { return true }
304-func (t *proxyCommandTestTimer) Fire() {
305- t.once.Do(func() { t.ch <- time.Time{} })
306-}
307-
308-type proxyCommandTestClock struct{ timers chan *proxyCommandTestTimer }
309-
310-func newProxyCommandTestClock() *proxyCommandTestClock {
311- return &proxyCommandTestClock{timers: make(chan *proxyCommandTestTimer, 8)}
312-}
313-
314-func (*proxyCommandTestClock) Now() time.Time { return time.Time{} }
315-func (c *proxyCommandTestClock) NewTimer(delay time.Duration) ports.Timer {
316- if delay != proxyAttachedCommandTimeout {
317- return stubTimer{}
318- }
319- timer := newProxyCommandTestTimer()
320- c.timers <- timer
321- return timer
322-}
323-
324-func requireProxyCommandRequest(t *testing.T, link *proxyTestTransport) ports.CommandRequest {
325- t.Helper()
326- frame := awaitTestValue(t, link.sent, "proxy command was not sent")
327- require.Equal(t, ports.MsgCommand, frame.Type)
328- request, err := ports.UnmarshalCommandRequest(frame.Payload)
329- require.NoError(t, err)
330- return request
331-}
332-
333-func commandResultFrame(result ports.CommandResult) ports.Frame {
334- return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}
335-}
336-
337-func TestProxyAttachedCommandCorrelatesInterleavedWrongAndLateResults(t *testing.T) {
338- d, proxy, _, link, _ := newProxyInputHarness(t)
339- clock := newProxyCommandTestClock()
340-
341- firstDone := make(chan ports.CommandResult, 1)
342- firstErr := make(chan error, 1)
343- go func() {
344- result, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil)
345- firstDone <- result
346- firstErr <- err
347- }()
348- first := requireProxyCommandRequest(t, link)
349- require.Equal(t, uint64(1), first.RequestID)
350- require.True(t, first.Attached)
351- _ = awaitTestValue(t, clock.timers, "first command did not arm timeout")
352-
353- generation := proxy.linkGeneration
354- result, err := d.handleLinkFrame(proxy, generation, proxyHandshakeSnapshot())
355- require.NoError(t, err)
356- require.Equal(t, proxyLinkResume, result)
357- require.Equal(t, ports.MsgAck, awaitTestValue(t, link.sent, "interleaved output was not acknowledged").Type)
358- result, err = d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})})
359- require.NoError(t, err)
360- require.Equal(t, proxyLinkResume, result)
361- result, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: 999, OK: true}))
362- require.NoError(t, err)
363- require.Equal(t, proxyLinkResume, result)
364- select {
365- case <-firstDone:
366- t.Fatal("wrong request ID completed command")
367- default:
368- }
369- require.NoError(t, proxy.sendInput([]byte("x")))
370- requireProxyInputFrame(t, link, 1, []byte("x"))
371-
372- _, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: first.RequestID, OK: true}))
373- require.NoError(t, err)
374- require.True(t, (<-firstDone).OK)
375- require.NoError(t, <-firstErr)
376-
377- secondDone := make(chan ports.CommandResult, 1)
378- go func() {
379- result, _ := proxy.sendCommand(context.Background(), clock, "previous-tab", nil)
380- secondDone <- result
381- }()
382- second := requireProxyCommandRequest(t, link)
383- require.Equal(t, uint64(2), second.RequestID)
384- _ = awaitTestValue(t, clock.timers, "second command did not arm timeout")
385- _, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: first.RequestID, OK: true}))
386- require.NoError(t, err)
387- select {
388- case <-secondDone:
389- t.Fatal("late request ID completed replacement command")
390- default:
391- }
392- _, err = d.handleLinkFrame(proxy, generation, commandResultFrame(ports.CommandResult{RequestID: second.RequestID, Code: ports.ErrInternal, Text: "remote failed"}))
393- require.NoError(t, err)
394- remoteFailure := <-secondDone
395- require.False(t, remoteFailure.OK)
396- require.Equal(t, "remote failed", remoteFailure.Text)
397-}
398-
399-func TestProxyAttachedCommandSharesSerializedSender(t *testing.T) {
400- d, proxy, _, link, _ := newProxyInputHarness(t)
401- clock := newProxyCommandTestClock()
402- gate := make(chan struct{})
403- entered := make(chan struct{}, 2)
404- link.sendGate = gate
405- link.sendEntered = entered
406-
407- commandDone := make(chan error, 1)
408- go func() {
409- _, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil)
410- commandDone <- err
411- }()
412- awaitTestCompletion(t, entered, "command sender did not enter transport")
413- inputDone := make(chan error, 1)
414- go func() { inputDone <- proxy.sendInput([]byte("x")) }()
415- select {
416- case <-entered:
417- t.Fatal("input entered Transport.Send while command send held the shared sender")
418- default:
419- }
420- close(gate)
421- request := requireProxyCommandRequest(t, link)
422- require.NoError(t, awaitTestValue(t, inputDone, "input send did not finish"))
423- requireProxyInputFrame(t, link, 1, []byte("x"))
424- _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
425- _, err := d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))
426- require.NoError(t, err)
427- require.NoError(t, awaitTestValue(t, commandDone, "command did not finish"))
428- require.False(t, link.concurrent.Load(), "transport observed concurrent Send calls")
429-}
430-
431-func TestProxyAttachedCommandAllowsOnlyOneOutstandingInteractiveRequest(t *testing.T) {
432- d, proxy, _, link, _ := newProxyInputHarness(t)
433- clock := newProxyCommandTestClock()
434- firstDone := make(chan struct{})
435- go func() {
436- _, _ = proxy.sendCommand(context.Background(), clock, "next-tab", nil)
437- close(firstDone)
438- }()
439- first := requireProxyCommandRequest(t, link)
440- _ = awaitTestValue(t, clock.timers, "first command did not arm timeout")
441-
442- secondDone := make(chan struct{})
443- go func() {
444- _, _ = proxy.sendCommand(context.Background(), clock, "previous-tab", nil)
445- close(secondDone)
446- }()
447- requireNoProxyFrame(t, link)
448- _, err := d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: first.RequestID, OK: true}))
449- require.NoError(t, err)
450- awaitTestCompletion(t, firstDone, "first command did not complete")
451- second := requireProxyCommandRequest(t, link)
452- _ = awaitTestValue(t, clock.timers, "second command did not arm timeout")
453- _, err = d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: second.RequestID, OK: true}))
454- require.NoError(t, err)
455- awaitTestCompletion(t, secondDone, "second command did not complete")
456-}
457-
458-func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T) {
459- t.Run("timeout", func(t *testing.T) {
460- _, proxy, _, link, _ := newProxyInputHarness(t)
461- clock := newProxyCommandTestClock()
462- done := make(chan error, 1)
463- go func() {
464- _, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil)
465- done <- err
466- }()
467- _ = requireProxyCommandRequest(t, link)
468- timer := awaitTestValue(t, clock.timers, "command did not arm timeout")
469- timer.Fire()
470- require.ErrorIs(t, awaitTestValue(t, done, "command did not time out"), errProxyCommandTimeout)
471- })
472-
473- t.Run("context cancellation", func(t *testing.T) {
474- _, proxy, _, link, _ := newProxyInputHarness(t)
475- clock := newProxyCommandTestClock()
476- ctx, cancel := context.WithCancel(context.Background())
477- done := make(chan error, 1)
478- go func() {
479- _, err := proxy.sendCommand(ctx, clock, "next-tab", nil)
480- done <- err
481- }()
482- _ = requireProxyCommandRequest(t, link)
483- _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
484- cancel()
485- require.ErrorIs(t, awaitTestValue(t, done, "command did not cancel"), context.Canceled)
486- })
487-
488- t.Run("link generation replacement", func(t *testing.T) {
489- d, proxy, _, link, _ := newProxyInputHarness(t)
490- clock := newProxyCommandTestClock()
491- done := make(chan error, 1)
492- go func() {
493- _, err := proxy.sendCommand(context.Background(), clock, "next-tab", nil)
494- done <- err
495- }()
496- request := requireProxyCommandRequest(t, link)
497- _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
498- oldGeneration := proxy.linkGeneration
499- newGeneration, _ := proxy.installTransport(newProxyTestTransport())
500- require.Greater(t, newGeneration, oldGeneration)
501- require.Error(t, awaitTestValue(t, done, "replacement did not cancel command"))
502- _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))
503- require.NoError(t, err, "late old-generation result must be harmless")
504- })
505-}
506-
507-func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *testing.T) {
508- d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
509- second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
510- publishTiledPaneOwners(sess, second)
511- sess.mu.Lock()
512- sess.tabs = append(sess.tabs, second)
513- selectTestAttachmentTabLocked(sess, 0)
514- sess.mu.Unlock()
515- d.attachCoordinator(sess, ac, true)
516-
517- send := func(request ports.CommandRequest) ports.CommandResult {
518- payload, err := ports.MarshalCommandRequest(request)
519- require.NoError(t, err)
520- token := sess.attachmentToken(ac, ac.transport())
521- require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
522- frame := awaitFrame(t, sends, ports.MsgCommandResult)
523- result, err := ports.UnmarshalCommandResult(frame.Payload)
524- require.NoError(t, err)
525- return result
526- }
527-
528- result := send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 1, Attached: true, Slug: "next-tab"})
529- require.True(t, result.OK)
530- sess.mu.Lock()
531- require.Equal(t, 1, testAttachmentTabIndexLocked(sess))
532- sess.mu.Unlock()
533-
534- result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 2, Attached: true, Slug: "previous-tab", TargetSession: "other"})
535- require.False(t, result.OK)
536- sess.mu.Lock()
537- require.Equal(t, 1, testAttachmentTabIndexLocked(sess), "target override must not execute against any session")
538- sess.mu.Unlock()
539-
540- result = send(ports.CommandRequest{Version: ports.ProtocolVersion, RequestID: 3, Attached: true, Slug: "rename-tab"})
541- require.True(t, result.OK)
542- require.True(t, ac.overlays.promptActive(), "attached interactive command must be able to open the remote prompt")
543-}
544-
545-func TestProxyAttachedCommandRemoteErrorCreatesLocalNotice(t *testing.T) {
546- d, proxy, ac, link, handler := newProxyInputHarness(t)
547- clock := newProxyCommandTestClock()
548- d.clock = clock
549- handler.enterPalette()
550- done := make(chan struct{})
551- go func() {
552- d.handlePaletteInput(ac, []byte("NXT\r"), handler.connectionToken.effect)
553- close(done)
554- }()
555- request := requireProxyCommandRequest(t, link)
556- _ = awaitTestValue(t, clock.timers, "palette command did not arm timeout")
557- _, err := d.handleLinkFrame(proxy, proxy.linkGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, Code: ports.ErrInternal, Text: "remote failed"}))
558- require.NoError(t, err)
559- awaitTestCompletion(t, done, "palette command did not finish")
560- notice, ok := d.notices.latest()
561- require.True(t, ok)
562- require.Equal(t, domain.NoticeSessionUnavailable, notice.Code)
563- require.Contains(t, notice.Message, "remote failed")
564-}
565-
566-func TestProxyAttachedCommandMalformedAndUnavailableResultsAreSafe(t *testing.T) {
567- d, proxy, _, _, _ := newProxyInputHarness(t)
568- result, err := d.handleLinkFrame(proxy, proxy.linkGeneration, ports.Frame{Type: ports.MsgCommandResult, Payload: []byte{1}})
569- require.Equal(t, proxyLinkStop, result)
570- require.Error(t, err)
571-
572- proxy.mu.Lock()
573- proxy.transport = nil
574- proxy.mu.Unlock()
575- _, err = proxy.sendCommand(context.Background(), stubClock{}, "next-tab", nil)
576- require.True(t, errors.Is(err, errProxyCommandUnavailable))
577-}
578-
579-func TestProxyOwnershipMakesCompatibleRemoteRowsSelectable(t *testing.T) {
580- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
581- for _, status := range []remoteHostStatus{remoteHostCached, remoteHostRefreshing, remoteHostFresh, remoteHostUnreachable} {
582- view := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running", Tabs: 2}, status, time.Unix(1, 0))
583- require.True(t, view.RemoteAttachReady, "compatible status %d must be enabled after ownership is complete", status)
584- model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
585- target, selectable := model.Selected()
586- require.True(t, selectable)
587- require.Equal(t, key, *target.RemoteKey)
588- }
589- mismatch := remotePickerView(key, ports.RemoteCatalogSession{Name: key.Name, State: "running"}, remoteHostVersionMismatch, time.Time{})
590- require.False(t, mismatch.RemoteAttachReady)
591- _, selectable := picker.New([]picker.SessionView{mismatch}, picker.SelectionConfig{Mode: picker.SelectNavigationTab}).Selected()
592- require.False(t, selectable)
593-}
594-
595-func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *testing.T) {
596- d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
597- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
598- transport := newProxyTestTransport()
599- transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
600- factory := portsmocks.NewMockRemoteDialerFactory(t)
601- dialer := portsmocks.NewMockDialer(t)
602- var daemonLockAvailable, sourceLockAvailable bool
603- factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).
604- Run(func(string, string, ports.RemoteTransportMode, *slog.Logger) {
605- // If picker selection retained either architecture lock while dialing,
606- // this synchronous callback would deadlock.
607- d.mu.Lock()
608- daemonLockAvailable = true
609- d.mu.Unlock()
610- source.mu.Lock()
611- sourceLockAvailable = true
612- source.mu.Unlock()
613- }).Return(dialer, nil).Once()
614- dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
615- d.remoteDialerFactory = factory
616- d.remoteTransportMode = ports.RemoteTransportUDP
617- d.attachCoordinator(source, ac, true)
618-
619- token := source.attachmentToken(ac, ac.transport())
620- effect, admitted := ac.beginAttachmentEffect(token)
621- require.True(t, admitted)
622- token.effect = effect
623- err := d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
624- require.NoError(t, err)
625- require.True(t, daemonLockAvailable)
626- require.True(t, sourceLockAvailable)
627- proxy, ok := ac.currentAttachmentSession().(*proxySession)
628- require.True(t, ok)
629- require.Equal(t, key, proxy.key)
630- t.Cleanup(func() { stopProxy(t, proxy) })
631-
632- wrong := domain.RemoteSessionKey{Host: "arch", Name: "other"}
633- currentToken := attachmentToken(proxy, ac, ac.transport())
634- currentEffect, admitted := ac.beginAttachmentEffect(currentToken)
635- require.True(t, admitted)
636- currentToken.effect = currentEffect
637- err = d.switchToTargetForAttachment(currentToken, picker.Target{Session: key.ID(), RemoteKey: &wrong, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
638- require.ErrorIs(t, err, errAttachmentTransition)
639- require.Same(t, proxy, ac.currentAttachmentSession(), "mismatched structured key must not redirect attachment")
640-
641- staleToken := attachmentToken(proxy, ac, ac.transport())
642- staleEffect, admitted := ac.beginAttachmentEffect(staleToken)
643- require.True(t, admitted)
644- staleToken.effect = staleEffect
645- ac.connectionGeneration.Add(1) // model a concurrent role replacement after selection
646- err = d.switchToTargetForAttachment(staleToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
647- require.ErrorIs(t, err, errAttachmentTransition)
648- require.Same(t, proxy, ac.currentAttachmentSession(), "stale initiating role must not republish attachment")
649-}
650-
651-func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) {
652- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
653- d.attachCoordinator(local, ac, true)
654- key := domain.RemoteSessionKey{Host: "arch", Name: "enter"}
655- transport := newProxyTestTransport()
656- transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
657- d.remoteDialerFactory = newProxyConstructionFactory(transport)
658- d.remoteTransportMode = ports.RemoteTransportUDP
659- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
660- Host: key.Host, FetchedAt: time.Unix(1, 0),
661- Sessions: []ports.RemoteCatalogSession{{Name: key.Name, State: "running"}},
662- }})
663-
664- model := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
665- d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
666- target, selectable := model.Selected()
667- require.True(t, selectable)
668- require.Equal(t, key, *target.RemoteKey)
669-
670- token := local.attachmentToken(ac, ac.transport())
671- effect, admitted := ac.beginAttachmentEffect(token)
672- require.True(t, admitted)
673- d.handlePickerInput(ac, []byte("\r"), effect)
674- proxy, ok := ac.currentAttachmentSession().(*proxySession)
675- require.True(t, ok, "Enter must route the row instead of only marking it selectable")
676- require.Equal(t, key, proxy.key)
677- t.Cleanup(func() { stopProxy(t, proxy) })
678-}
679-
680-func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
681- d, proxy, ac, _, handler := newProxyInputHarness(t)
682- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
683- local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
684- publishTiledPaneOwners(local, tb)
685- d.mu.Lock()
686- require.True(t, d.registerSessionLocked(local))
687- d.mu.Unlock()
688-
689- model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
690- d.publishPicker(proxy, ac, model, pickerNavigate, moveSourceLocator{})
691- target, selectable := model.Selected()
692- require.True(t, selectable)
693- require.Equal(t, local.id, target.Session)
694- require.Equal(t, local.name, target.Name)
695- require.False(t, target.Stopped)
696-
697- require.NoError(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
698- require.Same(t, local, ac.currentSession())
699- require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker")
700-
701- d.remoteDialerFactory = portsmocks.NewMockRemoteDialerFactory(t)
702- localToken := attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
703- require.NoError(t, d.backSessionForAttachment(localToken))
704- require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")
705-
706- proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
707- require.NoError(t, d.switchToTargetForAttachment(proxyToken, target, sessionHandoffGuard{}, "picker-select"))
708- require.Same(t, local, ac.currentSession())
709- d.backSession(local, ac)
710- require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity")
711-}
712-
713-func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) {
714- d, proxy, ac, _, handler := newProxyInputHarness(t)
715- err := d.switchToTargetForAttachment(handler.connectionToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select")
716- require.Error(t, err)
717- require.Same(t, proxy, ac.currentAttachmentSession())
718-}
719-
720-func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {
721- d, proxy, ac, _, handler := newProxyInputHarness(t)
722- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
723- local := &session{sessionCore: sessionCore{id: "local", name: "local", createdAt: 1}, tabs: []*tab{tb}}
724- publishTiledPaneOwners(local, tb)
725- d.mu.Lock()
726- require.True(t, d.registerSessionLocked(local))
727- d.mu.Unlock()
728-
729- model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
730- target, selectable := model.Selected()
731- require.True(t, selectable)
732- d.afterAttachmentEffectsFrozen = func() {
733- d.afterAttachmentEffectsFrozen = nil
734- local.mu.Lock()
735- local.name = "replacement"
736- local.createdAt++
737- local.mu.Unlock()
738- }
739-
740- require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
741- require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")
742-}
743-
744-func TestRemotePickerRejectsReplacedLocalIncarnation(t *testing.T) {
745- d, proxy, ac, _, handler := newProxyInputHarness(t)
746- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
747- tb.stableID = "local-tab"
748- local := &session{sessionCore: sessionCore{id: "local", name: "local", ephemeral: true, incarnation: domain.IncarnationID{1}}, tabs: []*tab{tb}}
749- publishTiledPaneOwners(local, tb)
750- d.mu.Lock()
751- require.True(t, d.registerSessionLocked(local))
752- d.mu.Unlock()
753-
754- model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, Incarnation: local.incarnation, TabID: domain.TabStableID(tb.stableID)})
755- target, selectable := model.Selected()
756- require.True(t, selectable)
757- d.afterAttachmentEffectsFrozen = func() {
758- d.afterAttachmentEffectsFrozen = nil
759- local.mu.Lock()
760- local.incarnation = domain.IncarnationID{2}
761- local.mu.Unlock()
762- }
763-
764- require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
765- require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker incarnation must not redirect the attachment")
766-}
767-
768-func TestRemotePickerRejectsReplacedLocalTab(t *testing.T) {
769- d, proxy, ac, _, handler := newProxyInputHarness(t)
770- first := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
771- first.stableID = "first"
772- second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
773- second.stableID = "second"
774- local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{first, second}}
775- publishTiledPaneOwners(local, first)
776- publishTiledPaneOwners(local, second)
777- d.mu.Lock()
778- require.True(t, d.registerSessionLocked(local))
779- d.mu.Unlock()
780-
781- model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(second.stableID)})
782- target, selectable := model.Selected()
783- require.True(t, selectable)
784- require.Equal(t, domain.TabStableID(second.stableID), target.TabID)
785- d.afterAttachmentEffectsFrozen = func() {
786- d.afterAttachmentEffectsFrozen = nil
787- local.mu.Lock()
788- local.tabs[0], local.tabs[1] = local.tabs[1], local.tabs[0]
789- local.mu.Unlock()
790- }
791-
792- require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
793- require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker tab must not redirect the attachment")
794-}
795-
796-func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
797- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
798- d.attachCoordinator(local, ac, true)
799- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
800- remote := newProxyTestTransport()
801- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
802- d.remoteDialerFactory = newProxyConstructionFactory(remote)
803- d.remoteTransportMode = ports.RemoteTransportUDP
804-
805- localToken := attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
806- require.NoError(t, d.switchToTargetForAttachment(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
807- proxy, ok := ac.currentAttachmentSession().(*proxySession)
808- require.True(t, ok)
809- t.Cleanup(func() { stopProxy(t, proxy) })
810-
811- proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
812- require.NoError(t, d.switchToTargetForAttachment(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
813- require.Same(t, local, ac.currentSession())
814-
815- model := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
816- d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{})
817- target, selectable := model.Selected()
818- require.True(t, selectable)
819- require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key")
820- localToken = attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
821- require.NoError(t, d.switchToTargetForAttachment(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
822- require.Same(t, proxy, ac.currentAttachmentSession())
823- require.False(t, ac.overlays.pickerActive())
824-}
825-
826-func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) {
827- d := newTestDaemon(t, nil, stubClock{})
828- client := newProxyTestTransport()
829- ac := &attachedClient{}
830- ac.replaceTransport(client)
831- first := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
832- second := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
833- ac.setSession(first)
834-
835- const unstableSnapshots = 100
836- attempts := 0
837- d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
838- attempts++
839- if attempts >= unstableSnapshots {
840- return
841- }
842- if snapshot == first {
843- ac.setSession(second)
844- } else {
845- ac.setSession(first)
846- }
847- }
848-
849- _, _, ok := d.currentAttachmentConnection(ac, client)
850- require.False(t, ok)
851- require.Less(t, attempts, unstableSnapshots)
852-}
853-
854-func TestConnectionLoopStopsWhenReceiveErrorCleanupMakesNoProgress(t *testing.T) {
855- d := newTestDaemon(t, nil, stubClock{})
856- client := newProxyTestTransport()
857- ac := &attachedClient{}
858- ac.replaceTransport(client)
859- local := &session{sessionCore: sessionCore{attachments: map[*attachedClient]struct{}{ac: {}}}}
860- ac.setSession(local)
861-
862- cleanupAttempts := 0
863- d.beforeClientGoneDetach = func() {
864- cleanupAttempts++
865- local.mu.Lock()
866- local.registerAttachmentLocked(&attachedClient{})
867- local.mu.Unlock()
868- }
869- snapshots := 0
870- d.afterConnectionSessionSnapshot = func(attachmentSession) {
871- snapshots++
872- if snapshots == 100 {
873- ac.setSession(nil)
874- }
875- }
876- client.recv <- proxyRecv{err: io.EOF}
877-
878- d.runConnLoop(ac)
879-
880- require.Equal(t, 1, cleanupAttempts)
881- require.Equal(t, 1, snapshots)
882-}
883-
884-func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
885- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
886- client := newProxyTestTransport()
887- ac.replaceTransport(client)
888- d.attachCoordinator(local, ac, true)
889-
890- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
891- remote := newProxyTestTransport()
892- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
893- d.remoteDialerFactory = newProxyConstructionFactory(remote)
894- d.remoteTransportMode = ports.RemoteTransportUDP
895- token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
896- snapshots := make(chan attachmentSession, 1)
897- transitionErr := make(chan error, 1)
898- d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
899- d.afterConnectionSessionSnapshot = nil
900- snapshots <- snapshot
901- transitionErr <- d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
902- }
903-
904- loopDone := make(chan struct{})
905- go func() {
906- d.runConnLoop(ac)
907- close(loopDone)
908- }()
909- client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}}
910- require.Same(t, local, awaitTestValue(t, snapshots, "connection role was not snapshotted"))
911- require.NoError(t, awaitTestValue(t, transitionErr, "handoff did not run during role resolution"))
912- proxy, ok := ac.currentAttachmentSession().(*proxySession)
913- require.True(t, ok)
914- t.Cleanup(func() { stopProxy(t, proxy) })
915- _ = awaitFrame(t, client.sent, ports.MsgPong)
916-
917- client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
918- awaitTestCompletion(t, loopDone, "connection loop did not continue after retrying role resolution")
919-}
920-
921-func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) {
922- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
923- client := newProxyTestTransport()
924- ac.replaceTransport(client)
925- d.attachCoordinator(local, ac, true)
926-
927- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
928- remote := newProxyTestTransport()
929- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
930- d.remoteDialerFactory = newProxyConstructionFactory(remote)
931- d.remoteTransportMode = ports.RemoteTransportUDP
932-
933- loopDone := make(chan struct{})
934- go func() {
935- d.runConnLoop(ac)
936- close(loopDone)
937- }()
938- require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
939-
940- token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
941- require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
942- proxy, ok := ac.currentAttachmentSession().(*proxySession)
943- require.True(t, ok)
944- t.Cleanup(func() { stopProxy(t, proxy) })
945-
946- client.recv <- proxyRecv{err: io.EOF}
947- awaitTestCompletion(t, loopDone, "connection loop did not return after receive failure")
948- require.Nil(t, ac.currentAttachmentSession(), "receive failure cleaned the stale local role instead of the current proxy role")
949-}
950-
951-func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {
952- d, _, ac, _, handler := newProxyInputHarness(t)
953- client, ok := ac.transport().(*proxyTestTransport)
954- require.True(t, ok)
955- tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
956- local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}}
957- publishTiledPaneOwners(local, tb)
958- d.mu.Lock()
959- require.True(t, d.registerSessionLocked(local))
960- d.mu.Unlock()
961-
962- loopDone := make(chan struct{})
963- go func() {
964- d.runConnLoop(ac)
965- close(loopDone)
966- }()
967- require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
968-
969- require.NoError(t, d.switchToTargetForAttachment(handler.connectionToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
970- require.Same(t, local, ac.currentSession())
971- client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
972- awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session")
973- require.Nil(t, ac.currentAttachmentSession())
974-}
975-
976-func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
977- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
978- client := newProxyTestTransport()
979- ac.replaceTransport(client)
980- d.attachCoordinator(local, ac, true)
981-
982- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
983- remote := newProxyTestTransport()
984- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
985- d.remoteDialerFactory = newProxyConstructionFactory(remote)
986- d.remoteTransportMode = ports.RemoteTransportUDP
987-
988- loopDone := make(chan struct{})
989- go func() {
990- d.runConnLoop(ac)
991- close(loopDone)
992- }()
993- require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
994-
995- token := local.attachmentToken(ac, client)
996- effect, admitted := ac.beginAttachmentEffect(token)
997- require.True(t, admitted)
998- token.effect = effect
999- require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
1000- proxy, ok := ac.currentAttachmentSession().(*proxySession)
1001- require.True(t, ok)
1002- t.Cleanup(func() { stopProxy(t, proxy) })
1003-
1004- client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
1005- awaitTestCompletion(t, loopDone, "connection loop did not handle input after the proxy handoff")
1006- require.Nil(t, ac.currentAttachmentSession(), "connection loop abandoned the client after the proxy handoff")
1007-}
D internal/usecase/daemon/proxy_kill.go +0 -276
@@ -1,276 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "io"
8- "time"
9-
10- "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/usecase/picker"
12-)
13-
14-const proxyKillTimeout = 10 * time.Second
15-
16-var (
17- errProxyKillTimeout = errors.New("proxy session: remote kill timed out")
18- errProxyKillGeneration = errors.New("proxy session: remote kill lifecycle changed")
19- errProxyKillConfirmation = errors.New("type the full remote session label exactly")
20-)
21-
22-type proxyKillIOResult struct {
23- frame ports.Frame
24- err error
25-}
26-
27-// enterRemoteKillConfirmation turns a remote picker deletion into an exact,
28-// typed lifecycle capability. The proxy pointer is resolved when the prompt is
29-// opened, while its generation is deliberately captured only after the exact
30-// label is submitted.
31-func (d *Daemon) enterRemoteKillConfirmation(owner attachmentSession, ac *attachedClient, target picker.Target) bool {
32- if d == nil || owner == nil || ac == nil || target.RemoteKey == nil || target.Session != target.RemoteKey.ID() {
33- return false
34- }
35- key := *target.RemoteKey
36- d.mu.Lock()
37- proxy, ok := d.sessions[target.Session].(*proxySession)
38- ok = ok && proxy.key == key
39- d.mu.Unlock()
40- if !ok {
41- return false
42- }
43- title := " Type " + key.Display() + " to kill "
44- proxy.mu.Lock()
45- unreachable := proxy.expired || proxy.linkState == ports.LinkStateOffline || proxy.linkState == ports.LinkStateDead
46- proxy.mu.Unlock()
47- if unreachable {
48- title = " Type " + key.Display() + " to kill (remote unreachable) "
49- }
50- d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentConnectionToken) error {
51- if value != key.Display() {
52- return errProxyKillConfirmation
53- }
54- if token.ac != nil {
55- if !token.attachmentCurrent() {
56- return errAttachmentTransition
57- }
58- token.effect.bindActionEnd(d, "remote-picker-delete")
59- token.effect.End()
60- }
61- d.mu.Lock()
62- if d.sessions[proxy.id] != proxy {
63- d.mu.Unlock()
64- return errProxyKillGeneration
65- }
66- proxy.mu.Lock()
67- generation := proxy.generation
68- proxy.mu.Unlock()
69- d.mu.Unlock()
70- ctx := d.serveCtx
71- if ctx == nil {
72- ctx = context.Background()
73- }
74- return d.killRemoteProxy(ctx, proxy, generation)
75- })
76- return true
77-}
78-
79-// killRemoteProxy uses a fresh control transport and accepts only EOF as the
80-// remote daemon's authoritative kill acknowledgement. No live or cached state
81-// is changed before that acknowledgement and exact generation publication.
82-func (d *Daemon) killRemoteProxy(ctx context.Context, proxy *proxySession, generation uint64) error {
83- if d == nil || proxy == nil || d.remoteDialerFactory == nil || d.clock == nil {
84- return errors.New("proxy session: remote kill is unavailable")
85- }
86- if ctx == nil {
87- ctx = context.Background()
88- }
89- if err := ctx.Err(); err != nil {
90- return err
91- }
92-
93- d.mu.Lock()
94- current := d.sessions[proxy.id] == proxy
95- if current {
96- proxy.mu.Lock()
97- current = proxy.generation == generation
98- proxy.mu.Unlock()
99- }
100- d.mu.Unlock()
101- if !current {
102- return errProxyKillGeneration
103- }
104-
105- timer := d.clock.NewTimer(proxyKillTimeout)
106- if timer == nil {
107- return errProxyKillTimeout
108- }
109- defer timer.Stop()
110- boundedCtx, cancel := context.WithCancel(ctx)
111- defer cancel()
112- timedOut := make(chan struct{})
113- stopTimeout := make(chan struct{})
114- defer close(stopTimeout)
115- go func() {
116- select {
117- case <-timer.C():
118- close(timedOut)
119- cancel()
120- case <-stopTimeout:
121- }
122- }()
123-
124- dialer, err := d.remoteDialerFactory.DialerForRemote(proxy.key.Host, proxy.key.Name, d.remoteTransportMode, d.log)
125- if err != nil {
126- return fmt.Errorf("proxy session: select remote kill dialer: %w", err)
127- }
128- if dialer == nil {
129- return errors.New("proxy session: remote kill dialer is nil")
130- }
131- transport, err := dialer.Dial(boundedCtx)
132- if err != nil {
133- return proxyKillContextError(ctx, timedOut, fmt.Errorf("proxy session: remote kill dial: %w", err))
134- }
135- if transport == nil {
136- return errors.New("proxy session: remote kill transport is nil")
137- }
138- defer func() { _ = transport.Close() }()
139-
140- killFrame := ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: proxy.key.Name})}
141- sendDone := make(chan error, 1)
142- go func() { sendDone <- transport.Send(killFrame) }()
143- select {
144- case err := <-sendDone:
145- if err != nil {
146- return fmt.Errorf("proxy session: send remote kill: %w", err)
147- }
148- case <-boundedCtx.Done():
149- _ = transport.Close()
150- // Transport promises Close unblocks Send, but this boundary cannot let a
151- // broken adapter defeat the caller's bound. The buffered result lets the
152- // caller return; a non-cooperative adapter may retain this one transport
153- // goroutine until Send eventually exits. It owns no daemon locks or state.
154- return proxyKillContextError(ctx, timedOut, boundedCtx.Err())
155- }
156-
157- recvDone := make(chan proxyKillIOResult, 1)
158- go func() {
159- frame, err := transport.Recv()
160- recvDone <- proxyKillIOResult{frame: frame, err: err}
161- }()
162- var received proxyKillIOResult
163- select {
164- case received = <-recvDone:
165- case <-boundedCtx.Done():
166- _ = transport.Close()
167- // Apply the same bounded boundary defensively to Recv. A conforming
168- // transport exits on Close; a broken one may retain this transport-owned
169- // goroutine, but cannot delay or mutate the failed kill operation.
170- return proxyKillContextError(ctx, timedOut, boundedCtx.Err())
171- }
172- if !errors.Is(received.err, io.EOF) {
173- if received.err != nil {
174- return fmt.Errorf("proxy session: receive remote kill result: %w", received.err)
175- }
176- if received.frame.Type == ports.MsgError {
177- remoteErr, decodeErr := ports.UnmarshalErrorMsg(received.frame.Payload)
178- if decodeErr != nil {
179- return fmt.Errorf("proxy session: malformed remote kill error: %w", decodeErr)
180- }
181- return fmt.Errorf("proxy session: remote kill failed (%d): %s", remoteErr.Code, remoteErr.Text)
182- }
183- return fmt.Errorf("proxy session: unexpected remote kill reply type %d", received.frame.Type)
184- }
185- return d.publishRemoteProxyKill(proxy, generation)
186-}
187-
188-func proxyKillContextError(parent context.Context, timedOut <-chan struct{}, fallback error) error {
189- select {
190- case <-timedOut:
191- return errProxyKillTimeout
192- default:
193- }
194- if err := parent.Err(); err != nil {
195- return err
196- }
197- return fallback
198-}
199-
200-// publishRemoteProxyKill atomically removes only the acknowledged proxy
201-// generation and matching discovery row. Teardown and persistence are external
202-// operations and therefore happen after every architecture lock is released.
203-func (d *Daemon) publishRemoteProxyKill(proxy *proxySession, generation uint64) error {
204- d.mu.Lock()
205- if d.sessions[proxy.id] != proxy {
206- d.mu.Unlock()
207- return errProxyKillGeneration
208- }
209- proxy.sessionCore.mu.Lock()
210- proxy.mu.Lock()
211- if proxy.generation != generation {
212- proxy.mu.Unlock()
213- proxy.sessionCore.mu.Unlock()
214- d.mu.Unlock()
215- return errProxyKillGeneration
216- }
217- proxy.generation++
218- proxy.expired = true
219- warm := proxy.warm
220- proxy.warm = nil
221- cancel := proxy.cancel
222- transport := proxy.transport
223- coordinator := proxy.coordinator.Load()
224- proxy.mu.Unlock()
225- if !d.unregisterSessionLocked(proxy) {
226- proxy.sessionCore.mu.Unlock()
227- d.mu.Unlock()
228- return errProxyKillGeneration
229- }
230- proxy.sessionCore.mu.Unlock()
231-
232- cacheChanged := false
233- d.remoteCatalog.mu.Lock()
234- if entry, ok := d.remoteCatalog.cache[proxy.key.Host]; ok {
235- sessions := make([]ports.RemoteCatalogSession, 0, len(entry.Sessions))
236- for _, session := range entry.Sessions {
237- if session.Name == proxy.key.Name {
238- cacheChanged = true
239- continue
240- }
241- sessions = append(sessions, session)
242- }
243- if cacheChanged {
244- entry.Sessions = sessions
245- d.remoteCatalog.cache[proxy.key.Host] = entry
246- }
247- }
248- d.remoteCatalog.mu.Unlock()
249- empty := len(d.sessions) == 0
250- if empty {
251- d.closing = true
252- }
253- d.mu.Unlock()
254-
255- warm.stop()
256- if cancel != nil {
257- cancel()
258- }
259- if transport != nil {
260- _ = transport.Close()
261- }
262- if coordinator != nil {
263- coordinator.beginSessionTeardown().finish()
264- coordinator.waitForTimerWorkers()
265- }
266- proxy.finish()
267- if cacheChanged {
268- d.persistRemoteCatalogCache()
269- }
270- if empty {
271- d.doneOnce.Do(func() { close(d.done) })
272- } else {
273- d.refreshRemoteOpenPickers()
274- }
275- return nil
276-}
D internal/usecase/daemon/proxy_lifecycle.go +0 -344
@@ -1,344 +0,0 @@
1-package daemon
2-
3-import (
4- "sync"
5- "time"
6-
7- "github.com/bnema/vev/internal/ports"
8-)
9-
10-// Proxy lifecycle owns the dormant-expiry timer and the link-state publication
11-// of one remote attachment. It extends the package lock ordering documented at
12-// the top of client.go: every function in this file acquires
13-// d.mu -> p.sessionCore.mu -> p.mu, in that order and never any other, and
14-// holds them only to validate and publish state. Clock, Timer, transport,
15-// coordinator, and repaint work always runs after all three are released.
16-
17-const proxyWarmDuration = 5 * time.Minute
18-
19-// proxyWarmTimer is an exact lifecycle capability. Both pointer identity and
20-// generation must still match before its callback may remove a registry entry.
21-type proxyWarmTimer struct {
22- generation uint64
23- timer ports.Timer
24- cancel chan struct{}
25- done chan struct{}
26- cancelOnce sync.Once
27-}
28-
29-func (t *proxyWarmTimer) stop() {
30- if t == nil {
31- return
32- }
33- if t.timer != nil {
34- t.timer.Stop()
35- }
36- t.cancelOnce.Do(func() { close(t.cancel) })
37-}
38-
39-// proxyAttachmentTransitionCommitted applies lifecycle policy only after the
40-// attachment publication has released every architecture lock. Each helper
41-// performs its own exact pointer/client revalidation before changing a timer.
42-func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveAttachment bool) {
43- if d == nil || preserveAttachment || source == target {
44- return
45- }
46- if proxy, ok := source.(*proxySession); ok {
47- d.armProxyWarm(proxy)
48- }
49- if proxy, ok := target.(*proxySession); ok {
50- if d.cancelProxyWarmForAttachment(proxy, ac) {
51- d.touchMRU(proxy)
52- }
53- }
54-}
55-
56-// cancelProxyWarmForAttachment marks an exact published attachment as active.
57-// Timer operations occur only after d.mu, sessionCore.mu, and proxy.mu have all
58-// been released.
59-func (d *Daemon) cancelProxyWarmForAttachment(p *proxySession, ac *attachedClient) bool {
60- if d == nil || p == nil || ac == nil {
61- return false
62- }
63- d.mu.Lock()
64- if d.sessions[p.id] != p {
65- d.mu.Unlock()
66- return false
67- }
68- p.sessionCore.mu.Lock()
69- if !attachmentRegisteredLocked(attachmentSession(p), ac) {
70- p.sessionCore.mu.Unlock()
71- d.mu.Unlock()
72- return false
73- }
74- p.mu.Lock()
75- p.generation++
76- old := p.warm
77- p.warm = nil
78- p.mu.Unlock()
79- p.sessionCore.mu.Unlock()
80- d.mu.Unlock()
81- old.stop()
82- return true
83-}
84-
85-// armProxyWarm reserves and publishes a fresh five-minute timer only while the
86-// exact proxy remains registered and headless. Clock and Timer methods are
87-// external operations and therefore run outside architecture locks.
88-//
89-// The lifecycle generation is bumped and the incumbent token released only in
90-// phase 2, atomically with publishing the replacement. A failed revalidation
91-// therefore leaves the already-armed token both installed and current, so the
92-// proxy can never be left registered without a path out of the live registry.
93-func (d *Daemon) armProxyWarm(p *proxySession) bool {
94- if d == nil || p == nil || d.clock == nil {
95- return false
96- }
97- d.mu.Lock()
98- if d.sessions[p.id] != p || d.closing {
99- d.mu.Unlock()
100- return false
101- }
102- p.sessionCore.mu.Lock()
103- if len(p.attachments) != 0 {
104- p.sessionCore.mu.Unlock()
105- d.mu.Unlock()
106- return false
107- }
108- p.mu.Lock()
109- generation := p.generation
110- old := p.warm
111- p.mu.Unlock()
112- p.sessionCore.mu.Unlock()
113- d.mu.Unlock()
114-
115- timer := d.clock.NewTimer(proxyWarmDuration)
116- if timer == nil {
117- return false
118- }
119- token := &proxyWarmTimer{
120- timer: timer,
121- cancel: make(chan struct{}),
122- done: make(chan struct{}),
123- }
124-
125- d.mu.Lock()
126- valid := d.sessions[p.id] == p && !d.closing
127- if valid {
128- p.sessionCore.mu.Lock()
129- valid = len(p.attachments) == 0
130- if valid {
131- p.mu.Lock()
132- valid = p.generation == generation && p.warm == old
133- if valid {
134- p.generation++
135- token.generation = p.generation
136- p.warm = token
137- }
138- p.mu.Unlock()
139- }
140- p.sessionCore.mu.Unlock()
141- }
142- d.mu.Unlock()
143- if !valid {
144- timer.Stop()
145- close(token.done)
146- return false
147- }
148- // The incumbent token is retired only once its successor owns the exact
149- // lifecycle generation, never before.
150- old.stop()
151-
152- go func() {
153- defer close(token.done)
154- select {
155- case <-timer.C():
156- d.expireWarmProxy(p, token)
157- case <-token.cancel:
158- }
159- }()
160- return true
161-}
162-
163-// expireWarmProxy removes only the exact dormant proxy pointer and lifecycle
164-// generation. The canonical d.mu -> core.mu -> proxy.mu order is held only for
165-// validation/publication; cancellation, coordinator teardown, transport close,
166-// picker refresh, and daemon completion happen afterward.
167-func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {
168- if d == nil || p == nil || token == nil {
169- return false
170- }
171- d.mu.Lock()
172- if d.sessions[p.id] != p {
173- d.mu.Unlock()
174- return false
175- }
176- p.sessionCore.mu.Lock()
177- if len(p.attachments) != 0 {
178- p.sessionCore.mu.Unlock()
179- d.mu.Unlock()
180- return false
181- }
182- p.mu.Lock()
183- if p.warm != token || p.generation != token.generation {
184- p.mu.Unlock()
185- p.sessionCore.mu.Unlock()
186- d.mu.Unlock()
187- return false
188- }
189- // Removal from the registry is what commits this expiry, so it is attempted
190- // before any terminal lifecycle state is published. unregisterSessionLocked
191- // only reads d.sessions, which this call already owns; it acquires no lock.
192- if !d.unregisterSessionLocked(p) {
193- p.mu.Unlock()
194- p.sessionCore.mu.Unlock()
195- d.mu.Unlock()
196- return false
197- }
198- p.warm = nil
199- p.generation++
200- p.expired = true
201- cancel := p.cancel
202- transport := p.transport
203- coordinator := p.coordinator.Load()
204- p.mu.Unlock()
205- p.sessionCore.mu.Unlock()
206- empty := len(d.sessions) == 0
207- if empty {
208- d.closing = true
209- }
210- d.mu.Unlock()
211-
212- if cancel != nil {
213- cancel()
214- }
215- if transport != nil {
216- _ = transport.Close()
217- }
218- if coordinator != nil {
219- coordinator.beginSessionTeardown().finish()
220- coordinator.waitForTimerWorkers()
221- }
222- p.finish()
223- if empty {
224- d.doneOnce.Do(func() { close(d.done) })
225- } else {
226- d.refreshRemoteOpenPickers()
227- }
228- return true
229-}
230-
231-// withProxyLocked evaluates one lifecycle predicate under the canonical
232-// d.mu -> p.sessionCore.mu -> p.mu order, but only while p is still the exact
233-// registered session. update owns every state mutation it wants published and
234-// reports whether it applied. Repaint is deliberately after all architecture
235-// locks have been released.
236-func (d *Daemon) withProxyLocked(p *proxySession, update func() bool) bool {
237- if d == nil || p == nil {
238- return false
239- }
240- d.mu.Lock()
241- if d.sessions[p.id] != p {
242- d.mu.Unlock()
243- return false
244- }
245- p.sessionCore.mu.Lock()
246- p.mu.Lock()
247- current := update()
248- attachments := append([]*attachedClient(nil), p.snapshotAttachmentsLocked()...)
249- p.mu.Unlock()
250- p.sessionCore.mu.Unlock()
251- d.mu.Unlock()
252- if current {
253- for _, ac := range attachments {
254- d.invalidateRender(p, ac, false, "proxy_lifecycle.go")
255- }
256- }
257- return current
258-}
259-
260-// updateProxyLinkState publishes one transport callback only if the exact proxy,
261-// link generation, and transport remain current.
262-func (d *Daemon) updateProxyLinkState(p *proxySession, generation uint64, transport ports.Transport, state ports.LinkState) bool {
263- if d == nil || p == nil || transport == nil {
264- return false
265- }
266- return d.withProxyLocked(p, func() bool {
267- current := !p.expired && p.linkGeneration == generation && p.transport == transport
268- if current {
269- p.linkState = state
270- }
271- return current
272- })
273-}
274-
275-// updateProxyDisconnectedState publishes a state after the exact generation's
276-// transport has retired. It is used while resume dialing is pending or failed.
277-func (d *Daemon) updateProxyDisconnectedState(p *proxySession, generation uint64, state ports.LinkState) bool {
278- if d == nil || p == nil {
279- return false
280- }
281- return d.withProxyLocked(p, func() bool {
282- current := !p.expired && p.linkGeneration == generation && p.transport == nil
283- if current {
284- p.linkState = state
285- }
286- return current
287- })
288-}
289-
290-func (d *Daemon) repaintProxyLifecycle(p *proxySession) {
291- d.withProxyLocked(p, func() bool {
292- return !p.expired && p.transport != nil
293- })
294-}
295-
296-// markProxyReplaced makes ReasonReplaced terminal for this exact remote link.
297-// Once published, no later state callback or resume attempt can revive it. An
298-// already-armed dormant expiry remains the lifecycle capability for this exact
299-// proxy and generation; replacement must not invalidate or clear that only path
300-// out of the live registry. A dormant proxy without a timer is armed afterward,
301-// outside every architecture lock.
302-func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport ports.Transport) bool {
303- if d == nil || p == nil || transport == nil {
304- return false
305- }
306- // Arming and repainting are mutually exclusive: needsWarm requires a headless
307- // proxy, and the helper only repaints an attached one. Deferring the arm until
308- // after the helper's repaint therefore preserves the published order.
309- var needsWarm bool
310- current := d.withProxyLocked(p, func() bool {
311- current := p.linkGeneration == generation && p.transport == transport
312- if current {
313- p.expired = true
314- p.linkState = ports.LinkStateDead
315- p.generation++
316- if p.warm != nil {
317- // Rekey the already-published exact timer instead of stopping it. This
318- // preserves its deadline while invalidating older lifecycle observers.
319- p.warm.generation = p.generation
320- }
321- }
322- needsWarm = current && len(p.attachments) == 0 && p.warm == nil
323- return current
324- })
325- if needsWarm {
326- d.armProxyWarm(p)
327- }
328- return current
329-}
330-
331-func (p *proxySession) lifecycleDisplayName() string {
332- if p == nil {
333- return ""
334- }
335- p.mu.Lock()
336- defer p.mu.Unlock()
337- if p.expired {
338- return p.name + " [expired]"
339- }
340- if p.linkState == ports.LinkStateConnected {
341- return p.name
342- }
343- return p.name + " [" + p.linkState.String() + "]"
344-}
D internal/usecase/daemon/proxy_lifecycle_test.go +0 -1624
@@ -1,1624 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "io"
8- "log/slog"
9- "sync"
10- "testing"
11- "time"
12-
13- "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/ports"
15- "github.com/bnema/vev/internal/usecase/picker"
16- "github.com/stretchr/testify/require"
17-)
18-
19-type proxyLifecycleClock struct {
20- mu sync.RWMutex
21- now time.Time
22- timers chan *proxyLifecycleTimer
23-}
24-
25-func newProxyLifecycleClock() *proxyLifecycleClock {
26- return &proxyLifecycleClock{
27- now: time.Unix(1_800_000_000, 0),
28- timers: make(chan *proxyLifecycleTimer, 16),
29- }
30-}
31-
32-func (c *proxyLifecycleClock) Now() time.Time {
33- c.mu.RLock()
34- defer c.mu.RUnlock()
35- return c.now
36-}
37-
38-func (c *proxyLifecycleClock) Advance(delay time.Duration) {
39- c.mu.Lock()
40- c.now = c.now.Add(delay)
41- c.mu.Unlock()
42-}
43-
44-func (c *proxyLifecycleClock) NewTimer(delay time.Duration) ports.Timer {
45- timer := &proxyLifecycleTimer{clock: c, delay: delay, ticks: make(chan time.Time, 1)}
46- select {
47- case c.timers <- timer:
48- default:
49- }
50- return timer
51-}
52-
53-type proxyLifecycleTimer struct {
54- mu sync.Mutex
55- clock *proxyLifecycleClock
56- delay time.Duration
57- ticks chan time.Time
58- stopped bool
59-}
60-
61-func (t *proxyLifecycleTimer) C() <-chan time.Time { return t.ticks }
62-func (t *proxyLifecycleTimer) Reset(delay time.Duration) bool {
63- t.mu.Lock()
64- wasActive := !t.stopped
65- t.delay = delay
66- t.stopped = false
67- t.mu.Unlock()
68- return wasActive
69-}
70-func (t *proxyLifecycleTimer) Stop() bool {
71- t.mu.Lock()
72- wasActive := !t.stopped
73- t.stopped = true
74- t.mu.Unlock()
75- return wasActive
76-}
77-func (t *proxyLifecycleTimer) fire() {
78- t.clock.Advance(t.delay)
79- select {
80- case t.ticks <- t.clock.Now():
81- default:
82- }
83-}
84-
85-func TestProxyLifecycleClockTimerRegistrationDoesNotBlock(t *testing.T) {
86- clock := newProxyLifecycleClock()
87- for range cap(clock.timers) {
88- clock.NewTimer(time.Minute)
89- }
90-
91- registered := make(chan struct{})
92- go func() {
93- clock.NewTimer(time.Minute)
94- close(registered)
95- }()
96- awaitTestCompletion(t, registered, "timer registration blocked on observer capacity")
97- for range cap(clock.timers) {
98- _ = awaitTestValue(t, clock.timers, "registered timer was not observable")
99- }
100-}
101-
102-type proxyLifecycleTransport struct {
103- d *Daemon
104- proxy *proxySession
105- closed chan struct{}
106- closeOne sync.Once
107- unlocked bool
108-}
109-
110-func (t *proxyLifecycleTransport) Send(ports.Frame) error { return nil }
111-func (t *proxyLifecycleTransport) Recv() (ports.Frame, error) {
112- <-t.closed
113- return ports.Frame{}, nil
114-}
115-func (t *proxyLifecycleTransport) Close() error {
116- t.closeOne.Do(func() {
117- daemonUnlocked := t.d.mu.TryLock()
118- if daemonUnlocked {
119- t.d.mu.Unlock()
120- }
121- coreUnlocked := t.proxy.sessionCore.mu.TryLock()
122- if coreUnlocked {
123- t.proxy.sessionCore.mu.Unlock()
124- }
125- proxyUnlocked := t.proxy.mu.TryLock()
126- if proxyUnlocked {
127- t.proxy.mu.Unlock()
128- }
129- t.unlocked = daemonUnlocked && coreUnlocked && proxyUnlocked
130- close(t.closed)
131- })
132- return nil
133-}
134-
135-func newRegisteredLifecycleProxy(t *testing.T, clock ports.Clock) (*Daemon, *proxySession, *proxyLifecycleTransport) {
136- t.Helper()
137- d := newTestDaemon(t, nil, clock)
138- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize)
139- require.NoError(t, err)
140- transport := &proxyLifecycleTransport{d: d, proxy: proxy, closed: make(chan struct{})}
141- proxy.transport = transport
142- proxy.linkGeneration = 1
143- d.mu.Lock()
144- require.True(t, d.registerSessionLocked(proxy))
145- d.mu.Unlock()
146- return d, proxy, transport
147-}
148-
149-func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
150- clock := newProxyLifecycleClock()
151- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
152- d.clock = clock
153- localCoordinator := d.attachCoordinator(local, ac, true)
154- localToken := attachmentToken(local, ac, ac.transport())
155- localToken.lease = localCoordinator.attachmentLease(ac)
156-
157- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ac.size)
158- require.NoError(t, err)
159- d.mu.Lock()
160- require.True(t, d.registerSessionLocked(proxy))
161- d.mu.Unlock()
162-
163- toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
164- source: local, target: proxy, next: ac,
165-
166- expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
167- action: "lifecycle test", ready: true,
168- })
169- require.NoError(t, err)
170- require.NotZero(t, proxy.mruAt.Load(), "proxy activation must participate in unified MRU ordering")
171- proxy.mu.Lock()
172- require.Nil(t, proxy.warm, "attachment must leave no warm timer published")
173- proxy.mu.Unlock()
174-
175- _, err = d.transitionAttachment(attachmentTransitionRequest{
176- source: proxy, target: local, next: ac,
177-
178- expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
179- action: "lifecycle test", ready: true,
180- })
181- require.NoError(t, err)
182- timer := awaitTestValue(t, clock.timers, "proxy timer was not registered")
183- require.Equal(t, proxyWarmDuration, timer.delay)
184- proxy.mu.Lock()
185- require.NotNil(t, proxy.warm, "switch-away must arm the warm lifecycle")
186- proxy.mu.Unlock()
187-}
188-
189-func TestProxyWarmLifecycleUsesExactFiveMinuteGeneration(t *testing.T) {
190- clock := newProxyLifecycleClock()
191- d, proxy, transport := newRegisteredLifecycleProxy(t, clock)
192- d.remoteCatalog.mu.Lock()
193- d.remoteCatalog.cache[proxy.key.Host] = ports.RemoteCatalogCacheEntry{Host: proxy.key.Host, FetchedAt: clock.Now()}
194- d.remoteCatalog.mu.Unlock()
195-
196- d.armProxyWarm(proxy)
197- timer := awaitTestValue(t, clock.timers, "proxy timer was not registered")
198- require.Equal(t, 5*time.Minute, timer.delay)
199- proxy.mu.Lock()
200- token := proxy.warm
201- generation := proxy.generation
202- proxy.mu.Unlock()
203- require.NotNil(t, token)
204-
205- timer.fire()
206- awaitTestCompletion(t, token.done, "proxy warm lifecycle did not complete")
207-
208- d.mu.Lock()
209- require.NotContains(t, d.sessions, proxy.id)
210- require.True(t, d.closing, "the last warm proxy expiry must begin daemon shutdown")
211- d.mu.Unlock()
212- require.True(t, transport.unlocked, "transport close must run after daemon/core/proxy locks are released")
213- proxy.mu.Lock()
214- require.Greater(t, proxy.generation, generation)
215- proxy.mu.Unlock()
216- d.remoteCatalog.mu.Lock()
217- require.Contains(t, d.remoteCatalog.cache, proxy.key.Host, "warm expiry must preserve discovery cache")
218- d.remoteCatalog.mu.Unlock()
219-}
220-
221-func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
222- clock := newProxyLifecycleClock()
223- d, proxy, _ := newRegisteredLifecycleProxy(t, clock)
224-
225- d.armProxyWarm(proxy)
226- firstTimer := awaitTestValue(t, clock.timers, "first proxy timer was not registered")
227- proxy.mu.Lock()
228- firstToken := proxy.warm
229- proxy.mu.Unlock()
230- require.NotNil(t, firstToken)
231-
232- ac := &attachedClient{}
233- proxy.sessionCore.mu.Lock()
234- proxy.registerAttachmentLocked(ac)
235- proxy.sessionCore.mu.Unlock()
236- d.cancelProxyWarmForAttachment(proxy, ac)
237- awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete")
238- firstTimer.fire()
239-
240- d.mu.Lock()
241- require.Same(t, proxy, d.sessions[proxy.id])
242- d.mu.Unlock()
243-
244- proxy.sessionCore.mu.Lock()
245- for _, attachment := range proxy.snapshotAttachmentsLocked() {
246- proxy.unregisterAttachmentLocked(attachment)
247- }
248- proxy.sessionCore.mu.Unlock()
249- d.armProxyWarm(proxy)
250- secondTimer := awaitTestValue(t, clock.timers, "second proxy timer was not registered")
251- proxy.mu.Lock()
252- secondToken := proxy.warm
253- proxy.mu.Unlock()
254- require.NotNil(t, secondToken)
255- require.NotSame(t, firstToken, secondToken)
256-
257- secondTimer.fire()
258- awaitTestCompletion(t, secondToken.done, "second proxy warm lifecycle did not complete")
259- d.mu.Lock()
260- require.NotContains(t, d.sessions, proxy.id)
261- d.mu.Unlock()
262-}
263-
264-func TestProxyWarmTimerRejectsReplacementPointer(t *testing.T) {
265- clock := newProxyLifecycleClock()
266- d, proxy, transport := newRegisteredLifecycleProxy(t, clock)
267- d.armProxyWarm(proxy)
268- timer := awaitTestValue(t, clock.timers, "proxy timer was not registered")
269- proxy.mu.Lock()
270- token := proxy.warm
271- proxy.mu.Unlock()
272-
273- replacement, err := newProxySession(proxy.key, defaultSize)
274- require.NoError(t, err)
275- d.mu.Lock()
276- d.sessions[proxy.id] = replacement
277- d.mu.Unlock()
278-
279- timer.fire()
280- awaitTestCompletion(t, token.done, "proxy warm lifecycle did not complete")
281- d.mu.Lock()
282- require.Same(t, replacement, d.sessions[proxy.id])
283- d.mu.Unlock()
284- select {
285- case <-transport.closed:
286- t.Fatal("stale pointer timer closed the old transport")
287- default:
288- }
289-}
290-
291-type proxyResumeTestFactory struct {
292- mu sync.Mutex
293- transports []ports.Transport
294- callCount int
295- calls chan int
296-}
297-
298-func newProxyResumeTestFactory(transports ...ports.Transport) *proxyResumeTestFactory {
299- return &proxyResumeTestFactory{transports: transports, calls: make(chan int, proxyResumeMaxAttempts+2)}
300-}
301-
302-func (f *proxyResumeTestFactory) DialerForRemote(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error) {
303- f.mu.Lock()
304- f.callCount++
305- callNumber := f.callCount
306- var selected ports.Transport
307- for i, transport := range f.transports {
308- if transport != nil {
309- selected = transport
310- f.transports[i] = nil
311- break
312- }
313- }
314- f.mu.Unlock()
315- f.calls <- callNumber
316- if selected == nil {
317- return nil, errors.New("resume dial unavailable")
318- }
319- return proxyConstructionDialer{transport: selected}, nil
320-}
321-
322-func newProxyResumeHarness(t *testing.T, clock *proxyLifecycleClock, factory ports.RemoteDialerFactory) (*Daemon, *proxySession, *proxyTestTransport) {
323- t.Helper()
324- d := newTestDaemon(t, nil, clock)
325- d.remoteDialerFactory = factory
326- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize)
327- require.NoError(t, err)
328- initial := newProxyTestTransport()
329- proxy.mu.Lock()
330- proxy.transport = initial
331- proxy.linkGeneration = 1
332- proxy.mu.Unlock()
333- d.mu.Lock()
334- require.True(t, d.registerSessionLocked(proxy))
335- d.mu.Unlock()
336- return d, proxy, initial
337-}
338-
339-func TestProxyResumeBackoffIsFakeClockBoundedAndParksOffline(t *testing.T) {
340- clock := newProxyLifecycleClock()
341- factory := newProxyResumeTestFactory()
342- d, proxy, initial := newProxyResumeHarness(t, clock, factory)
343- initial.recv <- proxyRecv{err: io.ErrUnexpectedEOF}
344-
345- go d.runProxyLink(context.Background(), proxy)
346- for attempt := range proxyResumeMaxAttempts {
347- timer := awaitTestValue(t, clock.timers, "proxy resume did not arm fake-clock backoff")
348- require.Equal(t, proxyResumeBackoff(attempt), timer.delay)
349- timer.fire()
350- require.Equal(t, attempt+1, awaitTestValue(t, factory.calls, "proxy resume dial was not attempted"))
351- handshakeTimer := awaitTestValue(t, clock.timers, "proxy resume handshake did not arm its timeout")
352- require.Equal(t, proxyHandshakeTimeout, handshakeTimer.delay)
353- }
354- awaitTestCompletion(t, proxy.done, "proxy resume did not stop after its attempt cap")
355-
356- proxy.mu.Lock()
357- require.Nil(t, proxy.transport)
358- require.Equal(t, ports.LinkStateOffline, proxy.linkState)
359- proxy.mu.Unlock()
360- d.mu.Lock()
361- require.Same(t, proxy, d.sessions[proxy.id], "exhaustion must park the proxy instead of unregistering it")
362- d.mu.Unlock()
363- select {
364- case timer := <-clock.timers:
365- t.Fatalf("attempt cap armed an extra retry: %v", timer.delay)
366- default:
367- }
368-}
369-
370-func TestProxyResumeCancellationDuringBackoffDoesNotRedial(t *testing.T) {
371- clock := newProxyLifecycleClock()
372- factory := newProxyResumeTestFactory()
373- d, proxy, initial := newProxyResumeHarness(t, clock, factory)
374- initial.recv <- proxyRecv{err: io.EOF}
375- ctx, cancel := context.WithCancel(context.Background())
376- go d.runProxyLink(ctx, proxy)
377- _ = awaitTestValue(t, clock.timers, "proxy resume did not arm backoff")
378- cancel()
379- awaitTestCompletion(t, proxy.done, "canceled proxy resume did not stop")
380- select {
381- case <-factory.calls:
382- t.Fatal("canceled backoff dialed a replacement link")
383- default:
384- }
385-}
386-
387-func TestProxyResumeBackoffResetsAfterStableConnection(t *testing.T) {
388- clock := newProxyLifecycleClock()
389- resumed := newProxyTestTransport()
390- factory := newProxyResumeTestFactory(resumed)
391- d, proxy, initial := newProxyResumeHarness(t, clock, factory)
392- initial.recv <- proxyRecv{err: io.EOF}
393- ctx, cancel := context.WithCancel(context.Background())
394- defer cancel()
395- go d.runProxyLink(ctx, proxy)
396-
397- first := awaitTestValue(t, clock.timers, "initial resume did not arm backoff")
398- require.Equal(t, proxyResumeInitialBackoff, first.delay)
399- first.fire()
400- _ = awaitTestValue(t, factory.calls, "stable resume did not dial")
401- handshakeTimer := awaitTestValue(t, clock.timers, "stable resume handshake did not arm its timeout")
402- require.Equal(t, proxyHandshakeTimeout, handshakeTimer.delay)
403- _ = requireProxyHello(t, resumed)
404- resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume)}
405- require.Eventually(t, func() bool {
406- proxy.mu.Lock()
407- defer proxy.mu.Unlock()
408- return proxy.transport == resumed && proxy.resumeToken == 2
409- }, time.Second, time.Millisecond)
410- resumed.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}}
411- _ = awaitFrame(t, resumed.sent, ports.MsgPong)
412-
413- clock.Advance(proxyResumeStableDuration)
414- resumed.recv <- proxyRecv{err: io.EOF}
415- second := awaitTestValue(t, clock.timers, "stable link loss did not arm another backoff")
416- require.Equal(t, proxyResumeInitialBackoff, second.delay, "stable connection must reset exponential backoff")
417- cancel()
418- awaitTestCompletion(t, proxy.done, "stable-reset proxy did not stop after cancellation")
419-}
420-
421-func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) {
422- clock := newProxyLifecycleClock()
423- d, proxy, transport := newRegisteredLifecycleProxy(t, clock)
424-
425- tests := []struct {
426- state ports.LinkState
427- want string
428- }{
429- {ports.LinkStateConnected, proxy.name},
430- {ports.LinkStateDegraded, proxy.name + " [degraded]"},
431- {ports.LinkStateProbing, proxy.name + " [probing]"},
432- {ports.LinkStateOffline, proxy.name + " [offline]"},
433- {ports.LinkStateDead, proxy.name + " [dead]"},
434- }
435- for _, test := range tests {
436- require.True(t, d.updateProxyLinkState(proxy, 1, transport, test.state))
437- require.Equal(t, test.want, proxy.statusSegments(false).session)
438- }
439- require.False(t, d.updateProxyLinkState(proxy, 2, transport, ports.LinkStateConnected))
440- require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session)
441-}
442-
443-func TestIncomingDirectAttachTerminatesRemoteAttachment(t *testing.T) {
444- d := newTestDaemon(t, nil, newProxyLifecycleClock())
445- sess := &session{sessionCore: sessionCore{id: "work", name: "work"}}
446- oldTransport := newProxyTestTransport()
447- old := &attachedClient{tr: oldTransport, output: newOutputStateStream()}
448- old.initOverlays()
449- old.setSession(sess)
450- sess.registerAttachment(old)
451- d.mu.Lock()
452- require.True(t, d.registerSessionLocked(sess))
453- d.mu.Unlock()
454- d.attachCoordinator(sess, old, true)
455-
456- newTransport := newProxyTestTransport()
457- next := &attachedClient{tr: newTransport, output: newOutputStateStream()}
458- next.initOverlays()
459- result, err := d.transitionAttachment(attachmentTransitionRequest{
460- target: sess, next: next,
461-
462- expectedTransport: next.transportSnapshot(), ready: true,
463- })
464- require.NoError(t, err)
465- require.NotNil(t, result.published.ac)
466- require.Contains(t, sess.snapshotAttachments(), next)
467- require.Same(t, sess, old.currentAttachmentSession())
468-
469- d.deferAttachmentTransitionCleanups(result)
470- d.waitNotifies()
471- select {
472- case <-oldTransport.sent:
473- t.Fatal("publishing an attachment displaced the existing connection")
474- default:
475- }
476- select {
477- case <-oldTransport.closed:
478- t.Fatal("publishing an attachment closed the existing connection")
479- default:
480- }
481-}
482-
483-func TestProxyReplacedIsTerminalExpired(t *testing.T) {
484- clock := newProxyLifecycleClock()
485- d, proxy, transport := newRegisteredLifecycleProxy(t, clock)
486-
487- require.True(t, d.markProxyReplaced(proxy, 1, transport))
488- proxy.mu.Lock()
489- require.True(t, proxy.expired)
490- proxy.mu.Unlock()
491- require.Equal(t, proxy.name+" [expired]", proxy.statusSegments(false).session)
492- require.False(t, d.updateProxyLinkState(proxy, 1, transport, ports.LinkStateConnected), "replacement expiry must reject later link recovery")
493-}
494-
495-func TestRemoteProxyPickerUsesExplicitExpiredLifecycleState(t *testing.T) {
496- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
497- spoofed := remoteProxyPickerView(key, sessionView{name: key.Display() + " [expired]", tabCount: 1})
498- require.Equal(t, key.Display(), spoofed.Name)
499- require.Equal(t, picker.RemoteFresh, spoofed.RemoteAvailability)
500- require.True(t, spoofed.RemoteAttachReady)
501-
502- expired := remoteProxyPickerView(key, sessionView{name: "unrelated presentation", tabCount: 1, expired: true})
503- require.Equal(t, key.Display()+" [expired]", expired.Name)
504- require.Equal(t, picker.RemoteStale, expired.RemoteAvailability)
505- require.False(t, expired.RemoteAttachReady)
506-}
507-
508-func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T) {
509- clock := newProxyLifecycleClock()
510- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
511- d.clock = clock
512- coordinator := d.attachCoordinator(local, ac, true)
513- localToken := attachmentToken(local, ac, ac.transport())
514- localToken.lease = coordinator.attachmentLease(ac)
515-
516- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
517- proxy, err := newProxySession(key, ac.size)
518- require.NoError(t, err)
519- transport := &proxyLifecycleTransport{d: d, proxy: proxy, closed: make(chan struct{})}
520- proxy.transport = transport
521- proxy.linkGeneration = 1
522- factory := newProxyConstructionFactory(newProxyTestTransport())
523- d.remoteDialerFactory = factory
524- d.mu.Lock()
525- require.True(t, d.registerSessionLocked(proxy))
526- d.mu.Unlock()
527-
528- toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
529- source: local, target: proxy, next: ac,
530-
531- expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
532- action: "replaced picker regression", ready: true,
533- })
534- require.NoError(t, err)
535-
536- result, err := d.handleLinkFrame(proxy, proxy.linkGeneration, ports.Frame{
537- Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced}),
538- })
539- require.NoError(t, err)
540- require.Equal(t, proxyLinkReplaced, result)
541- require.True(t, d.markProxyReplaced(proxy, proxy.linkGeneration, transport))
542-
543- _, err = d.transitionAttachment(attachmentTransitionRequest{
544- source: proxy, target: local, next: ac,
545-
546- expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
547- action: "replaced picker regression", ready: true,
548- })
549- require.NoError(t, err)
550- require.Same(t, local, ac.currentAttachmentSession())
551- proxy.mu.Lock()
552- warm := proxy.warm
553- proxy.mu.Unlock()
554- require.NotNil(t, warm, "switch-away did not retain warm expiry")
555- warmTimer, ok := warm.timer.(*proxyLifecycleTimer)
556- require.True(t, ok)
557- require.Equal(t, proxyWarmDuration, warmTimer.delay)
558-
559- views, _ := d.pickerViews(local, nil)
560- var expiredView picker.SessionView
561- for _, view := range views {
562- if view.RemoteKey != nil && *view.RemoteKey == key {
563- expiredView = view
564- break
565- }
566- }
567- require.Equal(t, key.ID(), expiredView.ID, "exact registered proxy was absent from unified picker")
568- require.Equal(t, key.Display()+" [expired]", expiredView.Name)
569- require.Equal(t, picker.RemoteStale, expiredView.RemoteAvailability)
570- require.Equal(t, "expired", expiredView.RemoteDetail)
571- require.False(t, expiredView.RemoteAttachReady)
572- require.Equal(t, key.Display()+" [expired]", proxy.lifecycleDisplayName())
573-
574- d.enterPicker(local, ac)
575- forced := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{
576- Session: key.ID(), RemoteKey: &key,
577- })
578- ac.overlays.pickerMu.Lock()
579- ac.overlays.picker = forced
580- cursor, cursorOK := forced.Cursor()
581- _, selectable := forced.Selected()
582- ac.overlays.pickerMu.Unlock()
583- require.True(t, cursorOK)
584- require.Equal(t, key, *cursor.RemoteKey)
585- require.False(t, selectable, "terminal proxy row must not be activatable")
586- d.handlePickerInput(ac, []byte("\r"))
587- require.False(t, ac.overlays.pickerActive())
588- require.Same(t, local, ac.currentAttachmentSession(), "picker activation reattached the terminal proxy")
589-
590- token := attachmentToken(local, ac, ac.transport())
591- effect, admitted := ac.beginAttachmentEffect(token)
592- require.True(t, admitted)
593- token.effect = effect
594- err = d.switchToTargetForAttachment(token, cursor, sessionHandoffGuard{}, "replaced picker regression")
595- require.Error(t, err)
596- require.Same(t, local, ac.currentAttachmentSession(), "defensive activation reused the terminal proxy")
597-
598- reopened, err := d.openProxySession(context.Background(), key, ac.size)
599- require.Error(t, err)
600- require.Nil(t, reopened)
601- require.Equal(t, 0, factory.callCount(), "terminal proxy triggered an automatic IntentAttach/redial")
602- d.mu.Lock()
603- require.Same(t, proxy, d.sessions[key.ID()], "terminal proxy registration changed before warm expiry")
604- d.mu.Unlock()
605-
606- proxy.mu.Lock()
607- require.Same(t, warm, proxy.warm)
608- proxy.mu.Unlock()
609- warmTimer.fire()
610- awaitTestCompletion(t, warm.done, "terminal proxy warm expiry did not complete")
611-}
612-
613-func TestDormantProxyReplacedRetainsAutomaticWarmExpiry(t *testing.T) {
614- clock := newProxyLifecycleClock()
615- d, proxy, _ := newRegisteredLifecycleProxy(t, clock)
616- link := newProxyKillControlTransport(d, proxy)
617- proxy.mu.Lock()
618- proxy.transport = link
619- proxy.mu.Unlock()
620-
621- require.True(t, d.armProxyWarm(proxy))
622- timer := awaitTestValue(t, clock.timers, "dormant proxy did not arm its warm deadline")
623- proxy.mu.Lock()
624- token := proxy.warm
625- generation := proxy.generation
626- proxy.mu.Unlock()
627- require.NotNil(t, token)
628- require.Equal(t, proxyWarmDuration, timer.delay)
629-
630- link.recv <- proxyKillRecv{frame: ports.Frame{
631- Type: ports.MsgDetached,
632- Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced}),
633- }}
634- go d.runProxyLink(context.Background(), proxy)
635- awaitTestCompletion(t, proxy.done, "replacement did not terminate the proxy link")
636-
637- proxy.mu.Lock()
638- require.True(t, proxy.expired)
639- require.Same(t, token, proxy.warm, "terminal replacement cleared the only expiry capability")
640- require.Greater(t, proxy.generation, generation, "terminal replacement did not invalidate stale lifecycle observers")
641- require.Equal(t, proxy.generation, token.generation, "preserved expiry capability was not rekeyed to the terminal generation")
642- proxy.mu.Unlock()
643- select {
644- case extra := <-clock.timers:
645- t.Fatalf("replacement unexpectedly rearmed the original deadline: %v", extra.delay)
646- default:
647- }
648-
649- timer.fire()
650- awaitTestCompletion(t, token.done, "original warm deadline did not complete")
651- d.mu.Lock()
652- require.NotContains(t, d.sessions, proxy.id, "exact replaced proxy was not automatically unregistered")
653- d.mu.Unlock()
654-}
655-
656-type proxyKillRecv struct {
657- frame ports.Frame
658- err error
659-}
660-
661-type proxyKillControlTransport struct {
662- d *Daemon
663- proxy *proxySession
664- sent chan ports.Frame
665- recv chan proxyKillRecv
666- recvLocksChecked chan struct{}
667- closed chan struct{}
668- close sync.Once
669- recvCheck sync.Once
670- sendErr error
671-}
672-
673-func newProxyKillControlTransport(d *Daemon, proxy *proxySession) *proxyKillControlTransport {
674- return &proxyKillControlTransport{
675- d: d, proxy: proxy, sent: make(chan ports.Frame, 2), recv: make(chan proxyKillRecv, 1),
676- recvLocksChecked: make(chan struct{}), closed: make(chan struct{}),
677- }
678-}
679-
680-func (t *proxyKillControlTransport) locksAvailable() bool {
681- daemonUnlocked := t.d.mu.TryLock()
682- if daemonUnlocked {
683- t.d.mu.Unlock()
684- }
685- coreUnlocked := t.proxy.sessionCore.mu.TryLock()
686- if coreUnlocked {
687- t.proxy.sessionCore.mu.Unlock()
688- }
689- proxyUnlocked := t.proxy.mu.TryLock()
690- if proxyUnlocked {
691- t.proxy.mu.Unlock()
692- }
693- catalogUnlocked := t.d.remoteCatalog.mu.TryLock()
694- if catalogUnlocked {
695- t.d.remoteCatalog.mu.Unlock()
696- }
697- return daemonUnlocked && coreUnlocked && proxyUnlocked && catalogUnlocked
698-}
699-
700-func (t *proxyKillControlTransport) Send(frame ports.Frame) error {
701- if !t.locksAvailable() {
702- return errors.New("architecture lock held during control send")
703- }
704- t.sent <- frame
705- return t.sendErr
706-}
707-
708-func (t *proxyKillControlTransport) Recv() (ports.Frame, error) {
709- if !t.locksAvailable() {
710- return ports.Frame{}, errors.New("architecture lock held during control receive")
711- }
712- t.recvCheck.Do(func() { close(t.recvLocksChecked) })
713- select {
714- case result := <-t.recv:
715- return result.frame, result.err
716- case <-t.closed:
717- return ports.Frame{}, io.EOF
718- }
719-}
720-
721-func (t *proxyKillControlTransport) Close() error {
722- if !t.locksAvailable() {
723- return errors.New("architecture lock held during control close")
724- }
725- t.close.Do(func() { close(t.closed) })
726- return nil
727-}
728-
729-type proxyKillBlockedSendTransport struct {
730- *proxyKillControlTransport
731- started chan struct{}
732- release chan struct{}
733- finished chan struct{}
734- start sync.Once
735- finish sync.Once
736-}
737-
738-func (t *proxyKillBlockedSendTransport) Send(ports.Frame) error {
739- if !t.locksAvailable() {
740- return errors.New("architecture lock held during blocked control send")
741- }
742- t.start.Do(func() { close(t.started) })
743- <-t.release
744- t.finish.Do(func() { close(t.finished) })
745- return nil
746-}
747-
748-type proxyKillDialer struct {
749- d *Daemon
750- proxy *proxySession
751- transport ports.Transport
752- err error
753-}
754-
755-func (d proxyKillDialer) Dial(ctx context.Context) (ports.Transport, error) {
756- if ctx == nil {
757- return nil, errors.New("nil kill context")
758- }
759- checker := newProxyKillControlTransport(d.d, d.proxy)
760- if !checker.locksAvailable() {
761- return nil, errors.New("architecture lock held during control dial")
762- }
763- return d.transport, d.err
764-}
765-
766-type proxyKillDialerFactory struct {
767- d *Daemon
768- proxy *proxySession
769- dialer ports.Dialer
770- err error
771- targets chan domain.RemoteSessionKey
772-}
773-
774-func (f *proxyKillDialerFactory) DialerForRemote(target, session string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) {
775- checker := newProxyKillControlTransport(f.d, f.proxy)
776- if !checker.locksAvailable() {
777- return nil, errors.New("architecture lock held during dialer selection")
778- }
779- if f.targets != nil {
780- f.targets <- domain.RemoteSessionKey{Host: target, Name: session}
781- }
782- return f.dialer, f.err
783-}
784-
785-type proxyKillCache struct {
786- d *Daemon
787- proxy *proxySession
788- stores chan []ports.RemoteCatalogCacheEntry
789-}
790-
791-func (*proxyKillCache) Load() ([]ports.RemoteCatalogCacheEntry, error) { return nil, nil }
792-func (c *proxyKillCache) Store(entries []ports.RemoteCatalogCacheEntry) error {
793- checker := newProxyKillControlTransport(c.d, c.proxy)
794- if !checker.locksAvailable() {
795- return errors.New("architecture lock held during cache persistence")
796- }
797- copyEntries := make([]ports.RemoteCatalogCacheEntry, len(entries))
798- for i, entry := range entries {
799- copyEntries[i] = cloneRemoteCatalogEntry(entry)
800- }
801- c.stores <- copyEntries
802- return nil
803-}
804-
805-func newProxyKillHarness(t *testing.T) (*Daemon, *proxySession, *proxyLifecycleClock, *proxyKillControlTransport, *proxyKillCache) {
806- t.Helper()
807- clock := newProxyLifecycleClock()
808- d, proxy, _ := newRegisteredLifecycleProxy(t, clock)
809- control := newProxyKillControlTransport(d, proxy)
810- factory := &proxyKillDialerFactory{d: d, proxy: proxy, targets: make(chan domain.RemoteSessionKey, 1)}
811- factory.dialer = proxyKillDialer{d: d, proxy: proxy, transport: control}
812- d.remoteDialerFactory = factory
813- d.remoteTransportMode = ports.RemoteTransportUDP
814- cache := &proxyKillCache{d: d, proxy: proxy, stores: make(chan []ports.RemoteCatalogCacheEntry, 2)}
815- d.remoteCatalogCache = cache
816- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
817- Host: proxy.key.Host, FetchedAt: clock.Now(), Sessions: []ports.RemoteCatalogSession{
818- {Name: proxy.key.Name, State: "running"}, {Name: "other", State: "running"},
819- },
820- }})
821- return d, proxy, clock, control, cache
822-}
823-
824-func proxyKillGeneration(p *proxySession) uint64 {
825- p.mu.Lock()
826- defer p.mu.Unlock()
827- return p.generation
828-}
829-
830-func requireProxyKillState(t *testing.T, d *Daemon, proxy *proxySession, present bool) {
831- t.Helper()
832- d.mu.Lock()
833- require.Equal(t, present, d.sessions[proxy.id] == proxy)
834- d.mu.Unlock()
835- d.remoteCatalog.mu.Lock()
836- entry := d.remoteCatalog.cache[proxy.key.Host]
837- d.remoteCatalog.mu.Unlock()
838- found := false
839- for _, session := range entry.Sessions {
840- found = found || session.Name == proxy.key.Name
841- }
842- require.Equal(t, present, found)
843-}
844-
845-func TestProxyKillEOFSuccessRemovesExactLiveAndCacheWithoutLockedIO(t *testing.T) {
846- d, proxy, _, control, cache := newProxyKillHarness(t)
847- proxy.mu.Lock()
848- liveTransport, ok := proxy.transport.(*proxyLifecycleTransport)
849- liveGeneration := proxy.generation
850- proxy.mu.Unlock()
851- require.True(t, ok)
852- control.recv <- proxyKillRecv{err: fmt.Errorf("ssh stream closed: %w", io.EOF)}
853-
854- require.NoError(t, d.killRemoteProxy(context.Background(), proxy, liveGeneration))
855- frame := awaitTestValue(t, control.sent, "remote control kill was not sent")
856- require.Equal(t, ports.MsgKill, frame.Type)
857- kill, err := ports.UnmarshalKill(frame.Payload)
858- require.NoError(t, err)
859- require.Equal(t, ports.Kill{Name: proxy.key.Name}, kill)
860- requireProxyKillState(t, d, proxy, false)
861- awaitTestCompletion(t, liveTransport.closed, "successful remote kill did not close the exact live transport generation")
862- require.True(t, liveTransport.unlocked, "exact live transport close ran under an architecture lock")
863- proxy.mu.Lock()
864- require.Greater(t, proxy.generation, liveGeneration)
865- require.Same(t, liveTransport, proxy.transport, "teardown must not substitute a different link generation before closing it")
866- proxy.mu.Unlock()
867- stored := awaitTestValue(t, cache.stores, "successful remote kill did not persist cache removal")
868- require.Len(t, stored, 1)
869- require.Equal(t, []ports.RemoteCatalogSession{{Name: "other", State: "running"}}, stored[0].Sessions)
870-}
871-
872-func TestProxyKillFailuresPreserveLiveAndCache(t *testing.T) {
873- remoteFailure := ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrInternal, Text: "no"})}
874- tests := []struct {
875- name string
876- setup func(*Daemon, *proxySession, *proxyKillControlTransport)
877- }{
878- {name: "dialer selection", setup: func(d *Daemon, proxy *proxySession, _ *proxyKillControlTransport) {
879- d.remoteDialerFactory = &proxyKillDialerFactory{d: d, proxy: proxy, err: errors.New("factory")}
880- }},
881- {name: "dial", setup: func(d *Daemon, proxy *proxySession, _ *proxyKillControlTransport) {
882- d.remoteDialerFactory = &proxyKillDialerFactory{d: d, proxy: proxy, dialer: proxyKillDialer{d: d, proxy: proxy, err: errors.New("dial")}}
883- }},
884- {name: "send", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) {
885- control.sendErr = errors.New("send")
886- }},
887- {name: "receive error", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) {
888- control.recv <- proxyKillRecv{err: errors.New("truncated control reply")}
889- }},
890- {name: "remote error", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) {
891- control.recv <- proxyKillRecv{frame: remoteFailure}
892- }},
893- {name: "unexpected reply", setup: func(_ *Daemon, _ *proxySession, control *proxyKillControlTransport) {
894- control.recv <- proxyKillRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
895- }},
896- }
897- for _, test := range tests {
898- t.Run(test.name, func(t *testing.T) {
899- d, proxy, _, control, cache := newProxyKillHarness(t)
900- test.setup(d, proxy, control)
901- require.Error(t, d.killRemoteProxy(context.Background(), proxy, proxyKillGeneration(proxy)))
902- requireProxyKillState(t, d, proxy, true)
903- select {
904- case <-cache.stores:
905- t.Fatal("failed remote kill persisted a cache mutation")
906- default:
907- }
908- })
909- }
910-}
911-
912-func TestProxyKillTimeoutPreservesState(t *testing.T) {
913- d, proxy, clock, _, cache := newProxyKillHarness(t)
914- done := make(chan error, 1)
915- go func() { done <- d.killRemoteProxy(context.Background(), proxy, proxyKillGeneration(proxy)) }()
916- timer := awaitTestValue(t, clock.timers, "remote kill did not arm a bounded timeout")
917- require.Equal(t, proxyKillTimeout, timer.delay)
918- timer.fire()
919- require.ErrorIs(t, awaitTestValue(t, done, "remote kill timeout did not unblock receive"), errProxyKillTimeout)
920- requireProxyKillState(t, d, proxy, true)
921- select {
922- case <-cache.stores:
923- t.Fatal("timed out remote kill persisted a cache mutation")
924- default:
925- }
926-}
927-
928-func TestProxyKillBlockedSendReturnsAtBoundWithoutMutatingState(t *testing.T) {
929- d, proxy, clock, control, cache := newProxyKillHarness(t)
930- blocked := &proxyKillBlockedSendTransport{
931- proxyKillControlTransport: control,
932- started: make(chan struct{}), release: make(chan struct{}), finished: make(chan struct{}),
933- }
934- defer func() {
935- close(blocked.release)
936- awaitTestCompletion(t, blocked.finished, "abandoned send did not finish after test release")
937- }()
938- d.remoteDialerFactory = &proxyKillDialerFactory{
939- d: d, proxy: proxy,
940- dialer: proxyKillDialer{d: d, proxy: proxy, transport: blocked},
941- }
942-
943- d.mu.Lock()
944- beforeLive := d.sessions[proxy.id]
945- beforeClosing := d.closing
946- d.mu.Unlock()
947- proxy.mu.Lock()
948- beforeGeneration, beforeExpired, beforeWarm, beforeTransport := proxy.generation, proxy.expired, proxy.warm, proxy.transport
949- proxy.mu.Unlock()
950- d.remoteCatalog.mu.Lock()
951- beforeCache := cloneRemoteCatalogEntry(d.remoteCatalog.cache[proxy.key.Host])
952- d.remoteCatalog.mu.Unlock()
953-
954- done := make(chan error, 1)
955- go func() { done <- d.killRemoteProxy(context.Background(), proxy, beforeGeneration) }()
956- awaitTestCompletion(t, blocked.started, "remote kill did not enter adversarial Send")
957- timer := awaitTestValue(t, clock.timers, "remote kill did not arm a bounded timeout")
958- require.Equal(t, proxyKillTimeout, timer.delay)
959- timer.fire()
960- require.ErrorIs(t, awaitTestValue(t, done, "remote kill waited for Send after its bound"), errProxyKillTimeout)
961-
962- d.mu.Lock()
963- require.Same(t, beforeLive, d.sessions[proxy.id])
964- require.Equal(t, beforeClosing, d.closing)
965- d.mu.Unlock()
966- proxy.mu.Lock()
967- require.Equal(t, beforeGeneration, proxy.generation)
968- require.Equal(t, beforeExpired, proxy.expired)
969- require.Same(t, beforeWarm, proxy.warm)
970- require.Same(t, beforeTransport, proxy.transport)
971- proxy.mu.Unlock()
972- d.remoteCatalog.mu.Lock()
973- require.Equal(t, beforeCache, d.remoteCatalog.cache[proxy.key.Host])
974- d.remoteCatalog.mu.Unlock()
975- select {
976- case <-cache.stores:
977- t.Fatal("blocked remote kill persisted a cache mutation")
978- default:
979- }
980- select {
981- case <-blocked.finished:
982- t.Fatal("adversarial Send unexpectedly returned when transport closed")
983- default:
984- }
985-}
986-
987-func TestProxyKillStaleSuccessPreservesReplacementGeneration(t *testing.T) {
988- tests := []struct {
989- name string
990- win func(*Daemon, *proxySession)
991- }{
992- {name: "reattach generation", win: func(d *Daemon, proxy *proxySession) {
993- d.mu.Lock()
994- proxy.sessionCore.mu.Lock()
995- proxy.mu.Lock()
996- proxy.generation++
997- proxy.mu.Unlock()
998- proxy.sessionCore.mu.Unlock()
999- d.mu.Unlock()
1000- }},
1001- {name: "replacement pointer", win: func(d *Daemon, proxy *proxySession) {
1002- replacement, err := newProxySession(proxy.key, defaultSize)
1003- require.NoError(t, err)
1004- d.mu.Lock()
1005- d.sessions[proxy.id] = replacement
1006- d.mu.Unlock()
1007- }},
1008- }
1009- for _, test := range tests {
1010- t.Run(test.name, func(t *testing.T) {
1011- d, proxy, _, control, cache := newProxyKillHarness(t)
1012- generation := proxyKillGeneration(proxy)
1013- done := make(chan error, 1)
1014- go func() { done <- d.killRemoteProxy(context.Background(), proxy, generation) }()
1015- _ = awaitTestValue(t, control.sent, "remote kill did not reach the control transport")
1016- awaitTestCompletion(t, control.recvLocksChecked, "remote kill did not verify unlocked architecture before generation replacement")
1017- test.win(d, proxy)
1018- control.recv <- proxyKillRecv{err: io.EOF}
1019- require.ErrorIs(t, awaitTestValue(t, done, "stale remote kill did not finish"), errProxyKillGeneration)
1020- d.remoteCatalog.mu.Lock()
1021- entry := d.remoteCatalog.cache[proxy.key.Host]
1022- d.remoteCatalog.mu.Unlock()
1023- require.Len(t, entry.Sessions, 2, "stale success removed cached discovery")
1024- select {
1025- case <-cache.stores:
1026- t.Fatal("stale remote success persisted a cache mutation")
1027- default:
1028- }
1029- })
1030- }
1031-}
1032-
1033-func TestProxyKillCurrentLinkDetachedCanRaceControlEOF(t *testing.T) {
1034- d, proxy, _, control, _ := newProxyKillHarness(t)
1035- generation := proxyKillGeneration(proxy)
1036- done := make(chan error, 1)
1037- go func() { done <- d.killRemoteProxy(context.Background(), proxy, generation) }()
1038- _ = awaitTestValue(t, control.sent, "remote kill did not reach the control transport")
1039- result, err := d.handleLinkFrame(proxy, proxy.linkGeneration, ports.Frame{
1040- Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}),
1041- })
1042- require.NoError(t, err)
1043- require.Equal(t, proxyLinkStop, result)
1044- control.recv <- proxyKillRecv{err: io.EOF}
1045- require.NoError(t, awaitTestValue(t, done, "remote kill did not accept EOF after current-link detach"))
1046- requireProxyKillState(t, d, proxy, false)
1047-}
1048-
1049-func TestProxyKillConfirmationRequiresExactRemoteDisplay(t *testing.T) {
1050- p, release := newBlockingPTY(t)
1051- defer release()
1052- d, local, ac, _ := newManualSessionWithPTYs(t, p)
1053- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, defaultSize)
1054- require.NoError(t, err)
1055- d.mu.Lock()
1056- require.True(t, d.registerSessionLocked(proxy))
1057- d.mu.Unlock()
1058-
1059- target := picker.Target{Session: proxy.id, RemoteKey: &proxy.key, TabIndex: -1}
1060- require.True(t, d.enterRemoteKillConfirmation(local, ac, target))
1061- ac.overlays.promptMu.Lock()
1062- require.Equal(t, " Type work@arch to kill ", ac.overlays.prompt.Title())
1063- submit := ac.overlays.promptTransitionSubmit
1064- ac.overlays.promptMu.Unlock()
1065- require.Error(t, submit("work", attachmentConnectionToken{}))
1066- d.mu.Lock()
1067- require.Same(t, proxy, d.sessions[proxy.id])
1068- d.mu.Unlock()
1069-}
1070-
1071-func TestProxyKillConfirmationWorksWhileAttachedToProxyAndCancelsWithoutIO(t *testing.T) {
1072- d, proxy, ac, _, _, _, _ := newProxyRoleDetachFixture(t)
1073- target := picker.Target{Session: proxy.id, RemoteKey: &proxy.key, TabIndex: -1}
1074- require.True(t, d.enterRemoteKillConfirmation(proxy, ac, target))
1075- d.handlePromptInput(ac, []byte("\x03"))
1076- require.False(t, ac.overlays.promptActive())
1077- d.mu.Lock()
1078- require.Same(t, proxy, d.sessions[proxy.id])
1079- d.mu.Unlock()
1080-}
1081-
1082-type proxyConstructionDialer struct{ transport ports.Transport }
1083-
1084-func (d proxyConstructionDialer) Dial(context.Context) (ports.Transport, error) {
1085- return d.transport, nil
1086-}
1087-
1088-type proxyConstructionFactory struct {
1089- mu sync.Mutex
1090- transports []ports.Transport
1091- calls chan int
1092- secondAttempt chan struct{}
1093- releaseSecond chan struct{}
1094- secondCallOnce sync.Once
1095-}
1096-
1097-func newProxyConstructionFactory(transports ...ports.Transport) *proxyConstructionFactory {
1098- return &proxyConstructionFactory{
1099- transports: transports, calls: make(chan int, len(transports)+1),
1100- secondAttempt: make(chan struct{}), releaseSecond: make(chan struct{}),
1101- }
1102-}
1103-
1104-func (f *proxyConstructionFactory) DialerForRemote(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error) {
1105- f.mu.Lock()
1106- call := len(f.transports)
1107- for i := range f.transports {
1108- if f.transports[i] != nil {
1109- call = i
1110- break
1111- }
1112- }
1113- if call == len(f.transports) {
1114- f.mu.Unlock()
1115- return nil, errors.New("unexpected extra proxy construction")
1116- }
1117- transport := f.transports[call]
1118- f.transports[call] = nil
1119- f.mu.Unlock()
1120- f.calls <- call + 1
1121- if call == 1 {
1122- f.secondCallOnce.Do(func() { close(f.secondAttempt) })
1123- <-f.releaseSecond
1124- }
1125- return proxyConstructionDialer{transport: transport}, nil
1126-}
1127-
1128-func (f *proxyConstructionFactory) callCount() int {
1129- f.mu.Lock()
1130- defer f.mu.Unlock()
1131- count := 0
1132- for _, transport := range f.transports {
1133- if transport == nil {
1134- count++
1135- }
1136- }
1137- return count
1138-}
1139-
1140-type proxyConstructionWaitContext struct {
1141- context.Context
1142- observed chan struct{}
1143- once sync.Once
1144-}
1145-
1146-func (c *proxyConstructionWaitContext) Done() <-chan struct{} {
1147- c.once.Do(func() { close(c.observed) })
1148- return c.Context.Done()
1149-}
1150-
1151-type proxyOpenResult struct {
1152- proxy *proxySession
1153- err error
1154-}
1155-
1156-func openProxyForTest(d *Daemon, ctx context.Context, key domain.RemoteSessionKey) <-chan proxyOpenResult {
1157- result := make(chan proxyOpenResult, 1)
1158- go func() {
1159- proxy, err := d.openProxySession(ctx, key, defaultSize)
1160- result <- proxyOpenResult{proxy: proxy, err: err}
1161- }()
1162- return result
1163-}
1164-
1165-func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
1166- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1167- first := newProxyTestTransport()
1168- second := newProxyTestTransport()
1169- second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityResume)}
1170- factory := newProxyConstructionFactory(first, second)
1171- d := newProxyTestDaemon(t, factory, nil)
1172-
1173- leader := openProxyForTest(d, context.Background(), key)
1174- require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
1175- _ = requireProxyHello(t, first)
1176-
1177- waitCtx, cancelWait := context.WithCancel(t.Context())
1178- defer cancelWait()
1179- observed := &proxyConstructionWaitContext{Context: waitCtx, observed: make(chan struct{})}
1180- waiter := openProxyForTest(d, observed, key)
1181-
1182- secondStarted := false
1183- select {
1184- case <-observed.observed:
1185- // The waiter reached the cancellable construction reservation.
1186- case <-factory.secondAttempt:
1187- // This is the adversarial pre-fix path: the later remote IntentAttach
1188- // would replace the leader before its otherwise-valid reply arrives.
1189- secondStarted = true
1190- close(factory.releaseSecond)
1191- _ = requireProxyHello(t, second)
1192- }
1193-
1194- first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
1195- leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
1196- waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish")
1197- require.NoError(t, leaderResult.err)
1198- require.NoError(t, waiterResult.err)
1199- require.False(t, secondStarted, "a second same-key IntentAttach handshake started")
1200- require.Equal(t, 1, factory.callCount())
1201- require.Same(t, leaderResult.proxy, waiterResult.proxy, "all callers must receive the exact registered winner")
1202- d.mu.Lock()
1203- require.Same(t, leaderResult.proxy, d.sessions[key.ID()])
1204- d.mu.Unlock()
1205- stopProxy(t, leaderResult.proxy)
1206-}
1207-
1208-func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
1209- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1210- first := newProxyTestTransport()
1211- second := newProxyTestTransport()
1212- factory := newProxyConstructionFactory(first, second)
1213- d := newProxyTestDaemon(t, factory, nil)
1214-
1215- leader := openProxyForTest(d, context.Background(), key)
1216- require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
1217- _ = requireProxyHello(t, first)
1218-
1219- base, cancel := context.WithCancel(t.Context())
1220- observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})}
1221- waiter := openProxyForTest(d, observed, key)
1222- select {
1223- case <-observed.observed:
1224- case <-factory.secondAttempt:
1225- close(factory.releaseSecond)
1226- t.Fatal("cancelable waiter started another same-key handshake")
1227- }
1228- cancel()
1229- waiterResult := awaitTestValue(t, waiter, "canceled waiter did not finish")
1230- require.ErrorIs(t, waiterResult.err, context.Canceled)
1231- require.Nil(t, waiterResult.proxy)
1232- require.Equal(t, 1, factory.callCount())
1233-
1234- first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
1235- leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
1236- require.NoError(t, leaderResult.err)
1237- stopProxy(t, leaderResult.proxy)
1238-}
1239-
1240-type warmHookClock struct {
1241- timers chan *signalTimer
1242-
1243- mu sync.Mutex
1244- hook func()
1245-}
1246-
1247-func (c *warmHookClock) Now() time.Time { return time.Time{} }
1248-
1249-func (c *warmHookClock) NewTimer(d time.Duration) ports.Timer {
1250- timer := &signalTimer{ch: make(chan time.Time, 1), duration: d}
1251- c.mu.Lock()
1252- hook := c.hook
1253- c.hook = nil
1254- c.mu.Unlock()
1255- if hook != nil {
1256- hook()
1257- }
1258- if c.timers != nil {
1259- c.timers <- timer
1260- }
1261- return timer
1262-}
1263-
1264-func (c *warmHookClock) setHook(hook func()) {
1265- c.mu.Lock()
1266- c.hook = hook
1267- c.mu.Unlock()
1268-}
1269-
1270-func registerLifecycleProxy(t *testing.T, d *Daemon, key domain.RemoteSessionKey) *proxySession {
1271- t.Helper()
1272- p, err := newProxySession(key, domain.Size{Cols: 80, Rows: 24})
1273- require.NoError(t, err)
1274- d.mu.Lock()
1275- registered := d.registerSessionLocked(p)
1276- d.mu.Unlock()
1277- require.True(t, registered, "lifecycle fixture proxy was not published")
1278- return p
1279-}
1280-
1281-func newProxyLifecycleFixture(t *testing.T) (*Daemon, *proxySession, *warmHookClock) {
1282- t.Helper()
1283- clock := &warmHookClock{timers: make(chan *signalTimer, 4)}
1284- d := newProxyTestDaemon(t, nil, clock)
1285- return d, registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "work"}), clock
1286-}
1287-
1288-func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) {
1289- p.mu.Lock()
1290- defer p.mu.Unlock()
1291- return p.warm, p.generation
1292-}
1293-
1294-func setProxyLifecycleClient(p *proxySession, ac *attachedClient) {
1295- p.sessionCore.mu.Lock()
1296- p.registerAttachmentLocked(ac)
1297- p.sessionCore.mu.Unlock()
1298-}
1299-
1300-func proxyRegistered(d *Daemon, p *proxySession) bool {
1301- d.mu.Lock()
1302- defer d.mu.Unlock()
1303- return d.sessions[p.id] == p
1304-}
1305-
1306-func proxyExpired(p *proxySession) bool {
1307- p.mu.Lock()
1308- defer p.mu.Unlock()
1309- return p.expired
1310-}
1311-
1312-func armWarmTimer(t *testing.T, d *Daemon, p *proxySession, clock *warmHookClock) (*proxyWarmTimer, *signalTimer) {
1313- t.Helper()
1314- require.True(t, d.armProxyWarm(p), "warm timer was not published")
1315- timer := awaitTestValue(t, clock.timers, "warm timer was never created")
1316- token, _ := proxyWarmToken(p)
1317- require.NotNil(t, token, "published arm left no warm token")
1318- return token, timer
1319-}
1320-
1321-// TestArmProxyWarmKeepsExpiryPathWhenPublicationFails pins the two-phase arm:
1322-// a re-arm that loses its revalidation must leave the incumbent timer both
1323-// installed and current, otherwise a registered proxy is left with no path out
1324-// of the live registry.
1325-func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
1326- tests := []struct {
1327- name string
1328- race func(*Daemon, *proxySession) func()
1329- }{
1330- {
1331- name: "fresh attachment publishes during arm",
1332- race: func(_ *Daemon, p *proxySession) func() {
1333- ac := &attachedClient{}
1334- p.sessionCore.mu.Lock()
1335- p.sessionCore.registerAttachmentLocked(ac)
1336- p.sessionCore.mu.Unlock()
1337- return func() {
1338- p.sessionCore.mu.Lock()
1339- p.sessionCore.unregisterAttachmentLocked(ac)
1340- p.sessionCore.mu.Unlock()
1341- }
1342- },
1343- },
1344- {
1345- name: "daemon shutdown publishes during arm",
1346- race: func(d *Daemon, _ *proxySession) func() {
1347- d.mu.Lock()
1348- d.closing = true
1349- d.mu.Unlock()
1350- return func() {
1351- d.mu.Lock()
1352- d.closing = false
1353- d.mu.Unlock()
1354- }
1355- },
1356- },
1357- }
1358- for _, tt := range tests {
1359- t.Run(tt.name, func(t *testing.T) {
1360- d, p, clock := newProxyLifecycleFixture(t)
1361- token, timer := armWarmTimer(t, d, p, clock)
1362-
1363- var undo func()
1364- clock.setHook(func() { undo = tt.race(d, p) })
1365- require.False(t, d.armProxyWarm(p), "raced re-arm must not publish a replacement")
1366- awaitTestValue(t, clock.timers, "raced re-arm never reached its clock")
1367-
1368- retained, generation := proxyWarmToken(p)
1369- require.NotNil(t, retained, "failed re-arm stripped the proxy of its only expiry path")
1370- require.Same(t, token, retained, "failed re-arm replaced the armed expiry timer")
1371- require.Equal(t, generation, retained.generation, "retained timer no longer owns the lifecycle generation")
1372- require.True(t, proxyRegistered(d, p), "a failed re-arm must not unregister the proxy")
1373-
1374- require.NotNil(t, undo, "publication race did not run")
1375- undo()
1376- timer.ch <- time.Time{}
1377- awaitTestCompletion(t, token.done, "retained warm timer never ran to completion")
1378- require.False(t, proxyRegistered(d, p), "retained warm timer did not expire the dormant proxy")
1379- require.True(t, proxyExpired(p), "expiry did not publish terminal lifecycle state")
1380- awaitTestCompletion(t, d.done, "last proxy expiry did not complete the daemon")
1381- })
1382- }
1383-}
1384-
1385-func TestDetachProxyIfCurrentTransportArmsExpiredProxy(t *testing.T) {
1386- d, p, clock := newProxyLifecycleFixture(t)
1387- transport := newProxyTestTransport()
1388- t.Cleanup(func() { _ = transport.Close() })
1389- ac := &attachedClient{tr: transport, output: newOutputStateStream()}
1390- ac.setSession(p)
1391- setProxyLifecycleClient(p, ac)
1392- p.mu.Lock()
1393- p.expired = true
1394- p.mu.Unlock()
1395-
1396- require.True(t, d.detachProxyIfCurrentTransport(p, ac, ac.transportSnapshot()))
1397- awaitTestValue(t, clock.timers, "expired proxy detach did not arm a warm timer")
1398- token, generation := proxyWarmToken(p)
1399- require.NotNil(t, token, "expired headless proxy has no expiry path")
1400- require.Equal(t, generation, token.generation)
1401- require.Nil(t, ac.currentAttachmentSession())
1402-
1403- token.stop()
1404- awaitTestCompletion(t, token.done, "warm timer did not stop during cleanup")
1405-}
1406-
1407-func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
1408- t.Run("rekeys an already armed timer", func(t *testing.T) {
1409- d, p, clock := newProxyLifecycleFixture(t)
1410- transport := newProxyTestTransport()
1411- t.Cleanup(func() { _ = transport.Close() })
1412- generation, _ := p.installTransport(transport)
1413- token, timer := armWarmTimer(t, d, p, clock)
1414-
1415- require.True(t, d.markProxyReplaced(p, generation, transport))
1416- require.Empty(t, clock.timers, "replacement armed a second timer instead of rekeying")
1417-
1418- rekeyed, lifecycle := proxyWarmToken(p)
1419- require.Same(t, token, rekeyed, "replacement replaced the armed timer")
1420- require.Equal(t, lifecycle, rekeyed.generation, "replacement did not rekey the armed timer")
1421- require.True(t, proxyExpired(p))
1422- p.mu.Lock()
1423- state := p.linkState
1424- p.mu.Unlock()
1425- require.Equal(t, ports.LinkStateDead, state)
1426-
1427- timer.ch <- time.Time{}
1428- awaitTestCompletion(t, token.done, "rekeyed warm timer never ran to completion")
1429- require.False(t, proxyRegistered(d, p), "rekeyed warm timer did not expire the proxy")
1430- })
1431-
1432- t.Run("arms an expiry for a headless proxy", func(t *testing.T) {
1433- d, p, clock := newProxyLifecycleFixture(t)
1434- transport := newProxyTestTransport()
1435- t.Cleanup(func() { _ = transport.Close() })
1436- generation, _ := p.installTransport(transport)
1437-
1438- require.True(t, d.markProxyReplaced(p, generation, transport))
1439- timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")
1440- token, lifecycle := proxyWarmToken(p)
1441- require.NotNil(t, token, "replaced headless proxy has no expiry path")
1442- require.Equal(t, lifecycle, token.generation)
1443-
1444- timer.ch <- time.Time{}
1445- awaitTestCompletion(t, token.done, "warm timer never ran to completion")
1446- require.False(t, proxyRegistered(d, p))
1447- })
1448-
1449- t.Run("stale link generation publishes nothing", func(t *testing.T) {
1450- d, p, _ := newProxyLifecycleFixture(t)
1451- transport := newProxyTestTransport()
1452- t.Cleanup(func() { _ = transport.Close() })
1453- generation, _ := p.installTransport(transport)
1454-
1455- require.False(t, d.markProxyReplaced(p, generation-1, transport))
1456- require.False(t, proxyExpired(p))
1457- warm, _ := proxyWarmToken(p)
1458- require.Nil(t, warm, "a rejected replacement must not arm an expiry")
1459- })
1460-}
1461-
1462-func TestExpireWarmProxyCompletesDaemonOnlyOnLastSession(t *testing.T) {
1463- t.Run("last session completes the daemon", func(t *testing.T) {
1464- d, p, clock := newProxyLifecycleFixture(t)
1465- token, timer := armWarmTimer(t, d, p, clock)
1466-
1467- timer.ch <- time.Time{}
1468- awaitTestCompletion(t, token.done, "warm timer never ran to completion")
1469- awaitTestCompletion(t, p.done, "expiry did not finish the proxy")
1470- awaitTestCompletion(t, d.done, "last session expiry did not complete the daemon")
1471- require.False(t, proxyRegistered(d, p))
1472- require.True(t, proxyExpired(p))
1473- })
1474-
1475- t.Run("surviving session keeps the daemon running", func(t *testing.T) {
1476- d, p, clock := newProxyLifecycleFixture(t)
1477- survivor := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "other"})
1478- token, timer := armWarmTimer(t, d, p, clock)
1479-
1480- timer.ch <- time.Time{}
1481- awaitTestCompletion(t, token.done, "warm timer never ran to completion")
1482- require.False(t, proxyRegistered(d, p))
1483- require.True(t, proxyRegistered(d, survivor))
1484- select {
1485- case <-d.done:
1486- t.Fatal("daemon completed while a session was still registered")
1487- default:
1488- }
1489- })
1490-}
1491-
1492-// TestExpireWarmProxyIgnoresSupersededToken proves the expiry callback is exact:
1493-// a token that lost its lifecycle generation must publish no terminal state and
1494-// must not unregister the proxy.
1495-func TestExpireWarmProxyIgnoresSupersededToken(t *testing.T) {
1496- d, p, clock := newProxyLifecycleFixture(t)
1497- stale, _ := armWarmTimer(t, d, p, clock)
1498- current, currentTimer := armWarmTimer(t, d, p, clock)
1499- require.NotSame(t, stale, current, "re-arm did not publish a replacement timer")
1500- awaitTestCompletion(t, stale.done, "superseded warm timer was not canceled")
1501-
1502- require.False(t, d.expireWarmProxy(p, stale), "a superseded token expired the proxy")
1503- require.True(t, proxyRegistered(d, p))
1504- require.False(t, proxyExpired(p))
1505- installed, generation := proxyWarmToken(p)
1506- require.Same(t, current, installed)
1507- require.Equal(t, generation, installed.generation)
1508-
1509- currentTimer.ch <- time.Time{}
1510- awaitTestCompletion(t, current.done, "current warm timer never ran to completion")
1511- require.False(t, proxyRegistered(d, p))
1512-}
1513-
1514-func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedClient, *closeTrackingTransport, *warmHookClock, attachmentConnectionToken, *renderCoordinator) {
1515- t.Helper()
1516- d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1517- clock := &warmHookClock{timers: make(chan *signalTimer, 4)}
1518- d.clock = clock
1519- clientTransport := &closeTrackingTransport{}
1520- ac.replaceTransport(clientTransport)
1521- localCoordinator := d.attachCoordinator(local, ac, true)
1522- localToken := attachmentToken(local, ac, clientTransport)
1523- localToken.lease = localCoordinator.attachmentLease(ac)
1524- proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"})
1525- transition, err := d.transitionAttachment(attachmentTransitionRequest{
1526- source: local, target: proxy, next: ac,
1527-
1528- expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
1529- action: "proxy detach fixture", ready: true,
1530- })
1531- require.NoError(t, err)
1532- return d, proxy, ac, clientTransport, clock, transition.published, proxy.coordinator.Load()
1533-}
1534-
1535-func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {
1536- tests := []struct {
1537- name string
1538- detach func(*Daemon, attachmentConnectionToken, ports.Transport) bool
1539- }{
1540- {
1541- name: "explicit",
1542- detach: func(d *Daemon, token attachmentConnectionToken, _ ports.Transport) bool {
1543- effect, admitted := token.ac.beginAttachmentEffect(token)
1544- require.True(t, admitted)
1545- token.effect = effect
1546- return d.clientGoneForAttachment(token, true)
1547- },
1548- },
1549- {
1550- name: "send error",
1551- detach: func(d *Daemon, token attachmentConnectionToken, failed ports.Transport) bool {
1552- d.detachOnAttachmentSendError(token, failed)
1553- return token.ac.currentAttachmentSession() == nil
1554- },
1555- },
1556- }
1557- for _, tt := range tests {
1558- t.Run(tt.name, func(t *testing.T) {
1559- d, proxy, ac, tr, clock, token, coordinator := newProxyRoleDetachFixture(t)
1560- require.True(t, tt.detach(d, token, tr))
1561- require.Nil(t, ac.currentAttachmentSession())
1562- require.True(t, tr.Closed())
1563- require.Nil(t, coordinator.attachmentLease(ac), "coordinator must retire the detached client")
1564- var warmTimers []*signalTimer
1565- for len(warmTimers) == 0 {
1566- timer := awaitTestValue(t, clock.timers, "proxy detach did not arm a warm timer")
1567- if timer.duration == proxyWarmDuration {
1568- warmTimers = append(warmTimers, timer)
1569- }
1570- }
1571- select {
1572- case timer := <-clock.timers:
1573- if timer.duration == proxyWarmDuration {
1574- warmTimers = append(warmTimers, timer)
1575- }
1576- default:
1577- }
1578- require.Len(t, warmTimers, 1, "proxy detach must arm exactly one warm timer")
1579- warm, _ := proxyWarmToken(proxy)
1580- require.NotNil(t, warm)
1581- warm.stop()
1582- awaitTestCompletion(t, warm.done, "warm timer did not stop")
1583- })
1584- }
1585-}
1586-
1587-func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
1588- tests := []struct {
1589- name string
1590- stale func(*attachedClient, *attachmentConnectionToken) *closeTrackingTransport
1591- }{
1592- {
1593- name: "transport",
1594- stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport {
1595- current := &closeTrackingTransport{}
1596- ac.replaceTransport(current)
1597- return current
1598- },
1599- },
1600- {
1601- name: "role generation",
1602- stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport {
1603- ac.connectionGeneration.Add(1)
1604- return nil
1605- },
1606- },
1607- }
1608- for _, tt := range tests {
1609- t.Run(tt.name, func(t *testing.T) {
1610- d, proxy, ac, failed, clock, token, _ := newProxyRoleDetachFixture(t)
1611- current := tt.stale(ac, &token)
1612- d.detachOnAttachmentSendError(token, failed)
1613- require.Same(t, proxy, ac.currentAttachmentSession())
1614- if current != nil {
1615- require.False(t, current.Closed(), "stale transport error closed the rebound client")
1616- }
1617- select {
1618- case <-clock.timers:
1619- t.Fatal("stale proxy detach armed a warm timer")
1620- default:
1621- }
1622- })
1623- }
1624-}
D internal/usecase/daemon/proxy_link.go +0 -674
@@ -1,674 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "io"
8- "time"
9-
10- "github.com/bnema/vev/internal/ports"
11-)
12-
13-const (
14- proxyHandshakeTimeout = 15 * time.Second
15- proxyAttachedCommandTimeout = 10 * time.Second
16- proxyResumeInitialBackoff = 250 * time.Millisecond
17- proxyResumeMaxBackoff = 4 * time.Second
18- proxyResumeMaxAttempts = 5
19- proxyResumeStableDuration = 30 * time.Second
20-)
21-
22-var (
23- errProxyHandshakeTimeout = errors.New("proxy session: remote handshake timed out")
24- errProxyCommandTimeout = errors.New("proxy session: attached command timed out")
25- errProxyCommandUnavailable = errors.New("proxy session: attached command link is unavailable")
26- errProxyCommandGeneration = errors.New("proxy session: attached command link was replaced")
27- // errProxyLinkSend marks a reply that could not be written to the remote.
28- // The received frame was well formed, so the link is resumable rather than
29- // terminally broken.
30- errProxyLinkSend = errors.New("proxy session: link reply send failed")
31-)
32-
33-type proxyCommandOutcome struct {
34- result ports.CommandResult
35- err error
36-}
37-
38-type proxyCommandPending struct {
39- generation uint64
40- outcome chan proxyCommandOutcome
41-}
42-
43-type proxyRecvResult struct {
44- frame ports.Frame
45- err error
46-}
47-
48-type proxyLinkResult uint8
49-
50-const (
51- proxyLinkStop proxyLinkResult = iota
52- proxyLinkResume
53- proxyLinkReplaced
54-)
55-
56-// dialProxyHandshake installs a candidate transport, sends an ordinary Hello
57-// through the session's sole sender, and requires a matching Welcome. The
58-// candidate is never registry-visible.
59-func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, intent uint8) (err error) {
60- if parent == nil {
61- //nolint:contextcheck // nil is intentionally supported for direct internal callers; Background is the root fallback.
62- parent = context.Background()
63- }
64- ctx, timedOut, stop := d.proxyHandshakeContext(parent)
65- defer stop()
66- defer func() {
67- if err != nil {
68- err = proxyHandshakeContextError(parent, timedOut, err)
69- }
70- }()
71- if err := ctx.Err(); err != nil {
72- return err
73- }
74- dialer, err := d.remoteDialerFactory.DialerForRemote(p.key.Host, p.key.Name, d.remoteTransportMode, d.log)
75- if err != nil {
76- return fmt.Errorf("proxy session: select remote dialer: %w", err)
77- }
78- if dialer == nil {
79- return errors.New("proxy session: remote dialer factory returned nil dialer")
80- }
81- if err := ctx.Err(); err != nil {
82- return err
83- }
84- transport, err := dialer.Dial(ctx)
85- if err != nil {
86- return fmt.Errorf("proxy session: dial: %w", err)
87- }
88- if transport == nil {
89- return errors.New("proxy session: remote dialer returned nil transport")
90- }
91- if err := ctx.Err(); err != nil {
92- _ = transport.Close()
93- return err
94- }
95-
96- p.mu.Lock()
97- resumeToken := p.resumeToken
98- clientID := p.clientID
99- size := p.contentSize
100- p.mu.Unlock()
101- resume := intent == ports.IntentResume
102- if !resume {
103- resumeToken = 0
104- }
105- generation, previous := p.installTransport(transport)
106- if previous != nil && previous != transport {
107- _ = previous.Close()
108- }
109- failed := true
110- defer func() {
111- if failed {
112- p.retireTransport(generation, transport)
113- _ = transport.Close()
114- }
115- }()
116- stopTransport := watchProxyHandshakeTransport(ctx, transport)
117- defer stopTransport()
118- hello := ports.Hello{
119- Version: ports.ProtocolVersion,
120- Intent: intent,
121- ClientID: clientID,
122- ResumeToken: resumeToken,
123- Name: p.key.Name,
124- Size: size,
125- MaxOutputInFlight: proxyOutputWindow(transport),
126- }
127- if err := p.sendGeneration(generation, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
128- return fmt.Errorf("proxy session: send hello: %w", err)
129- }
130-
131- welcome, err := recvProxyHandshake(ctx, transport, p.key.Name)
132- if err != nil {
133- return err
134- }
135- if err := ctx.Err(); err != nil {
136- return err
137- }
138-
139- p.mu.Lock()
140- if p.linkGeneration != generation || p.transport != transport {
141- p.mu.Unlock()
142- return errors.New("proxy session: link replaced during handshake")
143- }
144- p.resumeToken = welcome.ResumeToken
145- p.linkState = ports.LinkStateConnected
146- p.expired = false
147- p.mu.Unlock()
148- failed = false
149- return nil
150-}
151-
152-func proxyOutputWindow(transport ports.Transport) uint8 {
153- if _, ok := transport.(ports.DatagramTransport); ok {
154- return 1
155- }
156- return maxUnackedOutputStates
157-}
158-
159-func (d *Daemon) proxyHandshakeContext(parent context.Context) (context.Context, <-chan struct{}, func()) {
160- ctx, cancel := context.WithCancel(parent)
161- timedOut := make(chan struct{})
162- clock := ports.Clock(systemClock{})
163- if d != nil && d.clock != nil {
164- clock = d.clock
165- }
166- timer := clock.NewTimer(proxyHandshakeTimeout)
167- if timer == nil {
168- timer = systemClock{}.NewTimer(proxyHandshakeTimeout)
169- }
170- stop := make(chan struct{})
171- done := make(chan struct{})
172- go func() {
173- defer close(done)
174- select {
175- case <-timer.C():
176- close(timedOut)
177- cancel()
178- case <-stop:
179- timer.Stop()
180- }
181- }()
182- return ctx, timedOut, func() {
183- close(stop)
184- <-done
185- cancel()
186- }
187-}
188-
189-func watchProxyHandshakeTransport(ctx context.Context, transport ports.Transport) func() {
190- stop := make(chan struct{})
191- done := make(chan struct{})
192- go func() {
193- defer close(done)
194- select {
195- case <-ctx.Done():
196- _ = transport.Close()
197- case <-stop:
198- }
199- }()
200- return func() {
201- close(stop)
202- <-done
203- }
204-}
205-
206-func proxyHandshakeContextError(parent context.Context, timedOut <-chan struct{}, fallback error) error {
207- select {
208- case <-timedOut:
209- return errProxyHandshakeTimeout
210- default:
211- }
212- if err := parent.Err(); err != nil {
213- return err
214- }
215- return fallback
216-}
217-
218-func recvProxyHandshake(ctx context.Context, transport ports.Transport, sessionName string) (ports.Welcome, error) {
219- frame, err := recvProxyHandshakeFrame(ctx, transport)
220- if err != nil {
221- return ports.Welcome{}, fmt.Errorf("proxy session: receive welcome: %w", err)
222- }
223- welcome, err := validateProxyWelcome(frame, sessionName)
224- if err != nil {
225- return ports.Welcome{}, err
226- }
227- return welcome, nil
228-}
229-
230-func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) {
231- if frame.Type == ports.MsgError {
232- remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
233- if err != nil {
234- return ports.Welcome{}, fmt.Errorf("proxy session: malformed remote error: %w", err)
235- }
236- return ports.Welcome{}, fmt.Errorf("proxy session: remote rejected handshake (%d): %s", remoteErr.Code, remoteErr.Text)
237- }
238- if frame.Type != ports.MsgWelcome {
239- return ports.Welcome{}, fmt.Errorf("proxy session: first server frame is %d, want Welcome", frame.Type)
240- }
241- welcome, err := ports.UnmarshalWelcome(frame.Payload)
242- if err != nil {
243- return ports.Welcome{}, fmt.Errorf("proxy session: decode welcome: %w", err)
244- }
245- if welcome.SessionID == "" || welcome.SessionName != sessionName {
246- return ports.Welcome{}, fmt.Errorf("proxy session: welcome identity mismatch: got %q", welcome.SessionName)
247- }
248- return welcome, nil
249-}
250-
251-func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
252- result := make(chan proxyRecvResult, 1)
253- go func() {
254- frame, err := transport.Recv()
255- result <- proxyRecvResult{frame: frame, err: err}
256- }()
257- select {
258- case received := <-result:
259- return received.frame, received.err
260- case <-ctx.Done():
261- _ = transport.Close()
262- received := <-result // Transport.Close is required to unblock Recv.
263- if received.err != nil && !errors.Is(received.err, io.EOF) {
264- return ports.Frame{}, errors.Join(ctx.Err(), received.err)
265- }
266- return ports.Frame{}, ctx.Err()
267- }
268-}
269-
270-func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) {
271- // Link replacement participates in the same sender serialization as every
272- // frame. A request can therefore only be published wholly before the
273- // generation changes or wholly after it.
274- p.sendMu.Lock()
275- defer p.sendMu.Unlock()
276- p.mu.Lock()
277- previous := p.transport
278- p.linkGeneration++
279- generation := p.linkGeneration
280- // Each transport must establish its ordinary output dependency with a
281- // dependency-free full frame before incremental bytes are forwarded.
282- p.resetOutputStateLocked()
283- p.transport = transport
284- // Authentication is complete after the remote Welcome; ordinary output
285- // establishes the dependency chain asynchronously.
286- p.linkState = ports.LinkStateProbing
287- p.mu.Unlock()
288- p.failPendingCommandsLocked(errProxyCommandGeneration)
289- return generation, previous
290-}
291-
292-func (p *proxySession) retireTransport(generation uint64, transport ports.Transport) {
293- p.sendMu.Lock()
294- defer p.sendMu.Unlock()
295- p.mu.Lock()
296- current := p.linkGeneration == generation && p.transport == transport
297- if current {
298- p.transport = nil
299- }
300- p.mu.Unlock()
301- if current {
302- p.failPendingCommandsLocked(errProxyCommandUnavailable)
303- }
304-}
305-
306-func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error {
307- p.sendMu.Lock()
308- defer p.sendMu.Unlock()
309- p.mu.Lock()
310- if p.linkGeneration != generation || p.transport == nil {
311- p.mu.Unlock()
312- return errors.New("proxy session: stale or unavailable link")
313- }
314- transport := p.transport
315- p.mu.Unlock()
316- return transport.Send(frame)
317-}
318-
319-// sendCommand sends one correlated attached palette command and waits for its
320-// exact result. commandMu serializes interactive requests while sendMu remains
321-// available to all other link traffic during the wait.
322-func (p *proxySession) sendCommand(ctx context.Context, clock ports.Clock, slug string, args []string) (ports.CommandResult, error) {
323- if p == nil || clock == nil {
324- return ports.CommandResult{}, errProxyCommandUnavailable
325- }
326- if ctx == nil {
327- ctx = context.Background()
328- }
329- if err := ctx.Err(); err != nil {
330- return ports.CommandResult{}, err
331- }
332- p.commandMu.Lock()
333- defer p.commandMu.Unlock()
334- if err := ctx.Err(); err != nil {
335- return ports.CommandResult{}, err
336- }
337-
338- p.sendMu.Lock()
339- p.mu.Lock()
340- generation, transport := p.linkGeneration, p.transport
341- p.mu.Unlock()
342- if transport == nil {
343- p.sendMu.Unlock()
344- return ports.CommandResult{}, errProxyCommandUnavailable
345- }
346- p.commandNext++
347- if p.commandNext == 0 {
348- p.commandNext++
349- }
350- requestID := p.commandNext
351- payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
352- Version: ports.ProtocolVersion, RequestID: requestID, Attached: true,
353- Slug: slug, Args: append([]string(nil), args...),
354- })
355- if err != nil {
356- p.sendMu.Unlock()
357- return ports.CommandResult{}, err
358- }
359- outcome := make(chan proxyCommandOutcome, 1)
360- p.commandPending[requestID] = proxyCommandPending{generation: generation, outcome: outcome}
361- if err := transport.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
362- delete(p.commandPending, requestID)
363- p.sendMu.Unlock()
364- return ports.CommandResult{}, err
365- }
366- p.sendMu.Unlock()
367-
368- timer := clock.NewTimer(proxyAttachedCommandTimeout)
369- if timer == nil {
370- p.removePendingCommand(requestID, generation)
371- return ports.CommandResult{}, errProxyCommandTimeout
372- }
373- defer timer.Stop()
374- select {
375- case completed := <-outcome:
376- return completed.result, completed.err
377- case <-ctx.Done():
378- p.removePendingCommand(requestID, generation)
379- return ports.CommandResult{}, ctx.Err()
380- case <-timer.C():
381- p.removePendingCommand(requestID, generation)
382- return ports.CommandResult{}, errProxyCommandTimeout
383- }
384-}
385-
386-func (p *proxySession) removePendingCommand(requestID, generation uint64) {
387- p.sendMu.Lock()
388- pending, ok := p.commandPending[requestID]
389- if ok && pending.generation == generation {
390- delete(p.commandPending, requestID)
391- }
392- p.sendMu.Unlock()
393-}
394-
395-// completeCommandResult ignores malformed correlation state, unknown/late IDs,
396-// and results received by a replacement generation.
397-func (p *proxySession) completeCommandResult(generation uint64, result ports.CommandResult) {
398- if result.RequestID == 0 {
399- return
400- }
401- p.sendMu.Lock()
402- pending, ok := p.commandPending[result.RequestID]
403- if !ok || pending.generation != generation {
404- p.sendMu.Unlock()
405- return
406- }
407- delete(p.commandPending, result.RequestID)
408- p.sendMu.Unlock()
409- pending.outcome <- proxyCommandOutcome{result: result}
410-}
411-
412-// failPendingCommandsLocked requires sendMu and never blocks.
413-func (p *proxySession) failPendingCommandsLocked(err error) {
414- for id, pending := range p.commandPending {
415- delete(p.commandPending, id)
416- pending.outcome <- proxyCommandOutcome{err: err}
417- }
418-}
419-
420-func (d *Daemon) runProxyLink(ctx context.Context, p *proxySession) {
421- defer p.finish()
422- connectedAt := d.clock.Now()
423- resumeAttempts := 0
424- for {
425- p.mu.Lock()
426- generation := p.linkGeneration
427- transport := p.transport
428- p.mu.Unlock()
429- if transport == nil {
430- return
431- }
432-
433- result := d.runProxyTransport(ctx, p, generation, transport)
434- if result == proxyLinkReplaced {
435- // ReasonReplaced is terminal for the exact transport incarnation. Publish
436- // expiry before retirement clears the transport identity used by the
437- // callback fence; the switch below deliberately never redials it.
438- d.markProxyReplaced(p, generation, transport)
439- }
440- p.retireTransport(generation, transport)
441- switch result {
442- case proxyLinkReplaced, proxyLinkStop:
443- return
444- case proxyLinkResume:
445- if ctx.Err() != nil {
446- return
447- }
448- if d.clock.Now().Sub(connectedAt) >= proxyResumeStableDuration {
449- resumeAttempts = 0
450- }
451- d.updateProxyDisconnectedState(p, generation, ports.LinkStateOffline)
452-
453- for {
454- if resumeAttempts >= proxyResumeMaxAttempts {
455- d.log.Warn("proxy resume attempts exhausted", "host", p.key.Host, "session", p.key.Name, "attempts", resumeAttempts)
456- return
457- }
458- delay := proxyResumeBackoff(resumeAttempts)
459- if !waitProxyResume(ctx, d.clock, delay) {
460- return
461- }
462- resumeAttempts++
463- if err := d.dialProxyHandshake(ctx, p, ports.IntentResume); err != nil {
464- p.mu.Lock()
465- failedGeneration := p.linkGeneration
466- p.mu.Unlock()
467- d.updateProxyDisconnectedState(p, failedGeneration, ports.LinkStateOffline)
468- if ctx.Err() != nil {
469- return
470- }
471- d.log.Warn("proxy resume failed", "host", p.key.Host, "session", p.key.Name, "attempt", resumeAttempts, "err", err)
472- continue
473- }
474- connectedAt = d.clock.Now()
475- d.repaintProxyLifecycle(p)
476- break
477- }
478- }
479- }
480-}
481-
482-func proxyResumeBackoff(attempt int) time.Duration {
483- delay := proxyResumeInitialBackoff
484- for range attempt {
485- if delay >= proxyResumeMaxBackoff/2 {
486- return proxyResumeMaxBackoff
487- }
488- delay *= 2
489- }
490- return min(delay, proxyResumeMaxBackoff)
491-}
492-
493-func waitProxyResume(ctx context.Context, clock ports.Clock, delay time.Duration) bool {
494- if err := ctx.Err(); err != nil {
495- return false
496- }
497- timer := clock.NewTimer(delay)
498- if timer == nil {
499- return false
500- }
501- defer timer.Stop()
502- select {
503- case <-ctx.Done():
504- return false
505- case <-timer.C():
506- return true
507- }
508-}
509-
510-func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generation uint64, transport ports.Transport) proxyLinkResult {
511- recvCtx, cancelRecv := context.WithCancel(ctx)
512- recv := make(chan proxyRecvResult, 1)
513- recvDone := make(chan struct{})
514- go func() {
515- defer close(recvDone)
516- for {
517- frame, err := transport.Recv()
518- select {
519- case recv <- proxyRecvResult{frame: frame, err: err}:
520- case <-recvCtx.Done():
521- return
522- }
523- if err != nil {
524- return
525- }
526- }
527- }()
528- defer func() {
529- cancelRecv()
530- _ = transport.Close()
531- <-recvDone
532- }()
533-
534- var events <-chan ports.LinkEvent
535- if reporter, ok := transport.(ports.LinkStateReporter); ok {
536- events = reporter.LinkEvents()
537- }
538- for {
539- select {
540- case <-ctx.Done():
541- return proxyLinkStop
542- case event, ok := <-events:
543- if !ok {
544- events = nil
545- continue
546- }
547- if !d.updateProxyLinkState(p, generation, transport, event.State) {
548- return proxyLinkStop
549- }
550- if event.State == ports.LinkStateOffline || event.State == ports.LinkStateDead {
551- _ = transport.Close()
552- return proxyLinkResume
553- }
554- case received := <-recv:
555- if received.err != nil {
556- if ctx.Err() != nil {
557- return proxyLinkStop
558- }
559- return proxyLinkResume
560- }
561- result, err := d.handleLinkFrame(p, generation, received.frame)
562- if err != nil {
563- // A failed reply says nothing about the remote's framing, so it
564- // retires this transport instead of the whole proxy link.
565- if errors.Is(err, errProxyLinkSend) {
566- d.log.Warn("proxy link reply failed", "host", p.key.Host, "session", p.key.Name, "err", err)
567- return proxyLinkResume
568- }
569- d.log.Warn("invalid proxy link frame", "host", p.key.Host, "session", p.key.Name, "err", err)
570- return proxyLinkStop
571- }
572- if result != proxyLinkResume {
573- return result
574- }
575- }
576- }
577-}
578-
579-// handleProxySideEffect forwards a stateless remote effect to every live local
580-// attachment. Connection admission and transport incarnation checks make a stale
581-// handoff a harmless drop rather than a send to the client's next session.
582-func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error {
583- if p == nil || !p.currentLinkGeneration(generation) {
584- return nil
585- }
586- attachments := snapshotAttachmentSession(p)
587- var sendErr error
588- for _, ac := range attachments {
589- if ac == nil || ac.output == nil {
590- continue
591- }
592- expected := ac.transportSnapshot()
593- if expected.transport == nil {
594- continue
595- }
596- token := attachmentToken(p, ac, expected.transport)
597- if token.sess != p {
598- continue
599- }
600- ticket, admitted := ac.beginAttachmentEffect(token)
601- if !admitted {
602- continue
603- }
604- if !p.currentLinkGeneration(generation) {
605- ticket.End()
606- return nil
607- }
608- err := ac.sendExpectedSideEffectForAttachment(expected, out.Data, ac.echoAck.Load(), ticket)
609- ticket.End()
610- if err == nil || errors.Is(err, errAttachmentTransition) {
611- continue
612- }
613- // Try every live attachment before retiring the remote link for a failed
614- // client send; one blocked or dead peer must not starve the others.
615- if sendErr == nil {
616- sendErr = err
617- }
618- }
619- if sendErr != nil {
620- return fmt.Errorf("proxy session: side-effect send: %w: %w", errProxyLinkSend, sendErr)
621- }
622- return nil
623-}
624-
625-// handleLinkFrame returns proxyLinkResume to mean "continue this transport";
626-// the other values are terminal actions.
627-func (d *Daemon) handleLinkFrame(p *proxySession, generation uint64, frame ports.Frame) (proxyLinkResult, error) {
628- switch frame.Type {
629- case ports.MsgOutput:
630- out, err := ports.UnmarshalOutput(frame.Payload)
631- if err != nil {
632- return proxyLinkStop, err
633- }
634- if err := d.handleProxyOutput(p, generation, out); err != nil {
635- return proxyLinkStop, err
636- }
637- return proxyLinkResume, nil
638- case ports.MsgCommandResult:
639- result, err := ports.UnmarshalCommandResult(frame.Payload)
640- if err != nil {
641- return proxyLinkStop, err
642- }
643- p.completeCommandResult(generation, result)
644- return proxyLinkResume, nil
645- case ports.MsgPong:
646- _, err := ports.UnmarshalPong(frame.Payload)
647- return proxyLinkResume, err
648- case ports.MsgPing:
649- if _, err := ports.UnmarshalPing(frame.Payload); err != nil {
650- return proxyLinkStop, err
651- }
652- if err := p.sendGeneration(generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}); err != nil {
653- return proxyLinkStop, fmt.Errorf("proxy pong reply: %w: %w", errProxyLinkSend, err)
654- }
655- return proxyLinkResume, nil
656- case ports.MsgDetached:
657- detached, err := ports.UnmarshalDetached(frame.Payload)
658- if err != nil {
659- return proxyLinkStop, err
660- }
661- if detached.Reason == ports.ReasonReplaced {
662- return proxyLinkReplaced, nil
663- }
664- return proxyLinkStop, nil
665- case ports.MsgError:
666- remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
667- if err != nil {
668- return proxyLinkStop, err
669- }
670- return proxyLinkStop, fmt.Errorf("remote proxy error (%d): %s", remoteErr.Code, remoteErr.Text)
671- default:
672- return proxyLinkStop, fmt.Errorf("unexpected proxy server frame type %d", frame.Type)
673- }
674-}
D internal/usecase/daemon/proxy_output.go +0 -109
@@ -1,109 +0,0 @@
1-package daemon
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/vev/internal/ports"
7-)
8-
9-// applyOutputForGeneration validates the remote ordinary output dependency
10-// chain without retaining a second terminal or renderer state. Bytes are
11-// forwarded only after a full frame establishes the current remote stream.
12-func (p *proxySession) applyOutputForGeneration(generation uint64, out ports.Output) (ack uint64, requestReset, changed bool) {
13- p.mu.Lock()
14- defer p.mu.Unlock()
15- if p.linkGeneration != generation {
16- return 0, false, false
17- }
18- invalid := func() (uint64, bool, bool) {
19- if p.outputResetRequested {
20- return 0, false, false
21- }
22- p.outputResetRequested = true
23- return 0, true, false
24- }
25- if out.Epoch == 0 {
26- return invalid()
27- }
28- if out.New == 0 {
29- // New == 0 is an ordinary byte side effect, not a state transition.
30- // Match the thin client: it is accepted before the first state-bearing
31- // frame, and thereafter only within the current epoch.
32- if out.Base != 0 || out.Full || (p.outputReady && out.Epoch != p.outputEpoch) {
33- return invalid()
34- }
35- return 0, false, len(out.Data) != 0
36- }
37- if !p.outputReady {
38- if out.Base != 0 || !out.Full {
39- return invalid()
40- }
41- p.outputEpoch = out.Epoch
42- p.outputState = out.New
43- p.outputReady = true
44- p.outputResetRequested = false
45- return out.New, false, len(out.Data) != 0
46- }
47- if out.Epoch < p.outputEpoch {
48- return invalid()
49- }
50- if out.Epoch == p.outputEpoch {
51- if p.outputResetRequested || out.Full || out.Base != p.outputState || out.New != out.Base+1 {
52- return invalid()
53- }
54- p.outputState = out.New
55- return out.New, false, len(out.Data) != 0
56- }
57- if out.Base != 0 || !out.Full {
58- return invalid()
59- }
60- p.outputEpoch = out.Epoch
61- p.outputState = out.New
62- p.outputResetRequested = false
63- return out.New, false, len(out.Data) != 0
64-}
65-
66-// handleProxyOutput forwards ordinary remote terminal bytes to the local
67-// attachment and acknowledges a state-bearing frame only after forwarding.
68-func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports.Output) error {
69- ack, requestReset, changed := p.applyOutputForGeneration(generation, out)
70- if requestReset {
71- if err := p.sendGeneration(generation, ports.Frame{
72- Type: ports.MsgOutputResetRequest,
73- Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
74- }); err != nil {
75- return fmt.Errorf("proxy output reset request: %w: %w", errProxyLinkSend, err)
76- }
77- }
78- if changed {
79- if err := d.handleProxySideEffect(p, generation, out); err != nil {
80- return err
81- }
82- }
83- if ack != 0 {
84- payload, err := ports.MarshalAck(ports.Ack{Epoch: out.Epoch, State: ack})
85- if err != nil {
86- return fmt.Errorf("proxy output ACK: %w", err)
87- }
88- if err := p.sendGeneration(generation, ports.Frame{
89- Type: ports.MsgAck,
90- Payload: payload,
91- }); err != nil {
92- return fmt.Errorf("proxy output ACK: %w: %w", errProxyLinkSend, err)
93- }
94- }
95- return nil
96-}
97-
98-func (p *proxySession) resetOutputStateLocked() {
99- p.outputEpoch = 0
100- p.outputState = 0
101- p.outputReady = false
102- p.outputResetRequested = false
103-}
104-
105-func (p *proxySession) currentLinkGeneration(generation uint64) bool {
106- p.mu.Lock()
107- defer p.mu.Unlock()
108- return p.linkGeneration == generation
109-}
D internal/usecase/daemon/proxy_output_test.go +0 -166
@@ -1,166 +0,0 @@
1-package daemon
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
8- "github.com/stretchr/testify/require"
9-)
10-
11-func newProxyOutputAttachment(t *testing.T, proxy *proxySession, id byte) (*attachedClient, *proxyTestTransport) {
12- t.Helper()
13- transport := newProxyTestTransport()
14- ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: proxy.contentSize}
15- ac.output.attachment = ac
16- ac.clientID[0] = id
17- ac.setSession(proxy)
18- proxy.sessionCore.mu.Lock()
19- require.True(t, proxy.registerAttachmentLocked(ac))
20- proxy.sessionCore.mu.Unlock()
21- return ac, transport
22-}
23-
24-func TestProxyOutputRejectsStaleStateAndRequestsOneReset(t *testing.T) {
25- tests := []struct {
26- name string
27- state ports.Output
28- stale ports.Output
29- }{
30- {
31- name: "duplicate full",
32- state: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true},
33- stale: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true, Data: []byte("duplicate")},
34- },
35- {
36- name: "older epoch",
37- state: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true},
38- stale: ports.Output{Epoch: 2, Base: 0, New: 5, Full: true, Data: []byte("older")},
39- },
40- {
41- name: "base-zero non-full",
42- state: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true},
43- stale: ports.Output{Epoch: 3, Base: 0, New: 5, Data: []byte("not full")},
44- },
45- }
46- for _, tt := range tests {
47- t.Run(tt.name, func(t *testing.T) {
48- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
49- require.NoError(t, err)
50- proxy.linkGeneration = 1
51- ack, reset, changed := proxy.applyOutputForGeneration(1, tt.state)
52- require.Equal(t, uint64(4), ack)
53- require.False(t, reset)
54- require.Equal(t, len(tt.state.Data) != 0, changed)
55-
56- ack, reset, changed = proxy.applyOutputForGeneration(1, tt.stale)
57- require.Zero(t, ack)
58- require.True(t, reset)
59- require.False(t, changed)
60- ack, reset, changed = proxy.applyOutputForGeneration(1, tt.stale)
61- require.Zero(t, ack)
62- require.False(t, reset)
63- require.False(t, changed)
64- })
65- }
66-}
67-
68-func TestProxyOutputFansOutToEveryLiveAttachment(t *testing.T) {
69- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
70- require.NoError(t, err)
71- remote := newProxyTestTransport()
72- proxy.transport = remote
73- proxy.linkGeneration = 1
74- d := &Daemon{}
75-
76- attachments := make([]*proxyTestTransport, 0, 2)
77- for id := byte(1); id <= 2; id++ {
78- _, clientTransport := newProxyOutputAttachment(t, proxy, id)
79- attachments = append(attachments, clientTransport)
80- }
81-
82- result, err := d.handleLinkFrame(proxy, 1, ports.Frame{
83- Type: ports.MsgOutput,
84- Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}),
85- })
86- require.NoError(t, err)
87- require.Equal(t, proxyLinkResume, result)
88- for _, transport := range attachments {
89- frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive output")
90- require.Equal(t, ports.MsgOutput, frame.Type)
91- out, err := ports.UnmarshalOutput(frame.Payload)
92- require.NoError(t, err)
93- require.Zero(t, out.New, "local proxy forwarding must not create a second ACK path")
94- require.Equal(t, []byte("full"), out.Data)
95- }
96- ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive output acknowledgement")
97- require.Equal(t, ports.MsgAck, ackFrame.Type)
98-}
99-
100-func TestProxyOutputAttachAfterStreamReceivesCurrentFullOutput(t *testing.T) {
101- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
102- require.NoError(t, err)
103- remote := newProxyTestTransport()
104- proxy.transport = remote
105- proxy.linkGeneration = 1
106- d := &Daemon{}
107-
108- _, firstTransport := newProxyOutputAttachment(t, proxy, 1)
109-
110- handle := func(output ports.Output) {
111- t.Helper()
112- result, err := d.handleLinkFrame(proxy, 1, ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(output)})
113- require.NoError(t, err)
114- require.Equal(t, proxyLinkResume, result)
115- }
116- handle(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("before attach")})
117- _ = awaitTestValue(t, firstTransport.sent, "first proxy attachment did not receive output")
118- _ = awaitTestValue(t, remote.sent, "proxy remote did not receive first acknowledgement")
119-
120- _, secondTransport := newProxyOutputAttachment(t, proxy, 2)
121-
122- handle(ports.Output{Epoch: 4, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("current full")})
123- for _, transport := range []*proxyTestTransport{firstTransport, secondTransport} {
124- frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive current output")
125- require.Equal(t, ports.MsgOutput, frame.Type)
126- out, err := ports.UnmarshalOutput(frame.Payload)
127- require.NoError(t, err)
128- require.Zero(t, out.New)
129- require.False(t, out.Full, "local proxy side effects must remain stateless")
130- require.Equal(t, []byte("current full"), out.Data)
131- }
132- ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive current acknowledgement")
133- require.Equal(t, ports.MsgAck, ackFrame.Type)
134-}
135-
136-func TestProxyOutputInvalidStateNeverForwardsBytes(t *testing.T) {
137- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
138- require.NoError(t, err)
139- remote := newProxyTestTransport()
140- proxy.transport = remote
141- proxy.linkGeneration = 1
142- _, clientTransport := newProxyOutputAttachment(t, proxy, 1)
143- d := &Daemon{}
144-
145- _, err = d.handleLinkFrame(proxy, 1, ports.Frame{
146- Type: ports.MsgOutput,
147- Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}),
148- })
149- require.NoError(t, err)
150- _ = awaitTestValue(t, clientTransport.sent, "proxy attachment did not receive initial output")
151- _ = awaitTestValue(t, remote.sent, "proxy remote did not receive initial acknowledgement")
152-
153- result, err := d.handleLinkFrame(proxy, 1, ports.Frame{
154- Type: ports.MsgOutput,
155- Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("stale")}),
156- })
157- require.NoError(t, err)
158- require.Equal(t, proxyLinkResume, result)
159- select {
160- case frame := <-clientTransport.sent:
161- t.Fatalf("stale proxy output forwarded as %v", frame.Type)
162- default:
163- }
164- reset := awaitTestValue(t, remote.sent, "proxy remote did not receive reset request")
165- require.Equal(t, ports.MsgOutputResetRequest, reset.Type)
166-}
D internal/usecase/daemon/proxy_session.go +0 -399
@@ -1,399 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "crypto/rand"
6- "errors"
7- "fmt"
8- "io"
9- "sync"
10- "time"
11-
12- "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/ports"
14-)
15-
16-// proxySession is the local attachment identity for one remote session key. mu is a leaf lock: callers may take it below d.mu or sessionCore
17-// locks, but must not acquire another architecture lock or perform transport I/O
18-// while holding it. All Transport.Send calls are owned by sendMu.
19-type proxySession struct {
20- sessionCore
21- key domain.RemoteSessionKey
22-
23- mu sync.Mutex
24- generation uint64
25- linkGeneration uint64
26- transport ports.Transport
27- resumeToken uint64
28- outputEpoch uint64
29- outputState uint64
30- outputReady bool
31- outputResetRequested bool
32- meta ports.SessionMeta
33- attentionAt []time.Time
34- linkState ports.LinkState
35- expired bool
36- warm *proxyWarmTimer
37- cancel context.CancelFunc
38- done chan struct{}
39- doneOnce sync.Once
40- clientID [16]byte
41- contentSize domain.Size
42-
43- sendMu sync.Mutex
44- inputNext uint64
45- // commandMu permits at most one interactive palette request to be
46- // outstanding without blocking input, ACK, resize, or liveness sends.
47- commandMu sync.Mutex
48- commandNext uint64
49- commandPending map[uint64]proxyCommandPending
50-}
51-
52-func (p *proxySession) core() *sessionCore {
53- if p == nil {
54- return nil
55- }
56- return &p.sessionCore
57-}
58-
59-func (p *proxySession) isProxy() bool { return true }
60-
61-func (p *proxySession) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) {
62- return nil, false
63-}
64-
65-func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
66- if p == nil || !size.Valid() {
67- return false, false
68- }
69- payload, err := ports.MarshalResize(ports.Resize{Size: size})
70- if err != nil {
71- return false, false
72- }
73- p.mu.Lock()
74- if p.contentSize == size {
75- available := p.transport != nil
76- p.mu.Unlock()
77- return false, available
78- }
79- p.contentSize = size
80- generation := p.linkGeneration
81- p.mu.Unlock()
82- return true, p.sendGeneration(generation, ports.Frame{
83- Type: ports.MsgResize,
84- Payload: payload,
85- }) == nil
86-}
87-
88-func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta {
89- meta.Tabs = append([]ports.SessionTabMeta(nil), meta.Tabs...)
90- return meta
91-}
92-
93-// replaceSessionMetaLocked atomically replaces the relayed snapshot while
94-// preserving the first-observed timestamp of tabs that remain attentive.
95-// Callers must validate meta and hold p.mu.
96-// applyProxySessionMeta validates and atomically applies relayed metadata only
97-// for the exact current link generation. A remote session rename cannot mutate
98-// the immutable local identity, so it expires that exact proxy instead.
99-func (p *proxySession) snapshotView(opts viewOptions) sessionView {
100- if p == nil {
101- return sessionView{}
102- }
103- p.mu.Lock()
104- meta := cloneSessionMeta(p.meta)
105- attentionAt := append([]time.Time(nil), p.attentionAt...)
106- name := p.name
107- expired := p.expired
108- if expired {
109- name += " [expired]"
110- }
111- p.mu.Unlock()
112-
113- view := sessionView{
114- id: p.id,
115- name: name,
116- expired: expired,
117- createdAt: p.createdAt,
118- mruAt: p.mruAt.Load(),
119- defaultTab: 0,
120- tabCount: len(meta.Tabs),
121- cannotAcceptMoves: true,
122- }
123- p.sessionCore.mu.Lock()
124- view.attached = len(p.attachments) != 0
125- p.sessionCore.mu.Unlock()
126- if opts.tabDetails {
127- view.tabs = make([]tabView, len(meta.Tabs))
128- for i, tab := range meta.Tabs {
129- view.tabs[i] = tabView{name: tab.Name, attention: tab.Attention}
130- if i < len(attentionAt) {
131- view.tabs[i].attentionAt = attentionAt[i]
132- }
133- view.hasAttention = view.hasAttention || tab.Attention
134- }
135- } else {
136- for _, tab := range meta.Tabs {
137- view.hasAttention = view.hasAttention || tab.Attention
138- }
139- }
140- return view
141-}
142-
143-func (p *proxySession) statusSegments(_ bool) statusSnapshot {
144- if p == nil {
145- return statusSnapshot{}
146- }
147- name := p.lifecycleDisplayName()
148- p.mu.Lock()
149- meta := cloneSessionMeta(p.meta)
150- p.mu.Unlock()
151- snapshot := statusSnapshot{session: name, tabs: make([]statusTab, len(meta.Tabs))}
152- for i, tab := range meta.Tabs {
153- snapshot.tabs[i] = statusTab{
154- name: tab.Name,
155- active: uint16(i) == meta.Active,
156- attention: tab.Attention,
157- }
158- }
159- return snapshot
160-}
161-
162-func (p *proxySession) validTargetTabLocked(tabIndex int) bool {
163- if tabIndex < 0 {
164- return true
165- }
166- p.mu.Lock()
167- defer p.mu.Unlock()
168- return tabIndex < len(p.meta.Tabs)
169-}
170-
171-func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySession, error) {
172- if err := key.Validate(); err != nil {
173- return nil, fmt.Errorf("proxy session key: %w", err)
174- }
175- if !size.Valid() {
176- return nil, fmt.Errorf("proxy session size: invalid size %dx%d", size.Cols, size.Rows)
177- }
178- var clientID [16]byte
179- if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil {
180- return nil, fmt.Errorf("generate proxy client id: %w", err)
181- }
182- p := &proxySession{
183- sessionCore: sessionCore{
184- id: key.ID(),
185- name: key.Display(),
186- caps: sessionCapabilities{cannotAcceptMoves: true, cannotYieldMoves: true},
187- },
188- key: key,
189- generation: 1,
190- linkState: ports.LinkStateConnected,
191- done: make(chan struct{}),
192- clientID: clientID,
193- contentSize: size,
194- commandPending: make(map[uint64]proxyCommandPending),
195- }
196- return p, nil
197-}
198-
199-type proxyConstruction struct {
200- done chan struct{}
201- err error
202-}
203-
204-var errProxySessionExpired = errors.New("proxy session: remote attachment has expired")
205-
206-// openProxySession returns the exact already-published proxy for key, or elects
207-// one constructor for that remote key. Waiters never dial: they wait
208-// cancellably, then revalidate the exact registry winner under d.mu. The
209-// reservation itself is held only under d.mu; dial and handshake I/O run after
210-// releasing every daemon, core, and proxy architecture lock.
211-func (d *Daemon) openProxySession(ctx context.Context, key domain.RemoteSessionKey, size domain.Size) (*proxySession, error) {
212- if d == nil {
213- return nil, errors.New("proxy session: nil daemon")
214- }
215- if ctx == nil {
216- ctx = context.Background()
217- }
218- if err := key.Validate(); err != nil {
219- return nil, fmt.Errorf("proxy session: %w", err)
220- }
221- if d.remoteDialerFactory == nil {
222- return nil, errors.New("proxy session: remote dialer is not configured")
223- }
224-
225- for {
226- d.mu.Lock()
227- existing, found, err := d.proxyForKeyLocked(key)
228- if err != nil {
229- d.mu.Unlock()
230- return nil, err
231- }
232- if found {
233- d.mu.Unlock()
234- return existing, nil
235- }
236- if d.closing {
237- d.mu.Unlock()
238- return nil, errors.New("proxy session: daemon is shutting down")
239- }
240- if construction := d.proxyConstructions[key]; construction != nil {
241- done := construction.done
242- d.mu.Unlock()
243- select {
244- case <-ctx.Done():
245- return nil, ctx.Err()
246- case <-done:
247- }
248- if err := ctx.Err(); err != nil {
249- return nil, err
250- }
251-
252- // Publication and construction completion are ordered under d.mu.
253- // Revalidate rather than trusting a stale candidate pointer.
254- d.mu.Lock()
255- winner, registered, collisionErr := d.proxyForKeyLocked(key)
256- constructionErr := construction.err
257- d.mu.Unlock()
258- if collisionErr != nil {
259- return nil, collisionErr
260- }
261- if registered {
262- return winner, nil
263- }
264- if constructionErr != nil &&
265- !errors.Is(constructionErr, context.Canceled) &&
266- !errors.Is(constructionErr, context.DeadlineExceeded) {
267- return nil, constructionErr
268- }
269- // A canceled owner does not poison independent waiters. Re-elect
270- // under d.mu if this caller still wants the proxy.
271- continue
272- }
273-
274- construction := &proxyConstruction{done: make(chan struct{})}
275- if d.proxyConstructions == nil {
276- d.proxyConstructions = make(map[domain.RemoteSessionKey]*proxyConstruction)
277- }
278- d.proxyConstructions[key] = construction
279- d.mu.Unlock()
280-
281- proxy, constructionErr := d.constructProxySession(ctx, key, size)
282- d.mu.Lock()
283- construction.err = constructionErr
284- if d.proxyConstructions[key] == construction {
285- delete(d.proxyConstructions, key)
286- close(construction.done)
287- }
288- d.mu.Unlock()
289- return proxy, constructionErr
290- }
291-}
292-
293-// proxyForKeyLocked validates the opaque session-ID collision while d.mu is
294-// held. A true result is always the exact currently registered proxy.
295-func (d *Daemon) proxyForKeyLocked(key domain.RemoteSessionKey) (*proxySession, bool, error) {
296- existing := d.sessions[key.ID()]
297- if existing == nil {
298- return nil, false, nil
299- }
300- proxy, ok := existing.(*proxySession)
301- if !ok || proxy.key != key {
302- return nil, false, errors.New("proxy session: remote identity collision")
303- }
304- proxy.mu.Lock()
305- expired := proxy.expired
306- proxy.mu.Unlock()
307- if expired {
308- return nil, false, errProxySessionExpired
309- }
310- return proxy, true, nil
311-}
312-
313-// constructProxySession owns one reserved key. The caller has released d.mu
314-// before entering this function, and no architecture lock spans handshake I/O.
315-func (d *Daemon) constructProxySession(ctx context.Context, key domain.RemoteSessionKey, size domain.Size) (*proxySession, error) {
316- proxy, err := newProxySession(key, size)
317- if err != nil {
318- return nil, err
319- }
320- root := context.Background()
321- if d.serveCtx != nil {
322- root = d.serveCtx
323- }
324- linkCtx, cancel := context.WithCancel(root)
325- proxy.cancel = cancel
326- published := false
327- defer func() {
328- if !published {
329- proxy.disposeUnpublished()
330- }
331- }()
332-
333- if err := d.dialProxyHandshake(ctx, proxy, ports.IntentAttach); err != nil {
334- return nil, err
335- }
336- if err := ctx.Err(); err != nil {
337- return nil, err
338- }
339-
340- d.mu.Lock()
341- if err := ctx.Err(); err != nil {
342- d.mu.Unlock()
343- return nil, err
344- }
345- if existing, found, err := d.proxyForKeyLocked(key); err != nil {
346- d.mu.Unlock()
347- return nil, err
348- } else if found {
349- d.mu.Unlock()
350- return existing, nil
351- }
352- if d.closing || !d.registerSessionLocked(proxy) {
353- d.mu.Unlock()
354- return nil, errors.New("proxy session: daemon rejected publication")
355- }
356- published = true
357- d.mu.Unlock()
358-
359- go d.runProxyLink(linkCtx, proxy)
360- return proxy, nil
361-}
362-
363-func (p *proxySession) finish() {
364- if p != nil {
365- p.doneOnce.Do(func() { close(p.done) })
366- }
367-}
368-
369-func (p *proxySession) disposeUnpublished() {
370- if p == nil {
371- return
372- }
373- p.stop()
374- p.finish()
375-}
376-
377-func (p *proxySession) stop() {
378- if p == nil {
379- return
380- }
381- if p.cancel != nil {
382- p.cancel()
383- }
384- p.closeCurrentTransport()
385-}
386-
387-func (p *proxySession) closeCurrentTransport() {
388- if p == nil {
389- return
390- }
391- p.mu.Lock()
392- transport := p.transport
393- p.mu.Unlock()
394- if transport != nil {
395- _ = transport.Close()
396- }
397-}
398-
399-var _ attachmentSession = (*proxySession)(nil)
D internal/usecase/daemon/proxy_test_helpers_test.go +0 -127
@@ -1,127 +0,0 @@
1-package daemon
2-
3-import (
4- "io"
5- "log/slog"
6- "sync"
7- "sync/atomic"
8- "testing"
9- "time"
10-
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
13- "github.com/stretchr/testify/require"
14-)
15-
16-type proxyRecv struct {
17- frame ports.Frame
18- err error
19-}
20-
21-type proxyTestTransport struct {
22- recv chan proxyRecv
23- closed chan struct{}
24- sent chan ports.Frame
25- once sync.Once
26-
27- sendFails atomic.Bool
28- sendGate <-chan struct{}
29- sendEntered chan struct{}
30- receiving atomic.Int32
31- concurrent atomic.Bool
32- sending atomic.Int32
33-}
34-
35-func newProxyTestTransport() *proxyTestTransport {
36- return &proxyTestTransport{recv: make(chan proxyRecv, 16), closed: make(chan struct{}), sent: make(chan ports.Frame, 32)}
37-}
38-
39-func (t *proxyTestTransport) Send(frame ports.Frame) error {
40- if t.sending.Add(1) != 1 {
41- t.concurrent.Store(true)
42- }
43- defer t.sending.Add(-1)
44- if t.sendFails.Load() {
45- return io.ErrClosedPipe
46- }
47- if t.sendGate != nil {
48- if t.sendEntered != nil {
49- select {
50- case t.sendEntered <- struct{}{}:
51- default:
52- }
53- }
54- select {
55- case <-t.sendGate:
56- case <-t.closed:
57- return io.ErrClosedPipe
58- }
59- }
60- select {
61- case t.sent <- frame:
62- return nil
63- case <-t.closed:
64- return io.ErrClosedPipe
65- }
66-}
67-
68-func (t *proxyTestTransport) Recv() (ports.Frame, error) {
69- t.receiving.Add(1)
70- defer t.receiving.Add(-1)
71- select {
72- case item := <-t.recv:
73- return item.frame, item.err
74- case <-t.closed:
75- return ports.Frame{}, io.EOF
76- }
77-}
78-
79-func (t *proxyTestTransport) Close() error {
80- t.once.Do(func() { close(t.closed) })
81- return nil
82-}
83-
84-func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame {
85- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
86- SessionID: "remote-id", SessionName: name, ResumeToken: token, Capabilities: capabilities,
87- })}
88-}
89-
90-func proxyHandshakeSnapshot() ports.Frame {
91- return ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
92- Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("remote initial\n"),
93- })}
94-}
95-
96-func newProxyTestDaemon(t *testing.T, factory ports.RemoteDialerFactory, clk ports.Clock) *Daemon {
97- t.Helper()
98- if clk == nil {
99- clk = stubClock{}
100- }
101- return New(nil, clk, slog.New(slog.NewTextHandler(io.Discard, nil)),
102- WithRemoteDiscovery(nil, nil, nil, factory, ports.RemoteTransportUDP))
103-}
104-
105-func requireProxyHello(t *testing.T, tr *proxyTestTransport) ports.Hello {
106- t.Helper()
107- select {
108- case frame := <-tr.sent:
109- require.Equal(t, ports.MsgHello, frame.Type)
110- hello, err := ports.UnmarshalHello(frame.Payload)
111- require.NoError(t, err)
112- return hello
113- case <-time.After(time.Second):
114- t.Fatal("timed out waiting for proxy Hello")
115- return ports.Hello{}
116- }
117-}
118-
119-func stopProxy(t *testing.T, p *proxySession) {
120- t.Helper()
121- p.stop()
122- select {
123- case <-p.done:
124- case <-time.After(time.Second):
125- t.Fatal("timed out waiting for proxy link to stop")
126- }
127-}
M internal/usecase/daemon/remote_picker.go +0 -27
@@ -139,33 +139,6 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi
139139 }
140140 }
141141
142-func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker.SessionView {
143- view := snap.pickerView()
144- // Proxy construction uses this same ID and label. Restating them from the
145- // structured key makes the cache-to-live row upgrade independent of mutable
146- // presentation or relayed metadata. Lifecycle is carried separately from
147- // presentation so a display-name change can never spoof expiry.
148- view.ID = key.ID()
149- view.Name = key.Display()
150- if snap.expired {
151- view.Name += " [expired]"
152- }
153- view.TargetName = ""
154- view.ExpectedCreatedAt = nil
155- view.RemoteKey = &key
156- view.RemoteAvailability = picker.RemoteFresh
157- view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount))
158- view.ConnectOnly = true
159- view.RemoteAttachReady = true
160- if snap.expired {
161- view.RemoteAvailability = picker.RemoteStale
162- view.RemoteDetail = "expired"
163- view.RemoteAttachReady = false
164- }
165- view.CannotAcceptMoves = true
166- return view
167-}
168-
169142 func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
170143 for i := range entries {
171144 sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
M internal/usecase/daemon/remote_picker_test.go +25 -151
@@ -14,7 +14,6 @@ import (
1414
1515 "github.com/bnema/vev/internal/domain"
1616 "github.com/bnema/vev/internal/ports"
17- portsmocks "github.com/bnema/vev/internal/ports/mocks"
1817 "github.com/bnema/vev/internal/usecase/picker"
1918 )
2019
@@ -56,91 +55,6 @@ func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
5655 return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
5756 }
5857
59-func TestRemotePickerMerge(t *testing.T) {
60- store := portsmocks.NewMockRemoteHostStore(t)
61- store.EXPECT().Hosts().Return([]string{"pinned"}, []string{"learned"}, nil).Once()
62- d := newRemotePickerDaemon(store)
63- local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{{}}}
64- local.mruAt.Store(6)
65- key := domain.RemoteSessionKey{Host: "pinned", Name: "work"}
66- proxy := &proxySession{
67- sessionCore: sessionCore{id: key.ID(), name: key.Display()},
68- key: key,
69- meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{
70- {Index: 0, Name: "shell"},
71- }},
72- }
73- proxy.mruAt.Store(5)
74- d.sessions[local.id] = local
75- d.sessions[proxy.id] = proxy
76- d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 2, lastUsedSeq: 9}
77- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
78- {Host: "learned", FetchedAt: time.Unix(20, 0), Sessions: []ports.RemoteCatalogSession{{Name: "alpha", State: "running", Tabs: 3}}},
79- {Host: "pinned", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running", Tabs: 9}, {Name: "alpha", State: "running", Tabs: 2}}},
80- })
81- d.remoteCatalog.mu.Lock()
82- d.remoteCatalog.status["pinned"] = remoteHostFresh
83- d.remoteCatalog.status["learned"] = remoteHostFresh
84- d.remoteCatalog.mu.Unlock()
85-
86- views, _ := d.pickerViews(local, nil)
87-
88- require.Equal(t, []string{"local", "work@pinned", "alpha@pinned", "alpha@learned", "stopped"}, pickerViewNames(views))
89- require.False(t, views[0].Stopped)
90- require.Equal(t, key, *views[1].RemoteKey)
91- require.Equal(t, picker.RemoteFresh, views[1].RemoteAvailability)
92- require.True(t, views[1].ConnectOnly)
93- require.Equal(t, "2 tabs", views[2].RemoteDetail)
94- require.Equal(t, picker.RemoteFresh, views[2].RemoteAvailability)
95- require.Equal(t, picker.RemoteFresh, views[3].RemoteAvailability)
96- require.True(t, views[4].Stopped)
97-}
98-
99-func TestRemotePickerDedupeUsesExactKeyAndKeepsLiveID(t *testing.T) {
100- d := newRemotePickerDaemon(nil)
101- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
102- proxy := &proxySession{
103- sessionCore: sessionCore{id: key.ID(), name: key.Display()},
104- key: key,
105- meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{
106- {Index: 0, Name: "live tab"},
107- }},
108- }
109- d.sessions[key.ID()] = proxy
110- d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
111- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{
112- {Name: "work", State: "running", Tabs: 7},
113- },
114- }})
115-
116- views, _ := d.pickerViews(nil, nil)
117-
118- require.Len(t, views, 1)
119- require.Equal(t, key.ID(), views[0].ID)
120- require.Equal(t, key, *views[0].RemoteKey)
121- require.Equal(t, []picker.TabEntry{{Name: "live tab"}}, views[0].Tabs, "live proxy snapshot wins over cached discovery")
122- require.Equal(t, picker.RemoteFresh, views[0].RemoteAvailability)
123-}
124-
125-func TestRemotePickerSnapshotsDoNotRetainProxyLock(t *testing.T) {
126- d := newRemotePickerDaemon(nil)
127- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
128- proxy := &proxySession{
129- sessionCore: sessionCore{id: key.ID(), name: key.Display()},
130- key: key,
131- meta: ports.SessionMeta{SessionName: key.Name, Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}},
132- }
133- d.sessions[key.ID()] = proxy
134-
135- views, _ := d.pickerViews(nil, nil)
136-
137- require.Len(t, views, 1)
138- require.True(t, proxy.mu.TryLock(), "picker construction must release the proxy leaf lock")
139- proxy.mu.Unlock()
140- require.True(t, d.remoteCatalog.mu.TryLock(), "picker construction must release the remote catalog lock")
141- d.remoteCatalog.mu.Unlock()
142-}
143-
14458 func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
14559 tests := []struct {
14660 name string
@@ -219,33 +133,6 @@ func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) {
219133 }
220134 }
221135
222-func TestRemotePickerGroupedSortTreatsProxiesAsLiveSessions(t *testing.T) {
223- d := newRemotePickerDaemon(nil)
224- d.pickerSort.Store(uint32(pickerSortGrouped))
225- ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
226- ephemeral.mruAt.Store(9)
227- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
228- proxy := &proxySession{sessionCore: sessionCore{id: key.ID(), name: key.Display()}, key: key}
229- proxy.mruAt.Store(1)
230- named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
231- named.mruAt.Store(2)
232- d.sessions[ephemeral.id] = ephemeral
233- d.sessions[proxy.id] = proxy
234- d.sessions[named.id] = named
235-
236- views, _ := d.pickerViews(nil, nil)
237-
238- require.Equal(t, []string{"named", "work@arch", "1"}, pickerViewNames(views))
239-}
240-
241-func pickerViewNames(views []picker.SessionView) []string {
242- names := make([]string, len(views))
243- for i, view := range views {
244- names[i] = view.Name
245- }
246- return names
247-}
248-
249136 type remoteRefreshHostStore struct {
250137 mu sync.Mutex
251138 hosts []string
@@ -532,6 +419,31 @@ func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
532419 d.remoteCatalog.mu.Unlock()
533420 }
534421
422+func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
423+ d := newRemotePickerDaemon(nil)
424+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
425+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "running"}},
426+ }})
427+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
428+ token := sess.attachmentToken(ac, ac.transport())
429+ effect, admitted := ac.beginAttachmentEffect(token)
430+ require.True(t, admitted)
431+ token.effect = effect
432+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
433+ target := picker.Target{Session: key.ID(), RemoteKey: &key}
434+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
435+
436+ frame := receiveRemotePicker(t, sends, "attach target")
437+ require.Equal(t, ports.MsgAttachTarget, frame.Type)
438+ got, err := ports.UnmarshalAttachTarget(frame.Payload)
439+ require.NoError(t, err)
440+ require.Equal(t, ports.AttachTarget{Endpoint: "arch", Session: "work", Intent: ports.IntentAttach}, got)
441+ require.Nil(t, ac.currentAttachmentSession())
442+ d.mu.Lock()
443+ require.NotContains(t, d.sessions, key.ID(), "remote picker handoff must not create a local session shadow")
444+ d.mu.Unlock()
445+}
446+
535447 func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID) (*session, *attachedClient, chan ports.Frame) {
536448 t.Helper()
537449 tr, sends := newCapturingTransport(t)
@@ -744,44 +656,6 @@ func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
744656 ac.overlays.pickerMu.Unlock()
745657 }
746658
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-
785659 func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
786660 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
787661 d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(500, 0))
M internal/usecase/daemon/render.go +2 -3
@@ -291,9 +291,8 @@ func (b *runtimeMarkBatch) flush() {
291291 }
292292
293293 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {
294- // Local sessions retain their tab/PTY preparation below. Proxy sessions
295- // enter the same attachment pipeline but supply their VT snapshot through
296- // attachmentSession.captureRenderState.
294+ // Session-owned PTY preparation remains local; attachment rendering is
295+ // captured through attachmentSession.captureRenderState.
297296 sess, local := localSession(entry)
298297 marks := d.newRuntimeMarkBatch()
299298 if lease != nil {
M internal/usecase/daemon/render_pipeline.go +2 -5
@@ -491,9 +491,8 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
491491 }
492492 d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
493493 }
494- // Notices are session-scoped, but the repaint is not: a proxy attachment
495- // has no local session to report through and still needs its chrome
496- // redrawn after a failed transaction.
494+ // Notices are session-scoped, while the attachment repaint still needs
495+ // to redraw its chrome after a failed transaction.
497496 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
498497 return true
499498 }
@@ -566,8 +565,6 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
566565 if marks.attachmentEffect == nil {
567566 if sess, ok := localSession(entry); ok {
568567 d.detachOnSendError(sess, ac, sendTr)
569- } else if proxy, ok := entry.(*proxySession); ok {
570- d.detachProxyOnSendError(proxy, ac, sendTr)
571568 }
572569 } else {
573570 // Capture the exact admitted capability, including its coordinator
M internal/usecase/daemon/session.go +0 -38
@@ -170,8 +170,6 @@ func (d *Daemon) touchMRU(entry attachmentSession) {
170170 break
171171 }
172172 }
173- // Proxy recency is live derived state only. Local named sessions retain the
174- // existing catalogue metadata publication semantics.
175173 sess, ok := localSession(entry)
176174 if !ok {
177175 return
@@ -761,39 +759,6 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
761759 return current
762760 }
763761
764-// detachProxyIfCurrentTransport is detachIfCurrentTransport for a proxy
765-// attachment. It fences on the same published identity — exact session pointer,
766-// exact attachment, exact transport incarnation — but reads the attachment
767-// through the shared sessionCore rather than a local session.
768-func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClient, expected transportSnapshot) bool {
769- if p == nil || ac == nil {
770- return false
771- }
772- frozen := freezeAttachmentEffectGates(ac)
773- defer frozen.unfreeze()
774- if d.afterDetachAttachmentEffectsFrozen != nil {
775- d.afterDetachAttachmentEffectsFrozen()
776- }
777- d.notices.routingMu.Lock()
778- p.sessionCore.mu.Lock()
779- current := attachmentRegisteredLocked(attachmentSession(p), ac) && ac.currentAttachmentSession() == attachmentSession(p)
780- if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
781- current = false
782- }
783- if current {
784- unregisterAttachmentSessionLocked(attachmentSession(p), ac)
785- ac.connectionGeneration.Add(1)
786- ac.setSession(nil)
787- ac.invalidateFrozenAttachmentCapability()
788- }
789- p.sessionCore.mu.Unlock()
790- d.notices.routingMu.Unlock()
791- if current {
792- d.armProxyWarm(p)
793- }
794- return current
795-}
796-
797762 // detachIfAttachmentCurrent invalidates only the exact attachment generation and transport
798763 // whose admitted send failed. A queued stale error cannot detach a later
799764 // attachment publication or a rebound transport on the same attachment object.
@@ -812,9 +777,6 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
812777 if sess, ok := localSession(token.sess); ok {
813778 return d.detachIfCurrentTransport(sess, token.ac, token.transport)
814779 }
815- if proxy, ok := token.sess.(*proxySession); ok {
816- return d.detachProxyIfCurrentTransport(proxy, token.ac, token.transport)
817- }
818780 return false
819781 }
820782 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
M internal/usecase/daemon/session_back.go +0 -4
@@ -17,10 +17,6 @@ func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error
1717 return nil
1818 }
1919 pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}
20- if proxy, ok := target.(*proxySession); ok {
21- key := proxy.key
22- pickerTarget.RemoteKey = &key
23- }
2420 return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session")
2521 }
2622
M internal/usecase/daemon/session_capabilities.go +2 -4
@@ -3,8 +3,7 @@ package daemon
33 import "errors"
44
55 // errSessionCannotYieldMoves rejects move-picker entry for sessions whose
6-// tabs and panes cannot leave the session (no local session sets this today;
7-// proxy sessions will).
6+// tabs and panes cannot leave the session.
87 var errSessionCannotYieldMoves = errors.New("session does not support moving tabs or panes")
98
109 // sessionCapabilities declares what cross-session operations a session
@@ -14,8 +13,7 @@ var errSessionCannotYieldMoves = errors.New("session does not support moving tab
1413 //
1514 // Set once at construction, never mutated: reading needs no lock.
1615 type sessionCapabilities struct {
17- cannotAcceptMoves bool
18- cannotYieldMoves bool
16+ cannotYieldMoves bool
1917 }
2018
2119 func (c sessionCapabilities) yieldsMoves() bool { return !c.cannotYieldMoves }
M internal/usecase/daemon/session_capabilities_test.go +1 -20
@@ -10,30 +10,11 @@ import (
1010 func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) {
1111 s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha"}}
1212 caps := s.capabilities()
13- if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() {
13+ if caps.cannotYieldMoves || !caps.yieldsMoves() {
1414 t.Fatalf("zero-value capabilities = %+v, want fully capable", caps)
1515 }
1616 }
1717
18-func TestSnapshotViewCarriesCannotAcceptMoves(t *testing.T) {
19- tests := []struct {
20- name string
21- caps sessionCapabilities
22- want bool
23- }{
24- {name: "default local", caps: sessionCapabilities{}, want: false},
25- {name: "restricted proxy", caps: sessionCapabilities{cannotAcceptMoves: true}, want: true},
26- }
27- for _, tt := range tests {
28- t.Run(tt.name, func(t *testing.T) {
29- s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps}}
30- if got := s.snapshotView(viewOptions{}).cannotAcceptMoves; got != tt.want {
31- t.Fatalf("snapshotView().cannotAcceptMoves = %v, want %v", got, tt.want)
32- }
33- })
34- }
35-}
36-
3718 func TestEnterPickerForIntentRejectsNonYieldingSource(t *testing.T) {
3819 d, sess, ac, _ := newManualSessionWithPTYs(t)
3920 sess.caps = sessionCapabilities{cannotYieldMoves: true}
M internal/usecase/daemon/session_core.go +0 -3
@@ -15,7 +15,6 @@ type attachmentSession interface {
1515 statusSegments(includeTerminalTitle bool) statusSnapshot
1616 captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool)
1717 validTargetTabLocked(tabIndex int) bool
18- isProxy() bool
1918 }
2019
2120 // sessionCore is the state shared by every attachment-capable session. It is
@@ -47,8 +46,6 @@ func (s *session) core() *sessionCore {
4746 return &s.sessionCore
4847 }
4948
50-func (s *session) isProxy() bool { return false }
51-
5249 func (s *session) captureRenderState(ac *attachedClient, req renderCaptureRequest) (*capturedRenderState, bool) {
5350 return captureLocalRenderState(s, ac, req)
5451 }
M internal/usecase/daemon/session_core_test.go +0 -1
@@ -31,7 +31,6 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
3131 core := sess.core()
3232 require.Same(t, &sess.sessionCore, core)
3333 require.Same(t, sess, mustLocalSession(t, attachmentSession(sess)))
34- require.False(t, sess.isProxy())
3534 require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")
3635
3736 core.mu.Lock()
M internal/usecase/daemon/session_view.go +29 -33
@@ -25,23 +25,21 @@ type tabView struct {
2525
2626 // sessionView is an immutable, value-only description of a session. It
2727 // deliberately retains no live pointers, mirroring recentSession
28-// (recent_sessions.go). Listing paths (picker, MRU bar, palette,
29-// list-sessions) read local session state through snapshotView; proxy rows in
30-// later phases still come from registry/discovery, not this type alone.
28+// (recent_sessions.go). Listing paths (picker, MRU bar, palette, and
29+// list-sessions) read local session state through snapshotView.
3130 type sessionView struct {
32- id domain.SessionID
33- incarnation domain.IncarnationID
34- name string
35- ephemeral bool
36- createdAt int64
37- defaultTab int
38- mruAt uint64
39- attached bool
40- tabCount int
41- hasAttention bool
42- tabs []tabView
43- cannotAcceptMoves bool
44- expired bool
31+ id domain.SessionID
32+ incarnation domain.IncarnationID
33+ name string
34+ ephemeral bool
35+ createdAt int64
36+ defaultTab int
37+ mruAt uint64
38+ attached bool
39+ tabCount int
40+ hasAttention bool
41+ tabs []tabView
42+ expired bool
4543 }
4644
4745 // snapshotView reads session fields under s.mu and samples the independently
@@ -57,16 +55,15 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
5755 // default. Interactive callers pass an attachment and resolve its stable
5856 // view separately.
5957 view := sessionView{
60- id: s.id,
61- incarnation: s.incarnation,
62- name: s.name,
63- ephemeral: s.ephemeral,
64- createdAt: s.createdAt,
65- defaultTab: 0,
66- mruAt: s.mruAt.Load(),
67- attached: len(s.attachments) != 0,
68- tabCount: len(s.tabs),
69- cannotAcceptMoves: s.capabilities().cannotAcceptMoves,
58+ id: s.id,
59+ incarnation: s.incarnation,
60+ name: s.name,
61+ ephemeral: s.ephemeral,
62+ createdAt: s.createdAt,
63+ defaultTab: 0,
64+ mruAt: s.mruAt.Load(),
65+ attached: len(s.attachments) != 0,
66+ tabCount: len(s.tabs),
7067 }
7168 if opts.tabDetails {
7269 view.tabs = make([]tabView, 0, len(s.tabs))
@@ -100,13 +97,12 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
10097 // equivalent to the inline construction previously in pickerViews.
10198 func (view sessionView) pickerView() picker.SessionView {
10299 out := picker.SessionView{
103- ID: view.id,
104- Incarnation: view.incarnation,
105- Name: view.name,
106- TargetName: view.name,
107- Active: view.defaultTab,
108- Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
109- CannotAcceptMoves: view.cannotAcceptMoves,
100+ ID: view.id,
101+ Incarnation: view.incarnation,
102+ Name: view.name,
103+ TargetName: view.name,
104+ Active: view.defaultTab,
105+ Tabs: make([]picker.TabEntry, 0, len(view.tabs)),
110106 }
111107 if !view.ephemeral {
112108 createdAt := view.createdAt
M internal/usecase/daemon/status_test.go +0 -1
@@ -35,7 +35,6 @@ func (*coreNilAttachment) captureRenderState(*attachedClient, renderCaptureReque
3535 return nil, false
3636 }
3737 func (*coreNilAttachment) validTargetTabLocked(int) bool { return false }
38-func (*coreNilAttachment) isProxy() bool { return false }
3938
4039 // stubClock returns timers whose channel never fires, so a scheduler under it
4140 // blocks in its debounce loop until the session context is cancelled. Used by
M internal/usecase/picker/model.go +1 -1
@@ -43,7 +43,7 @@ type SessionView struct {
4343 RemoteAttachReady bool
4444 // CannotAcceptMoves reports whether this session cannot receive a moved tab
4545 // or pane. False for ordinary local (and stopped) sessions; true for
46- // restricted proxy sessions.
46+ // restricted remote rows.
4747 CannotAcceptMoves bool
4848 }
4949

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close