| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "runtime" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/config" |
| 15 | "reasonix/internal/remote" |
| 16 | "reasonix/internal/remote/bootstrap" |
| 17 | "reasonix/internal/remote/forward" |
| 18 | "reasonix/internal/remote/sftpfs" |
| 19 | ) |
| 20 | |
| 21 | type lifecycleSSHClient struct { |
| 22 | mu sync.Mutex |
| 23 | startErr error |
| 24 | closed bool |
| 25 | sub func(remote.StatusEvent) |
| 26 | forwards *forward.Set |
| 27 | } |
| 28 | |
| 29 | type lifecycleEventSink struct { |
| 30 | statuses chan RemoteConnectionStatusView |
| 31 | } |
| 32 | |
| 33 | func (s *lifecycleEventSink) onStatus(v RemoteConnectionStatusView) { s.statuses <- v } |
| 34 | func (*lifecycleEventSink) onForwards(string, []RemoteForwardView) {} |
| 35 | func (*lifecycleEventSink) onServer(RemoteServerView) {} |
| 36 | |
| 37 | func newLifecycleSSHClient(startErr error) *lifecycleSSHClient { |
| 38 | return &lifecycleSSHClient{startErr: startErr, forwards: forward.NewSet(nil)} |
| 39 | } |
| 40 | |
| 41 | func TestDesktopSecretPromptPublishesMetadataAndReturnsOneShotSecret(t *testing.T) { |
| 42 | sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 2)} |
| 43 | mgr := newDesktopRemoteManager(sink) |
| 44 | ctx, cancel := context.WithCancel(context.Background()) |
| 45 | defer cancel() |
| 46 | generation := &managedHost{ctx: ctx, cancel: cancel, status: RemoteConnectionStatusView{HostID: "box", State: "connecting"}} |
| 47 | mgr.hosts["box"] = generation |
| 48 | |
| 49 | type promptResult struct { |
| 50 | secret string |
| 51 | err error |
| 52 | } |
| 53 | result := make(chan promptResult, 1) |
| 54 | go func() { |
| 55 | secret, err := mgr.secretPrompt("box", generation)(ctx, remote.SecretPassword, "dev@box.test", "") |
| 56 | result <- promptResult{secret: secret, err: err} |
| 57 | }() |
| 58 | |
| 59 | var promptID string |
| 60 | select { |
| 61 | case status := <-sink.statuses: |
| 62 | if status.State != "pending_secret" || status.SecretPrompt == nil { |
| 63 | t.Fatalf("status = %+v", status) |
| 64 | } |
| 65 | if status.SecretPrompt.Host != "dev@box.test" || status.SecretPrompt.Kind != "password" { |
| 66 | t.Fatalf("prompt metadata = %+v", status.SecretPrompt) |
| 67 | } |
| 68 | promptID = status.SecretPrompt.PromptID |
| 69 | if promptID == "" { |
| 70 | t.Fatal("prompt ID was empty") |
| 71 | } |
| 72 | case <-time.After(2 * time.Second): |
| 73 | t.Fatal("secret prompt status was not emitted") |
| 74 | } |
| 75 | |
| 76 | if err := mgr.ResolveSecret("box", "stale-prompt", "wrong-secret", true); err == nil { |
| 77 | t.Fatal("stale prompt ID resolved the active credential request") |
| 78 | } |
| 79 | if err := mgr.ResolveSecret("box", promptID, "one-shot-secret", true); err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | select { |
| 83 | case got := <-result: |
| 84 | if got.err != nil || got.secret != "one-shot-secret" { |
| 85 | t.Fatalf("prompt result = %+v", got) |
| 86 | } |
| 87 | case <-time.After(2 * time.Second): |
| 88 | t.Fatal("secret prompt did not resolve") |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func (c *lifecycleSSHClient) Start(context.Context) error { |
| 93 | c.mu.Lock() |
| 94 | sub, err := c.sub, c.startErr |
| 95 | c.mu.Unlock() |
| 96 | if sub != nil { |
| 97 | if err != nil { |
| 98 | sub(remote.StatusEvent{Status: remote.StatusStopped, Err: err}) |
| 99 | } else { |
| 100 | sub(remote.StatusEvent{Status: remote.StatusConnected}) |
| 101 | } |
| 102 | } |
| 103 | return err |
| 104 | } |
| 105 | |
| 106 | func (c *lifecycleSSHClient) Close() error { |
| 107 | c.mu.Lock() |
| 108 | if c.closed { |
| 109 | c.mu.Unlock() |
| 110 | return nil |
| 111 | } |
| 112 | c.closed = true |
| 113 | c.mu.Unlock() |
| 114 | c.forwards.Close() |
| 115 | return nil |
| 116 | } |
| 117 | |
| 118 | func (c *lifecycleSSHClient) Subscribe(fn func(remote.StatusEvent)) func() { |
| 119 | c.mu.Lock() |
| 120 | c.sub = fn |
| 121 | c.mu.Unlock() |
| 122 | fn(remote.StatusEvent{Status: remote.StatusIdle}) |
| 123 | return func() {} |
| 124 | } |
| 125 | |
| 126 | func (c *lifecycleSSHClient) Forwards() *forward.Set { return c.forwards } |
| 127 | func (c *lifecycleSSHClient) Exec(context.Context, string) (remote.ExecResult, error) { |
| 128 | return remote.ExecResult{}, nil |
| 129 | } |
| 130 | func (c *lifecycleSSHClient) SFTP() (*sftpfs.FS, error) { return nil, errors.New("unused") } |
| 131 | |
| 132 | func seedLifecycleHost(t *testing.T, hostID string) { |
| 133 | t.Helper() |
| 134 | home := t.TempDir() |
| 135 | t.Setenv("REASONIX_HOME", home) |
| 136 | t.Setenv("HOME", home) |
| 137 | if err := editUserConfig(func(c *config.Config) error { |
| 138 | return c.UpsertRemoteHost(config.RemoteHostEntry{Name: hostID, Host: "127.0.0.1", Port: 22, User: "tester"}) |
| 139 | }); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestConnectCanReplaceStoppedGeneration(t *testing.T) { |
| 145 | seedLifecycleHost(t, "box") |
| 146 | mgr := newDesktopRemoteManager(nil) |
| 147 | first := newLifecycleSSHClient(errors.New("first dial failed")) |
| 148 | second := newLifecycleSSHClient(nil) |
| 149 | var calls int |
| 150 | mgr.newClient = func(remote.Options) (desktopSSHClient, error) { |
| 151 | calls++ |
| 152 | if calls == 1 { |
| 153 | return first, nil |
| 154 | } |
| 155 | return second, nil |
| 156 | } |
| 157 | |
| 158 | if err := mgr.Connect("box"); err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | deadline := time.Now().Add(2 * time.Second) |
| 162 | for { |
| 163 | statuses := mgr.Statuses() |
| 164 | if len(statuses) == 1 && statuses[0].State == "stopped" { |
| 165 | break |
| 166 | } |
| 167 | if time.Now().After(deadline) { |
| 168 | t.Fatalf("first generation did not stop: %+v", statuses) |
| 169 | } |
| 170 | time.Sleep(time.Millisecond) |
| 171 | } |
| 172 | if err := mgr.Connect("box"); err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | if calls != 2 { |
| 176 | t.Fatalf("newClient calls = %d, want 2", calls) |
| 177 | } |
| 178 | first.mu.Lock() |
| 179 | firstClosed := first.closed |
| 180 | first.mu.Unlock() |
| 181 | if !firstClosed { |
| 182 | t.Fatal("replaced stopped client was not closed") |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func TestStaleClientStatusCannotOverwriteReplacement(t *testing.T) { |
| 187 | mgr := newDesktopRemoteManager(nil) |
| 188 | oldCtx, oldCancel := context.WithCancel(context.Background()) |
| 189 | defer oldCancel() |
| 190 | newCtx, newCancel := context.WithCancel(context.Background()) |
| 191 | defer newCancel() |
| 192 | old := &managedHost{ctx: oldCtx, cancel: oldCancel, client: newLifecycleSSHClient(nil)} |
| 193 | current := &managedHost{ |
| 194 | ctx: newCtx, cancel: newCancel, client: newLifecycleSSHClient(nil), |
| 195 | status: RemoteConnectionStatusView{HostID: "box", State: "connected"}, |
| 196 | } |
| 197 | mgr.hosts["box"] = current |
| 198 | mgr.onClientStatus("box", old, remote.StatusEvent{Status: remote.StatusStopped, Err: errors.New("late")}) |
| 199 | if got := mgr.Statuses()[0]; got.State != "connected" || got.Error != "" { |
| 200 | t.Fatalf("replacement status was overwritten: %+v", got) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func TestServerLogsCancellationOnDisconnect(t *testing.T) { |
| 205 | sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 1)} |
| 206 | mgr := newDesktopRemoteManager(sink) |
| 207 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 208 | mh := &managedHost{ |
| 209 | ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil), |
| 210 | server: RemoteServerView{HostID: "box", Workspace: "/work", State: "ready"}, |
| 211 | } |
| 212 | mgr.hosts["box"] = mh |
| 213 | entered := make(chan struct{}) |
| 214 | mgr.serveLogs = func(ctx context.Context, _ bootstrap.Conn, _ string, _ int, _ *strings.Builder) error { |
| 215 | close(entered) |
| 216 | <-ctx.Done() |
| 217 | return ctx.Err() |
| 218 | } |
| 219 | done := make(chan error, 1) |
| 220 | go func() { |
| 221 | _, err := mgr.ServerLogs(context.Background(), "box", 20) |
| 222 | done <- err |
| 223 | }() |
| 224 | <-entered |
| 225 | if err := mgr.Disconnect("box"); err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | select { |
| 229 | case status := <-sink.statuses: |
| 230 | if status.HostID != "box" || status.State != "stopped" { |
| 231 | t.Fatalf("Disconnect status = %+v", status) |
| 232 | } |
| 233 | default: |
| 234 | t.Fatal("Disconnect did not publish a stopped status") |
| 235 | } |
| 236 | select { |
| 237 | case err := <-done: |
| 238 | if !errors.Is(err, context.Canceled) { |
| 239 | t.Fatalf("ServerLogs error = %v, want context canceled", err) |
| 240 | } |
| 241 | case <-time.After(2 * time.Second): |
| 242 | t.Fatal("ServerLogs was not canceled by Disconnect") |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | func TestEnsureServerResultCannotMutateReplacement(t *testing.T) { |
| 247 | seedLifecycleHost(t, "box") |
| 248 | mgr := newDesktopRemoteManager(nil) |
| 249 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 250 | old := &managedHost{ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil)} |
| 251 | mgr.hosts["box"] = old |
| 252 | entered := make(chan struct{}) |
| 253 | release := make(chan struct{}) |
| 254 | mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) { |
| 255 | close(entered) |
| 256 | <-release |
| 257 | return bootstrap.Result{State: bootstrap.ServeState{Addr: "127.0.0.1:9999"}, Token: "old-token"}, nil |
| 258 | } |
| 259 | mgr.localBinary = func() string { return "" } |
| 260 | done := make(chan error, 1) |
| 261 | go func() { |
| 262 | _, _, err := mgr.EnsureServer(context.Background(), "box", "/old") |
| 263 | done <- err |
| 264 | }() |
| 265 | <-entered |
| 266 | if err := mgr.Disconnect("box"); err != nil { |
| 267 | t.Fatal(err) |
| 268 | } |
| 269 | newCtx, newCancel := context.WithCancel(context.Background()) |
| 270 | defer newCancel() |
| 271 | replacement := &managedHost{ |
| 272 | ctx: newCtx, cancel: newCancel, client: newLifecycleSSHClient(nil), |
| 273 | server: RemoteServerView{HostID: "box", Workspace: "/new", State: "ready"}, token: "new-token", |
| 274 | } |
| 275 | mgr.mu.Lock() |
| 276 | mgr.hosts["box"] = replacement |
| 277 | mgr.mu.Unlock() |
| 278 | close(release) |
| 279 | if err := <-done; err == nil { |
| 280 | t.Fatal("stale EnsureServer unexpectedly succeeded") |
| 281 | } |
| 282 | if got := mgr.ServerStatus("box"); got.Workspace != "/new" || got.State != "ready" { |
| 283 | t.Fatalf("replacement server state was overwritten: %+v", got) |
| 284 | } |
| 285 | if replacement.token != "new-token" { |
| 286 | t.Fatalf("replacement token = %q, want new-token", replacement.token) |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | func TestStopServerRejectsEmptyWorkspace(t *testing.T) { |
| 291 | mgr := newDesktopRemoteManager(nil) |
| 292 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 293 | defer hostCancel() |
| 294 | mgr.hosts["box"] = &managedHost{ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil)} |
| 295 | called := false |
| 296 | mgr.stopServe = func(context.Context, bootstrap.Conn, string) error { called = true; return nil } |
| 297 | if err := mgr.StopServer("box"); err == nil { |
| 298 | t.Fatal("StopServer accepted an empty workspace") |
| 299 | } |
| 300 | if called { |
| 301 | t.Fatal("StopServer called bootstrap.Stop with an empty workspace") |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | func TestDesktopCLIBinaryPathFallsBackToPATH(t *testing.T) { |
| 306 | dir := t.TempDir() |
| 307 | _, name := desktopCLIBinaryNames(runtime.GOOS) |
| 308 | cli := filepath.Join(dir, name) |
| 309 | if err := os.WriteFile(cli, []byte("test"), 0o755); err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | t.Setenv("PATH", dir) |
| 313 | if got := desktopCLIBinaryPath(); got != cli { |
| 314 | t.Fatalf("desktopCLIBinaryPath = %q, want %q", got, cli) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | func TestDesktopCLIBinaryNamesAvoidWindowsPortableEntryCollision(t *testing.T) { |
| 319 | packaged, command := desktopCLIBinaryNames("windows") |
| 320 | if packaged != "reasonix-cli.exe" || command != "reasonix.exe" { |
| 321 | t.Fatalf("Windows CLI names = (%q, %q)", packaged, command) |
| 322 | } |
| 323 | if strings.EqualFold(packaged, "Reasonix.exe") { |
| 324 | t.Fatalf("packaged CLI %q collides with the desktop entry point", packaged) |
| 325 | } |
| 326 | if packaged, command := desktopCLIBinaryNames("linux"); packaged != "reasonix" || command != "reasonix" { |
| 327 | t.Fatalf("Linux CLI names = (%q, %q)", packaged, command) |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | func TestHasUsableServeForwardRequiresExactTargetAndURL(t *testing.T) { |
| 332 | entries := []forward.Entry{{ |
| 333 | Spec: forward.Spec{Name: serveForwardName, TargetAddr: "127.0.0.1:9000"}, |
| 334 | Up: true, BoundAddr: "127.0.0.1:45000", |
| 335 | }} |
| 336 | if !hasUsableServeForward(entries, "127.0.0.1:9000", "http://127.0.0.1:45000/") { |
| 337 | t.Fatal("exact existing serve forward was not reusable") |
| 338 | } |
| 339 | if hasUsableServeForward(entries, "127.0.0.1:9001", "http://127.0.0.1:45000/") { |
| 340 | t.Fatal("stale serve target was reused") |
| 341 | } |
| 342 | if hasUsableServeForward(entries, "127.0.0.1:9000", "http://127.0.0.1:45001/") { |
| 343 | t.Fatal("mismatched local URL was reused") |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | func TestDesktopNormalizeBind(t *testing.T) { |
| 348 | if got := desktopNormalizeBind("8080"); got != "127.0.0.1:8080" { |
| 349 | t.Fatalf("desktopNormalizeBind bare port = %q", got) |
| 350 | } |
| 351 | if got := desktopNormalizeBind("0.0.0.0:8080"); got != "0.0.0.0:8080" { |
| 352 | t.Fatalf("desktopNormalizeBind address = %q", got) |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | func TestHostKeyPromptsAreSerializedForGlobalDialog(t *testing.T) { |
| 357 | sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 2)} |
| 358 | mgr := newDesktopRemoteManager(sink) |
| 359 | ctx, cancel := context.WithCancel(context.Background()) |
| 360 | var wg sync.WaitGroup |
| 361 | t.Cleanup(func() { |
| 362 | cancel() |
| 363 | wg.Wait() |
| 364 | }) |
| 365 | type pendingPrompt struct { |
| 366 | hostID string |
| 367 | prompt remote.HostKeyPrompt |
| 368 | } |
| 369 | prompts := make([]pendingPrompt, 0, 2) |
| 370 | for _, hostID := range []string{"a", "b"} { |
| 371 | mh := &managedHost{ctx: ctx, cancel: cancel, client: newLifecycleSSHClient(nil)} |
| 372 | mgr.hosts[hostID] = mh |
| 373 | prompts = append(prompts, pendingPrompt{hostID: hostID, prompt: mgr.hostKeyPrompt(hostID, mh)}) |
| 374 | } |
| 375 | for _, pending := range prompts { |
| 376 | wg.Add(1) |
| 377 | go func() { |
| 378 | defer wg.Done() |
| 379 | _, _ = pending.prompt(ctx, remote.HostKeyQuestion{ |
| 380 | Address: pending.hostID + ":22", |
| 381 | KeyType: "ssh-ed25519", |
| 382 | Fingerprint: pending.hostID, |
| 383 | }) |
| 384 | }() |
| 385 | } |
| 386 | |
| 387 | first := <-sink.statuses |
| 388 | select { |
| 389 | case second := <-sink.statuses: |
| 390 | t.Fatalf("second prompt %q replaced unresolved prompt %q", second.HostID, first.HostID) |
| 391 | case <-time.After(50 * time.Millisecond): |
| 392 | } |
| 393 | if err := mgr.ResolveHostKey(first.HostID, true); err != nil { |
| 394 | t.Fatal(err) |
| 395 | } |
| 396 | select { |
| 397 | case second := <-sink.statuses: |
| 398 | if second.HostID == first.HostID { |
| 399 | t.Fatalf("serialized prompt repeated host %q", second.HostID) |
| 400 | } |
| 401 | if err := mgr.ResolveHostKey(second.HostID, false); err != nil { |
| 402 | t.Fatal(err) |
| 403 | } |
| 404 | case <-time.After(2 * time.Second): |
| 405 | t.Fatal("second prompt did not appear after resolving the first") |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | // TestEnsureServerFailureKeepsOwnershipOnPreviousReadyServe is the failed- |
| 410 | // switch atomicity contract: when the new workspace's Serve fails to start, |
| 411 | // the host's server ownership stays on the still-running previous Serve, so |
| 412 | // Stop and Logs keep operating on the workspace that actually runs. |
| 413 | func TestEnsureServerFailureKeepsOwnershipOnPreviousReadyServe(t *testing.T) { |
| 414 | seedLifecycleHost(t, "box") |
| 415 | mgr := newDesktopRemoteManager(nil) |
| 416 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 417 | defer hostCancel() |
| 418 | client := newLifecycleSSHClient(nil) |
| 419 | mgr.hosts["box"] = &managedHost{ |
| 420 | ctx: hostCtx, cancel: hostCancel, client: client, |
| 421 | server: RemoteServerView{HostID: "box", Workspace: "/srv/a", State: "ready", LocalURL: "http://127.0.0.1:54321/"}, |
| 422 | token: "token-a", |
| 423 | } |
| 424 | mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) { |
| 425 | return bootstrap.Result{}, errors.New("serve launch failed") |
| 426 | } |
| 427 | var stopped, logged []string |
| 428 | mgr.stopServe = func(_ context.Context, _ bootstrap.Conn, workspace string) error { |
| 429 | stopped = append(stopped, workspace) |
| 430 | return nil |
| 431 | } |
| 432 | mgr.serveLogs = func(_ context.Context, _ bootstrap.Conn, workspace string, _ int, _ *strings.Builder) error { |
| 433 | logged = append(logged, workspace) |
| 434 | return nil |
| 435 | } |
| 436 | |
| 437 | if _, _, err := mgr.EnsureServer(context.Background(), "box", "/srv/b"); err == nil { |
| 438 | t.Fatal("expected the serve launch failure") |
| 439 | } |
| 440 | status := mgr.ServerStatus("box") |
| 441 | if status.State != "ready" || status.Workspace != "/srv/a" { |
| 442 | t.Fatalf("server state after failed switch = %+v, want the previous ready /srv/a", status) |
| 443 | } |
| 444 | if got := mgr.hosts["box"].token; got != "token-a" { |
| 445 | t.Fatalf("token after failed switch = %q, want the previous token", got) |
| 446 | } |
| 447 | |
| 448 | if err := mgr.StopServer("box"); err != nil { |
| 449 | t.Fatal(err) |
| 450 | } |
| 451 | if len(stopped) != 1 || stopped[0] != "/srv/a" { |
| 452 | t.Fatalf("StopServer operated on %v, want the previous /srv/a", stopped) |
| 453 | } |
| 454 | if _, err := mgr.ServerLogs(context.Background(), "box", 50); err != nil { |
| 455 | t.Fatal(err) |
| 456 | } |
| 457 | if len(logged) != 1 || logged[0] != "/srv/a" { |
| 458 | t.Fatalf("ServerLogs operated on %v, want the previous /srv/a", logged) |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | // TestEnsureServerReplaceFailureKeepsOwnershipOnPreviousReadyServe covers the |
| 463 | // same contract when the new Serve started but its loopback tunnel could not |
| 464 | // be bound: the just-started Serve is stopped, ownership returns to the |
| 465 | // previous ready Serve, and the failed view never replaces it. |
| 466 | func TestEnsureServerReplaceFailureKeepsOwnershipOnPreviousReadyServe(t *testing.T) { |
| 467 | seedLifecycleHost(t, "box") |
| 468 | mgr := newDesktopRemoteManager(nil) |
| 469 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 470 | defer hostCancel() |
| 471 | client := newLifecycleSSHClient(nil) |
| 472 | // A closed forward set makes the tunnel Replace fail deterministically; |
| 473 | // the Set semantics keep any previous forward live on a failed Replace. |
| 474 | closedForwards := forward.NewSet(nil) |
| 475 | closedForwards.Close() |
| 476 | client.forwards = closedForwards |
| 477 | mgr.hosts["box"] = &managedHost{ |
| 478 | ctx: hostCtx, cancel: hostCancel, client: client, |
| 479 | server: RemoteServerView{HostID: "box", Workspace: "/srv/a", State: "ready", LocalURL: "http://127.0.0.1:54321/"}, |
| 480 | token: "token-a", |
| 481 | } |
| 482 | mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) { |
| 483 | return bootstrap.Result{State: bootstrap.ServeState{Addr: "127.0.0.1:9999"}}, nil |
| 484 | } |
| 485 | var stopped []string |
| 486 | mgr.stopServe = func(_ context.Context, _ bootstrap.Conn, workspace string) error { |
| 487 | stopped = append(stopped, workspace) |
| 488 | return nil |
| 489 | } |
| 490 | |
| 491 | if _, _, err := mgr.EnsureServer(context.Background(), "box", "/srv/b"); err == nil { |
| 492 | t.Fatal("expected the tunnel Replace failure") |
| 493 | } |
| 494 | // The newly started /srv/b Serve was cleaned up, not left orphaned. |
| 495 | if len(stopped) != 1 || stopped[0] != "/srv/b" { |
| 496 | t.Fatalf("cleanup stopped %v, want the failed /srv/b", stopped) |
| 497 | } |
| 498 | status := mgr.ServerStatus("box") |
| 499 | if status.State != "ready" || status.Workspace != "/srv/a" { |
| 500 | t.Fatalf("server state after failed tunnel switch = %+v, want the previous ready /srv/a", status) |
| 501 | } |
| 502 | if got := mgr.hosts["box"].token; got != "token-a" { |
| 503 | t.Fatalf("token after failed tunnel switch = %q, want the previous token", got) |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | // TestEnsureServerFirstStartFailurePublishesError keeps the informative error |
| 508 | // view when there is no previous ready Serve to preserve. |
| 509 | func TestEnsureServerFirstStartFailurePublishesError(t *testing.T) { |
| 510 | seedLifecycleHost(t, "box") |
| 511 | mgr := newDesktopRemoteManager(nil) |
| 512 | hostCtx, hostCancel := context.WithCancel(context.Background()) |
| 513 | defer hostCancel() |
| 514 | mgr.hosts["box"] = &managedHost{ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil)} |
| 515 | mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) { |
| 516 | return bootstrap.Result{}, errors.New("serve launch failed") |
| 517 | } |
| 518 | if _, _, err := mgr.EnsureServer(context.Background(), "box", "/srv/b"); err == nil { |
| 519 | t.Fatal("expected the serve launch failure") |
| 520 | } |
| 521 | status := mgr.ServerStatus("box") |
| 522 | if status.State != "error" || status.Workspace != "/srv/b" { |
| 523 | t.Fatalf("server state after first-start failure = %+v, want error /srv/b", status) |
| 524 | } |
| 525 | } |
| 526 |