| 1 | package bootstrap |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "runtime" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "golang.org/x/crypto/ssh" |
| 15 | |
| 16 | "reasonix/internal/remote" |
| 17 | "reasonix/internal/remote/sftpfs" |
| 18 | "reasonix/internal/remote/sshtest" |
| 19 | ) |
| 20 | |
| 21 | // fakeConn scripts exec responses and shares a real sftpfs.FS backed by an |
| 22 | // sshtest SFTP server rooted at a temp dir. The temp dir stands in for the |
| 23 | // remote home, so ~ resolves to it. |
| 24 | type fakeConn struct { |
| 25 | fs *sftpfs.FS |
| 26 | sftpErr error |
| 27 | mu sync.Mutex |
| 28 | execs []string |
| 29 | handler func(cmd string) (remote.ExecResult, error) |
| 30 | } |
| 31 | |
| 32 | func (f *fakeConn) Exec(_ context.Context, cmd string) (remote.ExecResult, error) { |
| 33 | f.mu.Lock() |
| 34 | f.execs = append(f.execs, cmd) |
| 35 | f.mu.Unlock() |
| 36 | return f.handler(cmd) |
| 37 | } |
| 38 | |
| 39 | func (f *fakeConn) SFTP() (*sftpfs.FS, error) { |
| 40 | if f.sftpErr != nil { |
| 41 | return nil, f.sftpErr |
| 42 | } |
| 43 | return f.fs, nil |
| 44 | } |
| 45 | |
| 46 | func (f *fakeConn) ranContaining(sub string) bool { |
| 47 | f.mu.Lock() |
| 48 | defer f.mu.Unlock() |
| 49 | for _, c := range f.execs { |
| 50 | if strings.Contains(c, sub) { |
| 51 | return true |
| 52 | } |
| 53 | } |
| 54 | return false |
| 55 | } |
| 56 | |
| 57 | // skipOnWindows guards the EnsureServe integration tests. They model a POSIX |
| 58 | // remote — pathsFor uses path.Join and the slug maps a POSIX home, while the |
| 59 | // SFTP harness serves the local FS. On Windows the temp-dir "remote home" is a |
| 60 | // drive path, so both the test's own pathsFor pre-writes and the harness break. |
| 61 | // This is a harness limitation, not a product one (V1 remotes are Linux/macOS); |
| 62 | // Linux/macOS CI covers these flows. Call it first thing in each such test, |
| 63 | // before any pathsFor/os setup. |
| 64 | func skipOnWindows(t *testing.T) { |
| 65 | t.Helper() |
| 66 | if runtime.GOOS == "windows" { |
| 67 | t.Skip("EnsureServe harness models a POSIX remote; exercised on Linux/macOS") |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func newFakeConn(t *testing.T, root string, handler func(cmd string) (remote.ExecResult, error)) *fakeConn { |
| 72 | t.Helper() |
| 73 | skipOnWindows(t) |
| 74 | srv := sshtest.Start(t, sshtest.Options{SFTPRoot: root}) |
| 75 | cfg := &ssh.ClientConfig{User: "t", HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second} |
| 76 | cl, err := ssh.Dial("tcp", srv.Addr, cfg) |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | t.Cleanup(func() { cl.Close() }) |
| 81 | fs, err := sftpfs.New(cl) |
| 82 | if err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | t.Cleanup(func() { fs.Close() }) |
| 86 | return &fakeConn{fs: fs, handler: handler} |
| 87 | } |
| 88 | |
| 89 | func ok(stdout string) (remote.ExecResult, error) { |
| 90 | return remote.ExecResult{Stdout: []byte(stdout)}, nil |
| 91 | } |
| 92 | |
| 93 | // TestEnsureServeLaunchesWhenAbsent drives a full cold start: no prior state, |
| 94 | // reasonix already on PATH, serve writes its port file. |
| 95 | func TestEnsureServeLaunchesWhenAbsent(t *testing.T) { |
| 96 | skipOnWindows(t) |
| 97 | root := t.TempDir() |
| 98 | var portFile string |
| 99 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 100 | switch { |
| 101 | case strings.Contains(cmd, "uname"): |
| 102 | return ok("Linux x86_64\n") |
| 103 | case strings.Contains(cmd, "command -v reasonix"): |
| 104 | // LocateCommand: report a path and a fresh version. |
| 105 | return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\nsessionevents:yes\ndetachedheal:yes\ncaps:yes\n") |
| 106 | case strings.Contains(cmd, "nohup"): |
| 107 | // Simulate serve writing the port file, then echo the pid. |
| 108 | if portFile != "" { |
| 109 | _ = os.WriteFile(portFile, []byte("127.0.0.1:44321\n"), 0o600) |
| 110 | } |
| 111 | return ok("54321\n") |
| 112 | case strings.Contains(cmd, "ps -p 54321"): |
| 113 | return ok("1\n") |
| 114 | default: |
| 115 | return ok("") |
| 116 | } |
| 117 | }) |
| 118 | // Discover the port-file path the bootstrap will use so the fake serve can |
| 119 | // write it. |
| 120 | paths := pathsFor(root, root) |
| 121 | portFile = paths.PortFile |
| 122 | |
| 123 | res, err := EnsureServe(context.Background(), conn, Options{ |
| 124 | Workspace: "~", |
| 125 | MinVersion: "1.0.0", |
| 126 | Clock: time.Now, |
| 127 | }) |
| 128 | if err != nil { |
| 129 | t.Fatalf("EnsureServe: %v", err) |
| 130 | } |
| 131 | if res.Reused { |
| 132 | t.Fatal("cold start should not report reuse") |
| 133 | } |
| 134 | if res.State.Addr != "127.0.0.1:44321" || res.State.PID != 54321 { |
| 135 | t.Fatalf("state wrong: %+v", res.State) |
| 136 | } |
| 137 | if res.Token == "" { |
| 138 | t.Fatal("no token generated") |
| 139 | } |
| 140 | // Token file written 0600. |
| 141 | fi, err := os.Stat(paths.TokenFile) |
| 142 | if err != nil { |
| 143 | t.Fatalf("token file missing: %v", err) |
| 144 | } |
| 145 | if fi.Mode().Perm() != 0o600 { |
| 146 | t.Fatalf("token perm = %v, want 0600", fi.Mode().Perm()) |
| 147 | } |
| 148 | // State file persisted and reloadable. |
| 149 | data, err := os.ReadFile(paths.StateJSON) |
| 150 | if err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | st, err := UnmarshalState(data) |
| 154 | if err != nil || st.Addr != "127.0.0.1:44321" { |
| 155 | t.Fatalf("persisted state wrong: %+v (%v)", st, err) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // TestEnsureServeReusesLiveProcess: a recorded, alive pid short-circuits to |
| 160 | // reuse without detecting/launching. |
| 161 | func TestEnsureServeReusesLiveProcess(t *testing.T) { |
| 162 | skipOnWindows(t) |
| 163 | root := t.TempDir() |
| 164 | paths := pathsFor(root, root) |
| 165 | // Pre-write state + token as if a serve is already running. |
| 166 | if err := os.MkdirAll(paths.Dir, 0o755); err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | st := ServeState{PID: 777, Addr: "127.0.0.1:5000", Workspace: root, ServeCaps: ServeCapsToken, TokenFile: paths.TokenFile} |
| 170 | data, _ := MarshalState(st) |
| 171 | if err := os.WriteFile(paths.StateJSON, data, 0o600); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | if err := os.WriteFile(paths.TokenFile, []byte("existing-token\n"), 0o600); err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | |
| 178 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 179 | if strings.Contains(cmd, "kill -0 777") { |
| 180 | return ok("1\n") // alive |
| 181 | } |
| 182 | if strings.Contains(cmd, "readlink /proc/777/exe") { |
| 183 | t.Fatal("managed capability token should avoid re-executing the live image") |
| 184 | } |
| 185 | if strings.Contains(cmd, "uname") || strings.Contains(cmd, "nohup") { |
| 186 | t.Errorf("reuse path should not detect/launch; ran: %s", cmd) |
| 187 | } |
| 188 | return ok("") |
| 189 | }) |
| 190 | |
| 191 | res, err := EnsureServe(context.Background(), conn, Options{Workspace: "~"}) |
| 192 | if err != nil { |
| 193 | t.Fatalf("EnsureServe: %v", err) |
| 194 | } |
| 195 | if !res.Reused { |
| 196 | t.Fatal("expected reuse of live process") |
| 197 | } |
| 198 | if res.Token != "existing-token" { |
| 199 | t.Fatalf("token = %q, want existing-token", res.Token) |
| 200 | } |
| 201 | if conn.ranContaining("nohup") { |
| 202 | t.Fatal("reuse path launched a new serve") |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | // TestEnsureServeRelaunchesDeadProcess: a recorded but dead pid triggers a |
| 207 | // fresh launch. |
| 208 | func TestEnsureServeRelaunchesDeadProcess(t *testing.T) { |
| 209 | skipOnWindows(t) |
| 210 | root := t.TempDir() |
| 211 | paths := pathsFor(root, root) |
| 212 | if err := os.MkdirAll(paths.Dir, 0o755); err != nil { |
| 213 | t.Fatal(err) |
| 214 | } |
| 215 | st := ServeState{PID: 888, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile} |
| 216 | data, _ := MarshalState(st) |
| 217 | _ = os.WriteFile(paths.StateJSON, data, 0o600) |
| 218 | _ = os.WriteFile(paths.TokenFile, []byte("stale\n"), 0o600) |
| 219 | |
| 220 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 221 | switch { |
| 222 | case strings.Contains(cmd, "kill -0 888"): |
| 223 | return ok("0\n") // dead |
| 224 | case strings.Contains(cmd, "uname"): |
| 225 | return ok("Linux aarch64\n") |
| 226 | case strings.Contains(cmd, "command -v reasonix"): |
| 227 | return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\nsessionevents:yes\ndetachedheal:yes\ncaps:yes\n") |
| 228 | case strings.Contains(cmd, "nohup"): |
| 229 | _ = os.WriteFile(paths.PortFile, []byte("127.0.0.1:6001\n"), 0o600) |
| 230 | return ok("999\n") |
| 231 | case strings.Contains(cmd, "ps -p 999"): |
| 232 | return ok("1\n") |
| 233 | default: |
| 234 | return ok("") |
| 235 | } |
| 236 | }) |
| 237 | |
| 238 | res, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", MinVersion: "1.0.0"}) |
| 239 | if err != nil { |
| 240 | t.Fatalf("EnsureServe: %v", err) |
| 241 | } |
| 242 | if res.Reused { |
| 243 | t.Fatal("dead process should be relaunched, not reused") |
| 244 | } |
| 245 | if res.State.PID != 999 || res.State.Addr != "127.0.0.1:6001" { |
| 246 | t.Fatalf("relaunched state wrong: %+v", res.State) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // TestEnsureServeInstallNeverErrorsWhenAbsent. |
| 251 | func TestEnsureServeInstallNeverErrorsWhenAbsent(t *testing.T) { |
| 252 | skipOnWindows(t) |
| 253 | root := t.TempDir() |
| 254 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 255 | switch { |
| 256 | case strings.Contains(cmd, "uname"): |
| 257 | return ok("Linux x86_64\n") |
| 258 | case strings.Contains(cmd, "command -v reasonix"): |
| 259 | return ok("\n") // not found anywhere |
| 260 | default: |
| 261 | return ok("") |
| 262 | } |
| 263 | }) |
| 264 | _, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", Install: InstallNever}) |
| 265 | if err == nil || !strings.Contains(err.Error(), "serve_install = never") { |
| 266 | t.Fatalf("expected install-never error, got %v", err) |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | func TestEnsureServeUpgradeFailurePreservesOutdatedProcess(t *testing.T) { |
| 271 | skipOnWindows(t) |
| 272 | root := t.TempDir() |
| 273 | paths := pathsFor(root, root) |
| 274 | if err := os.MkdirAll(paths.Dir, 0o755); err != nil { |
| 275 | t.Fatal(err) |
| 276 | } |
| 277 | state, _ := MarshalState(ServeState{PID: 777, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile}) |
| 278 | _ = os.WriteFile(paths.StateJSON, state, 0o600) |
| 279 | _ = os.WriteFile(paths.TokenFile, []byte("existing-token\n"), 0o600) |
| 280 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 281 | switch { |
| 282 | case strings.Contains(cmd, "kill -TERM 777"): |
| 283 | t.Fatal("outdated Serve was stopped before a replacement was available") |
| 284 | case strings.Contains(cmd, "ps -p 777"): |
| 285 | return ok("1\n") |
| 286 | case strings.Contains(cmd, "readlink /proc/777/exe"): |
| 287 | return ok("no\n") |
| 288 | case strings.Contains(cmd, "uname"): |
| 289 | return ok("Linux x86_64\n") |
| 290 | case strings.Contains(cmd, "command -v reasonix"): |
| 291 | return ok("\n") |
| 292 | } |
| 293 | return ok("") |
| 294 | }) |
| 295 | _, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", Install: InstallNever}) |
| 296 | if err == nil || !strings.Contains(err.Error(), "serve_install = never") { |
| 297 | t.Fatalf("upgrade error = %v, want install-never failure", err) |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | func TestEnsureServeTokenStageFailurePreservesOutdatedProcess(t *testing.T) { |
| 302 | skipOnWindows(t) |
| 303 | root := t.TempDir() |
| 304 | paths := pathsFor(root, root) |
| 305 | if err := os.MkdirAll(paths.Dir, 0o755); err != nil { |
| 306 | t.Fatal(err) |
| 307 | } |
| 308 | state, _ := MarshalState(ServeState{PID: 777, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile}) |
| 309 | if err := os.WriteFile(paths.StateJSON, state, 0o600); err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | if err := os.WriteFile(paths.TokenFile, []byte("existing-token\n"), 0o600); err != nil { |
| 313 | t.Fatal(err) |
| 314 | } |
| 315 | if err := os.Mkdir(paths.TokenFile+".next", 0o700); err != nil { |
| 316 | t.Fatal(err) |
| 317 | } |
| 318 | if err := os.WriteFile(filepath.Join(paths.TokenFile+".next", "block-rename"), []byte("x"), 0o600); err != nil { |
| 319 | t.Fatal(err) |
| 320 | } |
| 321 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 322 | switch { |
| 323 | case strings.Contains(cmd, "kill -TERM 777"): |
| 324 | t.Fatal("outdated Serve was stopped before the token was staged") |
| 325 | case strings.Contains(cmd, "kill -0 777"): |
| 326 | return ok("1\n") |
| 327 | case strings.Contains(cmd, "readlink /proc/777/exe"): |
| 328 | return ok("no\n") |
| 329 | case strings.Contains(cmd, "uname"): |
| 330 | return ok("Linux x86_64\n") |
| 331 | case strings.Contains(cmd, "command -v reasonix"): |
| 332 | return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\nsessionevents:yes\ndetachedheal:yes\ncaps:yes\n") |
| 333 | case strings.Contains(cmd, "nohup"): |
| 334 | t.Fatal("replacement launched after token staging failed") |
| 335 | } |
| 336 | return ok("") |
| 337 | }) |
| 338 | |
| 339 | _, err := EnsureServe(context.Background(), conn, Options{Workspace: "~"}) |
| 340 | if err == nil || !strings.Contains(err.Error(), "stage token") { |
| 341 | t.Fatalf("staging error = %v, want token staging failure", err) |
| 342 | } |
| 343 | token, readErr := os.ReadFile(paths.TokenFile) |
| 344 | if readErr != nil || string(token) != "existing-token\n" { |
| 345 | t.Fatalf("token after staging failure = %q, %v", token, readErr) |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | func TestEnsureServeRetirementFailurePreservesExistingToken(t *testing.T) { |
| 350 | skipOnWindows(t) |
| 351 | root := t.TempDir() |
| 352 | paths := pathsFor(root, root) |
| 353 | if err := os.MkdirAll(paths.Dir, 0o755); err != nil { |
| 354 | t.Fatal(err) |
| 355 | } |
| 356 | state, _ := MarshalState(ServeState{PID: 777, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile}) |
| 357 | if err := os.WriteFile(paths.StateJSON, state, 0o600); err != nil { |
| 358 | t.Fatal(err) |
| 359 | } |
| 360 | if err := os.WriteFile(paths.TokenFile, []byte("existing-token\n"), 0o600); err != nil { |
| 361 | t.Fatal(err) |
| 362 | } |
| 363 | |
| 364 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 365 | switch { |
| 366 | case strings.Contains(cmd, "kill -TERM 777"): |
| 367 | return remote.ExecResult{}, errors.New("temporary SSH failure") |
| 368 | case strings.Contains(cmd, "kill -0 777"): |
| 369 | return ok("1\n") |
| 370 | case strings.Contains(cmd, "readlink /proc/777/exe"): |
| 371 | return ok("no\n") |
| 372 | case strings.Contains(cmd, "uname"): |
| 373 | return ok("Linux x86_64\n") |
| 374 | case strings.Contains(cmd, "command -v reasonix"): |
| 375 | return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\nsessionevents:yes\ndetachedheal:yes\ncaps:yes\n") |
| 376 | case strings.Contains(cmd, "nohup"): |
| 377 | t.Fatal("replacement launched after retirement failed") |
| 378 | } |
| 379 | return ok("") |
| 380 | }) |
| 381 | |
| 382 | _, err := EnsureServe(context.Background(), conn, Options{Workspace: "~"}) |
| 383 | if err == nil || !strings.Contains(err.Error(), "stop outdated serve") { |
| 384 | t.Fatalf("retirement error = %v, want stop failure", err) |
| 385 | } |
| 386 | token, readErr := os.ReadFile(paths.TokenFile) |
| 387 | if readErr != nil { |
| 388 | t.Fatal(readErr) |
| 389 | } |
| 390 | if got := string(token); got != "existing-token\n" { |
| 391 | t.Fatalf("token after failed retirement = %q, want existing token", got) |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | func TestEnsureServeDarwinRetiresOldPIDAfterBinaryReplacement(t *testing.T) { |
| 396 | skipOnWindows(t) |
| 397 | root := t.TempDir() |
| 398 | paths := pathsFor(root, root) |
| 399 | if err := os.MkdirAll(paths.Dir, 0o755); err != nil { |
| 400 | t.Fatal(err) |
| 401 | } |
| 402 | state, _ := MarshalState(ServeState{PID: 777, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile}) |
| 403 | if err := os.WriteFile(paths.StateJSON, state, 0o600); err != nil { |
| 404 | t.Fatal(err) |
| 405 | } |
| 406 | if err := os.WriteFile(paths.TokenFile, []byte("existing-token\n"), 0o600); err != nil { |
| 407 | t.Fatal(err) |
| 408 | } |
| 409 | local := filepath.Join(root, "local-reasonix") |
| 410 | if err := os.WriteFile(local, []byte("fresh-darwin-cli"), 0o755); err != nil { |
| 411 | t.Fatal(err) |
| 412 | } |
| 413 | uploaded := uploadedBinPath(root) |
| 414 | stopped := false |
| 415 | capabilityProbes := 0 |
| 416 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 417 | switch { |
| 418 | case strings.Contains(cmd, "kill -TERM 777"): |
| 419 | stopped = true |
| 420 | return ok("") |
| 421 | case strings.Contains(cmd, "kill -0 777"): |
| 422 | return ok("1\n") |
| 423 | case strings.Contains(cmd, "readlink /proc/777/exe"): |
| 424 | capabilityProbes++ |
| 425 | if strings.Contains(cmd, "ps -p 777") { |
| 426 | t.Fatal("Darwin capability probe fell back to the replaced pathname") |
| 427 | } |
| 428 | return ok("no\n") |
| 429 | case strings.Contains(cmd, "uname"): |
| 430 | return ok("Darwin arm64\n") |
| 431 | case strings.Contains(cmd, "BIN=; if [ -x "+shellQuote(uploaded)): |
| 432 | return ok(uploaded + "\nreasonix v9.9.0\nportfile:yes\nsessionevents:yes\ndetachedheal:yes\ncaps:yes\n") |
| 433 | case strings.Contains(cmd, "command -v reasonix"): |
| 434 | return ok(uploaded + "\nreasonix v1.0.0\nportfile:yes\nsessionevents:no\ndetachedheal:no\ncaps:no\n") |
| 435 | case strings.Contains(cmd, "nohup"): |
| 436 | _ = os.WriteFile(paths.PortFile, []byte("127.0.0.1:6002\n"), 0o600) |
| 437 | return ok("999\n") |
| 438 | case strings.Contains(cmd, "kill -0 999"): |
| 439 | return ok("1\n") |
| 440 | default: |
| 441 | return ok("") |
| 442 | } |
| 443 | }) |
| 444 | |
| 445 | res, err := EnsureServe(context.Background(), conn, Options{ |
| 446 | Workspace: "~", Install: InstallUpload, LocalBinary: local, LocalGOOS: "darwin", LocalGOARCH: "arm64", |
| 447 | }) |
| 448 | if err != nil { |
| 449 | t.Fatal(err) |
| 450 | } |
| 451 | if res.Reused || !stopped || capabilityProbes < 2 || res.State.PID != 999 { |
| 452 | t.Fatalf("darwin replacement result = reused:%v stopped:%v probes:%d state:%+v", res.Reused, stopped, capabilityProbes, res.State) |
| 453 | } |
| 454 | if res.State.ServeCaps != ServeCapsToken { |
| 455 | t.Fatalf("launched state caps = %q, want %q", res.State.ServeCaps, ServeCapsToken) |
| 456 | } |
| 457 | } |
| 458 | |
| 459 | func TestStopRemovesStateFiles(t *testing.T) { |
| 460 | skipOnWindows(t) |
| 461 | root := t.TempDir() |
| 462 | paths := pathsFor(root, root) |
| 463 | _ = os.MkdirAll(paths.Dir, 0o755) |
| 464 | st := ServeState{PID: 555, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile} |
| 465 | data, _ := MarshalState(st) |
| 466 | _ = os.WriteFile(paths.StateJSON, data, 0o600) |
| 467 | _ = os.WriteFile(paths.TokenFile, []byte("tok\n"), 0o600) |
| 468 | |
| 469 | stopped := false |
| 470 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 471 | // Order matters: StopCommand also contains "kill -0 555" in its wait |
| 472 | // loop, so match the TERM (the stop signal) before the serve-alive probe. |
| 473 | if strings.Contains(cmd, "kill -TERM 555") { |
| 474 | stopped = true |
| 475 | return ok("") |
| 476 | } |
| 477 | // Stop verifies the pid is our serve (ServeAliveCommand) before signalling. |
| 478 | if strings.Contains(cmd, "ps -p 555") { |
| 479 | return ok("1\n") |
| 480 | } |
| 481 | return ok("") |
| 482 | }) |
| 483 | if err := Stop(context.Background(), conn, "~"); err != nil { |
| 484 | t.Fatalf("Stop: %v", err) |
| 485 | } |
| 486 | if !stopped { |
| 487 | t.Error("Stop did not TERM the pid") |
| 488 | } |
| 489 | if _, err := os.Stat(paths.StateJSON); !os.IsNotExist(err) { |
| 490 | t.Error("state file not removed") |
| 491 | } |
| 492 | if _, err := os.Stat(paths.TokenFile); !os.IsNotExist(err) { |
| 493 | t.Error("token file not removed") |
| 494 | } |
| 495 | } |
| 496 | |
| 497 | var _ = filepath.Join |
| 498 |