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

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close