Files
DeepSeek-Reasonix/desktop/remote_lifecycle_test.go
T
YHH 4fbbca6a99 refactor(go): adopt modern stdlib idioms and gate them (#7831)
* refactor(go): adopt modern stdlib idioms across kernel and desktop

Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/
usestdlibvars fixers plus hand-fixes for what they could not rewrite:
slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor,
maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int.
Net -549 lines with no behavior change.

Three loops in session.go stay index-based: slices.Backward yields element
copies, so the fixer's rewrite silently dropped writes to DecisionReceipts
and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49
slices.Backward sites were audited for that shape; only these three write
through the index, and they carry a nolint with the reason.

CaptureAfter's scan loop had an empty body and never ran any logic, so the
fixer could not express it. Drop it and the accessor it was the only caller
of. omitzero is deliberately untouched: turning omitempty into omitzero
changes the JSON written for nested structs, which is a wire-format change
to session files and the extension protocol.

* build(lint): gate the modern-idiom linters

Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both
modules are now clean for modernize, intrange, copyloopvar, and
usestdlibvars, so enable them rather than leaving the sweep as a one-off.

omitzero stays disabled with the reason inline; usetesting, perfsprint, and
errorlint still have residue and land separately.
2026-08-07 15:30:33 +08:00

524 lines
17 KiB
Go

package main
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/remote"
"reasonix/internal/remote/bootstrap"
"reasonix/internal/remote/forward"
"reasonix/internal/remote/sftpfs"
)
type lifecycleSSHClient struct {
mu sync.Mutex
startErr error
closed bool
sub func(remote.StatusEvent)
forwards *forward.Set
}
type lifecycleEventSink struct {
statuses chan RemoteConnectionStatusView
}
func (s *lifecycleEventSink) onStatus(v RemoteConnectionStatusView) { s.statuses <- v }
func (*lifecycleEventSink) onForwards(string, []RemoteForwardView) {}
func (*lifecycleEventSink) onServer(RemoteServerView) {}
func newLifecycleSSHClient(startErr error) *lifecycleSSHClient {
return &lifecycleSSHClient{startErr: startErr, forwards: forward.NewSet(nil)}
}
func TestDesktopSecretPromptPublishesMetadataAndReturnsOneShotSecret(t *testing.T) {
sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 2)}
mgr := newDesktopRemoteManager(sink)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
generation := &managedHost{ctx: ctx, cancel: cancel, status: RemoteConnectionStatusView{HostID: "box", State: "connecting"}}
mgr.hosts["box"] = generation
type promptResult struct {
secret string
err error
}
result := make(chan promptResult, 1)
go func() {
secret, err := mgr.secretPrompt("box", generation)(ctx, remote.SecretPassword, "[email protected]", "")
result <- promptResult{secret: secret, err: err}
}()
var promptID string
select {
case status := <-sink.statuses:
if status.State != "pending_secret" || status.SecretPrompt == nil {
t.Fatalf("status = %+v", status)
}
if status.SecretPrompt.Host != "[email protected]" || status.SecretPrompt.Kind != "password" {
t.Fatalf("prompt metadata = %+v", status.SecretPrompt)
}
promptID = status.SecretPrompt.PromptID
if promptID == "" {
t.Fatal("prompt ID was empty")
}
case <-time.After(2 * time.Second):
t.Fatal("secret prompt status was not emitted")
}
if err := mgr.ResolveSecret("box", "stale-prompt", "wrong-secret", true); err == nil {
t.Fatal("stale prompt ID resolved the active credential request")
}
if err := mgr.ResolveSecret("box", promptID, "one-shot-secret", true); err != nil {
t.Fatal(err)
}
select {
case got := <-result:
if got.err != nil || got.secret != "one-shot-secret" {
t.Fatalf("prompt result = %+v", got)
}
case <-time.After(2 * time.Second):
t.Fatal("secret prompt did not resolve")
}
}
func (c *lifecycleSSHClient) Start(context.Context) error {
c.mu.Lock()
sub, err := c.sub, c.startErr
c.mu.Unlock()
if sub != nil {
if err != nil {
sub(remote.StatusEvent{Status: remote.StatusStopped, Err: err})
} else {
sub(remote.StatusEvent{Status: remote.StatusConnected})
}
}
return err
}
func (c *lifecycleSSHClient) Close() error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
c.mu.Unlock()
c.forwards.Close()
return nil
}
func (c *lifecycleSSHClient) Subscribe(fn func(remote.StatusEvent)) func() {
c.mu.Lock()
c.sub = fn
c.mu.Unlock()
fn(remote.StatusEvent{Status: remote.StatusIdle})
return func() {}
}
func (c *lifecycleSSHClient) Forwards() *forward.Set { return c.forwards }
func (c *lifecycleSSHClient) Exec(context.Context, string) (remote.ExecResult, error) {
return remote.ExecResult{}, nil
}
func (c *lifecycleSSHClient) SFTP() (*sftpfs.FS, error) { return nil, errors.New("unused") }
func seedLifecycleHost(t *testing.T, hostID string) {
t.Helper()
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("HOME", home)
if err := editUserConfig(func(c *config.Config) error {
return c.UpsertRemoteHost(config.RemoteHostEntry{Name: hostID, Host: "127.0.0.1", Port: 22, User: "tester"})
}); err != nil {
t.Fatal(err)
}
}
func TestConnectCanReplaceStoppedGeneration(t *testing.T) {
seedLifecycleHost(t, "box")
mgr := newDesktopRemoteManager(nil)
first := newLifecycleSSHClient(errors.New("first dial failed"))
second := newLifecycleSSHClient(nil)
var calls int
mgr.newClient = func(remote.Options) (desktopSSHClient, error) {
calls++
if calls == 1 {
return first, nil
}
return second, nil
}
if err := mgr.Connect("box"); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for {
statuses := mgr.Statuses()
if len(statuses) == 1 && statuses[0].State == "stopped" {
break
}
if time.Now().After(deadline) {
t.Fatalf("first generation did not stop: %+v", statuses)
}
time.Sleep(time.Millisecond)
}
if err := mgr.Connect("box"); err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Fatalf("newClient calls = %d, want 2", calls)
}
first.mu.Lock()
firstClosed := first.closed
first.mu.Unlock()
if !firstClosed {
t.Fatal("replaced stopped client was not closed")
}
}
func TestStaleClientStatusCannotOverwriteReplacement(t *testing.T) {
mgr := newDesktopRemoteManager(nil)
oldCtx, oldCancel := context.WithCancel(context.Background())
defer oldCancel()
newCtx, newCancel := context.WithCancel(context.Background())
defer newCancel()
old := &managedHost{ctx: oldCtx, cancel: oldCancel, client: newLifecycleSSHClient(nil)}
current := &managedHost{
ctx: newCtx, cancel: newCancel, client: newLifecycleSSHClient(nil),
status: RemoteConnectionStatusView{HostID: "box", State: "connected"},
}
mgr.hosts["box"] = current
mgr.onClientStatus("box", old, remote.StatusEvent{Status: remote.StatusStopped, Err: errors.New("late")})
if got := mgr.Statuses()[0]; got.State != "connected" || got.Error != "" {
t.Fatalf("replacement status was overwritten: %+v", got)
}
}
func TestServerLogsCancellationOnDisconnect(t *testing.T) {
sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 1)}
mgr := newDesktopRemoteManager(sink)
hostCtx, hostCancel := context.WithCancel(context.Background())
mh := &managedHost{
ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil),
server: RemoteServerView{HostID: "box", Workspace: "/work", State: "ready"},
}
mgr.hosts["box"] = mh
entered := make(chan struct{})
mgr.serveLogs = func(ctx context.Context, _ bootstrap.Conn, _ string, _ int, _ *strings.Builder) error {
close(entered)
<-ctx.Done()
return ctx.Err()
}
done := make(chan error, 1)
go func() {
_, err := mgr.ServerLogs(context.Background(), "box", 20)
done <- err
}()
<-entered
if err := mgr.Disconnect("box"); err != nil {
t.Fatal(err)
}
select {
case status := <-sink.statuses:
if status.HostID != "box" || status.State != "stopped" {
t.Fatalf("Disconnect status = %+v", status)
}
default:
t.Fatal("Disconnect did not publish a stopped status")
}
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("ServerLogs error = %v, want context canceled", err)
}
case <-time.After(2 * time.Second):
t.Fatal("ServerLogs was not canceled by Disconnect")
}
}
func TestEnsureServerResultCannotMutateReplacement(t *testing.T) {
seedLifecycleHost(t, "box")
mgr := newDesktopRemoteManager(nil)
hostCtx, hostCancel := context.WithCancel(context.Background())
old := &managedHost{ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil)}
mgr.hosts["box"] = old
entered := make(chan struct{})
release := make(chan struct{})
mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) {
close(entered)
<-release
return bootstrap.Result{State: bootstrap.ServeState{Addr: "127.0.0.1:9999"}, Token: "old-token"}, nil
}
mgr.localBinary = func() string { return "" }
done := make(chan error, 1)
go func() {
_, _, err := mgr.EnsureServer(context.Background(), "box", "/old")
done <- err
}()
<-entered
if err := mgr.Disconnect("box"); err != nil {
t.Fatal(err)
}
newCtx, newCancel := context.WithCancel(context.Background())
defer newCancel()
replacement := &managedHost{
ctx: newCtx, cancel: newCancel, client: newLifecycleSSHClient(nil),
server: RemoteServerView{HostID: "box", Workspace: "/new", State: "ready"}, token: "new-token",
}
mgr.mu.Lock()
mgr.hosts["box"] = replacement
mgr.mu.Unlock()
close(release)
if err := <-done; err == nil {
t.Fatal("stale EnsureServer unexpectedly succeeded")
}
if got := mgr.ServerStatus("box"); got.Workspace != "/new" || got.State != "ready" {
t.Fatalf("replacement server state was overwritten: %+v", got)
}
if replacement.token != "new-token" {
t.Fatalf("replacement token = %q, want new-token", replacement.token)
}
}
func TestStopServerRejectsEmptyWorkspace(t *testing.T) {
mgr := newDesktopRemoteManager(nil)
hostCtx, hostCancel := context.WithCancel(context.Background())
defer hostCancel()
mgr.hosts["box"] = &managedHost{ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil)}
called := false
mgr.stopServe = func(context.Context, bootstrap.Conn, string) error { called = true; return nil }
if err := mgr.StopServer("box"); err == nil {
t.Fatal("StopServer accepted an empty workspace")
}
if called {
t.Fatal("StopServer called bootstrap.Stop with an empty workspace")
}
}
func TestDesktopCLIBinaryPathFallsBackToPATH(t *testing.T) {
dir := t.TempDir()
_, name := desktopCLIBinaryNames(runtime.GOOS)
cli := filepath.Join(dir, name)
if err := os.WriteFile(cli, []byte("test"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
if got := desktopCLIBinaryPath(); got != cli {
t.Fatalf("desktopCLIBinaryPath = %q, want %q", got, cli)
}
}
func TestDesktopCLIBinaryNamesAvoidWindowsPortableEntryCollision(t *testing.T) {
packaged, command := desktopCLIBinaryNames("windows")
if packaged != "reasonix-cli.exe" || command != "reasonix.exe" {
t.Fatalf("Windows CLI names = (%q, %q)", packaged, command)
}
if strings.EqualFold(packaged, "Reasonix.exe") {
t.Fatalf("packaged CLI %q collides with the desktop entry point", packaged)
}
if packaged, command := desktopCLIBinaryNames("linux"); packaged != "reasonix" || command != "reasonix" {
t.Fatalf("Linux CLI names = (%q, %q)", packaged, command)
}
}
func TestHasUsableServeForwardRequiresExactTargetAndURL(t *testing.T) {
entries := []forward.Entry{{
Spec: forward.Spec{Name: serveForwardName, TargetAddr: "127.0.0.1:9000"},
Up: true, BoundAddr: "127.0.0.1:45000",
}}
if !hasUsableServeForward(entries, "127.0.0.1:9000", "http://127.0.0.1:45000/") {
t.Fatal("exact existing serve forward was not reusable")
}
if hasUsableServeForward(entries, "127.0.0.1:9001", "http://127.0.0.1:45000/") {
t.Fatal("stale serve target was reused")
}
if hasUsableServeForward(entries, "127.0.0.1:9000", "http://127.0.0.1:45001/") {
t.Fatal("mismatched local URL was reused")
}
}
func TestDesktopNormalizeBind(t *testing.T) {
if got := desktopNormalizeBind("8080"); got != "127.0.0.1:8080" {
t.Fatalf("desktopNormalizeBind bare port = %q", got)
}
if got := desktopNormalizeBind("0.0.0.0:8080"); got != "0.0.0.0:8080" {
t.Fatalf("desktopNormalizeBind address = %q", got)
}
}
func TestHostKeyPromptsAreSerializedForGlobalDialog(t *testing.T) {
sink := &lifecycleEventSink{statuses: make(chan RemoteConnectionStatusView, 2)}
mgr := newDesktopRemoteManager(sink)
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
t.Cleanup(func() {
cancel()
wg.Wait()
})
type pendingPrompt struct {
hostID string
prompt remote.HostKeyPrompt
}
prompts := make([]pendingPrompt, 0, 2)
for _, hostID := range []string{"a", "b"} {
mh := &managedHost{ctx: ctx, cancel: cancel, client: newLifecycleSSHClient(nil)}
mgr.hosts[hostID] = mh
prompts = append(prompts, pendingPrompt{hostID: hostID, prompt: mgr.hostKeyPrompt(hostID, mh)})
}
for _, pending := range prompts {
wg.Go(func() {
_, _ = pending.prompt(ctx, remote.HostKeyQuestion{
Address: pending.hostID + ":22",
KeyType: "ssh-ed25519",
Fingerprint: pending.hostID,
})
})
}
first := <-sink.statuses
select {
case second := <-sink.statuses:
t.Fatalf("second prompt %q replaced unresolved prompt %q", second.HostID, first.HostID)
case <-time.After(50 * time.Millisecond):
}
if err := mgr.ResolveHostKey(first.HostID, true); err != nil {
t.Fatal(err)
}
select {
case second := <-sink.statuses:
if second.HostID == first.HostID {
t.Fatalf("serialized prompt repeated host %q", second.HostID)
}
if err := mgr.ResolveHostKey(second.HostID, false); err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("second prompt did not appear after resolving the first")
}
}
// TestEnsureServerFailureKeepsOwnershipOnPreviousReadyServe is the failed-
// switch atomicity contract: when the new workspace's Serve fails to start,
// the host's server ownership stays on the still-running previous Serve, so
// Stop and Logs keep operating on the workspace that actually runs.
func TestEnsureServerFailureKeepsOwnershipOnPreviousReadyServe(t *testing.T) {
seedLifecycleHost(t, "box")
mgr := newDesktopRemoteManager(nil)
hostCtx, hostCancel := context.WithCancel(context.Background())
defer hostCancel()
client := newLifecycleSSHClient(nil)
mgr.hosts["box"] = &managedHost{
ctx: hostCtx, cancel: hostCancel, client: client,
server: RemoteServerView{HostID: "box", Workspace: "/srv/a", State: "ready", LocalURL: "http://127.0.0.1:54321/"},
token: "token-a",
}
mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) {
return bootstrap.Result{}, errors.New("serve launch failed")
}
var stopped, logged []string
mgr.stopServe = func(_ context.Context, _ bootstrap.Conn, workspace string) error {
stopped = append(stopped, workspace)
return nil
}
mgr.serveLogs = func(_ context.Context, _ bootstrap.Conn, workspace string, _ int, _ *strings.Builder) error {
logged = append(logged, workspace)
return nil
}
if _, _, err := mgr.EnsureServer(context.Background(), "box", "/srv/b"); err == nil {
t.Fatal("expected the serve launch failure")
}
status := mgr.ServerStatus("box")
if status.State != "ready" || status.Workspace != "/srv/a" {
t.Fatalf("server state after failed switch = %+v, want the previous ready /srv/a", status)
}
if got := mgr.hosts["box"].token; got != "token-a" {
t.Fatalf("token after failed switch = %q, want the previous token", got)
}
if err := mgr.StopServer("box"); err != nil {
t.Fatal(err)
}
if len(stopped) != 1 || stopped[0] != "/srv/a" {
t.Fatalf("StopServer operated on %v, want the previous /srv/a", stopped)
}
if _, err := mgr.ServerLogs(context.Background(), "box", 50); err != nil {
t.Fatal(err)
}
if len(logged) != 1 || logged[0] != "/srv/a" {
t.Fatalf("ServerLogs operated on %v, want the previous /srv/a", logged)
}
}
// TestEnsureServerReplaceFailureKeepsOwnershipOnPreviousReadyServe covers the
// same contract when the new Serve started but its loopback tunnel could not
// be bound: the just-started Serve is stopped, ownership returns to the
// previous ready Serve, and the failed view never replaces it.
func TestEnsureServerReplaceFailureKeepsOwnershipOnPreviousReadyServe(t *testing.T) {
seedLifecycleHost(t, "box")
mgr := newDesktopRemoteManager(nil)
hostCtx, hostCancel := context.WithCancel(context.Background())
defer hostCancel()
client := newLifecycleSSHClient(nil)
// A closed forward set makes the tunnel Replace fail deterministically;
// the Set semantics keep any previous forward live on a failed Replace.
closedForwards := forward.NewSet(nil)
closedForwards.Close()
client.forwards = closedForwards
mgr.hosts["box"] = &managedHost{
ctx: hostCtx, cancel: hostCancel, client: client,
server: RemoteServerView{HostID: "box", Workspace: "/srv/a", State: "ready", LocalURL: "http://127.0.0.1:54321/"},
token: "token-a",
}
mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) {
return bootstrap.Result{State: bootstrap.ServeState{Addr: "127.0.0.1:9999"}}, nil
}
var stopped []string
mgr.stopServe = func(_ context.Context, _ bootstrap.Conn, workspace string) error {
stopped = append(stopped, workspace)
return nil
}
if _, _, err := mgr.EnsureServer(context.Background(), "box", "/srv/b"); err == nil {
t.Fatal("expected the tunnel Replace failure")
}
// The newly started /srv/b Serve was cleaned up, not left orphaned.
if len(stopped) != 1 || stopped[0] != "/srv/b" {
t.Fatalf("cleanup stopped %v, want the failed /srv/b", stopped)
}
status := mgr.ServerStatus("box")
if status.State != "ready" || status.Workspace != "/srv/a" {
t.Fatalf("server state after failed tunnel switch = %+v, want the previous ready /srv/a", status)
}
if got := mgr.hosts["box"].token; got != "token-a" {
t.Fatalf("token after failed tunnel switch = %q, want the previous token", got)
}
}
// TestEnsureServerFirstStartFailurePublishesError keeps the informative error
// view when there is no previous ready Serve to preserve.
func TestEnsureServerFirstStartFailurePublishesError(t *testing.T) {
seedLifecycleHost(t, "box")
mgr := newDesktopRemoteManager(nil)
hostCtx, hostCancel := context.WithCancel(context.Background())
defer hostCancel()
mgr.hosts["box"] = &managedHost{ctx: hostCtx, cancel: hostCancel, client: newLifecycleSSHClient(nil)}
mgr.ensureServe = func(context.Context, bootstrap.Conn, bootstrap.Options) (bootstrap.Result, error) {
return bootstrap.Result{}, errors.New("serve launch failed")
}
if _, _, err := mgr.EnsureServer(context.Background(), "box", "/srv/b"); err == nil {
t.Fatal("expected the serve launch failure")
}
status := mgr.ServerStatus("box")
if status.State != "error" || status.Workspace != "/srv/b" {
t.Fatalf("server state after first-start failure = %+v, want error /srv/b", status)
}
}