返回 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 )
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
526 lines GO