detached 26c89eb

feat(remote): add host management and remote listing

Signed-off-by: brice <b@bnema.dev>
commit
26c89eb69cb976bdd1a1bdc5797213544dc73202
author
brice · · 2026-07-30 12:21 UTC
parent
9fcc192
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +1455 -135
M README.md +25 -1
@@ -40,7 +40,12 @@ vev create an ephemeral session
4040 vev new <name> create a named session
4141 vev attach <name> attach to a session (alias: a)
4242 vev attach user@host[:session] attach to a remote daemon over SSH
43-vev ls list sessions
43+vev ls list local sessions
44+vev ls <host> list sessions on a known remote host
45+vev ls --all list local and remote sessions
46+vev host add <host> add a pinned remote host
47+vev host rm <host> remove a pinned remote host
48+vev host list list known remote hosts
4449 vev kill <name> kill a session (--all kills everything)
4550 ```
4651
@@ -76,6 +81,25 @@ sudo ufw allow 61000:61023/udp
7681
7782 Where UDP is not an option, `VEV_REMOTE_TRANSPORT=stdio` keeps everything inside SSH at the cost of slower disconnect detection. Details in [docs/remote-resilience.md](docs/remote-resilience.md).
7883
84+List sessions on a known host (OpenSSH resolves aliases from your SSH config):
85+
86+```sh
87+vev ls arch
88+vev ls --all
89+```
90+
91+Remote session names appear as `session@host`. `vev ls --all` prints local sessions first, then remote sessions in host order. If one host fails, vev still prints the rest and exits non-zero.
92+
93+Manage pinned hosts in the unified host state file through the CLI:
94+
95+```sh
96+vev host add arch
97+vev host rm arch
98+vev host list
99+```
100+
101+Successful attaches learn hosts into `$XDG_STATE_HOME/vev/hosts.json`; that file stores both pinned and learned hosts. See [docs/configuration.md](docs/configuration.md#remote-hosts).
102+
79103 ## Scripting
80104
81105 `vev cmd` runs control commands against a running daemon; it never starts one. For example:
@@ -40,7 +40,12 @@ vev create an ephemeral session
40 vev new <name> create a named session40 vev new <name> create a named session
41 vev attach <name> attach to a session (alias: a)41 vev attach <name> attach to a session (alias: a)
42 vev attach user@host[:session] attach to a remote daemon over SSH42 vev attach user@host[:session] attach to a remote daemon over SSH
43-vev ls list sessions43+vev ls list local sessions
44+vev ls <host> list sessions on a known remote host
45+vev ls --all list local and remote sessions
46+vev host add <host> add a pinned remote host
47+vev host rm <host> remove a pinned remote host
48+vev host list list known remote hosts
44 vev kill <name> kill a session (--all kills everything)49 vev kill <name> kill a session (--all kills everything)
45 ```50 ```
46 51
@@ -76,6 +81,25 @@ sudo ufw allow 61000:61023/udp
76 81
77 Where UDP is not an option, `VEV_REMOTE_TRANSPORT=stdio` keeps everything inside SSH at the cost of slower disconnect detection. Details in [docs/remote-resilience.md](docs/remote-resilience.md).82 Where UDP is not an option, `VEV_REMOTE_TRANSPORT=stdio` keeps everything inside SSH at the cost of slower disconnect detection. Details in [docs/remote-resilience.md](docs/remote-resilience.md).
78 83
84+List sessions on a known host (OpenSSH resolves aliases from your SSH config):
85+
86+```sh
87+vev ls arch
88+vev ls --all
89+```
90+
91+Remote session names appear as `session@host`. `vev ls --all` prints local sessions first, then remote sessions in host order. If one host fails, vev still prints the rest and exits non-zero.
92+
93+Manage pinned hosts in the unified host state file through the CLI:
94+
95+```sh
96+vev host add arch
97+vev host rm arch
98+vev host list
99+```
100+
101+Successful attaches learn hosts into `$XDG_STATE_HOME/vev/hosts.json`; that file stores both pinned and learned hosts. See [docs/configuration.md](docs/configuration.md#remote-hosts).
102+
79 ## Scripting103 ## Scripting
80 104
81 `vev cmd` runs control commands against a running daemon; it never starts one. For example:105 `vev cmd` runs control commands against a running daemon; it never starts one. For example:
M docs/configuration.md +9 -1
@@ -79,9 +79,17 @@ code.detach = DET
7979
8080 Invalid values log a warning and resolve that setting to its default on both initial load and reload.
8181
82+## Remote hosts
83+
84+Remote host commands, listing, and successful direct-attach learning are always active.
85+
86+`$XDG_STATE_HOME/vev/hosts.json` (or `~/.local/state/vev/hosts.json` when unset) is the only host-list location. It is versioned JSON with pinned and learned targets. `vev host add` adds a pinned target, `vev host rm` atomically removes a target from both sets, and `vev host list` shows `pinned`, `learned`, or `pinned,learned` in its `SOURCE` column. Pinned hosts keep stored order; learned-only hosts follow in lexical order. vev rejects empty targets, surrounding whitespace, and internal whitespace or control characters; SSH alias grammar is left to OpenSSH.
87+
88+`vev ls <host>` and `vev ls --all` run `ssh -- <host> 'vev cmd remote-catalog --json'` for each known host. OpenSSH resolves aliases and connection settings from your SSH config. Remote session names appear as `session@host`. `vev ls --all` prints local sessions first, then remote sessions in merged host order. A catalog failure is reported after the successful output with the host and error; the command exits non-zero so partial output is not mistaken for a complete inventory.
89+
8290 ## Logs and durable state
8391
84-Set `VEV_LOG=debug`, `VEV_LOG=warn`, or `VEV_LOG=error` to change verbosity; the default is `info`. JSON-line logs such as `vev-daemon.log` live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset. The same state directory contains the strict session catalogue, migration journal, notices, and `snapshots/`. The lifecycle lock and socket live in `$XDG_RUNTIME_DIR/vev` (with platform runtime fallbacks).
92+Set `VEV_LOG=debug`, `VEV_LOG=warn`, or `VEV_LOG=error` to change verbosity; the default is `info`. JSON-line logs such as `vev-daemon.log` live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset. The same state directory contains the strict session catalogue, migration journal, notices, `hosts.json`, and `snapshots/`. The lifecycle lock and socket live in `$XDG_RUNTIME_DIR/vev` (with platform runtime fallbacks).
8593
8694 Recovery events include `lifecycle_owner_wait`, `lifecycle_owner_acquired`, `lifecycle_owner_released`, `catalogue_validated`, `catalogue_compaction_recovery_complete`, `session_restore_complete`, `fallback_checkpoint_promoted`, `snapshot_head_repair_complete`, `session_degraded`, `snapshot_maintenance_progress`, `interrupted_transaction_recovery_complete`, and `daemon_startup_complete`.
8795
@@ -79,9 +79,17 @@ code.detach = DET
79 79
80 Invalid values log a warning and resolve that setting to its default on both initial load and reload.80 Invalid values log a warning and resolve that setting to its default on both initial load and reload.
81 81
82+## Remote hosts
83+
84+Remote host commands, listing, and successful direct-attach learning are always active.
85+
86+`$XDG_STATE_HOME/vev/hosts.json` (or `~/.local/state/vev/hosts.json` when unset) is the only host-list location. It is versioned JSON with pinned and learned targets. `vev host add` adds a pinned target, `vev host rm` atomically removes a target from both sets, and `vev host list` shows `pinned`, `learned`, or `pinned,learned` in its `SOURCE` column. Pinned hosts keep stored order; learned-only hosts follow in lexical order. vev rejects empty targets, surrounding whitespace, and internal whitespace or control characters; SSH alias grammar is left to OpenSSH.
87+
88+`vev ls <host>` and `vev ls --all` run `ssh -- <host> 'vev cmd remote-catalog --json'` for each known host. OpenSSH resolves aliases and connection settings from your SSH config. Remote session names appear as `session@host`. `vev ls --all` prints local sessions first, then remote sessions in merged host order. A catalog failure is reported after the successful output with the host and error; the command exits non-zero so partial output is not mistaken for a complete inventory.
89+
82 ## Logs and durable state90 ## Logs and durable state
83 91
84-Set `VEV_LOG=debug`, `VEV_LOG=warn`, or `VEV_LOG=error` to change verbosity; the default is `info`. JSON-line logs such as `vev-daemon.log` live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset. The same state directory contains the strict session catalogue, migration journal, notices, and `snapshots/`. The lifecycle lock and socket live in `$XDG_RUNTIME_DIR/vev` (with platform runtime fallbacks).92+Set `VEV_LOG=debug`, `VEV_LOG=warn`, or `VEV_LOG=error` to change verbosity; the default is `info`. JSON-line logs such as `vev-daemon.log` live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset. The same state directory contains the strict session catalogue, migration journal, notices, `hosts.json`, and `snapshots/`. The lifecycle lock and socket live in `$XDG_RUNTIME_DIR/vev` (with platform runtime fallbacks).
85 93
86 Recovery events include `lifecycle_owner_wait`, `lifecycle_owner_acquired`, `lifecycle_owner_released`, `catalogue_validated`, `catalogue_compaction_recovery_complete`, `session_restore_complete`, `fallback_checkpoint_promoted`, `snapshot_head_repair_complete`, `session_degraded`, `snapshot_maintenance_progress`, `interrupted_transaction_recovery_complete`, and `daemon_startup_complete`.94 Recovery events include `lifecycle_owner_wait`, `lifecycle_owner_acquired`, `lifecycle_owner_released`, `catalogue_validated`, `catalogue_compaction_recovery_complete`, `session_restore_complete`, `fallback_checkpoint_promoted`, `snapshot_head_repair_complete`, `session_degraded`, `snapshot_maintenance_progress`, `interrupted_transaction_recovery_complete`, and `daemon_startup_complete`.
87 95
M internal/adapters/remote/host_registry.go +10 -5
@@ -148,11 +148,11 @@ func (s *fileHostStore) Forget(target string) error {
148148 return err
149149 }
150150
151-func (s *fileHostStore) Remove(target string) error {
151+func (s *fileHostStore) Remove(target string) (deleted bool, err error) {
152152 if err := validateHostTarget(target); err != nil {
153- return err
153+ return false, err
154154 }
155- err := s.withLock(func() error {
155+ err = s.withLock(func() error {
156156 state, err := s.loadLocked()
157157 if err != nil {
158158 return err
@@ -164,12 +164,17 @@ func (s *fileHostStore) Remove(target string) error {
164164 }
165165 state.pinned = nextPinned
166166 delete(state.learned, target)
167- return s.saveLocked(state)
167+ if err := s.saveLocked(state); err != nil {
168+ return err
169+ }
170+ deleted = true
171+ return nil
168172 })
169173 if err != nil {
170174 slog.Debug("remote host store remove failed", "path", s.path, "target", target, "err", err)
175+ return false, err
171176 }
172- return err
177+ return deleted, nil
173178 }
174179
175180 func (s *fileHostStore) withLock(fn func() error) error {
@@ -148,11 +148,11 @@ func (s *fileHostStore) Forget(target string) error {
148 return err148 return err
149 }149 }
150 150
151-func (s *fileHostStore) Remove(target string) error {151+func (s *fileHostStore) Remove(target string) (deleted bool, err error) {
152 if err := validateHostTarget(target); err != nil {152 if err := validateHostTarget(target); err != nil {
153- return err153+ return false, err
154 }154 }
155- err := s.withLock(func() error {155+ err = s.withLock(func() error {
156 state, err := s.loadLocked()156 state, err := s.loadLocked()
157 if err != nil {157 if err != nil {
158 return err158 return err
@@ -164,12 +164,17 @@ func (s *fileHostStore) Remove(target string) error {
164 }164 }
165 state.pinned = nextPinned165 state.pinned = nextPinned
166 delete(state.learned, target)166 delete(state.learned, target)
167- return s.saveLocked(state)167+ if err := s.saveLocked(state); err != nil {
168+ return err
169+ }
170+ deleted = true
171+ return nil
168 })172 })
169 if err != nil {173 if err != nil {
170 slog.Debug("remote host store remove failed", "path", s.path, "target", target, "err", err)174 slog.Debug("remote host store remove failed", "path", s.path, "target", target, "err", err)
175+ return false, err
171 }176 }
172- return err177+ return deleted, nil
173 }178 }
174 179
175 func (s *fileHostStore) withLock(fn func() error) error {180 func (s *fileHostStore) withLock(fn func() error) error {
M internal/adapters/remote/host_registry_test.go +8 -4
@@ -229,12 +229,16 @@ func TestHostStore(t *testing.T) {
229229 if err := store.Forget("missing"); err != nil {
230230 t.Fatalf("Forget(missing) error = %v", err)
231231 }
232- if err := store.Remove("missing"); err != nil {
232+ if deleted, err := store.Remove("missing"); err != nil {
233233 t.Fatalf("Remove(missing) error = %v", err)
234+ } else if deleted {
235+ t.Fatal("Remove(missing) deleted = true, want false")
234236 }
235237
236- if err := store.Remove("arch"); err != nil {
238+ if deleted, err := store.Remove("arch"); err != nil {
237239 t.Fatalf("Remove(arch) error = %v", err)
240+ } else if !deleted {
241+ t.Fatal("Remove(arch) deleted = false, want true")
238242 }
239243 pinned, learned, err := store.Hosts()
240244 if err != nil {
@@ -278,7 +282,7 @@ func TestHostStore(t *testing.T) {
278282 if err := store.Remember("arch"); err == nil {
279283 t.Fatal("Remember() error = nil, want malformed error")
280284 }
281- if err := store.Remove("arch"); err == nil {
285+ if _, err := store.Remove("arch"); err == nil {
282286 t.Fatal("Remove() error = nil, want malformed error")
283287 }
284288
@@ -446,7 +450,7 @@ func TestHostStore(t *testing.T) {
446450 if err := store.Remember(target); err == nil {
447451 t.Fatalf("Remember(%q) error = nil, want validation error", target)
448452 }
449- if err := store.Remove(target); err == nil {
453+ if _, err := store.Remove(target); err == nil {
450454 t.Fatalf("Remove(%q) error = nil, want validation error", target)
451455 }
452456 }
@@ -229,12 +229,16 @@ func TestHostStore(t *testing.T) {
229 if err := store.Forget("missing"); err != nil {229 if err := store.Forget("missing"); err != nil {
230 t.Fatalf("Forget(missing) error = %v", err)230 t.Fatalf("Forget(missing) error = %v", err)
231 }231 }
232- if err := store.Remove("missing"); err != nil {232+ if deleted, err := store.Remove("missing"); err != nil {
233 t.Fatalf("Remove(missing) error = %v", err)233 t.Fatalf("Remove(missing) error = %v", err)
234+ } else if deleted {
235+ t.Fatal("Remove(missing) deleted = true, want false")
234 }236 }
235 237
236- if err := store.Remove("arch"); err != nil {238+ if deleted, err := store.Remove("arch"); err != nil {
237 t.Fatalf("Remove(arch) error = %v", err)239 t.Fatalf("Remove(arch) error = %v", err)
240+ } else if !deleted {
241+ t.Fatal("Remove(arch) deleted = false, want true")
238 }242 }
239 pinned, learned, err := store.Hosts()243 pinned, learned, err := store.Hosts()
240 if err != nil {244 if err != nil {
@@ -278,7 +282,7 @@ func TestHostStore(t *testing.T) {
278 if err := store.Remember("arch"); err == nil {282 if err := store.Remember("arch"); err == nil {
279 t.Fatal("Remember() error = nil, want malformed error")283 t.Fatal("Remember() error = nil, want malformed error")
280 }284 }
281- if err := store.Remove("arch"); err == nil {285+ if _, err := store.Remove("arch"); err == nil {
282 t.Fatal("Remove() error = nil, want malformed error")286 t.Fatal("Remove() error = nil, want malformed error")
283 }287 }
284 288
@@ -446,7 +450,7 @@ func TestHostStore(t *testing.T) {
446 if err := store.Remember(target); err == nil {450 if err := store.Remember(target); err == nil {
447 t.Fatalf("Remember(%q) error = nil, want validation error", target)451 t.Fatalf("Remember(%q) error = nil, want validation error", target)
448 }452 }
449- if err := store.Remove(target); err == nil {453+ if _, err := store.Remove(target); err == nil {
450 t.Fatalf("Remove(%q) error = nil, want validation error", target)454 t.Fatalf("Remove(%q) error = nil, want validation error", target)
451 }455 }
452 }456 }
M internal/app/daemon_integration_test.go +1 -1
@@ -481,7 +481,7 @@ func TestListWaitsForLifecycleOwner(t *testing.T) {
481481 require.NoError(t, err)
482482
483483 done := make(chan error, 1)
484- go func() { done <- runList(context.Background()) }()
484+ go func() { done <- runList(context.Background(), command{kind: kindList}) }()
485485 require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond)
486486 require.NoError(t, owner.Release())
487487 require.NoError(t, <-done)
@@ -481,7 +481,7 @@ func TestListWaitsForLifecycleOwner(t *testing.T) {
481 require.NoError(t, err)481 require.NoError(t, err)
482 482
483 done := make(chan error, 1)483 done := make(chan error, 1)
484- go func() { done <- runList(context.Background()) }()484+ go func() { done <- runList(context.Background(), command{kind: kindList}) }()
485 require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond)485 require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond)
486 require.NoError(t, owner.Release())486 require.NoError(t, owner.Release())
487 require.NoError(t, <-done)487 require.NoError(t, <-done)
M internal/app/remote.go +8 -2
@@ -6,14 +6,17 @@ import "strings"
66 // local session names. Only values with a user@host prefix are remote; local
77 // names may still contain ':' when they do not contain '@'.
88 func parseRemoteAttachTarget(s string) (target, session string, ok bool) {
9- at := strings.LastIndexByte(s, '@')
9+ if strings.Count(s, "@") != 1 {
10+ return "", "", false
11+ }
12+ at := strings.IndexByte(s, '@')
1013 if at <= 0 || at == len(s)-1 {
1114 return "", "", false
1215 }
1316 afterAt := s[at+1:]
1417 if strings.HasPrefix(afterAt, "[") {
1518 closeBracket := strings.IndexByte(afterAt, ']')
16- if closeBracket < 0 {
19+ if closeBracket <= 1 || strings.Contains(afterAt[closeBracket+1:], "]") {
1720 return "", "", false
1821 }
1922 suffix := afterAt[closeBracket+1:]
@@ -27,6 +30,9 @@ func parseRemoteAttachTarget(s string) (target, session string, ok bool) {
2730 return "", "", false
2831 }
2932 }
33+ if strings.ContainsAny(afterAt, "[]") || strings.Count(afterAt, ":") > 1 {
34+ return "", "", false
35+ }
3036 colon := strings.LastIndexByte(afterAt, ':')
3137 if colon < 0 {
3238 return s, "", true
@@ -6,14 +6,17 @@ import "strings"
6 // local session names. Only values with a user@host prefix are remote; local6 // local session names. Only values with a user@host prefix are remote; local
7 // names may still contain ':' when they do not contain '@'.7 // names may still contain ':' when they do not contain '@'.
8 func parseRemoteAttachTarget(s string) (target, session string, ok bool) {8 func parseRemoteAttachTarget(s string) (target, session string, ok bool) {
9- at := strings.LastIndexByte(s, '@')9+ if strings.Count(s, "@") != 1 {
10+ return "", "", false
11+ }
12+ at := strings.IndexByte(s, '@')
10 if at <= 0 || at == len(s)-1 {13 if at <= 0 || at == len(s)-1 {
11 return "", "", false14 return "", "", false
12 }15 }
13 afterAt := s[at+1:]16 afterAt := s[at+1:]
14 if strings.HasPrefix(afterAt, "[") {17 if strings.HasPrefix(afterAt, "[") {
15 closeBracket := strings.IndexByte(afterAt, ']')18 closeBracket := strings.IndexByte(afterAt, ']')
16- if closeBracket < 0 {19+ if closeBracket <= 1 || strings.Contains(afterAt[closeBracket+1:], "]") {
17 return "", "", false20 return "", "", false
18 }21 }
19 suffix := afterAt[closeBracket+1:]22 suffix := afterAt[closeBracket+1:]
@@ -27,6 +30,9 @@ func parseRemoteAttachTarget(s string) (target, session string, ok bool) {
27 return "", "", false30 return "", "", false
28 }31 }
29 }32 }
33+ if strings.ContainsAny(afterAt, "[]") || strings.Count(afterAt, ":") > 1 {
34+ return "", "", false
35+ }
30 colon := strings.LastIndexByte(afterAt, ':')36 colon := strings.LastIndexByte(afterAt, ':')
31 if colon < 0 {37 if colon < 0 {
32 return s, "", true38 return s, "", true
A internal/app/remote_hosts.go +283 -0
@@ -0,0 +1,283 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "log/slog"
9+ "os"
10+ "path/filepath"
11+ "sort"
12+ "text/tabwriter"
13+
14+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/platform"
17+ "github.com/bnema/vev/internal/ports"
18+)
19+
20+const (
21+ hostActionAdd = "add"
22+ hostActionRm = "rm"
23+ hostActionList = "list"
24+)
25+
26+// remoteHostDeps holds injectable seams for host management and remote listing.
27+type remoteHostDeps struct {
28+ stateDir func() string
29+ store ports.RemoteHostStore
30+ catalog ports.RemoteCatalogClient
31+ localList func(context.Context) ([]ports.SessionInfo, error)
32+ stdout io.Writer
33+}
34+
35+func defaultRemoteHostDeps() remoteHostDeps {
36+ return remoteHostDeps{
37+ stateDir: platform.StateDir,
38+ catalog: remoteadapter.NewCatalogClient(),
39+ localList: listLocalSessions,
40+ stdout: os.Stdout,
41+ }
42+}
43+
44+func (d remoteHostDeps) withDefaults() remoteHostDeps {
45+ if d.stateDir == nil {
46+ d.stateDir = platform.StateDir
47+ }
48+ if d.store == nil {
49+ d.store = remoteadapter.NewFileHostStore(filepath.Join(d.stateDir(), "hosts.json"))
50+ }
51+ if d.catalog == nil {
52+ d.catalog = remoteadapter.NewCatalogClient()
53+ }
54+ if d.localList == nil {
55+ d.localList = listLocalSessions
56+ }
57+ if d.stdout == nil {
58+ d.stdout = os.Stdout
59+ }
60+ return d
61+}
62+
63+func (d remoteHostDeps) hostStore() ports.RemoteHostStore {
64+ return d.store
65+}
66+
67+func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error {
68+ _ = ctx
69+ deps = deps.withDefaults()
70+
71+ switch cmd.hostAction {
72+ case hostActionAdd:
73+ return hostAdd(deps, cmd.hostTarget)
74+ case hostActionRm:
75+ return hostRm(deps, cmd.hostTarget)
76+ case hostActionList:
77+ return hostList(deps)
78+ default:
79+ return usagef("unknown host action %q", cmd.hostAction)
80+ }
81+}
82+
83+func hostAdd(deps remoteHostDeps, target string) error {
84+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
85+ return err
86+ }
87+ if err := deps.hostStore().AddPinned(target); err != nil {
88+ return fmt.Errorf("vev: adding pinned host %q: %w", target, err)
89+ }
90+ return nil
91+}
92+
93+func hostRm(deps remoteHostDeps, target string) error {
94+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
95+ return err
96+ }
97+ deleted, err := deps.hostStore().Remove(target)
98+ if err != nil {
99+ return fmt.Errorf("vev: removing host %q: %w", target, err)
100+ }
101+ if !deleted {
102+ return fmt.Errorf("vev: unknown host %q", target)
103+ }
104+ return nil
105+}
106+
107+func hostList(deps remoteHostDeps) error {
108+ hosts, err := mergeKnownHosts(deps)
109+ if err != nil {
110+ return err
111+ }
112+ if len(hosts) == 0 {
113+ _, _ = fmt.Fprintln(deps.stdout, "no hosts")
114+ return nil
115+ }
116+ tw := tabwriter.NewWriter(deps.stdout, 0, 4, 2, ' ', 0)
117+ _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE")
118+ for _, host := range hosts {
119+ _, _ = fmt.Fprintf(tw, "%s\t%s\n", host.Target, remoteHostSource(host))
120+ }
121+ return tw.Flush()
122+}
123+
124+func remoteHostSource(host domain.RemoteHost) string {
125+ switch {
126+ case host.Pinned && host.Learned:
127+ return "pinned,learned"
128+ case host.Pinned:
129+ return "pinned"
130+ case host.Learned:
131+ return "learned"
132+ default:
133+ return ""
134+ }
135+}
136+
137+func runRemoteList(ctx context.Context, cmd command, deps remoteHostDeps) error {
138+ deps = deps.withDefaults()
139+
140+ hosts, err := mergeKnownHosts(deps)
141+ if err != nil {
142+ return err
143+ }
144+ if cmd.listHost != "" {
145+ return listOneRemoteHost(ctx, deps, hosts, cmd.listHost)
146+ }
147+ return listAllSessions(ctx, deps, hosts)
148+}
149+
150+func listOneRemoteHost(ctx context.Context, deps remoteHostDeps, hosts []domain.RemoteHost, target string) error {
151+ known := false
152+ for _, host := range hosts {
153+ if host.Target == target {
154+ known = true
155+ break
156+ }
157+ }
158+ if !known {
159+ return fmt.Errorf("vev: unknown host %q", target)
160+ }
161+ catalog, err := deps.catalog.List(ctx, target)
162+ if err != nil {
163+ return fmt.Errorf("vev: listing sessions on %q: %w", target, err)
164+ }
165+ printSessions(deps.stdout, catalogSessionsAsInfo(target, catalog.Sessions))
166+ return nil
167+}
168+
169+// listAllSessions queries hosts sequentially for deterministic output ordering.
170+func listAllSessions(ctx context.Context, deps remoteHostDeps, hosts []domain.RemoteHost) error {
171+ local, err := deps.localList(ctx)
172+ if err != nil {
173+ if local != nil {
174+ printSessions(deps.stdout, local)
175+ }
176+ return err
177+ }
178+ all := append([]ports.SessionInfo(nil), local...)
179+ var hostErrs []error
180+ for _, host := range hosts {
181+ if err := ctx.Err(); err != nil {
182+ printSessions(deps.stdout, all)
183+ return err
184+ }
185+ catalog, listErr := deps.catalog.List(ctx, host.Target)
186+ if listErr != nil {
187+ hostErrs = append(hostErrs, fmt.Errorf("host %s: %w", host.Target, listErr))
188+ continue
189+ }
190+ all = append(all, catalogSessionsAsInfo(host.Target, catalog.Sessions)...)
191+ }
192+ printSessions(deps.stdout, all)
193+ if len(hostErrs) > 0 {
194+ return errors.Join(hostErrs...)
195+ }
196+ return nil
197+}
198+
199+func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) []ports.SessionInfo {
200+ out := make([]ports.SessionInfo, 0, len(sessions))
201+ for _, session := range sessions {
202+ info := ports.SessionInfo{
203+ Name: session.Name + "@" + host,
204+ Ephemeral: session.Ephemeral,
205+ Tabs: session.Tabs,
206+ Attached: session.Attached,
207+ }
208+ switch session.State {
209+ case "running":
210+ info.State = ports.SessionRunning
211+ case "stopped":
212+ info.State = ports.SessionStopped
213+ case "broken":
214+ info.State = ports.SessionBroken
215+ default:
216+ slog.Debug("remote catalog session has unknown state", "host", host, "session", session.Name, "state", session.State)
217+ info.State = ports.SessionBroken
218+ }
219+ out = append(out, info)
220+ }
221+ return out
222+}
223+
224+func mergeKnownHosts(deps remoteHostDeps) ([]domain.RemoteHost, error) {
225+ pinned, learned, err := deps.hostStore().Hosts()
226+ if err != nil {
227+ return nil, fmt.Errorf("vev: reading hosts: %w", err)
228+ }
229+ return mergeRemoteHosts(pinned, learned), nil
230+}
231+
232+// mergeRemoteHosts returns pinned hosts in stored order, then learned-only
233+// hosts in lexical order. Duplicates keep first occurrence and mark both sources.
234+func mergeRemoteHosts(pinned, learned []string) []domain.RemoteHost {
235+ pinned = domain.UniqueRemoteHostTargets(pinned)
236+ learnedSet := make(map[string]struct{}, len(learned))
237+ for _, target := range learned {
238+ learnedSet[target] = struct{}{}
239+ }
240+ pinnedSet := make(map[string]struct{}, len(pinned))
241+ out := make([]domain.RemoteHost, 0, len(pinned)+len(learned))
242+ for _, target := range pinned {
243+ pinnedSet[target] = struct{}{}
244+ _, isLearned := learnedSet[target]
245+ out = append(out, domain.RemoteHost{Target: target, Pinned: true, Learned: isLearned})
246+ }
247+ learnedOnly := make([]string, 0, len(learned))
248+ for _, target := range learned {
249+ if _, ok := pinnedSet[target]; !ok {
250+ learnedOnly = append(learnedOnly, target)
251+ }
252+ }
253+ learnedOnly = domain.UniqueRemoteHostTargets(learnedOnly)
254+ sort.Strings(learnedOnly)
255+ for _, target := range learnedOnly {
256+ out = append(out, domain.RemoteHost{Target: target, Learned: true})
257+ }
258+ return out
259+}
260+
261+type remoteHostLearner struct {
262+ store ports.RemoteHostStore
263+ target string
264+}
265+
266+func (l remoteHostLearner) RememberRemoteHost() error {
267+ return l.store.Remember(l.target)
268+}
269+
270+func attachRememberLearner(deps runAttachDeps, remoteTarget string, _ *slog.Logger) ports.RemoteHostLearner {
271+ if remoteTarget == "" {
272+ return nil
273+ }
274+ store := deps.hostStore
275+ if store == nil {
276+ stateDir := deps.stateDir
277+ if stateDir == nil {
278+ stateDir = platform.StateDir
279+ }
280+ store = remoteadapter.NewFileHostStore(filepath.Join(stateDir(), "hosts.json"))
281+ }
282+ return remoteHostLearner{store: store, target: remoteTarget}
283+}
@@ -0,0 +1,283 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "log/slog"
9+ "os"
10+ "path/filepath"
11+ "sort"
12+ "text/tabwriter"
13+
14+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/platform"
17+ "github.com/bnema/vev/internal/ports"
18+)
19+
20+const (
21+ hostActionAdd = "add"
22+ hostActionRm = "rm"
23+ hostActionList = "list"
24+)
25+
26+// remoteHostDeps holds injectable seams for host management and remote listing.
27+type remoteHostDeps struct {
28+ stateDir func() string
29+ store ports.RemoteHostStore
30+ catalog ports.RemoteCatalogClient
31+ localList func(context.Context) ([]ports.SessionInfo, error)
32+ stdout io.Writer
33+}
34+
35+func defaultRemoteHostDeps() remoteHostDeps {
36+ return remoteHostDeps{
37+ stateDir: platform.StateDir,
38+ catalog: remoteadapter.NewCatalogClient(),
39+ localList: listLocalSessions,
40+ stdout: os.Stdout,
41+ }
42+}
43+
44+func (d remoteHostDeps) withDefaults() remoteHostDeps {
45+ if d.stateDir == nil {
46+ d.stateDir = platform.StateDir
47+ }
48+ if d.store == nil {
49+ d.store = remoteadapter.NewFileHostStore(filepath.Join(d.stateDir(), "hosts.json"))
50+ }
51+ if d.catalog == nil {
52+ d.catalog = remoteadapter.NewCatalogClient()
53+ }
54+ if d.localList == nil {
55+ d.localList = listLocalSessions
56+ }
57+ if d.stdout == nil {
58+ d.stdout = os.Stdout
59+ }
60+ return d
61+}
62+
63+func (d remoteHostDeps) hostStore() ports.RemoteHostStore {
64+ return d.store
65+}
66+
67+func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error {
68+ _ = ctx
69+ deps = deps.withDefaults()
70+
71+ switch cmd.hostAction {
72+ case hostActionAdd:
73+ return hostAdd(deps, cmd.hostTarget)
74+ case hostActionRm:
75+ return hostRm(deps, cmd.hostTarget)
76+ case hostActionList:
77+ return hostList(deps)
78+ default:
79+ return usagef("unknown host action %q", cmd.hostAction)
80+ }
81+}
82+
83+func hostAdd(deps remoteHostDeps, target string) error {
84+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
85+ return err
86+ }
87+ if err := deps.hostStore().AddPinned(target); err != nil {
88+ return fmt.Errorf("vev: adding pinned host %q: %w", target, err)
89+ }
90+ return nil
91+}
92+
93+func hostRm(deps remoteHostDeps, target string) error {
94+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
95+ return err
96+ }
97+ deleted, err := deps.hostStore().Remove(target)
98+ if err != nil {
99+ return fmt.Errorf("vev: removing host %q: %w", target, err)
100+ }
101+ if !deleted {
102+ return fmt.Errorf("vev: unknown host %q", target)
103+ }
104+ return nil
105+}
106+
107+func hostList(deps remoteHostDeps) error {
108+ hosts, err := mergeKnownHosts(deps)
109+ if err != nil {
110+ return err
111+ }
112+ if len(hosts) == 0 {
113+ _, _ = fmt.Fprintln(deps.stdout, "no hosts")
114+ return nil
115+ }
116+ tw := tabwriter.NewWriter(deps.stdout, 0, 4, 2, ' ', 0)
117+ _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE")
118+ for _, host := range hosts {
119+ _, _ = fmt.Fprintf(tw, "%s\t%s\n", host.Target, remoteHostSource(host))
120+ }
121+ return tw.Flush()
122+}
123+
124+func remoteHostSource(host domain.RemoteHost) string {
125+ switch {
126+ case host.Pinned && host.Learned:
127+ return "pinned,learned"
128+ case host.Pinned:
129+ return "pinned"
130+ case host.Learned:
131+ return "learned"
132+ default:
133+ return ""
134+ }
135+}
136+
137+func runRemoteList(ctx context.Context, cmd command, deps remoteHostDeps) error {
138+ deps = deps.withDefaults()
139+
140+ hosts, err := mergeKnownHosts(deps)
141+ if err != nil {
142+ return err
143+ }
144+ if cmd.listHost != "" {
145+ return listOneRemoteHost(ctx, deps, hosts, cmd.listHost)
146+ }
147+ return listAllSessions(ctx, deps, hosts)
148+}
149+
150+func listOneRemoteHost(ctx context.Context, deps remoteHostDeps, hosts []domain.RemoteHost, target string) error {
151+ known := false
152+ for _, host := range hosts {
153+ if host.Target == target {
154+ known = true
155+ break
156+ }
157+ }
158+ if !known {
159+ return fmt.Errorf("vev: unknown host %q", target)
160+ }
161+ catalog, err := deps.catalog.List(ctx, target)
162+ if err != nil {
163+ return fmt.Errorf("vev: listing sessions on %q: %w", target, err)
164+ }
165+ printSessions(deps.stdout, catalogSessionsAsInfo(target, catalog.Sessions))
166+ return nil
167+}
168+
169+// listAllSessions queries hosts sequentially for deterministic output ordering.
170+func listAllSessions(ctx context.Context, deps remoteHostDeps, hosts []domain.RemoteHost) error {
171+ local, err := deps.localList(ctx)
172+ if err != nil {
173+ if local != nil {
174+ printSessions(deps.stdout, local)
175+ }
176+ return err
177+ }
178+ all := append([]ports.SessionInfo(nil), local...)
179+ var hostErrs []error
180+ for _, host := range hosts {
181+ if err := ctx.Err(); err != nil {
182+ printSessions(deps.stdout, all)
183+ return err
184+ }
185+ catalog, listErr := deps.catalog.List(ctx, host.Target)
186+ if listErr != nil {
187+ hostErrs = append(hostErrs, fmt.Errorf("host %s: %w", host.Target, listErr))
188+ continue
189+ }
190+ all = append(all, catalogSessionsAsInfo(host.Target, catalog.Sessions)...)
191+ }
192+ printSessions(deps.stdout, all)
193+ if len(hostErrs) > 0 {
194+ return errors.Join(hostErrs...)
195+ }
196+ return nil
197+}
198+
199+func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) []ports.SessionInfo {
200+ out := make([]ports.SessionInfo, 0, len(sessions))
201+ for _, session := range sessions {
202+ info := ports.SessionInfo{
203+ Name: session.Name + "@" + host,
204+ Ephemeral: session.Ephemeral,
205+ Tabs: session.Tabs,
206+ Attached: session.Attached,
207+ }
208+ switch session.State {
209+ case "running":
210+ info.State = ports.SessionRunning
211+ case "stopped":
212+ info.State = ports.SessionStopped
213+ case "broken":
214+ info.State = ports.SessionBroken
215+ default:
216+ slog.Debug("remote catalog session has unknown state", "host", host, "session", session.Name, "state", session.State)
217+ info.State = ports.SessionBroken
218+ }
219+ out = append(out, info)
220+ }
221+ return out
222+}
223+
224+func mergeKnownHosts(deps remoteHostDeps) ([]domain.RemoteHost, error) {
225+ pinned, learned, err := deps.hostStore().Hosts()
226+ if err != nil {
227+ return nil, fmt.Errorf("vev: reading hosts: %w", err)
228+ }
229+ return mergeRemoteHosts(pinned, learned), nil
230+}
231+
232+// mergeRemoteHosts returns pinned hosts in stored order, then learned-only
233+// hosts in lexical order. Duplicates keep first occurrence and mark both sources.
234+func mergeRemoteHosts(pinned, learned []string) []domain.RemoteHost {
235+ pinned = domain.UniqueRemoteHostTargets(pinned)
236+ learnedSet := make(map[string]struct{}, len(learned))
237+ for _, target := range learned {
238+ learnedSet[target] = struct{}{}
239+ }
240+ pinnedSet := make(map[string]struct{}, len(pinned))
241+ out := make([]domain.RemoteHost, 0, len(pinned)+len(learned))
242+ for _, target := range pinned {
243+ pinnedSet[target] = struct{}{}
244+ _, isLearned := learnedSet[target]
245+ out = append(out, domain.RemoteHost{Target: target, Pinned: true, Learned: isLearned})
246+ }
247+ learnedOnly := make([]string, 0, len(learned))
248+ for _, target := range learned {
249+ if _, ok := pinnedSet[target]; !ok {
250+ learnedOnly = append(learnedOnly, target)
251+ }
252+ }
253+ learnedOnly = domain.UniqueRemoteHostTargets(learnedOnly)
254+ sort.Strings(learnedOnly)
255+ for _, target := range learnedOnly {
256+ out = append(out, domain.RemoteHost{Target: target, Learned: true})
257+ }
258+ return out
259+}
260+
261+type remoteHostLearner struct {
262+ store ports.RemoteHostStore
263+ target string
264+}
265+
266+func (l remoteHostLearner) RememberRemoteHost() error {
267+ return l.store.Remember(l.target)
268+}
269+
270+func attachRememberLearner(deps runAttachDeps, remoteTarget string, _ *slog.Logger) ports.RemoteHostLearner {
271+ if remoteTarget == "" {
272+ return nil
273+ }
274+ store := deps.hostStore
275+ if store == nil {
276+ stateDir := deps.stateDir
277+ if stateDir == nil {
278+ stateDir = platform.StateDir
279+ }
280+ store = remoteadapter.NewFileHostStore(filepath.Join(stateDir(), "hosts.json"))
281+ }
282+ return remoteHostLearner{store: store, target: remoteTarget}
283+}
M internal/app/remote_test.go +307 -27
@@ -1,41 +1,321 @@
11 package app
22
3-import "testing"
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/usecase/client"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+)
418
519 func TestParseRemoteAttachTarget(t *testing.T) {
620 tests := []struct {
7- name string
8- input string
9- wantTarget string
10- wantSession string
11- wantOK bool
21+ name, input, target, session string
22+ ok bool
1223 }{
13- {name: "local plain", input: "work"},
14- {name: "local with colon", input: "work:logs"},
15- {name: "missing user", input: "@host"},
24+ {name: "local", input: "work"},
25+ {name: "remote", input: "user@example.com", target: "user@example.com", ok: true},
26+ {name: "remote session", input: "user@example.com:work", target: "user@example.com", session: "work", ok: true},
27+ {name: "empty session is ephemeral", input: "user@example.com:", target: "user@example.com", ok: true},
28+ {name: "ipv6", input: "user@[2001:db8::1]:work", target: "user@[2001:db8::1]", session: "work", ok: true},
29+ {name: "missing user", input: "@example.com"},
1630 {name: "missing host", input: "user@"},
17- {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true},
18- {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true},
19- {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true},
20- {name: "bracketed ipv6 no session", input: "user@[2001:db8::1]", wantTarget: "user@[2001:db8::1]", wantOK: true},
21- {name: "bracketed ipv6 with session", input: "user@[2001:db8::1]:work", wantTarget: "user@[2001:db8::1]", wantSession: "work", wantOK: true},
22- {name: "malformed bracketed ipv6", input: "user@[2001:db8::1"},
23- {name: "malformed suffix after bracket", input: "user@[2001:db8::1]work"},
24- {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true},
31+ {name: "multiple user separators", input: "user@example@com"},
32+ {name: "missing bracketed ipv6 host", input: "user@[]:work"},
33+ {name: "unclosed ipv6", input: "user@[2001:db8::1:work"},
34+ {name: "unexpected closing ipv6 bracket", input: "user@2001:db8::1]:work"},
35+ {name: "unbracketed ipv6", input: "user@2001:db8::1:work"},
36+ }
37+ for _, tt := range tests {
38+ t.Run(tt.name, func(t *testing.T) {
39+ target, session, ok := parseRemoteAttachTarget(tt.input)
40+ require.Equal(t, tt.target, target)
41+ require.Equal(t, tt.session, session)
42+ require.Equal(t, tt.ok, ok)
43+ })
44+ }
45+}
46+
47+func TestRemoteHostDepsDefaultsStore(t *testing.T) {
48+ stateDir := filepath.Join(t.TempDir(), "state")
49+ deps := (remoteHostDeps{stateDir: func() string { return stateDir }}).withDefaults()
50+ require.NotNil(t, deps.store)
51+ require.Same(t, deps.store, deps.hostStore())
52+ require.NoError(t, deps.store.AddPinned("arch"))
53+ require.FileExists(t, filepath.Join(stateDir, "hosts.json"))
54+}
55+
56+func TestDecodeSessionListErrorReply(t *testing.T) {
57+ t.Run("valid", func(t *testing.T) {
58+ _, err := decodeSessionListReply(ports.Frame{
59+ Type: ports.MsgError,
60+ Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Text: "denied"}),
61+ })
62+ require.EqualError(t, err, "vev: denied")
63+ })
64+
65+ t.Run("malformed", func(t *testing.T) {
66+ _, err := decodeSessionListReply(ports.Frame{Type: ports.MsgError, Payload: []byte{0}})
67+ require.ErrorContains(t, err, "vev: decoding error reply")
68+ })
69+}
70+
71+func TestMergeRemoteHostsOrderAndSource(t *testing.T) {
72+ got := mergeRemoteHosts([]string{"zebra", "arch"}, []string{"mule", "arch", "beta"})
73+ require.Equal(t, []domain.RemoteHost{
74+ {Target: "zebra", Pinned: true},
75+ {Target: "arch", Pinned: true, Learned: true},
76+ {Target: "beta", Learned: true},
77+ {Target: "mule", Learned: true},
78+ }, got)
79+}
80+
81+func TestRemoteHostCommands(t *testing.T) {
82+ newDeps := func(store ports.RemoteHostStore, out *bytes.Buffer) remoteHostDeps {
83+ return remoteHostDeps{
84+ store: store,
85+ stdout: out,
86+ }
87+ }
88+
89+ tests := []struct {
90+ name string
91+ run func(t *testing.T)
92+ }{
93+ {
94+ name: "add pins in state store",
95+ run: func(t *testing.T) {
96+ store := portsmocks.NewMockRemoteHostStore(t)
97+ store.EXPECT().AddPinned("mule").Return(nil).Once()
98+ err := runHostCommand(context.Background(), command{hostAction: hostActionAdd, hostTarget: "mule"}, newDeps(store, nil))
99+ require.NoError(t, err)
100+ },
101+ },
102+ {
103+ name: "remove known host uses one atomic store call",
104+ run: func(t *testing.T) {
105+ store := portsmocks.NewMockRemoteHostStore(t)
106+ store.EXPECT().Remove("mule").Return(true, nil).Once()
107+ err := runHostCommand(context.Background(), command{hostAction: hostActionRm, hostTarget: "mule"}, newDeps(store, nil))
108+ require.NoError(t, err)
109+ },
110+ },
111+ {
112+ name: "remove unknown host uses one atomic store call",
113+ run: func(t *testing.T) {
114+ store := portsmocks.NewMockRemoteHostStore(t)
115+ store.EXPECT().Remove("mule").Return(false, nil).Once()
116+ err := runHostCommand(context.Background(), command{hostAction: hostActionRm, hostTarget: "mule"}, newDeps(store, nil))
117+ require.ErrorContains(t, err, `unknown host "mule"`)
118+ },
119+ },
120+ {
121+ name: "remove atomic store error is surfaced",
122+ run: func(t *testing.T) {
123+ store := portsmocks.NewMockRemoteHostStore(t)
124+ removeErr := errors.New("rename hosts")
125+ store.EXPECT().Remove("mule").Return(false, removeErr).Once()
126+ err := runHostCommand(context.Background(), command{hostAction: hostActionRm, hostTarget: "mule"}, newDeps(store, nil))
127+ require.ErrorIs(t, err, removeErr)
128+ },
129+ },
130+ {
131+ name: "list preserves source markers and order",
132+ run: func(t *testing.T) {
133+ store := portsmocks.NewMockRemoteHostStore(t)
134+ store.EXPECT().Hosts().Return([]string{"zebra", "arch"}, []string{"mule", "arch", "beta"}, nil).Once()
135+ var out bytes.Buffer
136+ err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, &out))
137+ require.NoError(t, err)
138+ got := out.String()
139+ require.Contains(t, got, "pinned,learned")
140+ require.True(t, strings.Index(got, "zebra") < strings.Index(got, "arch"))
141+ require.True(t, strings.Index(got, "arch") < strings.Index(got, "beta"))
142+ require.True(t, strings.Index(got, "beta") < strings.Index(got, "mule"))
143+ },
144+ },
145+ {
146+ name: "malformed store is surfaced",
147+ run: func(t *testing.T) {
148+ store := portsmocks.NewMockRemoteHostStore(t)
149+ store.EXPECT().Hosts().Return(nil, nil, errors.New("malformed hosts file")).Once()
150+ err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, nil))
151+ require.ErrorContains(t, err, "malformed hosts file")
152+ },
153+ },
154+ {
155+ name: "list is active without config",
156+ run: func(t *testing.T) {
157+ store := portsmocks.NewMockRemoteHostStore(t)
158+ store.EXPECT().Hosts().Return(nil, nil, nil).Once()
159+ var out bytes.Buffer
160+ err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, &out))
161+ require.NoError(t, err)
162+ },
163+ },
25164 }
165+ for _, tt := range tests {
166+ t.Run(tt.name, tt.run)
167+ }
168+}
169+
170+func TestRemoteHostListingUsesUnifiedStore(t *testing.T) {
171+ store := portsmocks.NewMockRemoteHostStore(t)
172+ store.EXPECT().Hosts().Return([]string{"arch"}, []string{"beta"}, nil).Once()
173+ catalog := portsmocks.NewMockRemoteCatalogClient(t)
174+ catalog.EXPECT().List(mock.Anything, "arch").Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "build", State: "running"}}}, nil).Once()
175+ var out bytes.Buffer
176+ err := runRemoteList(context.Background(), command{listHost: "arch"}, remoteHostDeps{
177+ store: store,
178+ catalog: catalog,
179+ stdout: &out,
180+ })
181+ require.NoError(t, err)
182+ require.Contains(t, out.String(), "build@arch")
183+}
184+
185+func TestListAllSessionsInvariants(t *testing.T) {
186+ tests := []struct {
187+ name string
188+ run func(t *testing.T)
189+ }{
190+ {
191+ name: "prints partial local results on error",
192+ run: func(t *testing.T) {
193+ localErr := errors.New("local release failed")
194+ var out bytes.Buffer
195+ err := listAllSessions(context.Background(), remoteHostDeps{
196+ localList: func(context.Context) ([]ports.SessionInfo, error) {
197+ return []ports.SessionInfo{{Name: "local", State: ports.SessionRunning}}, localErr
198+ },
199+ stdout: &out,
200+ }, nil)
201+ require.ErrorIs(t, err, localErr)
202+ require.Contains(t, out.String(), "local")
203+ },
204+ },
205+ {
206+ name: "prints accumulated results and stops before next host on cancellation",
207+ run: func(t *testing.T) {
208+ ctx, cancel := context.WithCancel(context.Background())
209+ defer cancel()
210+ catalog := portsmocks.NewMockRemoteCatalogClient(t)
211+ catalog.EXPECT().List(mock.Anything, "arch").RunAndReturn(func(context.Context, string) (ports.RemoteCatalog, error) {
212+ cancel()
213+ return ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "running"}}}, nil
214+ }).Once()
215+ var out bytes.Buffer
216+ err := listAllSessions(ctx, remoteHostDeps{
217+ catalog: catalog,
218+ localList: func(context.Context) ([]ports.SessionInfo, error) {
219+ return []ports.SessionInfo{{Name: "local", State: ports.SessionRunning}}, nil
220+ },
221+ stdout: &out,
222+ }, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})
223+ require.ErrorIs(t, err, context.Canceled)
224+ require.Contains(t, out.String(), "local")
225+ require.Contains(t, out.String(), "dev@arch")
226+ require.NotContains(t, out.String(), "@mule")
227+ },
228+ },
229+ {
230+ name: "lists hosts sequentially in supplied order",
231+ run: func(t *testing.T) {
232+ catalog := portsmocks.NewMockRemoteCatalogClient(t)
233+ var calls []string
234+ catalog.EXPECT().List(mock.Anything, "arch").Run(func(_ context.Context, target string) {
235+ calls = append(calls, target)
236+ }).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "running"}}}, nil).Once()
237+ catalog.EXPECT().List(mock.Anything, "mule").Run(func(_ context.Context, target string) {
238+ calls = append(calls, target)
239+ }).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "ops", State: "running"}}}, nil).Once()
240+ var out bytes.Buffer
241+ err := listAllSessions(context.Background(), remoteHostDeps{
242+ catalog: catalog,
243+ localList: func(context.Context) ([]ports.SessionInfo, error) {
244+ return []ports.SessionInfo{{Name: "local", State: ports.SessionRunning}}, nil
245+ },
246+ stdout: &out,
247+ }, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})
248+ require.NoError(t, err)
249+ require.Equal(t, []string{"arch", "mule"}, calls)
250+ got := out.String()
251+ require.Less(t, strings.Index(got, "local"), strings.Index(got, "dev@arch"))
252+ require.Less(t, strings.Index(got, "dev@arch"), strings.Index(got, "ops@mule"))
253+ },
254+ },
255+ }
256+ for _, tt := range tests {
257+ t.Run(tt.name, tt.run)
258+ }
259+}
26260
261+func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
262+ tests := []struct {
263+ name string
264+ session ports.RemoteCatalogSession
265+ wantState ports.SessionState
266+ wantTabs uint16
267+ }{
268+ {name: "running", session: ports.RemoteCatalogSession{Name: "dev", State: "running", Tabs: 2}, wantState: ports.SessionRunning, wantTabs: 2},
269+ {name: "stopped", session: ports.RemoteCatalogSession{Name: "dev", State: "stopped", Tabs: 2}, wantState: ports.SessionStopped, wantTabs: 2},
270+ {name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
271+ {name: "unknown fails closed", session: ports.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
272+ }
27273 for _, tt := range tests {
28274 t.Run(tt.name, func(t *testing.T) {
29- gotTarget, gotSession, gotOK := parseRemoteAttachTarget(tt.input)
30- if gotOK != tt.wantOK {
31- t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK)
32- }
33- if gotTarget != tt.wantTarget {
34- t.Fatalf("target = %q, want %q", gotTarget, tt.wantTarget)
35- }
36- if gotSession != tt.wantSession {
37- t.Fatalf("session = %q, want %q", gotSession, tt.wantSession)
38- }
275+ infos := catalogSessionsAsInfo("arch", []ports.RemoteCatalogSession{tt.session})
276+ require.Len(t, infos, 1)
277+ require.Equal(t, "dev@arch", infos[0].Name)
278+ require.Equal(t, tt.wantState, infos[0].State)
279+ require.Equal(t, tt.wantTabs, infos[0].Tabs)
39280 })
40281 }
41282 }
283+
284+func TestRunAttachWithDepsRemoteLearning(t *testing.T) {
285+ store := portsmocks.NewMockRemoteHostStore(t)
286+ store.EXPECT().Remember("build@mule").Return(nil).Once()
287+ factory := portsmocks.NewMockRemoteDialerFactory(t)
288+ factory.EXPECT().DialerForRemote("build@mule", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
289+
290+ var learner ports.RemoteHostLearner
291+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "build@mule", "", nil, runAttachDeps{
292+ remoteDialerFactory: factory,
293+ hostStore: store,
294+ runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
295+ learner = deps.RemoteHostLearner
296+ return nil
297+ },
298+ })
299+ require.NoError(t, err)
300+ require.NotNil(t, learner)
301+ require.NoError(t, learner.RememberRemoteHost())
302+}
303+
304+func TestRunAttachWithDepsAlwaysLearnsRemoteHost(t *testing.T) {
305+ store := portsmocks.NewMockRemoteHostStore(t)
306+ store.EXPECT().Remember("arch").Return(nil).Once()
307+ factory := portsmocks.NewMockRemoteDialerFactory(t)
308+ factory.EXPECT().DialerForRemote("arch", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
309+ var learner ports.RemoteHostLearner
310+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "arch", "", nil, runAttachDeps{
311+ remoteDialerFactory: factory,
312+ hostStore: store,
313+ runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
314+ learner = deps.RemoteHostLearner
315+ return nil
316+ },
317+ })
318+ require.NoError(t, err)
319+ require.NotNil(t, learner)
320+ require.NoError(t, learner.RememberRemoteHost())
321+}
@@ -1,41 +1,321 @@
1 package app1 package app
2 2
3-import "testing"3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+ "github.com/bnema/vev/internal/usecase/client"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+)
4 18
5 func TestParseRemoteAttachTarget(t *testing.T) {19 func TestParseRemoteAttachTarget(t *testing.T) {
6 tests := []struct {20 tests := []struct {
7- name string21+ name, input, target, session string
8- input string22+ ok bool
9- wantTarget string
10- wantSession string
11- wantOK bool
12 }{23 }{
13- {name: "local plain", input: "work"},24+ {name: "local", input: "work"},
14- {name: "local with colon", input: "work:logs"},25+ {name: "remote", input: "user@example.com", target: "user@example.com", ok: true},
15- {name: "missing user", input: "@host"},26+ {name: "remote session", input: "user@example.com:work", target: "user@example.com", session: "work", ok: true},
27+ {name: "empty session is ephemeral", input: "user@example.com:", target: "user@example.com", ok: true},
28+ {name: "ipv6", input: "user@[2001:db8::1]:work", target: "user@[2001:db8::1]", session: "work", ok: true},
29+ {name: "missing user", input: "@example.com"},
16 {name: "missing host", input: "user@"},30 {name: "missing host", input: "user@"},
17- {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true},31+ {name: "multiple user separators", input: "user@example@com"},
18- {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true},32+ {name: "missing bracketed ipv6 host", input: "user@[]:work"},
19- {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true},33+ {name: "unclosed ipv6", input: "user@[2001:db8::1:work"},
20- {name: "bracketed ipv6 no session", input: "user@[2001:db8::1]", wantTarget: "user@[2001:db8::1]", wantOK: true},34+ {name: "unexpected closing ipv6 bracket", input: "user@2001:db8::1]:work"},
21- {name: "bracketed ipv6 with session", input: "user@[2001:db8::1]:work", wantTarget: "user@[2001:db8::1]", wantSession: "work", wantOK: true},35+ {name: "unbracketed ipv6", input: "user@2001:db8::1:work"},
22- {name: "malformed bracketed ipv6", input: "user@[2001:db8::1"},36+ }
23- {name: "malformed suffix after bracket", input: "user@[2001:db8::1]work"},37+ for _, tt := range tests {
24- {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true},38+ t.Run(tt.name, func(t *testing.T) {
39+ target, session, ok := parseRemoteAttachTarget(tt.input)
40+ require.Equal(t, tt.target, target)
41+ require.Equal(t, tt.session, session)
42+ require.Equal(t, tt.ok, ok)
43+ })
44+ }
45+}
46+
47+func TestRemoteHostDepsDefaultsStore(t *testing.T) {
48+ stateDir := filepath.Join(t.TempDir(), "state")
49+ deps := (remoteHostDeps{stateDir: func() string { return stateDir }}).withDefaults()
50+ require.NotNil(t, deps.store)
51+ require.Same(t, deps.store, deps.hostStore())
52+ require.NoError(t, deps.store.AddPinned("arch"))
53+ require.FileExists(t, filepath.Join(stateDir, "hosts.json"))
54+}
55+
56+func TestDecodeSessionListErrorReply(t *testing.T) {
57+ t.Run("valid", func(t *testing.T) {
58+ _, err := decodeSessionListReply(ports.Frame{
59+ Type: ports.MsgError,
60+ Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Text: "denied"}),
61+ })
62+ require.EqualError(t, err, "vev: denied")
63+ })
64+
65+ t.Run("malformed", func(t *testing.T) {
66+ _, err := decodeSessionListReply(ports.Frame{Type: ports.MsgError, Payload: []byte{0}})
67+ require.ErrorContains(t, err, "vev: decoding error reply")
68+ })
69+}
70+
71+func TestMergeRemoteHostsOrderAndSource(t *testing.T) {
72+ got := mergeRemoteHosts([]string{"zebra", "arch"}, []string{"mule", "arch", "beta"})
73+ require.Equal(t, []domain.RemoteHost{
74+ {Target: "zebra", Pinned: true},
75+ {Target: "arch", Pinned: true, Learned: true},
76+ {Target: "beta", Learned: true},
77+ {Target: "mule", Learned: true},
78+ }, got)
79+}
80+
81+func TestRemoteHostCommands(t *testing.T) {
82+ newDeps := func(store ports.RemoteHostStore, out *bytes.Buffer) remoteHostDeps {
83+ return remoteHostDeps{
84+ store: store,
85+ stdout: out,
86+ }
87+ }
88+
89+ tests := []struct {
90+ name string
91+ run func(t *testing.T)
92+ }{
93+ {
94+ name: "add pins in state store",
95+ run: func(t *testing.T) {
96+ store := portsmocks.NewMockRemoteHostStore(t)
97+ store.EXPECT().AddPinned("mule").Return(nil).Once()
98+ err := runHostCommand(context.Background(), command{hostAction: hostActionAdd, hostTarget: "mule"}, newDeps(store, nil))
99+ require.NoError(t, err)
100+ },
101+ },
102+ {
103+ name: "remove known host uses one atomic store call",
104+ run: func(t *testing.T) {
105+ store := portsmocks.NewMockRemoteHostStore(t)
106+ store.EXPECT().Remove("mule").Return(true, nil).Once()
107+ err := runHostCommand(context.Background(), command{hostAction: hostActionRm, hostTarget: "mule"}, newDeps(store, nil))
108+ require.NoError(t, err)
109+ },
110+ },
111+ {
112+ name: "remove unknown host uses one atomic store call",
113+ run: func(t *testing.T) {
114+ store := portsmocks.NewMockRemoteHostStore(t)
115+ store.EXPECT().Remove("mule").Return(false, nil).Once()
116+ err := runHostCommand(context.Background(), command{hostAction: hostActionRm, hostTarget: "mule"}, newDeps(store, nil))
117+ require.ErrorContains(t, err, `unknown host "mule"`)
118+ },
119+ },
120+ {
121+ name: "remove atomic store error is surfaced",
122+ run: func(t *testing.T) {
123+ store := portsmocks.NewMockRemoteHostStore(t)
124+ removeErr := errors.New("rename hosts")
125+ store.EXPECT().Remove("mule").Return(false, removeErr).Once()
126+ err := runHostCommand(context.Background(), command{hostAction: hostActionRm, hostTarget: "mule"}, newDeps(store, nil))
127+ require.ErrorIs(t, err, removeErr)
128+ },
129+ },
130+ {
131+ name: "list preserves source markers and order",
132+ run: func(t *testing.T) {
133+ store := portsmocks.NewMockRemoteHostStore(t)
134+ store.EXPECT().Hosts().Return([]string{"zebra", "arch"}, []string{"mule", "arch", "beta"}, nil).Once()
135+ var out bytes.Buffer
136+ err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, &out))
137+ require.NoError(t, err)
138+ got := out.String()
139+ require.Contains(t, got, "pinned,learned")
140+ require.True(t, strings.Index(got, "zebra") < strings.Index(got, "arch"))
141+ require.True(t, strings.Index(got, "arch") < strings.Index(got, "beta"))
142+ require.True(t, strings.Index(got, "beta") < strings.Index(got, "mule"))
143+ },
144+ },
145+ {
146+ name: "malformed store is surfaced",
147+ run: func(t *testing.T) {
148+ store := portsmocks.NewMockRemoteHostStore(t)
149+ store.EXPECT().Hosts().Return(nil, nil, errors.New("malformed hosts file")).Once()
150+ err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, nil))
151+ require.ErrorContains(t, err, "malformed hosts file")
152+ },
153+ },
154+ {
155+ name: "list is active without config",
156+ run: func(t *testing.T) {
157+ store := portsmocks.NewMockRemoteHostStore(t)
158+ store.EXPECT().Hosts().Return(nil, nil, nil).Once()
159+ var out bytes.Buffer
160+ err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, &out))
161+ require.NoError(t, err)
162+ },
163+ },
25 }164 }
165+ for _, tt := range tests {
166+ t.Run(tt.name, tt.run)
167+ }
168+}
169+
170+func TestRemoteHostListingUsesUnifiedStore(t *testing.T) {
171+ store := portsmocks.NewMockRemoteHostStore(t)
172+ store.EXPECT().Hosts().Return([]string{"arch"}, []string{"beta"}, nil).Once()
173+ catalog := portsmocks.NewMockRemoteCatalogClient(t)
174+ catalog.EXPECT().List(mock.Anything, "arch").Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "build", State: "running"}}}, nil).Once()
175+ var out bytes.Buffer
176+ err := runRemoteList(context.Background(), command{listHost: "arch"}, remoteHostDeps{
177+ store: store,
178+ catalog: catalog,
179+ stdout: &out,
180+ })
181+ require.NoError(t, err)
182+ require.Contains(t, out.String(), "build@arch")
183+}
184+
185+func TestListAllSessionsInvariants(t *testing.T) {
186+ tests := []struct {
187+ name string
188+ run func(t *testing.T)
189+ }{
190+ {
191+ name: "prints partial local results on error",
192+ run: func(t *testing.T) {
193+ localErr := errors.New("local release failed")
194+ var out bytes.Buffer
195+ err := listAllSessions(context.Background(), remoteHostDeps{
196+ localList: func(context.Context) ([]ports.SessionInfo, error) {
197+ return []ports.SessionInfo{{Name: "local", State: ports.SessionRunning}}, localErr
198+ },
199+ stdout: &out,
200+ }, nil)
201+ require.ErrorIs(t, err, localErr)
202+ require.Contains(t, out.String(), "local")
203+ },
204+ },
205+ {
206+ name: "prints accumulated results and stops before next host on cancellation",
207+ run: func(t *testing.T) {
208+ ctx, cancel := context.WithCancel(context.Background())
209+ defer cancel()
210+ catalog := portsmocks.NewMockRemoteCatalogClient(t)
211+ catalog.EXPECT().List(mock.Anything, "arch").RunAndReturn(func(context.Context, string) (ports.RemoteCatalog, error) {
212+ cancel()
213+ return ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "running"}}}, nil
214+ }).Once()
215+ var out bytes.Buffer
216+ err := listAllSessions(ctx, remoteHostDeps{
217+ catalog: catalog,
218+ localList: func(context.Context) ([]ports.SessionInfo, error) {
219+ return []ports.SessionInfo{{Name: "local", State: ports.SessionRunning}}, nil
220+ },
221+ stdout: &out,
222+ }, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})
223+ require.ErrorIs(t, err, context.Canceled)
224+ require.Contains(t, out.String(), "local")
225+ require.Contains(t, out.String(), "dev@arch")
226+ require.NotContains(t, out.String(), "@mule")
227+ },
228+ },
229+ {
230+ name: "lists hosts sequentially in supplied order",
231+ run: func(t *testing.T) {
232+ catalog := portsmocks.NewMockRemoteCatalogClient(t)
233+ var calls []string
234+ catalog.EXPECT().List(mock.Anything, "arch").Run(func(_ context.Context, target string) {
235+ calls = append(calls, target)
236+ }).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "running"}}}, nil).Once()
237+ catalog.EXPECT().List(mock.Anything, "mule").Run(func(_ context.Context, target string) {
238+ calls = append(calls, target)
239+ }).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "ops", State: "running"}}}, nil).Once()
240+ var out bytes.Buffer
241+ err := listAllSessions(context.Background(), remoteHostDeps{
242+ catalog: catalog,
243+ localList: func(context.Context) ([]ports.SessionInfo, error) {
244+ return []ports.SessionInfo{{Name: "local", State: ports.SessionRunning}}, nil
245+ },
246+ stdout: &out,
247+ }, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})
248+ require.NoError(t, err)
249+ require.Equal(t, []string{"arch", "mule"}, calls)
250+ got := out.String()
251+ require.Less(t, strings.Index(got, "local"), strings.Index(got, "dev@arch"))
252+ require.Less(t, strings.Index(got, "dev@arch"), strings.Index(got, "ops@mule"))
253+ },
254+ },
255+ }
256+ for _, tt := range tests {
257+ t.Run(tt.name, tt.run)
258+ }
259+}
26 260
261+func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
262+ tests := []struct {
263+ name string
264+ session ports.RemoteCatalogSession
265+ wantState ports.SessionState
266+ wantTabs uint16
267+ }{
268+ {name: "running", session: ports.RemoteCatalogSession{Name: "dev", State: "running", Tabs: 2}, wantState: ports.SessionRunning, wantTabs: 2},
269+ {name: "stopped", session: ports.RemoteCatalogSession{Name: "dev", State: "stopped", Tabs: 2}, wantState: ports.SessionStopped, wantTabs: 2},
270+ {name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
271+ {name: "unknown fails closed", session: ports.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
272+ }
27 for _, tt := range tests {273 for _, tt := range tests {
28 t.Run(tt.name, func(t *testing.T) {274 t.Run(tt.name, func(t *testing.T) {
29- gotTarget, gotSession, gotOK := parseRemoteAttachTarget(tt.input)275+ infos := catalogSessionsAsInfo("arch", []ports.RemoteCatalogSession{tt.session})
30- if gotOK != tt.wantOK {276+ require.Len(t, infos, 1)
31- t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK)277+ require.Equal(t, "dev@arch", infos[0].Name)
32- }278+ require.Equal(t, tt.wantState, infos[0].State)
33- if gotTarget != tt.wantTarget {279+ require.Equal(t, tt.wantTabs, infos[0].Tabs)
34- t.Fatalf("target = %q, want %q", gotTarget, tt.wantTarget)
35- }
36- if gotSession != tt.wantSession {
37- t.Fatalf("session = %q, want %q", gotSession, tt.wantSession)
38- }
39 })280 })
40 }281 }
41 }282 }
283+
284+func TestRunAttachWithDepsRemoteLearning(t *testing.T) {
285+ store := portsmocks.NewMockRemoteHostStore(t)
286+ store.EXPECT().Remember("build@mule").Return(nil).Once()
287+ factory := portsmocks.NewMockRemoteDialerFactory(t)
288+ factory.EXPECT().DialerForRemote("build@mule", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
289+
290+ var learner ports.RemoteHostLearner
291+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "build@mule", "", nil, runAttachDeps{
292+ remoteDialerFactory: factory,
293+ hostStore: store,
294+ runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
295+ learner = deps.RemoteHostLearner
296+ return nil
297+ },
298+ })
299+ require.NoError(t, err)
300+ require.NotNil(t, learner)
301+ require.NoError(t, learner.RememberRemoteHost())
302+}
303+
304+func TestRunAttachWithDepsAlwaysLearnsRemoteHost(t *testing.T) {
305+ store := portsmocks.NewMockRemoteHostStore(t)
306+ store.EXPECT().Remember("arch").Return(nil).Once()
307+ factory := portsmocks.NewMockRemoteDialerFactory(t)
308+ factory.EXPECT().DialerForRemote("arch", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
309+ var learner ports.RemoteHostLearner
310+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "arch", "", nil, runAttachDeps{
311+ remoteDialerFactory: factory,
312+ hostStore: store,
313+ runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
314+ learner = deps.RemoteHostLearner
315+ return nil
316+ },
317+ })
318+ require.NoError(t, err)
319+ require.NotNil(t, learner)
320+ require.NoError(t, learner.RememberRemoteHost())
321+}
M internal/app/run.go +110 -24
@@ -60,6 +60,7 @@ type cmdKind int
6060 const (
6161 kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent
6262 kindList
63+ kindHost
6364 kindKill
6465 kindCmd
6566 kindDaemon
@@ -78,6 +79,10 @@ type command struct {
7879 intent uint8
7980 name string
8081 remoteTarget string
82+ listHost string
83+ listAll bool
84+ hostAction string
85+ hostTarget string
8186 killAll bool
8287 killDaemon bool
8388 cmd cmdInvocation
@@ -101,7 +106,12 @@ usage:
101106 vev attach <name> attach to an existing session (alias: a)
102107 vev attach user@host[:session]
103108 attach through SSH to a remote vev daemon
104- vev ls list sessions
109+ vev ls list local sessions
110+ vev ls <host> list sessions on a known remote host
111+ vev ls --all list local and remote sessions
112+ vev host add <host> add a pinned remote host
113+ vev host rm <host> remove a pinned remote host
114+ vev host list list known remote hosts
105115 vev kill <name> kill a session
106116 vev kill --all kill all sessions and stop the daemon
107117 vev kill --daemon stop the active vev daemon
@@ -206,7 +216,9 @@ func parseArgs(args []string) (command, error) {
206216 }
207217 return cmd, nil
208218 case "ls", "list":
209- return command{kind: kindList}, nil
219+ return parseListArgs(args[1:])
220+ case "host":
221+ return parseHostArgs(args[1:])
210222 case "cmd":
211223 invocation, err := parseCmdArgs(args[1:])
212224 if err != nil {
@@ -263,7 +275,9 @@ func dispatch(ctx context.Context, cmd command) error {
263275 case kindUDPProxy:
264276 return runUDPProxy(ctx, cmd.name, os.Stdout)
265277 case kindList:
266- return runList(ctx)
278+ return runList(ctx, cmd)
279+ case kindHost:
280+ return runHostCommand(ctx, cmd, defaultRemoteHostDeps())
267281 case kindKill:
268282 return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)
269283 case kindCmd:
@@ -275,6 +289,53 @@ func dispatch(ctx context.Context, cmd command) error {
275289 }
276290 }
277291
292+func parseListArgs(args []string) (command, error) {
293+ if len(args) == 0 {
294+ return command{kind: kindList}, nil
295+ }
296+ if len(args) > 1 {
297+ return command{}, usagef("`ls` accepts at most one host or --all")
298+ }
299+ switch args[0] {
300+ case "--all":
301+ return command{kind: kindList, listAll: true}, nil
302+ default:
303+ if strings.HasPrefix(args[0], "-") {
304+ return command{}, usagef("unknown flag %q for `ls`", args[0])
305+ }
306+ if err := domain.ValidateRemoteHostTarget(args[0]); err != nil {
307+ return command{}, err
308+ }
309+ return command{kind: kindList, listHost: args[0]}, nil
310+ }
311+}
312+
313+func parseHostArgs(args []string) (command, error) {
314+ if len(args) == 0 {
315+ return command{}, usagef("`host` requires add, rm, or list")
316+ }
317+ switch args[0] {
318+ case hostActionAdd, hostActionRm:
319+ if len(args) < 2 || args[1] == "" {
320+ return command{}, usagef("`host %s` requires a host target", args[0])
321+ }
322+ if len(args) > 2 {
323+ return command{}, usagef("`host %s` accepts exactly one host target", args[0])
324+ }
325+ if err := domain.ValidateRemoteHostTarget(args[1]); err != nil {
326+ return command{}, err
327+ }
328+ return command{kind: kindHost, hostAction: args[0], hostTarget: args[1]}, nil
329+ case hostActionList:
330+ if len(args) > 1 {
331+ return command{}, usagef("`host list` accepts no arguments")
332+ }
333+ return command{kind: kindHost, hostAction: hostActionList}, nil
334+ default:
335+ return command{}, usagef("unknown host action %q", args[0])
336+ }
337+}
338+
278339 // performanceTrace creates one serialized timestamp owner for this process.
279340 // An empty trace environment leaves all production behavior and wire bytes
280341 // unchanged.
@@ -678,6 +739,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
678739 createDetached: createDetachedLocalSession,
679740 clipboard: clipboard.New(),
680741 runtimeObserver: observer,
742+ stateDir: platform.StateDir,
681743 })
682744 }
683745
@@ -703,6 +765,9 @@ type runAttachDeps struct {
703765 // Only used for the remote-dialer branch below; local attaches never
704766 // intercept Ctrl+V regardless of this field.
705767 clipboard ports.ClipboardReader
768+ // Optional remote-learning seams.
769+ stateDir func() string
770+ hostStore ports.RemoteHostStore
706771 }
707772
708773 func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {
@@ -745,12 +810,13 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
745810 return err
746811 }
747812 return runClient(ctx, client.Dependencies{
748- Dialer: dialer,
749- Terminal: term.New(),
750- Clock: clock.New(),
751- Clipboard: deps.clipboard,
752- Logger: log,
753- RuntimeObserver: deps.runtimeObserver,
813+ Dialer: dialer,
814+ Terminal: term.New(),
815+ Clock: clock.New(),
816+ Clipboard: deps.clipboard,
817+ Logger: log,
818+ RuntimeObserver: deps.runtimeObserver,
819+ RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
754820 }, client.AttachRequest{Intent: intent, SessionName: name, Remote: true})
755821 }
756822
@@ -1287,20 +1353,35 @@ func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger
12871353 }
12881354
12891355 // runList prints the daemon's session listing. With no daemon running, it
1290-// falls back to the persisted stopped-session records.
1291-func runList(ctx context.Context) (retErr error) {
1356+// falls back to the persisted stopped-session records. Remote forms list one
1357+// known host or local+remote when --all is set.
1358+func runList(ctx context.Context, cmd command) (retErr error) {
1359+ if cmd.listAll || cmd.listHost != "" {
1360+ return runRemoteList(ctx, cmd, defaultRemoteHostDeps())
1361+ }
1362+ sessions, err := listLocalSessions(ctx)
1363+ if sessions != nil {
1364+ printSessions(os.Stdout, sessions)
1365+ }
1366+ if err != nil {
1367+ return err
1368+ }
1369+ return nil
1370+}
1371+
1372+func listLocalSessions(ctx context.Context) (_ []ports.SessionInfo, retErr error) {
12921373 transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff)
12931374 if err != nil {
1294- return fmt.Errorf("vev: waiting for durable session state: %w", err)
1375+ return nil, fmt.Errorf("vev: waiting for durable session state: %w", err)
12951376 }
12961377 if owner != nil {
12971378 defer joinLifecycleReleaseError(&retErr, owner)
12981379 records, loadErr := persist.LoadReadOnly(platform.StateDir())
12991380 if loadErr != nil {
13001381 if errors.Is(loadErr, persist.ErrCatalogueUnreadable) {
1301- return unreadableCatalogueError(platform.StateDir())
1382+ return nil, unreadableCatalogueError(platform.StateDir())
13021383 }
1303- return fmt.Errorf("vev: reading stored sessions: %w", loadErr)
1384+ return nil, fmt.Errorf("vev: reading stored sessions: %w", loadErr)
13041385 }
13051386 infos := make([]ports.SessionInfo, 0, len(records))
13061387 for _, r := range records {
@@ -1310,31 +1391,36 @@ func runList(ctx context.Context) (retErr error) {
13101391 }
13111392 infos = append(infos, ports.SessionInfo{Name: r.Name, State: state})
13121393 }
1313- printSessions(os.Stdout, infos)
1314- return nil
1394+ return infos, nil
13151395 }
13161396 defer func() { _ = transport.Close() }()
13171397
13181398 if err := transport.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}); err != nil {
1319- return fmt.Errorf("vev: requesting session list: %w", err)
1399+ return nil, fmt.Errorf("vev: requesting session list: %w", err)
13201400 }
13211401 reply, err := transport.Recv()
13221402 if err != nil {
1323- return fmt.Errorf("vev: reading session list: %w", err)
1403+ return nil, fmt.Errorf("vev: reading session list: %w", err)
13241404 }
1405+ return decodeSessionListReply(reply)
1406+}
1407+
1408+func decodeSessionListReply(reply ports.Frame) ([]ports.SessionInfo, error) {
13251409 if reply.Type == ports.MsgError {
1326- em, _ := ports.UnmarshalErrorMsg(reply.Payload)
1327- return fmt.Errorf("vev: %s", em.Text)
1410+ em, err := ports.UnmarshalErrorMsg(reply.Payload)
1411+ if err != nil {
1412+ return nil, fmt.Errorf("vev: decoding error reply: %w", err)
1413+ }
1414+ return nil, fmt.Errorf("vev: %s", em.Text)
13281415 }
13291416 if reply.Type != ports.MsgSessions {
1330- return fmt.Errorf("vev: unexpected reply type %d to list", reply.Type)
1417+ return nil, fmt.Errorf("vev: unexpected reply type %d to list", reply.Type)
13311418 }
13321419 sessions, err := ports.UnmarshalSessions(reply.Payload)
13331420 if err != nil {
1334- return fmt.Errorf("vev: decoding session list: %w", err)
1421+ return nil, fmt.Errorf("vev: decoding session list: %w", err)
13351422 }
1336- printSessions(os.Stdout, sessions.Sessions)
1337- return nil
1423+ return sessions.Sessions, nil
13381424 }
13391425
13401426 // printSessions renders a session table (or a friendly note when empty).
@@ -60,6 +60,7 @@ type cmdKind int
60 const (60 const (
61 kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent61 kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent
62 kindList62 kindList
63+ kindHost
63 kindKill64 kindKill
64 kindCmd65 kindCmd
65 kindDaemon66 kindDaemon
@@ -78,6 +79,10 @@ type command struct {
78 intent uint879 intent uint8
79 name string80 name string
80 remoteTarget string81 remoteTarget string
82+ listHost string
83+ listAll bool
84+ hostAction string
85+ hostTarget string
81 killAll bool86 killAll bool
82 killDaemon bool87 killDaemon bool
83 cmd cmdInvocation88 cmd cmdInvocation
@@ -101,7 +106,12 @@ usage:
101 vev attach <name> attach to an existing session (alias: a)106 vev attach <name> attach to an existing session (alias: a)
102 vev attach user@host[:session]107 vev attach user@host[:session]
103 attach through SSH to a remote vev daemon108 attach through SSH to a remote vev daemon
104- vev ls list sessions109+ vev ls list local sessions
110+ vev ls <host> list sessions on a known remote host
111+ vev ls --all list local and remote sessions
112+ vev host add <host> add a pinned remote host
113+ vev host rm <host> remove a pinned remote host
114+ vev host list list known remote hosts
105 vev kill <name> kill a session115 vev kill <name> kill a session
106 vev kill --all kill all sessions and stop the daemon116 vev kill --all kill all sessions and stop the daemon
107 vev kill --daemon stop the active vev daemon117 vev kill --daemon stop the active vev daemon
@@ -206,7 +216,9 @@ func parseArgs(args []string) (command, error) {
206 }216 }
207 return cmd, nil217 return cmd, nil
208 case "ls", "list":218 case "ls", "list":
209- return command{kind: kindList}, nil219+ return parseListArgs(args[1:])
220+ case "host":
221+ return parseHostArgs(args[1:])
210 case "cmd":222 case "cmd":
211 invocation, err := parseCmdArgs(args[1:])223 invocation, err := parseCmdArgs(args[1:])
212 if err != nil {224 if err != nil {
@@ -263,7 +275,9 @@ func dispatch(ctx context.Context, cmd command) error {
263 case kindUDPProxy:275 case kindUDPProxy:
264 return runUDPProxy(ctx, cmd.name, os.Stdout)276 return runUDPProxy(ctx, cmd.name, os.Stdout)
265 case kindList:277 case kindList:
266- return runList(ctx)278+ return runList(ctx, cmd)
279+ case kindHost:
280+ return runHostCommand(ctx, cmd, defaultRemoteHostDeps())
267 case kindKill:281 case kindKill:
268 return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)282 return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon)
269 case kindCmd:283 case kindCmd:
@@ -275,6 +289,53 @@ func dispatch(ctx context.Context, cmd command) error {
275 }289 }
276 }290 }
277 291
292+func parseListArgs(args []string) (command, error) {
293+ if len(args) == 0 {
294+ return command{kind: kindList}, nil
295+ }
296+ if len(args) > 1 {
297+ return command{}, usagef("`ls` accepts at most one host or --all")
298+ }
299+ switch args[0] {
300+ case "--all":
301+ return command{kind: kindList, listAll: true}, nil
302+ default:
303+ if strings.HasPrefix(args[0], "-") {
304+ return command{}, usagef("unknown flag %q for `ls`", args[0])
305+ }
306+ if err := domain.ValidateRemoteHostTarget(args[0]); err != nil {
307+ return command{}, err
308+ }
309+ return command{kind: kindList, listHost: args[0]}, nil
310+ }
311+}
312+
313+func parseHostArgs(args []string) (command, error) {
314+ if len(args) == 0 {
315+ return command{}, usagef("`host` requires add, rm, or list")
316+ }
317+ switch args[0] {
318+ case hostActionAdd, hostActionRm:
319+ if len(args) < 2 || args[1] == "" {
320+ return command{}, usagef("`host %s` requires a host target", args[0])
321+ }
322+ if len(args) > 2 {
323+ return command{}, usagef("`host %s` accepts exactly one host target", args[0])
324+ }
325+ if err := domain.ValidateRemoteHostTarget(args[1]); err != nil {
326+ return command{}, err
327+ }
328+ return command{kind: kindHost, hostAction: args[0], hostTarget: args[1]}, nil
329+ case hostActionList:
330+ if len(args) > 1 {
331+ return command{}, usagef("`host list` accepts no arguments")
332+ }
333+ return command{kind: kindHost, hostAction: hostActionList}, nil
334+ default:
335+ return command{}, usagef("unknown host action %q", args[0])
336+ }
337+}
338+
278 // performanceTrace creates one serialized timestamp owner for this process.339 // performanceTrace creates one serialized timestamp owner for this process.
279 // An empty trace environment leaves all production behavior and wire bytes340 // An empty trace environment leaves all production behavior and wire bytes
280 // unchanged.341 // unchanged.
@@ -678,6 +739,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
678 createDetached: createDetachedLocalSession,739 createDetached: createDetachedLocalSession,
679 clipboard: clipboard.New(),740 clipboard: clipboard.New(),
680 runtimeObserver: observer,741 runtimeObserver: observer,
742+ stateDir: platform.StateDir,
681 })743 })
682 }744 }
683 745
@@ -703,6 +765,9 @@ type runAttachDeps struct {
703 // Only used for the remote-dialer branch below; local attaches never765 // Only used for the remote-dialer branch below; local attaches never
704 // intercept Ctrl+V regardless of this field.766 // intercept Ctrl+V regardless of this field.
705 clipboard ports.ClipboardReader767 clipboard ports.ClipboardReader
768+ // Optional remote-learning seams.
769+ stateDir func() string
770+ hostStore ports.RemoteHostStore
706 }771 }
707 772
708 func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {773 func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {
@@ -745,12 +810,13 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
745 return err810 return err
746 }811 }
747 return runClient(ctx, client.Dependencies{812 return runClient(ctx, client.Dependencies{
748- Dialer: dialer,813+ Dialer: dialer,
749- Terminal: term.New(),814+ Terminal: term.New(),
750- Clock: clock.New(),815+ Clock: clock.New(),
751- Clipboard: deps.clipboard,816+ Clipboard: deps.clipboard,
752- Logger: log,817+ Logger: log,
753- RuntimeObserver: deps.runtimeObserver,818+ RuntimeObserver: deps.runtimeObserver,
819+ RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
754 }, client.AttachRequest{Intent: intent, SessionName: name, Remote: true})820 }, client.AttachRequest{Intent: intent, SessionName: name, Remote: true})
755 }821 }
756 822
@@ -1287,20 +1353,35 @@ func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger
1287 }1353 }
1288 1354
1289 // runList prints the daemon's session listing. With no daemon running, it1355 // runList prints the daemon's session listing. With no daemon running, it
1290-// falls back to the persisted stopped-session records.1356+// falls back to the persisted stopped-session records. Remote forms list one
1291-func runList(ctx context.Context) (retErr error) {1357+// known host or local+remote when --all is set.
1358+func runList(ctx context.Context, cmd command) (retErr error) {
1359+ if cmd.listAll || cmd.listHost != "" {
1360+ return runRemoteList(ctx, cmd, defaultRemoteHostDeps())
1361+ }
1362+ sessions, err := listLocalSessions(ctx)
1363+ if sessions != nil {
1364+ printSessions(os.Stdout, sessions)
1365+ }
1366+ if err != nil {
1367+ return err
1368+ }
1369+ return nil
1370+}
1371+
1372+func listLocalSessions(ctx context.Context) (_ []ports.SessionInfo, retErr error) {
1292 transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff)1373 transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff)
1293 if err != nil {1374 if err != nil {
1294- return fmt.Errorf("vev: waiting for durable session state: %w", err)1375+ return nil, fmt.Errorf("vev: waiting for durable session state: %w", err)
1295 }1376 }
1296 if owner != nil {1377 if owner != nil {
1297 defer joinLifecycleReleaseError(&retErr, owner)1378 defer joinLifecycleReleaseError(&retErr, owner)
1298 records, loadErr := persist.LoadReadOnly(platform.StateDir())1379 records, loadErr := persist.LoadReadOnly(platform.StateDir())
1299 if loadErr != nil {1380 if loadErr != nil {
1300 if errors.Is(loadErr, persist.ErrCatalogueUnreadable) {1381 if errors.Is(loadErr, persist.ErrCatalogueUnreadable) {
1301- return unreadableCatalogueError(platform.StateDir())1382+ return nil, unreadableCatalogueError(platform.StateDir())
1302 }1383 }
1303- return fmt.Errorf("vev: reading stored sessions: %w", loadErr)1384+ return nil, fmt.Errorf("vev: reading stored sessions: %w", loadErr)
1304 }1385 }
1305 infos := make([]ports.SessionInfo, 0, len(records))1386 infos := make([]ports.SessionInfo, 0, len(records))
1306 for _, r := range records {1387 for _, r := range records {
@@ -1310,31 +1391,36 @@ func runList(ctx context.Context) (retErr error) {
1310 }1391 }
1311 infos = append(infos, ports.SessionInfo{Name: r.Name, State: state})1392 infos = append(infos, ports.SessionInfo{Name: r.Name, State: state})
1312 }1393 }
1313- printSessions(os.Stdout, infos)1394+ return infos, nil
1314- return nil
1315 }1395 }
1316 defer func() { _ = transport.Close() }()1396 defer func() { _ = transport.Close() }()
1317 1397
1318 if err := transport.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}); err != nil {1398 if err := transport.Send(ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})}); err != nil {
1319- return fmt.Errorf("vev: requesting session list: %w", err)1399+ return nil, fmt.Errorf("vev: requesting session list: %w", err)
1320 }1400 }
1321 reply, err := transport.Recv()1401 reply, err := transport.Recv()
1322 if err != nil {1402 if err != nil {
1323- return fmt.Errorf("vev: reading session list: %w", err)1403+ return nil, fmt.Errorf("vev: reading session list: %w", err)
1324 }1404 }
1405+ return decodeSessionListReply(reply)
1406+}
1407+
1408+func decodeSessionListReply(reply ports.Frame) ([]ports.SessionInfo, error) {
1325 if reply.Type == ports.MsgError {1409 if reply.Type == ports.MsgError {
1326- em, _ := ports.UnmarshalErrorMsg(reply.Payload)1410+ em, err := ports.UnmarshalErrorMsg(reply.Payload)
1327- return fmt.Errorf("vev: %s", em.Text)1411+ if err != nil {
1412+ return nil, fmt.Errorf("vev: decoding error reply: %w", err)
1413+ }
1414+ return nil, fmt.Errorf("vev: %s", em.Text)
1328 }1415 }
1329 if reply.Type != ports.MsgSessions {1416 if reply.Type != ports.MsgSessions {
1330- return fmt.Errorf("vev: unexpected reply type %d to list", reply.Type)1417+ return nil, fmt.Errorf("vev: unexpected reply type %d to list", reply.Type)
1331 }1418 }
1332 sessions, err := ports.UnmarshalSessions(reply.Payload)1419 sessions, err := ports.UnmarshalSessions(reply.Payload)
1333 if err != nil {1420 if err != nil {
1334- return fmt.Errorf("vev: decoding session list: %w", err)1421+ return nil, fmt.Errorf("vev: decoding session list: %w", err)
1335 }1422 }
1336- printSessions(os.Stdout, sessions.Sessions)1423+ return sessions.Sessions, nil
1337- return nil
1338 }1424 }
1339 1425
1340 // printSessions renders a session table (or a friendly note when empty).1426 // printSessions renders a session table (or a friendly note when empty).
M internal/app/run_test.go +55 -14
@@ -165,7 +165,9 @@ func TestOfflineCommandsPropagateLifecycleReleaseErrors(t *testing.T) {
165165 }{
166166 {
167167 name: "list joins release failure to success",
168- run: runList,
168+ run: func(ctx context.Context) error {
169+ return runList(ctx, command{kind: kindList})
170+ },
169171 },
170172 {
171173 name: "kill joins release failure to operation failure",
@@ -552,15 +554,20 @@ func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) {
552554
553555 func TestParseArgs(t *testing.T) {
554556 tests := []struct {
555- name string
556- args []string
557- wantKind cmdKind
558- wantIntent uint8
559- wantName string
560- wantRemote string
561- wantAll bool
562- wantDaemon bool
563- wantErr bool
557+ name string
558+ args []string
559+ wantKind cmdKind
560+ wantIntent uint8
561+ wantName string
562+ wantRemote string
563+ wantListHost string
564+ wantListAll bool
565+ wantHostAct string
566+ wantHostTgt string
567+ wantAll bool
568+ wantDaemon bool
569+ wantErr bool
570+ nonUsageErr bool
564571 }{
565572 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
566573 {name: "empty slice -> ephemeral attach", args: []string{}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
@@ -578,6 +585,21 @@ func TestParseArgs(t *testing.T) {
578585 {name: "attach without name", args: []string{"attach"}, wantErr: true},
579586 {name: "ls", args: []string{"ls"}, wantKind: kindList},
580587 {name: "list", args: []string{"list"}, wantKind: kindList},
588+ {name: "ls host", args: []string{"ls", "arch"}, wantKind: kindList, wantListHost: "arch"},
589+ {name: "list host", args: []string{"list", "build@mule"}, wantKind: kindList, wantListHost: "build@mule"},
590+ {name: "ls --all", args: []string{"ls", "--all"}, wantKind: kindList, wantListAll: true},
591+ {name: "list --all", args: []string{"list", "--all"}, wantKind: kindList, wantListAll: true},
592+ {name: "ls host extra", args: []string{"ls", "arch", "extra"}, wantErr: true},
593+ {name: "ls --all host", args: []string{"ls", "--all", "arch"}, wantErr: true},
594+ {name: "ls invalid host", args: []string{"ls", "bad host"}, wantErr: true, nonUsageErr: true},
595+ {name: "host add", args: []string{"host", "add", "arch"}, wantKind: kindHost, wantHostAct: "add", wantHostTgt: "arch"},
596+ {name: "host rm", args: []string{"host", "rm", "build@mule"}, wantKind: kindHost, wantHostAct: "rm", wantHostTgt: "build@mule"},
597+ {name: "host list", args: []string{"host", "list"}, wantKind: kindHost, wantHostAct: "list"},
598+ {name: "host alone", args: []string{"host"}, wantErr: true},
599+ {name: "host add missing target", args: []string{"host", "add"}, wantErr: true},
600+ {name: "host add extra", args: []string{"host", "add", "arch", "extra"}, wantErr: true},
601+ {name: "host add invalid", args: []string{"host", "add", " bad"}, wantErr: true, nonUsageErr: true},
602+ {name: "host unknown action", args: []string{"host", "rename", "arch"}, wantErr: true},
581603 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
582604 {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
583605 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
@@ -609,9 +631,16 @@ func TestParseArgs(t *testing.T) {
609631 if err == nil {
610632 t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)
611633 }
612- var ue *usageError
613- if !errors.As(err, &ue) {
614- t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)
634+ if tt.nonUsageErr {
635+ var ue *usageError
636+ if errors.As(err, &ue) {
637+ t.Fatalf("parseArgs(%q) error = *usageError, want non-usage error", tt.args)
638+ }
639+ } else {
640+ var ue *usageError
641+ if !errors.As(err, &ue) {
642+ t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)
643+ }
615644 }
616645 return
617646 }
@@ -630,6 +659,18 @@ func TestParseArgs(t *testing.T) {
630659 if got.remoteTarget != tt.wantRemote {
631660 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
632661 }
662+ if got.listHost != tt.wantListHost {
663+ t.Errorf("listHost = %q, want %q", got.listHost, tt.wantListHost)
664+ }
665+ if got.listAll != tt.wantListAll {
666+ t.Errorf("listAll = %v, want %v", got.listAll, tt.wantListAll)
667+ }
668+ if got.hostAction != tt.wantHostAct {
669+ t.Errorf("hostAction = %q, want %q", got.hostAction, tt.wantHostAct)
670+ }
671+ if got.hostTarget != tt.wantHostTgt {
672+ t.Errorf("hostTarget = %q, want %q", got.hostTarget, tt.wantHostTgt)
673+ }
633674 if got.killAll != tt.wantAll {
634675 t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
635676 }
@@ -711,7 +752,7 @@ func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
711752 }
712753
713754 got := captureStdout(t, func() {
714- if err := runList(context.Background()); err != nil {
755+ if err := runList(context.Background(), command{kind: kindList}); err != nil {
715756 t.Fatalf("runList error = %v", err)
716757 }
717758 })
@@ -165,7 +165,9 @@ func TestOfflineCommandsPropagateLifecycleReleaseErrors(t *testing.T) {
165 }{165 }{
166 {166 {
167 name: "list joins release failure to success",167 name: "list joins release failure to success",
168- run: runList,168+ run: func(ctx context.Context) error {
169+ return runList(ctx, command{kind: kindList})
170+ },
169 },171 },
170 {172 {
171 name: "kill joins release failure to operation failure",173 name: "kill joins release failure to operation failure",
@@ -552,15 +554,20 @@ func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) {
552 554
553 func TestParseArgs(t *testing.T) {555 func TestParseArgs(t *testing.T) {
554 tests := []struct {556 tests := []struct {
555- name string557+ name string
556- args []string558+ args []string
557- wantKind cmdKind559+ wantKind cmdKind
558- wantIntent uint8560+ wantIntent uint8
559- wantName string561+ wantName string
560- wantRemote string562+ wantRemote string
561- wantAll bool563+ wantListHost string
562- wantDaemon bool564+ wantListAll bool
563- wantErr bool565+ wantHostAct string
566+ wantHostTgt string
567+ wantAll bool
568+ wantDaemon bool
569+ wantErr bool
570+ nonUsageErr bool
564 }{571 }{
565 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},572 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
566 {name: "empty slice -> ephemeral attach", args: []string{}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},573 {name: "empty slice -> ephemeral attach", args: []string{}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
@@ -578,6 +585,21 @@ func TestParseArgs(t *testing.T) {
578 {name: "attach without name", args: []string{"attach"}, wantErr: true},585 {name: "attach without name", args: []string{"attach"}, wantErr: true},
579 {name: "ls", args: []string{"ls"}, wantKind: kindList},586 {name: "ls", args: []string{"ls"}, wantKind: kindList},
580 {name: "list", args: []string{"list"}, wantKind: kindList},587 {name: "list", args: []string{"list"}, wantKind: kindList},
588+ {name: "ls host", args: []string{"ls", "arch"}, wantKind: kindList, wantListHost: "arch"},
589+ {name: "list host", args: []string{"list", "build@mule"}, wantKind: kindList, wantListHost: "build@mule"},
590+ {name: "ls --all", args: []string{"ls", "--all"}, wantKind: kindList, wantListAll: true},
591+ {name: "list --all", args: []string{"list", "--all"}, wantKind: kindList, wantListAll: true},
592+ {name: "ls host extra", args: []string{"ls", "arch", "extra"}, wantErr: true},
593+ {name: "ls --all host", args: []string{"ls", "--all", "arch"}, wantErr: true},
594+ {name: "ls invalid host", args: []string{"ls", "bad host"}, wantErr: true, nonUsageErr: true},
595+ {name: "host add", args: []string{"host", "add", "arch"}, wantKind: kindHost, wantHostAct: "add", wantHostTgt: "arch"},
596+ {name: "host rm", args: []string{"host", "rm", "build@mule"}, wantKind: kindHost, wantHostAct: "rm", wantHostTgt: "build@mule"},
597+ {name: "host list", args: []string{"host", "list"}, wantKind: kindHost, wantHostAct: "list"},
598+ {name: "host alone", args: []string{"host"}, wantErr: true},
599+ {name: "host add missing target", args: []string{"host", "add"}, wantErr: true},
600+ {name: "host add extra", args: []string{"host", "add", "arch", "extra"}, wantErr: true},
601+ {name: "host add invalid", args: []string{"host", "add", " bad"}, wantErr: true, nonUsageErr: true},
602+ {name: "host unknown action", args: []string{"host", "rename", "arch"}, wantErr: true},
581 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},603 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
582 {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},604 {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
583 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},605 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
@@ -609,9 +631,16 @@ func TestParseArgs(t *testing.T) {
609 if err == nil {631 if err == nil {
610 t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)632 t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)
611 }633 }
612- var ue *usageError634+ if tt.nonUsageErr {
613- if !errors.As(err, &ue) {635+ var ue *usageError
614- t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)636+ if errors.As(err, &ue) {
637+ t.Fatalf("parseArgs(%q) error = *usageError, want non-usage error", tt.args)
638+ }
639+ } else {
640+ var ue *usageError
641+ if !errors.As(err, &ue) {
642+ t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)
643+ }
615 }644 }
616 return645 return
617 }646 }
@@ -630,6 +659,18 @@ func TestParseArgs(t *testing.T) {
630 if got.remoteTarget != tt.wantRemote {659 if got.remoteTarget != tt.wantRemote {
631 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)660 t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
632 }661 }
662+ if got.listHost != tt.wantListHost {
663+ t.Errorf("listHost = %q, want %q", got.listHost, tt.wantListHost)
664+ }
665+ if got.listAll != tt.wantListAll {
666+ t.Errorf("listAll = %v, want %v", got.listAll, tt.wantListAll)
667+ }
668+ if got.hostAction != tt.wantHostAct {
669+ t.Errorf("hostAction = %q, want %q", got.hostAction, tt.wantHostAct)
670+ }
671+ if got.hostTarget != tt.wantHostTgt {
672+ t.Errorf("hostTarget = %q, want %q", got.hostTarget, tt.wantHostTgt)
673+ }
633 if got.killAll != tt.wantAll {674 if got.killAll != tt.wantAll {
634 t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)675 t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
635 }676 }
@@ -711,7 +752,7 @@ func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
711 }752 }
712 753
713 got := captureStdout(t, func() {754 got := captureStdout(t, func() {
714- if err := runList(context.Background()); err != nil {755+ if err := runList(context.Background(), command{kind: kindList}); err != nil {
715 t.Fatalf("runList error = %v", err)756 t.Fatalf("runList error = %v", err)
716 }757 }
717 })758 })
M internal/ports/mocks/remote_mock.go +88 -8
@@ -363,20 +363,29 @@ func (_c *MockRemoteHostStore_Remember_Call) RunAndReturn(run func(target string
363363 }
364364
365365 // Remove provides a mock function for the type MockRemoteHostStore
366-func (_mock *MockRemoteHostStore) Remove(target string) error {
366+func (_mock *MockRemoteHostStore) Remove(target string) (bool, error) {
367367 ret := _mock.Called(target)
368368
369369 if len(ret) == 0 {
370370 panic("no return value specified for Remove")
371371 }
372372
373- var r0 error
374- if returnFunc, ok := ret.Get(0).(func(string) error); ok {
373+ var r0 bool
374+ var r1 error
375+ if returnFunc, ok := ret.Get(0).(func(string) (bool, error)); ok {
376+ return returnFunc(target)
377+ }
378+ if returnFunc, ok := ret.Get(0).(func(string) bool); ok {
375379 r0 = returnFunc(target)
376380 } else {
377- r0 = ret.Error(0)
381+ r0 = ret.Get(0).(bool)
378382 }
379- return r0
383+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
384+ r1 = returnFunc(target)
385+ } else {
386+ r1 = ret.Error(1)
387+ }
388+ return r0, r1
380389 }
381390
382391 // MockRemoteHostStore_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
@@ -403,12 +412,12 @@ func (_c *MockRemoteHostStore_Remove_Call) Run(run func(target string)) *MockRem
403412 return _c
404413 }
405414
406-func (_c *MockRemoteHostStore_Remove_Call) Return(err error) *MockRemoteHostStore_Remove_Call {
407- _c.Call.Return(err)
415+func (_c *MockRemoteHostStore_Remove_Call) Return(deleted bool, err error) *MockRemoteHostStore_Remove_Call {
416+ _c.Call.Return(deleted, err)
408417 return _c
409418 }
410419
411-func (_c *MockRemoteHostStore_Remove_Call) RunAndReturn(run func(target string) error) *MockRemoteHostStore_Remove_Call {
420+func (_c *MockRemoteHostStore_Remove_Call) RunAndReturn(run func(target string) (bool, error)) *MockRemoteHostStore_Remove_Call {
412421 _c.Call.Return(run)
413422 return _c
414423 }
@@ -464,6 +473,77 @@ func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target st
464473 return _c
465474 }
466475
476+// NewMockRemoteHostLearner creates a new instance of MockRemoteHostLearner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
477+// The first argument is typically a *testing.T value.
478+func NewMockRemoteHostLearner(t interface {
479+ mock.TestingT
480+ Cleanup(func())
481+}) *MockRemoteHostLearner {
482+ mock := &MockRemoteHostLearner{}
483+ mock.Mock.Test(t)
484+
485+ t.Cleanup(func() { mock.AssertExpectations(t) })
486+
487+ return mock
488+}
489+
490+// MockRemoteHostLearner is an autogenerated mock type for the RemoteHostLearner type
491+type MockRemoteHostLearner struct {
492+ mock.Mock
493+}
494+
495+type MockRemoteHostLearner_Expecter struct {
496+ mock *mock.Mock
497+}
498+
499+func (_m *MockRemoteHostLearner) EXPECT() *MockRemoteHostLearner_Expecter {
500+ return &MockRemoteHostLearner_Expecter{mock: &_m.Mock}
501+}
502+
503+// RememberRemoteHost provides a mock function for the type MockRemoteHostLearner
504+func (_mock *MockRemoteHostLearner) RememberRemoteHost() error {
505+ ret := _mock.Called()
506+
507+ if len(ret) == 0 {
508+ panic("no return value specified for RememberRemoteHost")
509+ }
510+
511+ var r0 error
512+ if returnFunc, ok := ret.Get(0).(func() error); ok {
513+ r0 = returnFunc()
514+ } else {
515+ r0 = ret.Error(0)
516+ }
517+ return r0
518+}
519+
520+// MockRemoteHostLearner_RememberRemoteHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RememberRemoteHost'
521+type MockRemoteHostLearner_RememberRemoteHost_Call struct {
522+ *mock.Call
523+}
524+
525+// RememberRemoteHost is a helper method to define mock.On call
526+func (_e *MockRemoteHostLearner_Expecter) RememberRemoteHost() *MockRemoteHostLearner_RememberRemoteHost_Call {
527+ return &MockRemoteHostLearner_RememberRemoteHost_Call{Call: _e.mock.On("RememberRemoteHost")}
528+}
529+
530+func (_c *MockRemoteHostLearner_RememberRemoteHost_Call) Run(run func()) *MockRemoteHostLearner_RememberRemoteHost_Call {
531+ _c.Call.Run(func(args mock.Arguments) {
532+ run()
533+ })
534+ return _c
535+}
536+
537+func (_c *MockRemoteHostLearner_RememberRemoteHost_Call) Return(err error) *MockRemoteHostLearner_RememberRemoteHost_Call {
538+ _c.Call.Return(err)
539+ return _c
540+}
541+
542+func (_c *MockRemoteHostLearner_RememberRemoteHost_Call) RunAndReturn(run func() error) *MockRemoteHostLearner_RememberRemoteHost_Call {
543+ _c.Call.Return(run)
544+ return _c
545+}
546+
467547 // NewMockRemoteCatalogClient creates a new instance of MockRemoteCatalogClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
468548 // The first argument is typically a *testing.T value.
469549 func NewMockRemoteCatalogClient(t interface {
@@ -363,20 +363,29 @@ func (_c *MockRemoteHostStore_Remember_Call) RunAndReturn(run func(target string
363 }363 }
364 364
365 // Remove provides a mock function for the type MockRemoteHostStore365 // Remove provides a mock function for the type MockRemoteHostStore
366-func (_mock *MockRemoteHostStore) Remove(target string) error {366+func (_mock *MockRemoteHostStore) Remove(target string) (bool, error) {
367 ret := _mock.Called(target)367 ret := _mock.Called(target)
368 368
369 if len(ret) == 0 {369 if len(ret) == 0 {
370 panic("no return value specified for Remove")370 panic("no return value specified for Remove")
371 }371 }
372 372
373- var r0 error373+ var r0 bool
374- if returnFunc, ok := ret.Get(0).(func(string) error); ok {374+ var r1 error
375+ if returnFunc, ok := ret.Get(0).(func(string) (bool, error)); ok {
376+ return returnFunc(target)
377+ }
378+ if returnFunc, ok := ret.Get(0).(func(string) bool); ok {
375 r0 = returnFunc(target)379 r0 = returnFunc(target)
376 } else {380 } else {
377- r0 = ret.Error(0)381+ r0 = ret.Get(0).(bool)
378 }382 }
379- return r0383+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
384+ r1 = returnFunc(target)
385+ } else {
386+ r1 = ret.Error(1)
387+ }
388+ return r0, r1
380 }389 }
381 390
382 // MockRemoteHostStore_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'391 // MockRemoteHostStore_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
@@ -403,12 +412,12 @@ func (_c *MockRemoteHostStore_Remove_Call) Run(run func(target string)) *MockRem
403 return _c412 return _c
404 }413 }
405 414
406-func (_c *MockRemoteHostStore_Remove_Call) Return(err error) *MockRemoteHostStore_Remove_Call {415+func (_c *MockRemoteHostStore_Remove_Call) Return(deleted bool, err error) *MockRemoteHostStore_Remove_Call {
407- _c.Call.Return(err)416+ _c.Call.Return(deleted, err)
408 return _c417 return _c
409 }418 }
410 419
411-func (_c *MockRemoteHostStore_Remove_Call) RunAndReturn(run func(target string) error) *MockRemoteHostStore_Remove_Call {420+func (_c *MockRemoteHostStore_Remove_Call) RunAndReturn(run func(target string) (bool, error)) *MockRemoteHostStore_Remove_Call {
412 _c.Call.Return(run)421 _c.Call.Return(run)
413 return _c422 return _c
414 }423 }
@@ -464,6 +473,77 @@ func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target st
464 return _c473 return _c
465 }474 }
466 475
476+// NewMockRemoteHostLearner creates a new instance of MockRemoteHostLearner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
477+// The first argument is typically a *testing.T value.
478+func NewMockRemoteHostLearner(t interface {
479+ mock.TestingT
480+ Cleanup(func())
481+}) *MockRemoteHostLearner {
482+ mock := &MockRemoteHostLearner{}
483+ mock.Mock.Test(t)
484+
485+ t.Cleanup(func() { mock.AssertExpectations(t) })
486+
487+ return mock
488+}
489+
490+// MockRemoteHostLearner is an autogenerated mock type for the RemoteHostLearner type
491+type MockRemoteHostLearner struct {
492+ mock.Mock
493+}
494+
495+type MockRemoteHostLearner_Expecter struct {
496+ mock *mock.Mock
497+}
498+
499+func (_m *MockRemoteHostLearner) EXPECT() *MockRemoteHostLearner_Expecter {
500+ return &MockRemoteHostLearner_Expecter{mock: &_m.Mock}
501+}
502+
503+// RememberRemoteHost provides a mock function for the type MockRemoteHostLearner
504+func (_mock *MockRemoteHostLearner) RememberRemoteHost() error {
505+ ret := _mock.Called()
506+
507+ if len(ret) == 0 {
508+ panic("no return value specified for RememberRemoteHost")
509+ }
510+
511+ var r0 error
512+ if returnFunc, ok := ret.Get(0).(func() error); ok {
513+ r0 = returnFunc()
514+ } else {
515+ r0 = ret.Error(0)
516+ }
517+ return r0
518+}
519+
520+// MockRemoteHostLearner_RememberRemoteHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RememberRemoteHost'
521+type MockRemoteHostLearner_RememberRemoteHost_Call struct {
522+ *mock.Call
523+}
524+
525+// RememberRemoteHost is a helper method to define mock.On call
526+func (_e *MockRemoteHostLearner_Expecter) RememberRemoteHost() *MockRemoteHostLearner_RememberRemoteHost_Call {
527+ return &MockRemoteHostLearner_RememberRemoteHost_Call{Call: _e.mock.On("RememberRemoteHost")}
528+}
529+
530+func (_c *MockRemoteHostLearner_RememberRemoteHost_Call) Run(run func()) *MockRemoteHostLearner_RememberRemoteHost_Call {
531+ _c.Call.Run(func(args mock.Arguments) {
532+ run()
533+ })
534+ return _c
535+}
536+
537+func (_c *MockRemoteHostLearner_RememberRemoteHost_Call) Return(err error) *MockRemoteHostLearner_RememberRemoteHost_Call {
538+ _c.Call.Return(err)
539+ return _c
540+}
541+
542+func (_c *MockRemoteHostLearner_RememberRemoteHost_Call) RunAndReturn(run func() error) *MockRemoteHostLearner_RememberRemoteHost_Call {
543+ _c.Call.Return(run)
544+ return _c
545+}
546+
467 // NewMockRemoteCatalogClient creates a new instance of MockRemoteCatalogClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.547 // NewMockRemoteCatalogClient creates a new instance of MockRemoteCatalogClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
468 // The first argument is typically a *testing.T value.548 // The first argument is typically a *testing.T value.
469 func NewMockRemoteCatalogClient(t interface {549 func NewMockRemoteCatalogClient(t interface {
M internal/ports/remote.go +10 -2
@@ -29,8 +29,16 @@ type RemoteHostStore interface {
2929 RemovePinned(target string) error
3030 Remember(target string) error
3131 Forget(target string) error
32- // Remove deletes target from both pinned and learned lists atomically.
33- Remove(target string) error
32+ // Remove deletes target from both pinned and learned lists atomically and
33+ // reports whether the target existed in either list.
34+ Remove(target string) (deleted bool, err error)
35+}
36+
37+// RemoteHostLearner records the remote target after a successful attach.
38+// It deliberately has no arguments because app captures the validated target
39+// while constructing the client dependency.
40+type RemoteHostLearner interface {
41+ RememberRemoteHost() error
3442 }
3543
3644 // RemoteCatalogClient fetches a versioned session catalog from a remote host.
@@ -29,8 +29,16 @@ type RemoteHostStore interface {
29 RemovePinned(target string) error29 RemovePinned(target string) error
30 Remember(target string) error30 Remember(target string) error
31 Forget(target string) error31 Forget(target string) error
32- // Remove deletes target from both pinned and learned lists atomically.32+ // Remove deletes target from both pinned and learned lists atomically and
33- Remove(target string) error33+ // reports whether the target existed in either list.
34+ Remove(target string) (deleted bool, err error)
35+}
36+
37+// RemoteHostLearner records the remote target after a successful attach.
38+// It deliberately has no arguments because app captures the validated target
39+// while constructing the client dependency.
40+type RemoteHostLearner interface {
41+ RememberRemoteHost() error
34 }42 }
35 43
36 // RemoteCatalogClient fetches a versioned session catalog from a remote host.44 // RemoteCatalogClient fetches a versioned session catalog from a remote host.
M internal/usecase/client/client.go +96 -46
@@ -134,6 +134,11 @@ const sendQueueDepth = 64
134134 // accepted the attach. Mosh uses the same 15-second startup budget.
135135 const preWelcomeTimeout = 15 * time.Second
136136
137+// remoteHostLearnerShutdownTimeout bounds how long Run waits for async remote
138+// host learning after terminal restoration. Learning is best-effort; a stalled
139+// learner must not block shell prompt return indefinitely.
140+const remoteHostLearnerShutdownTimeout = time.Second
141+
137142 var reconnectSleep = sleepReconnect
138143 var reconnectSleepWithResize = sleepReconnectWithResizeEvents
139144
@@ -144,12 +149,13 @@ const (
144149
145150 // Dependencies supplies the collaborators required by a Runner.
146151 type Dependencies struct {
147- Dialer ports.Dialer
148- Terminal ports.Terminal
149- Clock ports.Clock
150- Clipboard ports.ClipboardReader
151- Logger *slog.Logger
152- RuntimeObserver ports.SerializedRuntimeObserver
152+ Dialer ports.Dialer
153+ Terminal ports.Terminal
154+ Clock ports.Clock
155+ Clipboard ports.ClipboardReader
156+ Logger *slog.Logger
157+ RuntimeObserver ports.SerializedRuntimeObserver
158+ RemoteHostLearner ports.RemoteHostLearner
153159 }
154160
155161 // AttachRequest identifies the session and transport mode for one client run.
@@ -161,12 +167,13 @@ type AttachRequest struct {
161167
162168 // Runner owns the client lifecycle across one or more attachment attempts.
163169 type Runner struct {
164- dialer ports.Dialer
165- term ports.Terminal
166- clock ports.Clock
167- clipboard ports.ClipboardReader
168- logger *slog.Logger
169- runtimeObserver ports.SerializedRuntimeObserver
170+ dialer ports.Dialer
171+ term ports.Terminal
172+ clock ports.Clock
173+ clipboard ports.ClipboardReader
174+ logger *slog.Logger
175+ runtimeObserver ports.SerializedRuntimeObserver
176+ remoteHostLearner ports.RemoteHostLearner
170177
171178 inputMu sync.Mutex
172179 input *terminalInputPump
@@ -179,12 +186,13 @@ func NewRunner(deps Dependencies) *Runner {
179186 log = slog.Default()
180187 }
181188 return &Runner{
182- dialer: deps.Dialer,
183- term: deps.Terminal,
184- clock: deps.Clock,
185- clipboard: deps.Clipboard,
186- logger: log,
187- runtimeObserver: deps.RuntimeObserver,
189+ dialer: deps.Dialer,
190+ term: deps.Terminal,
191+ clock: deps.Clock,
192+ clipboard: deps.Clipboard,
193+ logger: log,
194+ runtimeObserver: deps.RuntimeObserver,
195+ remoteHostLearner: deps.RemoteHostLearner,
188196 }
189197 }
190198
@@ -217,13 +225,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
217225
218226 var restore func() error
219227 rawEntered := false
220- defer func() {
221- if rawEntered {
222- if rerr := restore(); rerr != nil && retErr == nil {
223- retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
224- }
225- }
226- }()
227228 enterRaw := func() error {
228229 if rawEntered {
229230 return nil
@@ -242,6 +243,48 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
242243 attemptRequest := request
243244 backoff := defaultReconnectBackoff.initial
244245 themeState := &terminalThemeState{}
246+ var rememberOnce sync.Once
247+ var rememberWG sync.WaitGroup
248+ var learnerStarted bool
249+ rememberRemoteHost := func() {
250+ if r.remoteHostLearner == nil {
251+ return
252+ }
253+ rememberOnce.Do(func() {
254+ learnerStarted = true
255+ rememberWG.Add(1)
256+ go func() {
257+ defer rememberWG.Done()
258+ if err := r.remoteHostLearner.RememberRemoteHost(); err != nil {
259+ r.logger.Warn("remembering remote host failed", "err", err)
260+ }
261+ }()
262+ })
263+ }
264+ defer func() {
265+ if !learnerStarted {
266+ return
267+ }
268+ done := make(chan struct{})
269+ go func() {
270+ rememberWG.Wait()
271+ close(done)
272+ }()
273+ timer := r.clock.NewTimer(remoteHostLearnerShutdownTimeout)
274+ defer timer.Stop()
275+ select {
276+ case <-done:
277+ case <-timer.C():
278+ r.logger.Warn("remote host learner stalled past shutdown timeout")
279+ }
280+ }()
281+ defer func() {
282+ if rawEntered {
283+ if rerr := restore(); rerr != nil && retErr == nil {
284+ retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
285+ }
286+ }
287+ }()
245288 reconnect := &reconnectUI{
246289 term: r.term,
247290 remote: request.Remote,
@@ -299,16 +342,17 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
299342 linkEvents = reporter.LinkEvents()
300343 }
301344 result := (&attachAttempt{
302- runner: r,
303- transport: transport,
304- request: attemptRequest,
305- resumeToken: resumeToken,
306- clientID: processClientID,
307- milestones: &ms,
308- themeState: themeState,
309- enterRaw: enterRaw,
310- reconnect: reconnect,
311- linkEvents: linkEvents,
345+ runner: r,
346+ transport: transport,
347+ request: attemptRequest,
348+ resumeToken: resumeToken,
349+ clientID: processClientID,
350+ milestones: &ms,
351+ themeState: themeState,
352+ enterRaw: enterRaw,
353+ reconnect: reconnect,
354+ linkEvents: linkEvents,
355+ rememberRemoteHost: rememberRemoteHost,
312356 terminalInput: func() *terminalInputPump {
313357 return input
314358 },
@@ -473,17 +517,18 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
473517 }
474518
475519 type attachAttempt struct {
476- runner *Runner
477- transport ports.Transport
478- request AttachRequest
479- resumeToken uint64
480- clientID [16]byte
481- milestones *milestones
482- themeState *terminalThemeState
483- enterRaw func() error
484- reconnect *reconnectUI
485- linkEvents <-chan ports.LinkEvent
486- terminalInput func() *terminalInputPump
520+ runner *Runner
521+ transport ports.Transport
522+ request AttachRequest
523+ resumeToken uint64
524+ clientID [16]byte
525+ milestones *milestones
526+ themeState *terminalThemeState
527+ enterRaw func() error
528+ reconnect *reconnectUI
529+ linkEvents <-chan ports.LinkEvent
530+ rememberRemoteHost func()
531+ terminalInput func() *terminalInputPump
487532 }
488533
489534 type attachResult struct {
@@ -636,6 +681,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
636681 name = welcome.SessionName
637682 ms.welcomed = true
638683 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
684+ if remote {
685+ if remember := a.rememberRemoteHost; remember != nil {
686+ remember()
687+ }
688+ }
639689 case ports.MsgError:
640690 em, derr := ports.UnmarshalErrorMsg(reply.Payload)
641691 if derr != nil {
@@ -134,6 +134,11 @@ const sendQueueDepth = 64
134 // accepted the attach. Mosh uses the same 15-second startup budget.134 // accepted the attach. Mosh uses the same 15-second startup budget.
135 const preWelcomeTimeout = 15 * time.Second135 const preWelcomeTimeout = 15 * time.Second
136 136
137+// remoteHostLearnerShutdownTimeout bounds how long Run waits for async remote
138+// host learning after terminal restoration. Learning is best-effort; a stalled
139+// learner must not block shell prompt return indefinitely.
140+const remoteHostLearnerShutdownTimeout = time.Second
141+
137 var reconnectSleep = sleepReconnect142 var reconnectSleep = sleepReconnect
138 var reconnectSleepWithResize = sleepReconnectWithResizeEvents143 var reconnectSleepWithResize = sleepReconnectWithResizeEvents
139 144
@@ -144,12 +149,13 @@ const (
144 149
145 // Dependencies supplies the collaborators required by a Runner.150 // Dependencies supplies the collaborators required by a Runner.
146 type Dependencies struct {151 type Dependencies struct {
147- Dialer ports.Dialer152+ Dialer ports.Dialer
148- Terminal ports.Terminal153+ Terminal ports.Terminal
149- Clock ports.Clock154+ Clock ports.Clock
150- Clipboard ports.ClipboardReader155+ Clipboard ports.ClipboardReader
151- Logger *slog.Logger156+ Logger *slog.Logger
152- RuntimeObserver ports.SerializedRuntimeObserver157+ RuntimeObserver ports.SerializedRuntimeObserver
158+ RemoteHostLearner ports.RemoteHostLearner
153 }159 }
154 160
155 // AttachRequest identifies the session and transport mode for one client run.161 // AttachRequest identifies the session and transport mode for one client run.
@@ -161,12 +167,13 @@ type AttachRequest struct {
161 167
162 // Runner owns the client lifecycle across one or more attachment attempts.168 // Runner owns the client lifecycle across one or more attachment attempts.
163 type Runner struct {169 type Runner struct {
164- dialer ports.Dialer170+ dialer ports.Dialer
165- term ports.Terminal171+ term ports.Terminal
166- clock ports.Clock172+ clock ports.Clock
167- clipboard ports.ClipboardReader173+ clipboard ports.ClipboardReader
168- logger *slog.Logger174+ logger *slog.Logger
169- runtimeObserver ports.SerializedRuntimeObserver175+ runtimeObserver ports.SerializedRuntimeObserver
176+ remoteHostLearner ports.RemoteHostLearner
170 177
171 inputMu sync.Mutex178 inputMu sync.Mutex
172 input *terminalInputPump179 input *terminalInputPump
@@ -179,12 +186,13 @@ func NewRunner(deps Dependencies) *Runner {
179 log = slog.Default()186 log = slog.Default()
180 }187 }
181 return &Runner{188 return &Runner{
182- dialer: deps.Dialer,189+ dialer: deps.Dialer,
183- term: deps.Terminal,190+ term: deps.Terminal,
184- clock: deps.Clock,191+ clock: deps.Clock,
185- clipboard: deps.Clipboard,192+ clipboard: deps.Clipboard,
186- logger: log,193+ logger: log,
187- runtimeObserver: deps.RuntimeObserver,194+ runtimeObserver: deps.RuntimeObserver,
195+ remoteHostLearner: deps.RemoteHostLearner,
188 }196 }
189 }197 }
190 198
@@ -217,13 +225,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
217 225
218 var restore func() error226 var restore func() error
219 rawEntered := false227 rawEntered := false
220- defer func() {
221- if rawEntered {
222- if rerr := restore(); rerr != nil && retErr == nil {
223- retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
224- }
225- }
226- }()
227 enterRaw := func() error {228 enterRaw := func() error {
228 if rawEntered {229 if rawEntered {
229 return nil230 return nil
@@ -242,6 +243,48 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
242 attemptRequest := request243 attemptRequest := request
243 backoff := defaultReconnectBackoff.initial244 backoff := defaultReconnectBackoff.initial
244 themeState := &terminalThemeState{}245 themeState := &terminalThemeState{}
246+ var rememberOnce sync.Once
247+ var rememberWG sync.WaitGroup
248+ var learnerStarted bool
249+ rememberRemoteHost := func() {
250+ if r.remoteHostLearner == nil {
251+ return
252+ }
253+ rememberOnce.Do(func() {
254+ learnerStarted = true
255+ rememberWG.Add(1)
256+ go func() {
257+ defer rememberWG.Done()
258+ if err := r.remoteHostLearner.RememberRemoteHost(); err != nil {
259+ r.logger.Warn("remembering remote host failed", "err", err)
260+ }
261+ }()
262+ })
263+ }
264+ defer func() {
265+ if !learnerStarted {
266+ return
267+ }
268+ done := make(chan struct{})
269+ go func() {
270+ rememberWG.Wait()
271+ close(done)
272+ }()
273+ timer := r.clock.NewTimer(remoteHostLearnerShutdownTimeout)
274+ defer timer.Stop()
275+ select {
276+ case <-done:
277+ case <-timer.C():
278+ r.logger.Warn("remote host learner stalled past shutdown timeout")
279+ }
280+ }()
281+ defer func() {
282+ if rawEntered {
283+ if rerr := restore(); rerr != nil && retErr == nil {
284+ retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
285+ }
286+ }
287+ }()
245 reconnect := &reconnectUI{288 reconnect := &reconnectUI{
246 term: r.term,289 term: r.term,
247 remote: request.Remote,290 remote: request.Remote,
@@ -299,16 +342,17 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
299 linkEvents = reporter.LinkEvents()342 linkEvents = reporter.LinkEvents()
300 }343 }
301 result := (&attachAttempt{344 result := (&attachAttempt{
302- runner: r,345+ runner: r,
303- transport: transport,346+ transport: transport,
304- request: attemptRequest,347+ request: attemptRequest,
305- resumeToken: resumeToken,348+ resumeToken: resumeToken,
306- clientID: processClientID,349+ clientID: processClientID,
307- milestones: &ms,350+ milestones: &ms,
308- themeState: themeState,351+ themeState: themeState,
309- enterRaw: enterRaw,352+ enterRaw: enterRaw,
310- reconnect: reconnect,353+ reconnect: reconnect,
311- linkEvents: linkEvents,354+ linkEvents: linkEvents,
355+ rememberRemoteHost: rememberRemoteHost,
312 terminalInput: func() *terminalInputPump {356 terminalInput: func() *terminalInputPump {
313 return input357 return input
314 },358 },
@@ -473,17 +517,18 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
473 }517 }
474 518
475 type attachAttempt struct {519 type attachAttempt struct {
476- runner *Runner520+ runner *Runner
477- transport ports.Transport521+ transport ports.Transport
478- request AttachRequest522+ request AttachRequest
479- resumeToken uint64523+ resumeToken uint64
480- clientID [16]byte524+ clientID [16]byte
481- milestones *milestones525+ milestones *milestones
482- themeState *terminalThemeState526+ themeState *terminalThemeState
483- enterRaw func() error527+ enterRaw func() error
484- reconnect *reconnectUI528+ reconnect *reconnectUI
485- linkEvents <-chan ports.LinkEvent529+ linkEvents <-chan ports.LinkEvent
486- terminalInput func() *terminalInputPump530+ rememberRemoteHost func()
531+ terminalInput func() *terminalInputPump
487 }532 }
488 533
489 type attachResult struct {534 type attachResult struct {
@@ -636,6 +681,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
636 name = welcome.SessionName681 name = welcome.SessionName
637 ms.welcomed = true682 ms.welcomed = true
638 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)683 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
684+ if remote {
685+ if remember := a.rememberRemoteHost; remember != nil {
686+ remember()
687+ }
688+ }
639 case ports.MsgError:689 case ports.MsgError:
640 em, derr := ports.UnmarshalErrorMsg(reply.Payload)690 em, derr := ports.UnmarshalErrorMsg(reply.Payload)
641 if derr != nil {691 if derr != nil {
M internal/usecase/client/client_test.go +445 -0
@@ -841,6 +841,451 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
841841 require.Contains(t, term.out.String(), "\x1b[2K")
842842 }
843843
844+func TestAttachRememberRemoteHost(t *testing.T) {
845+ tests := []struct {
846+ name string
847+ request client.AttachRequest
848+ remember func() error
849+ recv []recvItem
850+ preWelcomeReject bool
851+ wantCalled bool
852+ wantErr bool
853+ }{
854+ {
855+ name: "remote success",
856+ request: client.AttachRequest{
857+ Intent: ports.IntentAttach,
858+ SessionName: "main",
859+ Remote: true,
860+ },
861+ remember: func() error { return nil },
862+ recv: []recvItem{
863+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
864+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
865+ },
866+ wantCalled: true,
867+ },
868+ {
869+ name: "callback failure does not fail attach",
870+ request: client.AttachRequest{
871+ Intent: ports.IntentAttach,
872+ SessionName: "main",
873+ Remote: true,
874+ },
875+ remember: func() error { return errors.New("disk full") },
876+ recv: []recvItem{
877+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
878+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
879+ },
880+ wantCalled: true,
881+ },
882+ {
883+ name: "local attach does not call callback",
884+ request: client.AttachRequest{
885+ Intent: ports.IntentAttach,
886+ SessionName: "main",
887+ Remote: false,
888+ },
889+ remember: func() error {
890+ t.Fatal("remember callback must not run for local attach")
891+ return nil
892+ },
893+ recv: []recvItem{
894+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
895+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
896+ },
897+ },
898+ {
899+ name: "rejected before welcome does not call callback",
900+ request: client.AttachRequest{
901+ Intent: ports.IntentAttach,
902+ SessionName: "main",
903+ Remote: true,
904+ },
905+ remember: func() error {
906+ t.Fatal("remember callback must not run before welcome")
907+ return nil
908+ },
909+ recv: []recvItem{
910+ {f: frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"}))},
911+ },
912+ preWelcomeReject: true,
913+ wantErr: true,
914+ },
915+ {
916+ name: "post-welcome error still uses happy terminal fixture",
917+ request: client.AttachRequest{
918+ Intent: ports.IntentAttach,
919+ SessionName: "main",
920+ Remote: true,
921+ },
922+ remember: func() error { return nil },
923+ recv: []recvItem{
924+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
925+ {err: errors.New("connection reset")},
926+ },
927+ wantCalled: true,
928+ wantErr: true,
929+ },
930+ }
931+
932+ for _, tt := range tests {
933+ t.Run(tt.name, func(t *testing.T) {
934+ var tm *portsmocks.MockTerminal
935+ var unblockIn func()
936+ if tt.preWelcomeReject {
937+ tm = portsmocks.NewMockTerminal(t)
938+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
939+ // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
940+ } else {
941+ var out bytes.Buffer
942+ var restoreCount atomic.Int32
943+ resizeCh := make(chan domain.Size)
944+ var in *blockingReader
945+ tm, in = newHappyTerminal(t, &out, &restoreCount, resizeCh)
946+ unblockIn = in.unblock
947+ }
948+ if unblockIn != nil {
949+ defer unblockIn()
950+ }
951+
952+ var called atomic.Bool
953+ learnerDone := make(chan struct{})
954+ rememberHook := func() error {
955+ called.Store(true)
956+ defer close(learnerDone)
957+ if tt.remember != nil {
958+ return tt.remember()
959+ }
960+ return nil
961+ }
962+
963+ tr := portsmocks.NewMockTransport(t)
964+ if !tt.preWelcomeReject {
965+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
966+ }
967+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
968+ if tt.preWelcomeReject {
969+ tr.EXPECT().Recv().Return(tt.recv[0].f, tt.recv[0].err).Once()
970+ } else {
971+ unblock := scriptRecv(tr, tt.recv...)
972+ defer unblock()
973+ }
974+ tr.EXPECT().Close().Return(nil).Once()
975+
976+ deps := attachTestDependencies(tr, tm, realClock{})
977+ learner := portsmocks.NewMockRemoteHostLearner(t)
978+ if tt.wantCalled {
979+ learner.EXPECT().RememberRemoteHost().RunAndReturn(rememberHook).Once()
980+ }
981+ deps.RemoteHostLearner = learner
982+
983+ err := runTestClient(context.Background(), deps, tt.request)
984+ if tt.wantCalled {
985+ select {
986+ case <-learnerDone:
987+ case <-time.After(2 * time.Second):
988+ t.Fatal("remote host learner did not complete")
989+ }
990+ }
991+ if tt.wantErr {
992+ require.Error(t, err)
993+ } else {
994+ require.NoError(t, err)
995+ }
996+ require.Equal(t, tt.wantCalled, called.Load())
997+ })
998+ }
999+}
1000+
1001+func TestRunRememberRemoteHostAtMostOnceAcrossReconnects(t *testing.T) {
1002+ term := newRunTerminal()
1003+ defer term.in.unblock()
1004+
1005+ var rememberCalls atomic.Int32
1006+ learnerDone := make(chan struct{})
1007+
1008+ tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
1009+ tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
1010+ d := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}
1011+
1012+ deps := testDependencies(d, term, realClock{}, nil, nil)
1013+ learner := portsmocks.NewMockRemoteHostLearner(t)
1014+ learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error {
1015+ rememberCalls.Add(1)
1016+ close(learnerDone)
1017+ return nil
1018+ }).Once()
1019+ deps.RemoteHostLearner = learner
1020+ err := runTestClient(context.Background(), deps, client.AttachRequest{
1021+ Intent: ports.IntentAttach,
1022+ SessionName: "main",
1023+ Remote: true,
1024+ })
1025+ require.NoError(t, err)
1026+ select {
1027+ case <-learnerDone:
1028+ case <-time.After(2 * time.Second):
1029+ t.Fatal("remote host learner did not complete")
1030+ }
1031+ require.Equal(t, int32(1), rememberCalls.Load())
1032+ require.Equal(t, int32(2), d.calls.Load())
1033+}
1034+
1035+func TestAttachRememberRemoteHostDoesNotBlockAfterWelcome(t *testing.T) {
1036+ var out bytes.Buffer
1037+ var restoreCount atomic.Int32
1038+ resizeCh := make(chan domain.Size)
1039+ tm := portsmocks.NewMockTerminal(t)
1040+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1041+ enteredRaw := make(chan struct{})
1042+ tm.EXPECT().EnterRaw().Run(func() { close(enteredRaw) }).Return(func() error {
1043+ restoreCount.Add(1)
1044+ return nil
1045+ }, nil).Once()
1046+ in := newBlockingReader()
1047+ tm.EXPECT().In().Return(in).Maybe()
1048+ tm.EXPECT().Out().Return(&out).Maybe()
1049+ tm.EXPECT().Flush().Return(nil).Maybe()
1050+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1051+ defer in.unblock()
1052+
1053+ learnerStarted := make(chan struct{})
1054+ releaseLearner := make(chan struct{})
1055+ learnerDone := make(chan struct{})
1056+
1057+ tr := portsmocks.NewMockTransport(t)
1058+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
1059+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1060+ unblock := scriptRecv(tr,
1061+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
1062+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1063+ )
1064+ defer unblock()
1065+ tr.EXPECT().Close().Return(nil).Once()
1066+
1067+ deps := attachTestDependencies(tr, tm, realClock{})
1068+ learner := portsmocks.NewMockRemoteHostLearner(t)
1069+ learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error {
1070+ close(learnerStarted)
1071+ <-releaseLearner
1072+ close(learnerDone)
1073+ return nil
1074+ }).Once()
1075+ deps.RemoteHostLearner = learner
1076+
1077+ errCh := make(chan error, 1)
1078+ go func() {
1079+ errCh <- runTestClient(context.Background(), deps, client.AttachRequest{
1080+ Intent: ports.IntentAttach,
1081+ SessionName: "main",
1082+ Remote: true,
1083+ })
1084+ }()
1085+
1086+ select {
1087+ case <-learnerStarted:
1088+ case <-time.After(2 * time.Second):
1089+ t.Fatal("remote host learner did not start")
1090+ }
1091+ select {
1092+ case <-enteredRaw:
1093+ case <-time.After(2 * time.Second):
1094+ t.Fatal("attach did not enter raw mode while remote host learner was blocked")
1095+ }
1096+ select {
1097+ case err := <-errCh:
1098+ t.Fatalf("run returned before remote host learner completed: %v", err)
1099+ default:
1100+ }
1101+ close(releaseLearner)
1102+ select {
1103+ case <-learnerDone:
1104+ case <-time.After(2 * time.Second):
1105+ t.Fatal("remote host learner did not complete after release")
1106+ }
1107+ select {
1108+ case err := <-errCh:
1109+ require.NoError(t, err)
1110+ case <-time.After(2 * time.Second):
1111+ t.Fatal("run did not return after remote host learner completed")
1112+ }
1113+}
1114+
1115+func TestRunRestoresTerminalBeforeWaitingForLearner(t *testing.T) {
1116+ var out bytes.Buffer
1117+ var restoreCount atomic.Int32
1118+ restored := make(chan struct{})
1119+ resizeCh := make(chan domain.Size)
1120+ tm := portsmocks.NewMockTerminal(t)
1121+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1122+ tm.EXPECT().EnterRaw().Return(func() error {
1123+ restoreCount.Add(1)
1124+ close(restored)
1125+ return nil
1126+ }, nil).Once()
1127+ in := newBlockingReader()
1128+ tm.EXPECT().In().Return(in).Maybe()
1129+ tm.EXPECT().Out().Return(&out).Maybe()
1130+ tm.EXPECT().Flush().Return(nil).Maybe()
1131+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1132+ defer in.unblock()
1133+
1134+ learnerStarted := make(chan struct{})
1135+ releaseLearner := make(chan struct{})
1136+ learnerDone := make(chan struct{})
1137+
1138+ tr := portsmocks.NewMockTransport(t)
1139+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
1140+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1141+ unblock := scriptRecv(tr,
1142+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
1143+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1144+ )
1145+ defer unblock()
1146+ tr.EXPECT().Close().Return(nil).Once()
1147+
1148+ deps := attachTestDependencies(tr, tm, realClock{})
1149+ learner := portsmocks.NewMockRemoteHostLearner(t)
1150+ learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error {
1151+ close(learnerStarted)
1152+ <-releaseLearner
1153+ close(learnerDone)
1154+ return nil
1155+ }).Once()
1156+ deps.RemoteHostLearner = learner
1157+
1158+ errCh := make(chan error, 1)
1159+ go func() {
1160+ errCh <- runTestClient(context.Background(), deps, client.AttachRequest{
1161+ Intent: ports.IntentAttach,
1162+ SessionName: "main",
1163+ Remote: true,
1164+ })
1165+ }()
1166+
1167+ select {
1168+ case <-learnerStarted:
1169+ case <-time.After(2 * time.Second):
1170+ t.Fatal("remote host learner did not start")
1171+ }
1172+ select {
1173+ case <-restored:
1174+ case <-time.After(2 * time.Second):
1175+ t.Fatal("terminal was not restored while remote host learner was blocked")
1176+ }
1177+ require.Equal(t, int32(1), restoreCount.Load())
1178+ select {
1179+ case err := <-errCh:
1180+ t.Fatalf("run returned before remote host learner completed: %v", err)
1181+ default:
1182+ }
1183+ close(releaseLearner)
1184+ select {
1185+ case <-learnerDone:
1186+ case <-time.After(2 * time.Second):
1187+ t.Fatal("remote host learner did not complete after release")
1188+ }
1189+ select {
1190+ case err := <-errCh:
1191+ require.NoError(t, err)
1192+ case <-time.After(2 * time.Second):
1193+ t.Fatal("run did not return after remote host learner completed")
1194+ }
1195+}
1196+
1197+func TestRunReturnsWhenRemoteHostLearnerStalls(t *testing.T) {
1198+ var out bytes.Buffer
1199+ var restoreCount atomic.Int32
1200+ restored := make(chan struct{})
1201+ resizeCh := make(chan domain.Size)
1202+ tm := portsmocks.NewMockTerminal(t)
1203+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1204+ tm.EXPECT().EnterRaw().Return(func() error {
1205+ restoreCount.Add(1)
1206+ close(restored)
1207+ return nil
1208+ }, nil).Once()
1209+ in := newBlockingReader()
1210+ tm.EXPECT().In().Return(in).Maybe()
1211+ tm.EXPECT().Out().Return(&out).Maybe()
1212+ tm.EXPECT().Flush().Return(nil).Maybe()
1213+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1214+ defer in.unblock()
1215+
1216+ learnerStarted := make(chan struct{})
1217+ blockLearner := make(chan struct{})
1218+ defer close(blockLearner)
1219+
1220+ shutdownTimerC := make(chan time.Time, 1)
1221+ shutdownTimerCreated := make(chan struct{})
1222+ shutdownTimer := portsmocks.NewMockTimer(t)
1223+ shutdownTimer.EXPECT().C().Return((<-chan time.Time)(shutdownTimerC)).Once()
1224+ shutdownTimer.EXPECT().Stop().Return(true).Once()
1225+ clock := portsmocks.NewMockClock(t)
1226+ clock.EXPECT().Now().Return(time.Now()).Maybe()
1227+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
1228+ if d == time.Second {
1229+ close(shutdownTimerCreated)
1230+ return shutdownTimer
1231+ }
1232+ return realClock{}.NewTimer(d)
1233+ }).Maybe()
1234+
1235+ tr := portsmocks.NewMockTransport(t)
1236+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
1237+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1238+ unblock := scriptRecv(tr,
1239+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
1240+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1241+ )
1242+ defer unblock()
1243+ tr.EXPECT().Close().Return(nil).Once()
1244+
1245+ deps := attachTestDependencies(tr, tm, clock)
1246+ learner := portsmocks.NewMockRemoteHostLearner(t)
1247+ learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error {
1248+ close(learnerStarted)
1249+ <-blockLearner
1250+ return nil
1251+ }).Once()
1252+ deps.RemoteHostLearner = learner
1253+
1254+ errCh := make(chan error, 1)
1255+ go func() {
1256+ errCh <- runTestClient(context.Background(), deps, client.AttachRequest{
1257+ Intent: ports.IntentAttach,
1258+ SessionName: "main",
1259+ Remote: true,
1260+ })
1261+ }()
1262+
1263+ select {
1264+ case <-learnerStarted:
1265+ case <-time.After(2 * time.Second):
1266+ t.Fatal("remote host learner did not start")
1267+ }
1268+ select {
1269+ case <-restored:
1270+ case <-time.After(2 * time.Second):
1271+ t.Fatal("terminal was not restored while remote host learner was stalled")
1272+ }
1273+ require.Equal(t, int32(1), restoreCount.Load())
1274+
1275+ select {
1276+ case <-shutdownTimerCreated:
1277+ case <-time.After(2 * time.Second):
1278+ t.Fatal("remote host learner shutdown timer was not created")
1279+ }
1280+ shutdownTimerC <- time.Now()
1281+ select {
1282+ case err := <-errCh:
1283+ require.NoError(t, err)
1284+ case <-time.After(2 * time.Second):
1285+ t.Fatal("run did not return after remote host learner shutdown timer fired")
1286+ }
1287+}
1288+
8441289 func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
8451290 term := newRunTerminal()
8461291 defer term.in.unblock()
@@ -841,6 +841,451 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
841 require.Contains(t, term.out.String(), "\x1b[2K")841 require.Contains(t, term.out.String(), "\x1b[2K")
842 }842 }
843 843
844+func TestAttachRememberRemoteHost(t *testing.T) {
845+ tests := []struct {
846+ name string
847+ request client.AttachRequest
848+ remember func() error
849+ recv []recvItem
850+ preWelcomeReject bool
851+ wantCalled bool
852+ wantErr bool
853+ }{
854+ {
855+ name: "remote success",
856+ request: client.AttachRequest{
857+ Intent: ports.IntentAttach,
858+ SessionName: "main",
859+ Remote: true,
860+ },
861+ remember: func() error { return nil },
862+ recv: []recvItem{
863+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
864+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
865+ },
866+ wantCalled: true,
867+ },
868+ {
869+ name: "callback failure does not fail attach",
870+ request: client.AttachRequest{
871+ Intent: ports.IntentAttach,
872+ SessionName: "main",
873+ Remote: true,
874+ },
875+ remember: func() error { return errors.New("disk full") },
876+ recv: []recvItem{
877+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
878+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
879+ },
880+ wantCalled: true,
881+ },
882+ {
883+ name: "local attach does not call callback",
884+ request: client.AttachRequest{
885+ Intent: ports.IntentAttach,
886+ SessionName: "main",
887+ Remote: false,
888+ },
889+ remember: func() error {
890+ t.Fatal("remember callback must not run for local attach")
891+ return nil
892+ },
893+ recv: []recvItem{
894+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
895+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
896+ },
897+ },
898+ {
899+ name: "rejected before welcome does not call callback",
900+ request: client.AttachRequest{
901+ Intent: ports.IntentAttach,
902+ SessionName: "main",
903+ Remote: true,
904+ },
905+ remember: func() error {
906+ t.Fatal("remember callback must not run before welcome")
907+ return nil
908+ },
909+ recv: []recvItem{
910+ {f: frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"}))},
911+ },
912+ preWelcomeReject: true,
913+ wantErr: true,
914+ },
915+ {
916+ name: "post-welcome error still uses happy terminal fixture",
917+ request: client.AttachRequest{
918+ Intent: ports.IntentAttach,
919+ SessionName: "main",
920+ Remote: true,
921+ },
922+ remember: func() error { return nil },
923+ recv: []recvItem{
924+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
925+ {err: errors.New("connection reset")},
926+ },
927+ wantCalled: true,
928+ wantErr: true,
929+ },
930+ }
931+
932+ for _, tt := range tests {
933+ t.Run(tt.name, func(t *testing.T) {
934+ var tm *portsmocks.MockTerminal
935+ var unblockIn func()
936+ if tt.preWelcomeReject {
937+ tm = portsmocks.NewMockTerminal(t)
938+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
939+ // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
940+ } else {
941+ var out bytes.Buffer
942+ var restoreCount atomic.Int32
943+ resizeCh := make(chan domain.Size)
944+ var in *blockingReader
945+ tm, in = newHappyTerminal(t, &out, &restoreCount, resizeCh)
946+ unblockIn = in.unblock
947+ }
948+ if unblockIn != nil {
949+ defer unblockIn()
950+ }
951+
952+ var called atomic.Bool
953+ learnerDone := make(chan struct{})
954+ rememberHook := func() error {
955+ called.Store(true)
956+ defer close(learnerDone)
957+ if tt.remember != nil {
958+ return tt.remember()
959+ }
960+ return nil
961+ }
962+
963+ tr := portsmocks.NewMockTransport(t)
964+ if !tt.preWelcomeReject {
965+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
966+ }
967+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
968+ if tt.preWelcomeReject {
969+ tr.EXPECT().Recv().Return(tt.recv[0].f, tt.recv[0].err).Once()
970+ } else {
971+ unblock := scriptRecv(tr, tt.recv...)
972+ defer unblock()
973+ }
974+ tr.EXPECT().Close().Return(nil).Once()
975+
976+ deps := attachTestDependencies(tr, tm, realClock{})
977+ learner := portsmocks.NewMockRemoteHostLearner(t)
978+ if tt.wantCalled {
979+ learner.EXPECT().RememberRemoteHost().RunAndReturn(rememberHook).Once()
980+ }
981+ deps.RemoteHostLearner = learner
982+
983+ err := runTestClient(context.Background(), deps, tt.request)
984+ if tt.wantCalled {
985+ select {
986+ case <-learnerDone:
987+ case <-time.After(2 * time.Second):
988+ t.Fatal("remote host learner did not complete")
989+ }
990+ }
991+ if tt.wantErr {
992+ require.Error(t, err)
993+ } else {
994+ require.NoError(t, err)
995+ }
996+ require.Equal(t, tt.wantCalled, called.Load())
997+ })
998+ }
999+}
1000+
1001+func TestRunRememberRemoteHostAtMostOnceAcrossReconnects(t *testing.T) {
1002+ term := newRunTerminal()
1003+ defer term.in.unblock()
1004+
1005+ var rememberCalls atomic.Int32
1006+ learnerDone := make(chan struct{})
1007+
1008+ tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
1009+ tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
1010+ d := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}
1011+
1012+ deps := testDependencies(d, term, realClock{}, nil, nil)
1013+ learner := portsmocks.NewMockRemoteHostLearner(t)
1014+ learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error {
1015+ rememberCalls.Add(1)
1016+ close(learnerDone)
1017+ return nil
1018+ }).Once()
1019+ deps.RemoteHostLearner = learner
1020+ err := runTestClient(context.Background(), deps, client.AttachRequest{
1021+ Intent: ports.IntentAttach,
1022+ SessionName: "main",
1023+ Remote: true,
1024+ })
1025+ require.NoError(t, err)
1026+ select {
1027+ case <-learnerDone:
1028+ case <-time.After(2 * time.Second):
1029+ t.Fatal("remote host learner did not complete")
1030+ }
1031+ require.Equal(t, int32(1), rememberCalls.Load())
1032+ require.Equal(t, int32(2), d.calls.Load())
1033+}
1034+
1035+func TestAttachRememberRemoteHostDoesNotBlockAfterWelcome(t *testing.T) {
1036+ var out bytes.Buffer
1037+ var restoreCount atomic.Int32
1038+ resizeCh := make(chan domain.Size)
1039+ tm := portsmocks.NewMockTerminal(t)
1040+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1041+ enteredRaw := make(chan struct{})
1042+ tm.EXPECT().EnterRaw().Run(func() { close(enteredRaw) }).Return(func() error {
1043+ restoreCount.Add(1)
1044+ return nil
1045+ }, nil).Once()
1046+ in := newBlockingReader()
1047+ tm.EXPECT().In().Return(in).Maybe()
1048+ tm.EXPECT().Out().Return(&out).Maybe()
1049+ tm.EXPECT().Flush().Return(nil).Maybe()
1050+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1051+ defer in.unblock()
1052+
1053+ learnerStarted := make(chan struct{})
1054+ releaseLearner := make(chan struct{})
1055+ learnerDone := make(chan struct{})
1056+
1057+ tr := portsmocks.NewMockTransport(t)
1058+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
1059+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1060+ unblock := scriptRecv(tr,
1061+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
1062+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1063+ )
1064+ defer unblock()
1065+ tr.EXPECT().Close().Return(nil).Once()
1066+
1067+ deps := attachTestDependencies(tr, tm, realClock{})
1068+ learner := portsmocks.NewMockRemoteHostLearner(t)
1069+ learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error {
1070+ close(learnerStarted)
1071+ <-releaseLearner
1072+ close(learnerDone)
1073+ return nil
1074+ }).Once()
1075+ deps.RemoteHostLearner = learner
1076+
1077+ errCh := make(chan error, 1)
1078+ go func() {
1079+ errCh <- runTestClient(context.Background(), deps, client.AttachRequest{
1080+ Intent: ports.IntentAttach,
1081+ SessionName: "main",
1082+ Remote: true,
1083+ })
1084+ }()
1085+
1086+ select {
1087+ case <-learnerStarted:
1088+ case <-time.After(2 * time.Second):
1089+ t.Fatal("remote host learner did not start")
1090+ }
1091+ select {
1092+ case <-enteredRaw:
1093+ case <-time.After(2 * time.Second):
1094+ t.Fatal("attach did not enter raw mode while remote host learner was blocked")
1095+ }
1096+ select {
1097+ case err := <-errCh:
1098+ t.Fatalf("run returned before remote host learner completed: %v", err)
1099+ default:
1100+ }
1101+ close(releaseLearner)
1102+ select {
1103+ case <-learnerDone:
1104+ case <-time.After(2 * time.Second):
1105+ t.Fatal("remote host learner did not complete after release")
1106+ }
1107+ select {
1108+ case err := <-errCh:
1109+ require.NoError(t, err)
1110+ case <-time.After(2 * time.Second):
1111+ t.Fatal("run did not return after remote host learner completed")
1112+ }
1113+}
1114+
1115+func TestRunRestoresTerminalBeforeWaitingForLearner(t *testing.T) {
1116+ var out bytes.Buffer
1117+ var restoreCount atomic.Int32
1118+ restored := make(chan struct{})
1119+ resizeCh := make(chan domain.Size)
1120+ tm := portsmocks.NewMockTerminal(t)
1121+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1122+ tm.EXPECT().EnterRaw().Return(func() error {
1123+ restoreCount.Add(1)
1124+ close(restored)
1125+ return nil
1126+ }, nil).Once()
1127+ in := newBlockingReader()
1128+ tm.EXPECT().In().Return(in).Maybe()
1129+ tm.EXPECT().Out().Return(&out).Maybe()
1130+ tm.EXPECT().Flush().Return(nil).Maybe()
1131+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1132+ defer in.unblock()
1133+
1134+ learnerStarted := make(chan struct{})
1135+ releaseLearner := make(chan struct{})
1136+ learnerDone := make(chan struct{})
1137+
1138+ tr := portsmocks.NewMockTransport(t)
1139+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
1140+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1141+ unblock := scriptRecv(tr,
1142+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
1143+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1144+ )
1145+ defer unblock()
1146+ tr.EXPECT().Close().Return(nil).Once()
1147+
1148+ deps := attachTestDependencies(tr, tm, realClock{})
1149+ learner := portsmocks.NewMockRemoteHostLearner(t)
1150+ learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error {
1151+ close(learnerStarted)
1152+ <-releaseLearner
1153+ close(learnerDone)
1154+ return nil
1155+ }).Once()
1156+ deps.RemoteHostLearner = learner
1157+
1158+ errCh := make(chan error, 1)
1159+ go func() {
1160+ errCh <- runTestClient(context.Background(), deps, client.AttachRequest{
1161+ Intent: ports.IntentAttach,
1162+ SessionName: "main",
1163+ Remote: true,
1164+ })
1165+ }()
1166+
1167+ select {
1168+ case <-learnerStarted:
1169+ case <-time.After(2 * time.Second):
1170+ t.Fatal("remote host learner did not start")
1171+ }
1172+ select {
1173+ case <-restored:
1174+ case <-time.After(2 * time.Second):
1175+ t.Fatal("terminal was not restored while remote host learner was blocked")
1176+ }
1177+ require.Equal(t, int32(1), restoreCount.Load())
1178+ select {
1179+ case err := <-errCh:
1180+ t.Fatalf("run returned before remote host learner completed: %v", err)
1181+ default:
1182+ }
1183+ close(releaseLearner)
1184+ select {
1185+ case <-learnerDone:
1186+ case <-time.After(2 * time.Second):
1187+ t.Fatal("remote host learner did not complete after release")
1188+ }
1189+ select {
1190+ case err := <-errCh:
1191+ require.NoError(t, err)
1192+ case <-time.After(2 * time.Second):
1193+ t.Fatal("run did not return after remote host learner completed")
1194+ }
1195+}
1196+
1197+func TestRunReturnsWhenRemoteHostLearnerStalls(t *testing.T) {
1198+ var out bytes.Buffer
1199+ var restoreCount atomic.Int32
1200+ restored := make(chan struct{})
1201+ resizeCh := make(chan domain.Size)
1202+ tm := portsmocks.NewMockTerminal(t)
1203+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1204+ tm.EXPECT().EnterRaw().Return(func() error {
1205+ restoreCount.Add(1)
1206+ close(restored)
1207+ return nil
1208+ }, nil).Once()
1209+ in := newBlockingReader()
1210+ tm.EXPECT().In().Return(in).Maybe()
1211+ tm.EXPECT().Out().Return(&out).Maybe()
1212+ tm.EXPECT().Flush().Return(nil).Maybe()
1213+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1214+ defer in.unblock()
1215+
1216+ learnerStarted := make(chan struct{})
1217+ blockLearner := make(chan struct{})
1218+ defer close(blockLearner)
1219+
1220+ shutdownTimerC := make(chan time.Time, 1)
1221+ shutdownTimerCreated := make(chan struct{})
1222+ shutdownTimer := portsmocks.NewMockTimer(t)
1223+ shutdownTimer.EXPECT().C().Return((<-chan time.Time)(shutdownTimerC)).Once()
1224+ shutdownTimer.EXPECT().Stop().Return(true).Once()
1225+ clock := portsmocks.NewMockClock(t)
1226+ clock.EXPECT().Now().Return(time.Now()).Maybe()
1227+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
1228+ if d == time.Second {
1229+ close(shutdownTimerCreated)
1230+ return shutdownTimer
1231+ }
1232+ return realClock{}.NewTimer(d)
1233+ }).Maybe()
1234+
1235+ tr := portsmocks.NewMockTransport(t)
1236+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
1237+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1238+ unblock := scriptRecv(tr,
1239+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
1240+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1241+ )
1242+ defer unblock()
1243+ tr.EXPECT().Close().Return(nil).Once()
1244+
1245+ deps := attachTestDependencies(tr, tm, clock)
1246+ learner := portsmocks.NewMockRemoteHostLearner(t)
1247+ learner.EXPECT().RememberRemoteHost().RunAndReturn(func() error {
1248+ close(learnerStarted)
1249+ <-blockLearner
1250+ return nil
1251+ }).Once()
1252+ deps.RemoteHostLearner = learner
1253+
1254+ errCh := make(chan error, 1)
1255+ go func() {
1256+ errCh <- runTestClient(context.Background(), deps, client.AttachRequest{
1257+ Intent: ports.IntentAttach,
1258+ SessionName: "main",
1259+ Remote: true,
1260+ })
1261+ }()
1262+
1263+ select {
1264+ case <-learnerStarted:
1265+ case <-time.After(2 * time.Second):
1266+ t.Fatal("remote host learner did not start")
1267+ }
1268+ select {
1269+ case <-restored:
1270+ case <-time.After(2 * time.Second):
1271+ t.Fatal("terminal was not restored while remote host learner was stalled")
1272+ }
1273+ require.Equal(t, int32(1), restoreCount.Load())
1274+
1275+ select {
1276+ case <-shutdownTimerCreated:
1277+ case <-time.After(2 * time.Second):
1278+ t.Fatal("remote host learner shutdown timer was not created")
1279+ }
1280+ shutdownTimerC <- time.Now()
1281+ select {
1282+ case err := <-errCh:
1283+ require.NoError(t, err)
1284+ case <-time.After(2 * time.Second):
1285+ t.Fatal("run did not return after remote host learner shutdown timer fired")
1286+ }
1287+}
1288+
844 func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {1289 func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
845 term := newRunTerminal()1290 term := newRunTerminal()
846 defer term.in.unblock()1291 defer term.in.unblock()

Keyboard shortcuts

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

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

Branches 2

Tags 13

↑↓ moveenter openesc close