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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 40 | 40 | vev new <name> create a named session | |
| 41 | 41 | vev attach <name> attach to a session (alias: a) | |
| 42 | 42 | 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 | |
| 44 | 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 | 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 | 103 | ## Scripting | |
| 80 | 104 | ||
| 81 | 105 | `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 session | 40 | 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 SSH | 42 | 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 | |||
| 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 | ## Scripting | 103 | ## 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 | |||
| 79 | 79 | ||
| 80 | 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 | 90 | ## 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 | 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 | ||
| @@ -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 state | 90 | ## 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 { | |||
| 148 | 148 | 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 | 152 | if err := validateHostTarget(target); err != nil { | |
| 153 | - | return err | |
| 153 | + | return false, err | |
| 154 | 154 | } | |
| 155 | - | err := s.withLock(func() error { | |
| 155 | + | err = s.withLock(func() error { | |
| 156 | 156 | state, err := s.loadLocked() | |
| 157 | 157 | if err != nil { | |
| 158 | 158 | return err | |
| @@ -164,12 +164,17 @@ func (s *fileHostStore) Remove(target string) error { | |||
| 164 | 164 | } | |
| 165 | 165 | state.pinned = nextPinned | |
| 166 | 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 | 173 | if err != nil { | |
| 170 | 174 | slog.Debug("remote host store remove failed", "path", s.path, "target", target, "err", err) | |
| 175 | + | return false, err | |
| 171 | 176 | } | |
| 172 | - | return err | |
| 177 | + | return deleted, nil | |
| 173 | 178 | } | |
| 174 | 179 | ||
| 175 | 180 | func (s *fileHostStore) withLock(fn func() error) error { | |
| @@ -148,11 +148,11 @@ func (s *fileHostStore) Forget(target string) error { | |||||
| 148 | return err | 148 | 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 err | 153 | + | 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 err | 158 | return err | ||
| @@ -164,12 +164,17 @@ func (s *fileHostStore) Remove(target string) error { | |||||
| 164 | } | 164 | } | ||
| 165 | state.pinned = nextPinned | 165 | 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 err | 177 | + | 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) { | |||
| 229 | 229 | if err := store.Forget("missing"); err != nil { | |
| 230 | 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 | 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 | 239 | t.Fatalf("Remove(arch) error = %v", err) | |
| 240 | + | } else if !deleted { | |
| 241 | + | t.Fatal("Remove(arch) deleted = false, want true") | |
| 238 | 242 | } | |
| 239 | 243 | pinned, learned, err := store.Hosts() | |
| 240 | 244 | if err != nil { | |
| @@ -278,7 +282,7 @@ func TestHostStore(t *testing.T) { | |||
| 278 | 282 | if err := store.Remember("arch"); err == nil { | |
| 279 | 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 | 286 | t.Fatal("Remove() error = nil, want malformed error") | |
| 283 | 287 | } | |
| 284 | 288 | ||
| @@ -446,7 +450,7 @@ func TestHostStore(t *testing.T) { | |||
| 446 | 450 | if err := store.Remember(target); err == nil { | |
| 447 | 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 | 454 | t.Fatalf("Remove(%q) error = nil, want validation error", target) | |
| 451 | 455 | } | |
| 452 | 456 | } | |
| @@ -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) { | |||
| 481 | 481 | require.NoError(t, err) | |
| 482 | 482 | ||
| 483 | 483 | done := make(chan error, 1) | |
| 484 | - | go func() { done <- runList(context.Background()) }() | |
| 484 | + | go func() { done <- runList(context.Background(), command{kind: kindList}) }() | |
| 485 | 485 | require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond) | |
| 486 | 486 | require.NoError(t, owner.Release()) | |
| 487 | 487 | 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" | |||
| 6 | 6 | // local session names. Only values with a user@host prefix are remote; local | |
| 7 | 7 | // names may still contain ':' when they do not contain '@'. | |
| 8 | 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 | 13 | if at <= 0 || at == len(s)-1 { | |
| 11 | 14 | return "", "", false | |
| 12 | 15 | } | |
| 13 | 16 | afterAt := s[at+1:] | |
| 14 | 17 | if strings.HasPrefix(afterAt, "[") { | |
| 15 | 18 | closeBracket := strings.IndexByte(afterAt, ']') | |
| 16 | - | if closeBracket < 0 { | |
| 19 | + | if closeBracket <= 1 || strings.Contains(afterAt[closeBracket+1:], "]") { | |
| 17 | 20 | return "", "", false | |
| 18 | 21 | } | |
| 19 | 22 | suffix := afterAt[closeBracket+1:] | |
| @@ -27,6 +30,9 @@ func parseRemoteAttachTarget(s string) (target, session string, ok bool) { | |||
| 27 | 30 | return "", "", false | |
| 28 | 31 | } | |
| 29 | 32 | } | |
| 33 | + | if strings.ContainsAny(afterAt, "[]") || strings.Count(afterAt, ":") > 1 { | |
| 34 | + | return "", "", false | |
| 35 | + | } | |
| 30 | 36 | colon := strings.LastIndexByte(afterAt, ':') | |
| 31 | 37 | if colon < 0 { | |
| 32 | 38 | return s, "", true | |
| @@ -6,14 +6,17 @@ import "strings" | |||||
| 6 | // local session names. Only values with a user@host prefix are remote; local | 6 | // 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 "", "", false | 14 | 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 "", "", false | 20 | 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 "", "", false | 30 | 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, "", true | 38 | 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 @@ | |||
| 1 | 1 | 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 | 19 | func TestParseRemoteAttachTarget(t *testing.T) { | |
| 6 | 20 | 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 | |
| 12 | 23 | }{ | |
| 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"}, | |
| 16 | 30 | {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 | + | }, | |
| 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 | 273 | for _, tt := range tests { | |
| 28 | 274 | 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) | |
| 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 | + | } | |
| @@ -1,41 +1,321 @@ | |||||
| 1 | package app | 1 | 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 string | 21 | + | name, input, target, session string |
| 8 | - | input string | 22 | + | 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 | |||
| 60 | 60 | const ( | |
| 61 | 61 | kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent | |
| 62 | 62 | kindList | |
| 63 | + | kindHost | |
| 63 | 64 | kindKill | |
| 64 | 65 | kindCmd | |
| 65 | 66 | kindDaemon | |
| @@ -78,6 +79,10 @@ type command struct { | |||
| 78 | 79 | intent uint8 | |
| 79 | 80 | name string | |
| 80 | 81 | remoteTarget string | |
| 82 | + | listHost string | |
| 83 | + | listAll bool | |
| 84 | + | hostAction string | |
| 85 | + | hostTarget string | |
| 81 | 86 | killAll bool | |
| 82 | 87 | killDaemon bool | |
| 83 | 88 | cmd cmdInvocation | |
| @@ -101,7 +106,12 @@ usage: | |||
| 101 | 106 | vev attach <name> attach to an existing session (alias: a) | |
| 102 | 107 | vev attach user@host[:session] | |
| 103 | 108 | 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 | |
| 105 | 115 | vev kill <name> kill a session | |
| 106 | 116 | vev kill --all kill all sessions and stop the daemon | |
| 107 | 117 | vev kill --daemon stop the active vev daemon | |
| @@ -206,7 +216,9 @@ func parseArgs(args []string) (command, error) { | |||
| 206 | 216 | } | |
| 207 | 217 | return cmd, nil | |
| 208 | 218 | case "ls", "list": | |
| 209 | - | return command{kind: kindList}, nil | |
| 219 | + | return parseListArgs(args[1:]) | |
| 220 | + | case "host": | |
| 221 | + | return parseHostArgs(args[1:]) | |
| 210 | 222 | case "cmd": | |
| 211 | 223 | invocation, err := parseCmdArgs(args[1:]) | |
| 212 | 224 | if err != nil { | |
| @@ -263,7 +275,9 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 263 | 275 | case kindUDPProxy: | |
| 264 | 276 | return runUDPProxy(ctx, cmd.name, os.Stdout) | |
| 265 | 277 | case kindList: | |
| 266 | - | return runList(ctx) | |
| 278 | + | return runList(ctx, cmd) | |
| 279 | + | case kindHost: | |
| 280 | + | return runHostCommand(ctx, cmd, defaultRemoteHostDeps()) | |
| 267 | 281 | case kindKill: | |
| 268 | 282 | return runKill(ctx, cmd.name, cmd.killAll, cmd.killDaemon) | |
| 269 | 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 | 339 | // performanceTrace creates one serialized timestamp owner for this process. | |
| 279 | 340 | // An empty trace environment leaves all production behavior and wire bytes | |
| 280 | 341 | // unchanged. | |
| @@ -678,6 +739,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re | |||
| 678 | 739 | createDetached: createDetachedLocalSession, | |
| 679 | 740 | clipboard: clipboard.New(), | |
| 680 | 741 | runtimeObserver: observer, | |
| 742 | + | stateDir: platform.StateDir, | |
| 681 | 743 | }) | |
| 682 | 744 | } | |
| 683 | 745 | ||
| @@ -703,6 +765,9 @@ type runAttachDeps struct { | |||
| 703 | 765 | // Only used for the remote-dialer branch below; local attaches never | |
| 704 | 766 | // intercept Ctrl+V regardless of this field. | |
| 705 | 767 | clipboard ports.ClipboardReader | |
| 768 | + | // Optional remote-learning seams. | |
| 769 | + | stateDir func() string | |
| 770 | + | hostStore ports.RemoteHostStore | |
| 706 | 771 | } | |
| 707 | 772 | ||
| 708 | 773 | func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) { | |
| @@ -745,12 +810,13 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 745 | 810 | return err | |
| 746 | 811 | } | |
| 747 | 812 | 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), | |
| 754 | 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 | 1355 | // 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) { | |
| 1292 | 1373 | transport, owner, err := waitForDaemonOrLifecycle(ctx, ipc.SocketDir(), realDial, defaultBackoff) | |
| 1293 | 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 | 1377 | if owner != nil { | |
| 1297 | 1378 | defer joinLifecycleReleaseError(&retErr, owner) | |
| 1298 | 1379 | records, loadErr := persist.LoadReadOnly(platform.StateDir()) | |
| 1299 | 1380 | if loadErr != nil { | |
| 1300 | 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 | 1386 | infos := make([]ports.SessionInfo, 0, len(records)) | |
| 1306 | 1387 | for _, r := range records { | |
| @@ -1310,31 +1391,36 @@ func runList(ctx context.Context) (retErr error) { | |||
| 1310 | 1391 | } | |
| 1311 | 1392 | infos = append(infos, ports.SessionInfo{Name: r.Name, State: state}) | |
| 1312 | 1393 | } | |
| 1313 | - | printSessions(os.Stdout, infos) | |
| 1314 | - | return nil | |
| 1394 | + | return infos, nil | |
| 1315 | 1395 | } | |
| 1316 | 1396 | defer func() { _ = transport.Close() }() | |
| 1317 | 1397 | ||
| 1318 | 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 | 1401 | reply, err := transport.Recv() | |
| 1322 | 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 | 1409 | 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) | |
| 1328 | 1415 | } | |
| 1329 | 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 | 1419 | sessions, err := ports.UnmarshalSessions(reply.Payload) | |
| 1333 | 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) | |
| 1337 | - | return nil | |
| 1423 | + | return sessions.Sessions, nil | |
| 1338 | 1424 | } | |
| 1339 | 1425 | ||
| 1340 | 1426 | // 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 intent | 61 | kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent | ||
| 62 | kindList | 62 | kindList | ||
| 63 | + | kindHost | |||
| 63 | kindKill | 64 | kindKill | ||
| 64 | kindCmd | 65 | kindCmd | ||
| 65 | kindDaemon | 66 | kindDaemon | ||
| @@ -78,6 +79,10 @@ type command struct { | |||||
| 78 | intent uint8 | 79 | intent uint8 | ||
| 79 | name string | 80 | name string | ||
| 80 | remoteTarget string | 81 | remoteTarget string | ||
| 82 | + | listHost string | |||
| 83 | + | listAll bool | |||
| 84 | + | hostAction string | |||
| 85 | + | hostTarget string | |||
| 81 | killAll bool | 86 | killAll bool | ||
| 82 | killDaemon bool | 87 | killDaemon bool | ||
| 83 | cmd cmdInvocation | 88 | 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 daemon | 108 | 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 | |||
| 105 | vev kill <name> kill a session | 115 | vev kill <name> kill a session | ||
| 106 | vev kill --all kill all sessions and stop the daemon | 116 | vev kill --all kill all sessions and stop the daemon | ||
| 107 | vev kill --daemon stop the active vev daemon | 117 | vev kill --daemon stop the active vev daemon | ||
| @@ -206,7 +216,9 @@ func parseArgs(args []string) (command, error) { | |||||
| 206 | } | 216 | } | ||
| 207 | return cmd, nil | 217 | return cmd, nil | ||
| 208 | case "ls", "list": | 218 | case "ls", "list": | ||
| 209 | - | return command{kind: kindList}, nil | 219 | + | 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 bytes | 340 | // 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 never | 765 | // 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.ClipboardReader | 767 | 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 err | 810 | 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, it | 1355 | // 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) { | |||
| 165 | 165 | }{ | |
| 166 | 166 | { | |
| 167 | 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 | 173 | name: "kill joins release failure to operation failure", | |
| @@ -552,15 +554,20 @@ func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) { | |||
| 552 | 554 | ||
| 553 | 555 | func TestParseArgs(t *testing.T) { | |
| 554 | 556 | 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 | |
| 564 | 571 | }{ | |
| 565 | 572 | {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral}, | |
| 566 | 573 | {name: "empty slice -> ephemeral attach", args: []string{}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral}, | |
| @@ -578,6 +585,21 @@ func TestParseArgs(t *testing.T) { | |||
| 578 | 585 | {name: "attach without name", args: []string{"attach"}, wantErr: true}, | |
| 579 | 586 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | |
| 580 | 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 | 603 | {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"}, | |
| 582 | 604 | {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"}, | |
| 583 | 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 | 631 | if err == nil { | |
| 610 | 632 | t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got) | |
| 611 | 633 | } | |
| 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 | + | } | |
| 615 | 644 | } | |
| 616 | 645 | return | |
| 617 | 646 | } | |
| @@ -630,6 +659,18 @@ func TestParseArgs(t *testing.T) { | |||
| 630 | 659 | if got.remoteTarget != tt.wantRemote { | |
| 631 | 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 | 674 | if got.killAll != tt.wantAll { | |
| 634 | 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 | 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 | 756 | t.Fatalf("runList error = %v", err) | |
| 716 | 757 | } | |
| 717 | 758 | }) | |
| @@ -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 string | 557 | + | name string |
| 556 | - | args []string | 558 | + | args []string |
| 557 | - | wantKind cmdKind | 559 | + | wantKind cmdKind |
| 558 | - | wantIntent uint8 | 560 | + | wantIntent uint8 |
| 559 | - | wantName string | 561 | + | wantName string |
| 560 | - | wantRemote string | 562 | + | wantRemote string |
| 561 | - | wantAll bool | 563 | + | wantListHost string |
| 562 | - | wantDaemon bool | 564 | + | wantListAll bool |
| 563 | - | wantErr bool | 565 | + | 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 *usageError | 634 | + | 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 | return | 645 | 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 | |||
| 363 | 363 | } | |
| 364 | 364 | ||
| 365 | 365 | // 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 | 367 | ret := _mock.Called(target) | |
| 368 | 368 | ||
| 369 | 369 | if len(ret) == 0 { | |
| 370 | 370 | panic("no return value specified for Remove") | |
| 371 | 371 | } | |
| 372 | 372 | ||
| 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 { | |
| 375 | 379 | r0 = returnFunc(target) | |
| 376 | 380 | } else { | |
| 377 | - | r0 = ret.Error(0) | |
| 381 | + | r0 = ret.Get(0).(bool) | |
| 378 | 382 | } | |
| 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 | |
| 380 | 389 | } | |
| 381 | 390 | ||
| 382 | 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 | 412 | return _c | |
| 404 | 413 | } | |
| 405 | 414 | ||
| 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) | |
| 408 | 417 | 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 | 421 | _c.Call.Return(run) | |
| 413 | 422 | return _c | |
| 414 | 423 | } | |
| @@ -464,6 +473,77 @@ func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target st | |||
| 464 | 473 | 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 | 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 | 548 | // The first argument is typically a *testing.T value. | |
| 469 | 549 | 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 MockRemoteHostStore | 365 | // 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 error | 373 | + | 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 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 | |||
| 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 _c | 412 | 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 _c | 417 | 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 _c | 422 | return _c | ||
| 414 | } | 423 | } | ||
| @@ -464,6 +473,77 @@ func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target st | |||||
| 464 | return _c | 473 | 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 { | |||
| 29 | 29 | RemovePinned(target string) error | |
| 30 | 30 | Remember(target string) error | |
| 31 | 31 | 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 | |
| 34 | 42 | } | |
| 35 | 43 | ||
| 36 | 44 | // RemoteCatalogClient fetches a versioned session catalog from a remote host. | |
| @@ -29,8 +29,16 @@ type RemoteHostStore interface { | |||||
| 29 | RemovePinned(target string) error | 29 | RemovePinned(target string) error | ||
| 30 | Remember(target string) error | 30 | Remember(target string) error | ||
| 31 | Forget(target string) error | 31 | 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) error | 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 | |||
| 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 | |||
| 134 | 134 | // accepted the attach. Mosh uses the same 15-second startup budget. | |
| 135 | 135 | 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 | 142 | var reconnectSleep = sleepReconnect | |
| 138 | 143 | var reconnectSleepWithResize = sleepReconnectWithResizeEvents | |
| 139 | 144 | ||
| @@ -144,12 +149,13 @@ const ( | |||
| 144 | 149 | ||
| 145 | 150 | // Dependencies supplies the collaborators required by a Runner. | |
| 146 | 151 | 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 | |
| 153 | 159 | } | |
| 154 | 160 | ||
| 155 | 161 | // AttachRequest identifies the session and transport mode for one client run. | |
| @@ -161,12 +167,13 @@ type AttachRequest struct { | |||
| 161 | 167 | ||
| 162 | 168 | // Runner owns the client lifecycle across one or more attachment attempts. | |
| 163 | 169 | 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 | |
| 170 | 177 | ||
| 171 | 178 | inputMu sync.Mutex | |
| 172 | 179 | input *terminalInputPump | |
| @@ -179,12 +186,13 @@ func NewRunner(deps Dependencies) *Runner { | |||
| 179 | 186 | log = slog.Default() | |
| 180 | 187 | } | |
| 181 | 188 | 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, | |
| 188 | 196 | } | |
| 189 | 197 | } | |
| 190 | 198 | ||
| @@ -217,13 +225,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 217 | 225 | ||
| 218 | 226 | var restore func() error | |
| 219 | 227 | 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 | 228 | enterRaw := func() error { | |
| 228 | 229 | if rawEntered { | |
| 229 | 230 | return nil | |
| @@ -242,6 +243,48 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 242 | 243 | attemptRequest := request | |
| 243 | 244 | backoff := defaultReconnectBackoff.initial | |
| 244 | 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 | 288 | reconnect := &reconnectUI{ | |
| 246 | 289 | term: r.term, | |
| 247 | 290 | remote: request.Remote, | |
| @@ -299,16 +342,17 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 299 | 342 | linkEvents = reporter.LinkEvents() | |
| 300 | 343 | } | |
| 301 | 344 | 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, | |
| 312 | 356 | terminalInput: func() *terminalInputPump { | |
| 313 | 357 | return input | |
| 314 | 358 | }, | |
| @@ -473,17 +517,18 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time | |||
| 473 | 517 | } | |
| 474 | 518 | ||
| 475 | 519 | 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 | |
| 487 | 532 | } | |
| 488 | 533 | ||
| 489 | 534 | type attachResult struct { | |
| @@ -636,6 +681,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 636 | 681 | name = welcome.SessionName | |
| 637 | 682 | ms.welcomed = true | |
| 638 | 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 | 689 | case ports.MsgError: | |
| 640 | 690 | em, derr := ports.UnmarshalErrorMsg(reply.Payload) | |
| 641 | 691 | 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.Second | 135 | 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 = sleepReconnect | 142 | var reconnectSleep = sleepReconnect | ||
| 138 | var reconnectSleepWithResize = sleepReconnectWithResizeEvents | 143 | 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.Dialer | 152 | + | Dialer ports.Dialer |
| 148 | - | Terminal ports.Terminal | 153 | + | Terminal ports.Terminal |
| 149 | - | Clock ports.Clock | 154 | + | Clock ports.Clock |
| 150 | - | Clipboard ports.ClipboardReader | 155 | + | Clipboard ports.ClipboardReader |
| 151 | - | Logger *slog.Logger | 156 | + | Logger *slog.Logger |
| 152 | - | RuntimeObserver ports.SerializedRuntimeObserver | 157 | + | 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.Dialer | 170 | + | dialer ports.Dialer |
| 165 | - | term ports.Terminal | 171 | + | term ports.Terminal |
| 166 | - | clock ports.Clock | 172 | + | clock ports.Clock |
| 167 | - | clipboard ports.ClipboardReader | 173 | + | clipboard ports.ClipboardReader |
| 168 | - | logger *slog.Logger | 174 | + | logger *slog.Logger |
| 169 | - | runtimeObserver ports.SerializedRuntimeObserver | 175 | + | runtimeObserver ports.SerializedRuntimeObserver |
| 176 | + | remoteHostLearner ports.RemoteHostLearner | |||
| 170 | 177 | ||||
| 171 | inputMu sync.Mutex | 178 | inputMu sync.Mutex | ||
| 172 | input *terminalInputPump | 179 | 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() error | 226 | var restore func() error | ||
| 219 | rawEntered := false | 227 | 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 nil | 230 | return nil | ||
| @@ -242,6 +243,48 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 242 | attemptRequest := request | 243 | attemptRequest := request | ||
| 243 | backoff := defaultReconnectBackoff.initial | 244 | 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 input | 357 | 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 *Runner | 520 | + | runner *Runner |
| 477 | - | transport ports.Transport | 521 | + | transport ports.Transport |
| 478 | - | request AttachRequest | 522 | + | request AttachRequest |
| 479 | - | resumeToken uint64 | 523 | + | resumeToken uint64 |
| 480 | - | clientID [16]byte | 524 | + | clientID [16]byte |
| 481 | - | milestones *milestones | 525 | + | milestones *milestones |
| 482 | - | themeState *terminalThemeState | 526 | + | themeState *terminalThemeState |
| 483 | - | enterRaw func() error | 527 | + | enterRaw func() error |
| 484 | - | reconnect *reconnectUI | 528 | + | reconnect *reconnectUI |
| 485 | - | linkEvents <-chan ports.LinkEvent | 529 | + | linkEvents <-chan ports.LinkEvent |
| 486 | - | terminalInput func() *terminalInputPump | 530 | + | 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.SessionName | 681 | name = welcome.SessionName | ||
| 637 | ms.welcomed = true | 682 | 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) { | |||
| 841 | 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 | 1289 | func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |
| 845 | 1290 | term := newRunTerminal() | |
| 846 | 1291 | 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() | ||