返回 DeepSeek-Reasonix
remote_lifecycle_test.go
根目录 / desktop / remote_lifecycle_test.go
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 "reasonix/internal/remote/sshtest"
20
21 "golang.org/x/crypto/ssh"
22 )
23
24 type lifecycleSSHClient struct {
25 mu sync.Mutex
26 startErr error
27 closed bool
28 sub func(remote.StatusEvent)
29 forwards *forward.Set
30 }
31
32 type lifecycleEventSink struct {
33 statuses chan RemoteConnectionStatusView
34 }
35
36 func (s *lifecycleEventSink) onStatus(v RemoteConnectionStatusView) { s.statuses <- v }
37 func (*lifecycleEventSink) onForwards(string, []RemoteForwardView) {}
38 func (*lifecycleEventSink) onServer(RemoteServerView) {}
39
40 func newLifecycleSSHClient(startErr error) *lifecycleSSHClient {
41 return &lifecycleSSHClient{startErr: startErr, forwards: forward.NewSet(nil)}
42 }
43
44 func TestDesktopSecretPromptPublishesMetadataAndReturnsOneShotSecret(t *testing.T) {
45 sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 2)}
46 mgr := newDesktopRemoteManager(sink)
47 ctx, cancel := context.WithCancel(context.Background())
48 defer cancel()
49 generation := &managedHost{ctx: ctx, cancel: cancel, status: RemoteConnectionStatusView{HostID: "box", State: "connecting"}}
50 mgr.hosts["box"] = generation
51
52 type promptResult struct {
53 secret string
54 err error
55 }
56 result := make(chan promptResult, 1)
57 go func() {
58 secret, err := mgr.secretPrompt("box", generation)(ctx, remote.SecretPassword, "dev@box.test", "")
59 result <- promptResult{secret: secret, err: err}
60 }()
61
62 var promptID string
63 select {
64 case status := <-sink.statuses:
65 if status.State != "pending_secret" || status.SecretPrompt == nil {
66 t.Fatalf("status = %+v", status)
67 }
68 if status.SecretPrompt.Host != "dev@box.test" || status.SecretPrompt.Kind != "password" {
69 t.Fatalf("prompt metadata = %+v", status.SecretPrompt)
70 }
71 promptID = status.SecretPrompt.PromptID
72 if promptID == "" {
73 t.Fatal("prompt ID was empty")
74 }
75 case <-time.After(2 * time.Second):
76 t.Fatal("secret prompt status was not emitted")
77 }
78
79 if err := mgr.ResolveSecret("box", "stale-prompt", "wrong-secret", true); err == nil {
80 t.Fatal("stale prompt ID resolved the active credential request")
81 }
82 if err := mgr.ResolveSecret("box", promptID, "one-shot-secret", true); err != nil {
83 t.Fatal(err)
84 }
85 select {
86 case got := <-result:
87 if got.err != nil || got.secret != "one-shot-secret" {
88 t.Fatalf("prompt result = %+v", got)
89 }
90 case <-time.After(2 * time.Second):
91 t.Fatal("secret prompt did not resolve")
92 }
93 }
94
95 func (c *lifecycleSSHClient) Start(context.Context) error {
96 c.mu.Lock()
97 sub, err := c.sub, c.startErr
98 c.mu.Unlock()
99 if sub != nil {
100 if err != nil {
101 sub(remote.StatusEvent{Status: remote.StatusStopped, Err: err})
102 } else {
103 sub(remote.StatusEvent{Status: remote.StatusConnected})
104 }
105 }
106 return err
107 }
108
109 func (c *lifecycleSSHClient) Close() error {
110 c.mu.Lock()
111 if c.closed {
112 c.mu.Unlock()
113 return nil
114 }
115 c.closed = true
116 c.mu.Unlock()
117 c.forwards.Close()
118 return nil
119 }
120
121 func (c *lifecycleSSHClient) Subscribe(fn func(remote.StatusEvent)) func() {
122 c.mu.Lock()
123 c.sub = fn
124 c.mu.Unlock()
125 fn(remote.StatusEvent{Status: remote.StatusIdle})
126 return func() {}
127 }
128
129 func (c *lifecycleSSHClient) Forwards() *forward.Set { return c.forwards }
130 func (c *lifecycleSSHClient) Exec(context.Context, string) (remote.ExecResult, error) {
131 return remote.ExecResult{}, nil
132 }
133 func (c *lifecycleSSHClient) SFTP() (*sftpfs.FS, error) { return nil, errors.New("unused") }
134
135 func seedLifecycleHost(t *testing.T, hostID string) {
136 t.Helper()
137 home := t.TempDir()
138 t.Setenv("REASONIX_HOME", home)
139 t.Setenv("HOME", home)
140 if err := editUserConfig(func(c *config.Config) error {
141 return c.UpsertRemoteHost(config.RemoteHostEntry{Name: hostID, Host: "127.0.0.1", Port: 22, User: "tester"})
142 }); err != nil {
143 t.Fatal(err)
144 }
145 }
146
147 func TestConnectCanReplaceStoppedGeneration(t *testing.T) {
148 seedLifecycleHost(t, "box")
149 mgr := newDesktopRemoteManager(nil)
150 first := newLifecycleSSHClient(errors.New("first dial failed"))
151 second := newLifecycleSSHClient(nil)
152 var calls int
153 mgr.newClient = func(remote.Options) (desktopSSHClient, error) {
154 calls++
155 if calls == 1 {
156 return first, nil
157 }
158 return second, nil
159 }
160
161 if err := mgr.Connect("box"); err != nil {
162 t.Fatal(err)
163 }
164 deadline := time.Now().Add(2 * time.Second)
165 for {
166 statuses := mgr.Statuses()
167 if len(statuses) == 1 && statuses[0].State == "stopped" {
168 break
169 }
170 if time.Now().After(deadline) {
171 t.Fatalf("first generation did not stop: %+v", statuses)
172 }
173 time.Sleep(time.Millisecond)
174 }
175 if err := mgr.Connect("box"); err != nil {
176 t.Fatal(err)
177 }
178 if calls != 2 {
179 t.Fatalf("newClient calls = %d, want 2", calls)
180 }
181 first.mu.Lock()
182 firstClosed := first.closed
183 first.mu.Unlock()
184 if !firstClosed {
185 t.Fatal("replaced stopped client was not closed")
186 }
187 }
188
189 func TestStaleClientStatusCannotOverwriteReplacement(t *testing.T) {
190 mgr := newDesktopRemoteManager(nil)
191 oldCtx, oldCancel := context.WithCancel(context.Background())
192 defer oldCancel()
193 newCtx, newCancel := context.WithCancel(context.Background())
194 defer newCancel()
195 old := &managedHost{ctx: oldCtx, cancel: oldCancel, client: newLifecycleSSHClient(nil)}
196 current := &managedHost{
197 ctx: newCtx, cancel: newCancel, client: newLifecycleSSHClient(nil),
198 status: RemoteConnectionStatusView{HostID: "box", State: "connected"},
199 }
200 mgr.hosts["box"] = current
201 mgr.onClientStatus("box", old, remote.StatusEvent{Status: remote.StatusStopped, Err: errors.New("late")})
202 if got := mgr.Statuses()[0]; got.State != "connected" || got.Error != "" {
203 t.Fatalf("replacement status was overwritten: %+v", got)
204 }
205 }
206
207 func TestServerLogsCancellationOnDisconnect(t *testing.T) {
208 sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 1)}
209 mgr := newDesktopRemoteManager(sink)
210 hostCtx, hostCancel := context.WithCancel(context.Background())
211 mh := &managedHost{
212 ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil),
213 serves: map[string]*serveEntry{"/work": {view: RemoteServerView{HostID: "box", Workspace: "/work", State: "ready"}}},
214 }
215 mgr.hosts["box"] = mh
216 entered := make(chan struct{})
217 mgr.serveLogs = func(ctx context.Context, _ bootstrap.Conn, _ string, _ int, _ *strings.Builder) error {
218 close(entered)
219 <-ctx.Done()
220 return ctx.Err()
221 }
222 done := make(chan error, 1)
223 go func() {
224 _, err := mgr.ServerLogs(context.Background(), "box", "/work", 20)
225 done <- err
226 }()
227 <-entered
228 if err := mgr.Disconnect("box"); err != nil {
229 t.Fatal(err)
230 }
231 select {
232 case status := <-sink.statuses:
233 if status.HostID != "box" || status.State != "stopped" {
234 t.Fatalf("Disconnect status = %+v", status)
235 }
236 default:
237 t.Fatal("Disconnect did not publish a stopped status")
238 }
239 select {
240 case err := <-done:
241 if !errors.Is(err, context.Canceled) {
242 t.Fatalf("ServerLogs error = %v, want context canceled", err)
243 }
244 case <-time.After(2 * time.Second):
245 t.Fatal("ServerLogs was not canceled by Disconnect")
246 }
247 }
248
249 func TestEnsureServerResultCannotMutateReplacement(t *testing.T) {
250 seedLifecycleHost(t, "box")
251 mgr := newDesktopRemoteManager(nil)
252 hostCtx, hostCancel := context.WithCancel(context.Background())
253 old := &managedHost{ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil)}
254 mgr.hosts["box"] = old
255 entered := make(chan struct{})
256 release := make(chan struct{})
257 mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) {
258 close(entered)
259 <-release
260 return bootstrap.Result{State: bootstrap.ServeState{Addr: "127.0.0.1:9999"}, Token: "old-token"}, nil
261 }
262 mgr.localBinary = func() string { return "" }
263 done := make(chan error, 1)
264 go func() {
265 _, _, err := mgr.EnsureServer(context.Background(), "box", "/old")
266 done <- err
267 }()
268 <-entered
269 if err := mgr.Disconnect("box"); err != nil {
270 t.Fatal(err)
271 }
272 newCtx, newCancel := context.WithCancel(context.Background())
273 defer newCancel()
274 replacement := &managedHost{
275 ctx: newCtx, cancel: newCancel, client: newLifecycleSSHClient(nil),
276 serves: map[string]*serveEntry{
277 "/new": {view: RemoteServerView{HostID: "box", Workspace: "/new", State: "ready"}, token: "new-token"},
278 },
279 }
280 mgr.mu.Lock()
281 mgr.hosts["box"] = replacement
282 mgr.mu.Unlock()
283 close(release)
284 if err := <-done; err == nil {
285 t.Fatal("stale EnsureServer unexpectedly succeeded")
286 }
287 if got := mgr.ServerStatus("box", "/new"); got.Workspace != "/new" || got.State != "ready" {
288 t.Fatalf("replacement server state was overwritten: %+v", got)
289 }
290 if got := replacement.serves["/new"].token; got != "new-token" {
291 t.Fatalf("replacement token = %q, want new-token", got)
292 }
293 }
294
295 func TestStopServerRejectsUnknownWorkspace(t *testing.T) {
296 mgr := newDesktopRemoteManager(nil)
297 hostCtx, hostCancel := context.WithCancel(context.Background())
298 defer hostCancel()
299 mgr.hosts["box"] = &managedHost{
300 ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil),
301 serves: map[string]*serveEntry{"/srv/a": {view: RemoteServerView{HostID: "box", Workspace: "/srv/a", State: "ready"}}},
302 }
303 called := false
304 mgr.stopServe = func(context.Context, bootstrap.Conn, string) error { called = true; return nil }
305 if err := mgr.StopServer("box", "/srv/missing"); err == nil {
306 t.Fatal("StopServer accepted an unknown workspace")
307 }
308 if called {
309 t.Fatal("StopServer called bootstrap.Stop for an untracked workspace")
310 }
311 }
312
313 func TestDesktopCLIBinaryPathFallsBackToPATH(t *testing.T) {
314 dir := t.TempDir()
315 _, name := desktopCLIBinaryNames(runtime.GOOS)
316 cli := filepath.Join(dir, name)
317 if err := os.WriteFile(cli, []byte("test"), 0o755); err != nil {
318 t.Fatal(err)
319 }
320 t.Setenv("PATH", dir)
321 if got := desktopCLIBinaryPath(); got != cli {
322 t.Fatalf("desktopCLIBinaryPath = %q, want %q", got, cli)
323 }
324 }
325
326 func TestDesktopCLIBinaryNamesAvoidWindowsPortableEntryCollision(t *testing.T) {
327 packaged, command := desktopCLIBinaryNames("windows")
328 if packaged != "reasonix-cli.exe" || command != "reasonix.exe" {
329 t.Fatalf("Windows CLI names = (%q, %q)", packaged, command)
330 }
331 if strings.EqualFold(packaged, "Reasonix.exe") {
332 t.Fatalf("packaged CLI %q collides with the desktop entry point", packaged)
333 }
334 if packaged, command := desktopCLIBinaryNames("linux"); packaged != "reasonix" || command != "reasonix" {
335 t.Fatalf("Linux CLI names = (%q, %q)", packaged, command)
336 }
337 }
338
339 func TestHasUsableServeForwardRequiresExactTargetAndURL(t *testing.T) {
340 entries := []forward.Entry{{
341 Spec: forward.Spec{Name: serveForwardName("/srv/a"), TargetAddr: "127.0.0.1:9000"},
342 Up: true, BoundAddr: "127.0.0.1:45000",
343 }}
344 if !hasUsableServeForward(entries, serveForwardName("/srv/a"), "127.0.0.1:9000", "http://127.0.0.1:45000/") {
345 t.Fatal("exact existing serve forward was not reusable")
346 }
347 if hasUsableServeForward(entries, serveForwardName("/srv/a"), "127.0.0.1:9001", "http://127.0.0.1:45000/") {
348 t.Fatal("stale serve target was reused")
349 }
350 if hasUsableServeForward(entries, serveForwardName("/srv/a"), "127.0.0.1:9000", "http://127.0.0.1:45001/") {
351 t.Fatal("mismatched local URL was reused")
352 }
353 if hasUsableServeForward(entries, serveForwardName("/other"), "127.0.0.1:9000", "http://127.0.0.1:45000/") {
354 t.Fatal("another workspace's forward was reused")
355 }
356 }
357
358 func TestDesktopNormalizeBind(t *testing.T) {
359 if got := desktopNormalizeBind("8080"); got != "127.0.0.1:8080" {
360 t.Fatalf("desktopNormalizeBind bare port = %q", got)
361 }
362 if got := desktopNormalizeBind("0.0.0.0:8080"); got != "0.0.0.0:8080" {
363 t.Fatalf("desktopNormalizeBind address = %q", got)
364 }
365 }
366
367 func TestHostKeyPromptsAreSerializedForGlobalDialog(t *testing.T) {
368 sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 2)}
369 mgr := newDesktopRemoteManager(sink)
370 ctx, cancel := context.WithCancel(context.Background())
371 var wg sync.WaitGroup
372 t.Cleanup(func() {
373 cancel()
374 wg.Wait()
375 })
376 type pendingPrompt struct {
377 hostID string
378 prompt remote.HostKeyPrompt
379 }
380 prompts := make([]pendingPrompt, 0, 2)
381 for _, hostID := range []string{"a", "b"} {
382 mh := &managedHost{ctx: ctx, cancel: cancel, client: newLifecycleSSHClient(nil)}
383 mgr.hosts[hostID] = mh
384 prompts = append(prompts, pendingPrompt{hostID: hostID, prompt: mgr.hostKeyPrompt(hostID, mh)})
385 }
386 for _, pending := range prompts {
387 wg.Go(func() {
388 _, _ = pending.prompt(ctx, remote.HostKeyQuestion{
389 Address: pending.hostID + ":22",
390 KeyType: "ssh-ed25519",
391 Fingerprint: pending.hostID,
392 })
393 })
394 }
395
396 first := <-sink.statuses
397 select {
398 case second := <-sink.statuses:
399 t.Fatalf("second prompt %q replaced unresolved prompt %q", second.HostID, first.HostID)
400 case <-time.After(50 * time.Millisecond):
401 }
402 if err := mgr.ResolveHostKey(first.HostID, true); err != nil {
403 t.Fatal(err)
404 }
405 select {
406 case second := <-sink.statuses:
407 if second.HostID == first.HostID {
408 t.Fatalf("serialized prompt repeated host %q", second.HostID)
409 }
410 if err := mgr.ResolveHostKey(second.HostID, false); err != nil {
411 t.Fatal(err)
412 }
413 case <-time.After(2 * time.Second):
414 t.Fatal("second prompt did not appear after resolving the first")
415 }
416 }
417
418 // TestEnsureServerFailureKeepsOwnershipOnPreviousReadyServe is the failed-
419 // start isolation contract: when a new workspace's Serve fails to start, the
420 // still-running previous workspace's entry stays untouched, so Stop and Logs
421 // keep operating on the workspace that actually runs.
422 func TestEnsureServerFailureKeepsOwnershipOnPreviousReadyServe(t *testing.T) {
423 seedLifecycleHost(t, "box")
424 mgr := newDesktopRemoteManager(nil)
425 hostCtx, hostCancel := context.WithCancel(context.Background())
426 defer hostCancel()
427 client := newLifecycleSSHClient(nil)
428 mgr.hosts["box"] = &managedHost{
429 ctx: hostCtx, cancel: hostCancel, client: client,
430 serves: map[string]*serveEntry{
431 "/srv/a": {view: RemoteServerView{HostID: "box", Workspace: "/srv/a", State: "ready", LocalURL: "http://127.0.0.1:54321/"}, token: "token-a"},
432 },
433 }
434 mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) {
435 return bootstrap.Result{}, errors.New("serve launch failed")
436 }
437 var stopped, logged []string
438 mgr.stopServe = func(_ context.Context, _ bootstrap.Conn, workspace string) error {
439 stopped = append(stopped, workspace)
440 return nil
441 }
442 mgr.serveLogs = func(_ context.Context, _ bootstrap.Conn, workspace string, _ int, _ *strings.Builder) error {
443 logged = append(logged, workspace)
444 return nil
445 }
446
447 if _, _, err := mgr.EnsureServer(context.Background(), "box", "/srv/b"); err == nil {
448 t.Fatal("expected the serve launch failure")
449 }
450 status := mgr.ServerStatus("box", "/srv/a")
451 if status.State != "ready" || status.Workspace != "/srv/a" {
452 t.Fatalf("server state after failed start = %+v, want the previous ready /srv/a", status)
453 }
454 if got := mgr.hosts["box"].serves["/srv/a"].token; got != "token-a" {
455 t.Fatalf("token after failed start = %q, want the previous token", got)
456 }
457 if status := mgr.ServerStatus("box", "/srv/b"); status.State != "error" {
458 t.Fatalf("failed workspace state = %+v, want an error entry for /srv/b", status)
459 }
460
461 if err := mgr.StopServer("box", "/srv/a"); err != nil {
462 t.Fatal(err)
463 }
464 if len(stopped) != 1 || stopped[0] != "/srv/a" {
465 t.Fatalf("StopServer operated on %v, want the previous /srv/a", stopped)
466 }
467 if _, err := mgr.ServerLogs(context.Background(), "box", "/srv/a", 50); err != nil {
468 t.Fatal(err)
469 }
470 if len(logged) != 1 || logged[0] != "/srv/a" {
471 t.Fatalf("ServerLogs operated on %v, want the previous /srv/a", logged)
472 }
473 }
474
475 // TestEnsureServerReplaceFailureKeepsOwnershipOnPreviousReadyServe covers the
476 // same contract when the new Serve started but its loopback tunnel could not
477 // be bound: the just-started Serve is stopped, ownership returns to the
478 // previous ready Serve, and the failed view never replaces it.
479 func TestEnsureServerReplaceFailureKeepsOwnershipOnPreviousReadyServe(t *testing.T) {
480 seedLifecycleHost(t, "box")
481 mgr := newDesktopRemoteManager(nil)
482 hostCtx, hostCancel := context.WithCancel(context.Background())
483 defer hostCancel()
484 client := newLifecycleSSHClient(nil)
485 // A closed forward set makes the tunnel Replace fail deterministically;
486 // the Set semantics keep any previous forward live on a failed Replace.
487 closedForwards := forward.NewSet(nil)
488 closedForwards.Close()
489 client.forwards = closedForwards
490 mgr.hosts["box"] = &managedHost{
491 ctx: hostCtx, cancel: hostCancel, client: client,
492 serves: map[string]*serveEntry{
493 "/srv/a": {view: RemoteServerView{HostID: "box", Workspace: "/srv/a", State: "ready", LocalURL: "http://127.0.0.1:54321/"}, token: "token-a"},
494 },
495 }
496 mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) {
497 return bootstrap.Result{State: bootstrap.ServeState{Addr: "127.0.0.1:9999"}}, nil
498 }
499 var stopped []string
500 mgr.stopServe = func(_ context.Context, _ bootstrap.Conn, workspace string) error {
501 stopped = append(stopped, workspace)
502 return nil
503 }
504
505 if _, _, err := mgr.EnsureServer(context.Background(), "box", "/srv/b"); err == nil {
506 t.Fatal("expected the tunnel Replace failure")
507 }
508 // The newly started /srv/b Serve was cleaned up, not left orphaned.
509 if len(stopped) != 1 || stopped[0] != "/srv/b" {
510 t.Fatalf("cleanup stopped %v, want the failed /srv/b", stopped)
511 }
512 status := mgr.ServerStatus("box", "/srv/a")
513 if status.State != "ready" || status.Workspace != "/srv/a" {
514 t.Fatalf("server state after failed tunnel bind = %+v, want the previous ready /srv/a", status)
515 }
516 if got := mgr.hosts["box"].serves["/srv/a"].token; got != "token-a" {
517 t.Fatalf("token after failed tunnel bind = %q, want the previous token", got)
518 }
519 }
520
521 // TestEnsureServerFirstStartFailurePublishesError keeps the informative error
522 // view when there is no previous ready Serve to preserve.
523 func TestEnsureServerFirstStartFailurePublishesError(t *testing.T) {
524 seedLifecycleHost(t, "box")
525 mgr := newDesktopRemoteManager(nil)
526 hostCtx, hostCancel := context.WithCancel(context.Background())
527 defer hostCancel()
528 mgr.hosts["box"] = &managedHost{ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil)}
529 mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) {
530 return bootstrap.Result{}, errors.New("serve launch failed")
531 }
532 if _, _, err := mgr.EnsureServer(context.Background(), "box", "/srv/b"); err == nil {
533 t.Fatal("expected the serve launch failure")
534 }
535 status := mgr.ServerStatus("box", "/srv/b")
536 if status.State != "error" || status.Workspace != "/srv/b" {
537 t.Fatalf("server state after first-start failure = %+v, want error /srv/b", status)
538 }
539 }
540
541 // platformProbeClient scripts the uname probe CheckPlatform runs.
542 type platformProbeClient struct {
543 *lifecycleSSHClient
544 unameOut string
545 execErr error
546 }
547
548 func (c *platformProbeClient) Exec(context.Context, string) (remote.ExecResult, error) {
549 return remote.ExecResult{Stdout: []byte(c.unameOut)}, c.execErr
550 }
551
552 // TestCheckPlatformGatesUnsupportedOS applies the same ParseUname gate as
553 // EnsureServe at connect time: Linux/macOS pass, anything else fails with one
554 // clear message.
555 func TestCheckPlatformGatesUnsupportedOS(t *testing.T) {
556 cases := []struct {
557 name string
558 stdout string
559 execErr error
560 wantErr string
561 }{
562 {name: "linux passes", stdout: "Linux x86_64\n"},
563 {name: "darwin passes", stdout: "Darwin arm64\n"},
564 {name: "mingw rejected", stdout: "MINGW64_NT-10.0-19045 x86_64\n", wantErr: "unsupported remote OS"},
565 {name: "no uname rejected", stdout: "", wantErr: "cannot detect OS"},
566 {name: "exec failure rejected", stdout: "Linux x86_64\n", execErr: errors.New("broken pipe"), wantErr: "cannot detect OS"},
567 }
568 for _, tc := range cases {
569 t.Run(tc.name, func(t *testing.T) {
570 mgr := newDesktopRemoteManager(nil)
571 hostCtx, hostCancel := context.WithCancel(context.Background())
572 defer hostCancel()
573 cl := &platformProbeClient{
574 lifecycleSSHClient: newLifecycleSSHClient(nil),
575 unameOut: tc.stdout,
576 execErr: tc.execErr,
577 }
578 mgr.hosts["box"] = &managedHost{
579 ctx: hostCtx, cancel: hostCancel, client: cl,
580 status: RemoteConnectionStatusView{HostID: "box", State: "connected"},
581 }
582 err := mgr.CheckPlatform(context.Background(), "box")
583 if tc.wantErr == "" {
584 if err != nil {
585 t.Fatalf("unexpected error: %v", err)
586 }
587 return
588 }
589 if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
590 t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
591 }
592 })
593 }
594 }
595
596 func TestCheckPlatformRequiresConnection(t *testing.T) {
597 mgr := newDesktopRemoteManager(nil)
598 if err := mgr.CheckPlatform(context.Background(), "ghost"); err == nil || !strings.Contains(err.Error(), "not connected") {
599 t.Fatalf("err = %v, want not connected", err)
600 }
601 }
602
603 // multiServeEventSink records the per-workspace server views the kernel
604 // publishes while keeping the status channel behavior of lifecycleEventSink.
605 type multiServeEventSink struct {
606 lifecycleEventSink
607 mu sync.Mutex
608 servers []RemoteServerView
609 }
610
611 func (s *multiServeEventSink) onServer(v RemoteServerView) {
612 s.mu.Lock()
613 defer s.mu.Unlock()
614 s.servers = append(s.servers, v)
615 }
616
617 func (s *multiServeEventSink) readyWorkspaces() []string {
618 s.mu.Lock()
619 defer s.mu.Unlock()
620 var out []string
621 for _, v := range s.servers {
622 if v.State == "ready" {
623 out = append(out, v.Workspace)
624 }
625 }
626 return out
627 }
628
629 // newAttachedForwardsClient returns a lifecycleSSHClient whose forward set is
630 // attached to a real sshtest-backed SSH client, so EnsureServer's tunnel
631 // Replace path can bind local listeners.
632 func newAttachedForwardsClient(t *testing.T) *lifecycleSSHClient {
633 t.Helper()
634 srv := sshtest.Start(t, sshtest.Options{})
635 cfg := &ssh.ClientConfig{User: "t", HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second}
636 sshCl, err := ssh.Dial("tcp", srv.Addr, cfg)
637 if err != nil {
638 t.Fatal(err)
639 }
640 t.Cleanup(func() { _ = sshCl.Close() })
641 set := forward.NewSet(nil)
642 set.Attach(sshCl)
643 lc := newLifecycleSSHClient(nil)
644 lc.forwards = set
645 return lc
646 }
647
648 // TestEnsureServerTwoWorkspacesIndependentForwards: one host, two workspaces —
649 // two slug-named tunnels, two ready entries, per-workspace events.
650 func TestEnsureServerTwoWorkspacesIndependentForwards(t *testing.T) {
651 seedLifecycleHost(t, "box")
652 sink := &multiServeEventSink{lifecycleEventSink: lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 8)}}
653 mgr := newDesktopRemoteManager(sink)
654 hostCtx, hostCancel := context.WithCancel(context.Background())
655 defer hostCancel()
656 cl := newAttachedForwardsClient(t)
657 mgr.hosts["box"] = &managedHost{ctx: hostCtx, cancel: hostCancel, client: cl, serves: map[string]*serveEntry{}}
658 mgr.ensureServe = func(_ context.Context, _ bootstrap.Conn, opts bootstrap.Options) (bootstrap.Result, error) {
659 addr := "127.0.0.1:9101"
660 if opts.Workspace == "/srv/b" {
661 addr = "127.0.0.1:9102"
662 }
663 return bootstrap.Result{State: bootstrap.ServeState{Addr: addr}, Token: "tok-" + opts.Workspace}, nil
664 }
665 mgr.localBinary = func() string { return "" }
666
667 for _, ws := range []string{"/srv/a", "/srv/b"} {
668 view, _, err := mgr.EnsureServer(context.Background(), "box", ws)
669 if err != nil || view.State != "ready" || view.LocalURL == "" {
670 t.Fatalf("EnsureServer(%s) = %+v, %v", ws, view, err)
671 }
672 }
673
674 forwards := cl.Forwards().List()
675 if len(forwards) != 2 {
676 t.Fatalf("forward count = %d, want 2 (%+v)", len(forwards), forwards)
677 }
678 names := map[string]bool{}
679 for _, f := range forwards {
680 names[f.Spec.Name] = true
681 if !f.Up {
682 t.Fatalf("forward %q is not up", f.Spec.Name)
683 }
684 }
685 if !names[serveForwardName("/srv/a")] || !names[serveForwardName("/srv/b")] {
686 t.Fatalf("forward names = %v, want per-workspace serve-%s and serve-%s", names, serveForwardName("/srv/a"), serveForwardName("/srv/b"))
687 }
688 if serveForwardName("/srv/a") == serveForwardName("/srv/b") {
689 t.Fatal("distinct workspaces share one forward name")
690 }
691
692 if got := mgr.ServerStatus("box", "/srv/a"); got.State != "ready" || got.Workspace != "/srv/a" {
693 t.Fatalf("status A = %+v", got)
694 }
695 if got := mgr.ServerStatus("box", "/srv/b"); got.State != "ready" || got.Workspace != "/srv/b" {
696 t.Fatalf("status B = %+v", got)
697 }
698 if got := mgr.ServerStatus("box", "/srv/c"); got.State != "stopped" {
699 t.Fatalf("untracked workspace status = %+v, want stopped", got)
700 }
701 ready := sink.readyWorkspaces()
702 if len(ready) != 2 {
703 t.Fatalf("ready server events = %v, want one per workspace", ready)
704 }
705 }
706
707 // TestStopServerOneWorkspaceKeepsOther: stopping A removes only A's tunnel and
708 // entry; B keeps serving.
709 func TestStopServerOneWorkspaceKeepsOther(t *testing.T) {
710 seedLifecycleHost(t, "box")
711 mgr := newDesktopRemoteManager(nil)
712 hostCtx, hostCancel := context.WithCancel(context.Background())
713 defer hostCancel()
714 cl := newAttachedForwardsClient(t)
715 mgr.hosts["box"] = &managedHost{ctx: hostCtx, cancel: hostCancel, client: cl, serves: map[string]*serveEntry{}}
716 mgr.ensureServe = func(_ context.Context, _ bootstrap.Conn, opts bootstrap.Options) (bootstrap.Result, error) {
717 addr := "127.0.0.1:9111"
718 if opts.Workspace == "/srv/b" {
719 addr = "127.0.0.1:9112"
720 }
721 return bootstrap.Result{State: bootstrap.ServeState{Addr: addr}, Token: "tok"}, nil
722 }
723 var stopped []string
724 mgr.stopServe = func(_ context.Context, _ bootstrap.Conn, workspace string) error {
725 stopped = append(stopped, workspace)
726 return nil
727 }
728 mgr.localBinary = func() string { return "" }
729 for _, ws := range []string{"/srv/a", "/srv/b"} {
730 if _, _, err := mgr.EnsureServer(context.Background(), "box", ws); err != nil {
731 t.Fatal(err)
732 }
733 }
734
735 if err := mgr.StopServer("box", "/srv/a"); err != nil {
736 t.Fatal(err)
737 }
738 if len(stopped) != 1 || stopped[0] != "/srv/a" {
739 t.Fatalf("stopServe operated on %v, want [/srv/a]", stopped)
740 }
741 forwards := cl.Forwards().List()
742 if len(forwards) != 1 || forwards[0].Spec.Name != serveForwardName("/srv/b") {
743 t.Fatalf("forwards after stop = %+v, want only /srv/b's", forwards)
744 }
745 if got := mgr.ServerStatus("box", "/srv/a"); got.State != "stopped" {
746 t.Fatalf("status A after stop = %+v", got)
747 }
748 if got := mgr.ServerStatus("box", "/srv/b"); got.State != "ready" {
749 t.Fatalf("status B after stopping A = %+v, want ready", got)
750 }
751 }
752
752 lines GO