| 1 | package boot |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "io" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "strings" |
| 13 | "sync" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "reasonix/internal/config" |
| 18 | "reasonix/internal/extension" |
| 19 | "reasonix/internal/extension/protocol" |
| 20 | "reasonix/internal/extension/sidecar" |
| 21 | "reasonix/internal/pluginpkg" |
| 22 | ) |
| 23 | |
| 24 | // Boot-level fake sidecar (re-exec helper-process pattern, mirroring the |
| 25 | // sidecar package's own tests): the boot test binary re-executes itself with |
| 26 | // REASONIX_BOOT_FAKE_SIDECAR=1 and speaks Extension Protocol v1 over |
| 27 | // stdin/stdout. REASONIX_BOOT_FAKE_INIT_RESULT overrides the initialize |
| 28 | // result; REASONIX_BOOT_FAKE_MODE=ignore_shutdown keeps the process alive |
| 29 | // through extension/shutdown. Intercept steering for the dispatch tests: |
| 30 | // |
| 31 | // REASONIX_BOOT_FAKE_BLOCK_EVENT answer block at this event |
| 32 | // REASONIX_BOOT_FAKE_INVALID_EVENT answer a DTO-violating replace at this event |
| 33 | // REASONIX_BOOT_FAKE_REPLACE_PROMPT answer system_prompt.build replace with this prompt |
| 34 | // REASONIX_BOOT_FAKE_REPLACE_INPUT answer input.receive replace with this text |
| 35 | // REASONIX_BOOT_FAKE_EVENT_LOG append one "event payload" line per extension/event |
| 36 | // |
| 37 | // Provider steering for the stage 7 adapter tests: |
| 38 | // |
| 39 | // REASONIX_BOOT_FAKE_PLUGIN_NAME the installed plugin name (provider ref namespace) |
| 40 | // REASONIX_BOOT_FAKE_PROVIDER when "1", declare plugin/<name>/fake/x and serve |
| 41 | // catalog/stream/open/stream/cancel with a fixed |
| 42 | // two-chunk completion plus usage |
| 43 | // |
| 44 | // UI steering for the stage 8a hub tests: |
| 45 | // |
| 46 | // REASONIX_BOOT_FAKE_UI_PUBLISH when "1", publish one credential-bearing |
| 47 | // status surface through host/ui/publish after |
| 48 | // the handshake completes |
| 49 | // |
| 50 | // Process-lifecycle steering for the failure-cleanup tests: |
| 51 | // |
| 52 | // REASONIX_BOOT_FAKE_PID_FILE write the sidecar PID to this file on start, |
| 53 | // so the parent can poll for a leaked process |
| 54 | // REASONIX_BOOT_FAKE_EXIT_IMMEDIATELY when "1", write the PID file (if set) and |
| 55 | // exit 0 at once — a sidecar that dies before |
| 56 | // answering the handshake |
| 57 | const ( |
| 58 | bootFakeEnvEnable = "REASONIX_BOOT_FAKE_SIDECAR" |
| 59 | bootFakeEnvInitResult = "REASONIX_BOOT_FAKE_INIT_RESULT" |
| 60 | bootFakeEnvMode = "REASONIX_BOOT_FAKE_MODE" |
| 61 | bootFakeEnvBlockEvent = "REASONIX_BOOT_FAKE_BLOCK_EVENT" |
| 62 | bootFakeEnvInvalidEvent = "REASONIX_BOOT_FAKE_INVALID_EVENT" |
| 63 | bootFakeEnvReplacePrompt = "REASONIX_BOOT_FAKE_REPLACE_PROMPT" |
| 64 | bootFakeEnvReplaceInput = "REASONIX_BOOT_FAKE_REPLACE_INPUT" |
| 65 | bootFakeEnvEventLog = "REASONIX_BOOT_FAKE_EVENT_LOG" |
| 66 | bootFakeEnvPluginName = "REASONIX_BOOT_FAKE_PLUGIN_NAME" |
| 67 | bootFakeEnvProvider = "REASONIX_BOOT_FAKE_PROVIDER" |
| 68 | bootFakeEnvUIPublish = "REASONIX_BOOT_FAKE_UI_PUBLISH" |
| 69 | bootFakeEnvPIDFile = "REASONIX_BOOT_FAKE_PID_FILE" |
| 70 | bootFakeEnvExitImmediately = "REASONIX_BOOT_FAKE_EXIT_IMMEDIATELY" |
| 71 | ) |
| 72 | |
| 73 | // TestExtensionFakeSidecarHelperProcess is the re-exec entry point; it skips |
| 74 | // in the parent run. |
| 75 | func TestExtensionFakeSidecarHelperProcess(t *testing.T) { |
| 76 | if os.Getenv(bootFakeEnvEnable) != "1" { |
| 77 | t.Skip("boot fake sidecar helper process") |
| 78 | } |
| 79 | runBootFakeSidecar(os.Stdin, os.Stdout) |
| 80 | os.Exit(0) |
| 81 | } |
| 82 | |
| 83 | func runBootFakeSidecar(stdin io.Reader, stdout io.Writer) { |
| 84 | if pidFile := strings.TrimSpace(os.Getenv(bootFakeEnvPIDFile)); pidFile != "" { |
| 85 | _ = os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", os.Getpid())), 0o644) |
| 86 | } |
| 87 | if os.Getenv(bootFakeEnvExitImmediately) == "1" { |
| 88 | os.Exit(0) |
| 89 | } |
| 90 | out := bufio.NewWriter(stdout) |
| 91 | var writeMu sync.Mutex |
| 92 | write := func(format string, args ...any) { |
| 93 | writeMu.Lock() |
| 94 | defer writeMu.Unlock() |
| 95 | fmt.Fprintf(out, format+"\n", args...) |
| 96 | _ = out.Flush() |
| 97 | } |
| 98 | pluginName := strings.TrimSpace(os.Getenv(bootFakeEnvPluginName)) |
| 99 | providerMode := os.Getenv(bootFakeEnvProvider) == "1" && pluginName != "" |
| 100 | providerRef := "plugin/" + pluginName + "/fake/x" |
| 101 | providerDescriptor := func() string { |
| 102 | return fmt.Sprintf(`{"ref":%q,"displayName":"Boot Fake","model":"x","contextWindow":64000,"tools":true,"reasoning":true,"efforts":["low","high"],"defaultEffort":"low"}`, providerRef) |
| 103 | } |
| 104 | initResult := strings.TrimSpace(os.Getenv(bootFakeEnvInitResult)) |
| 105 | if initResult == "" && providerMode { |
| 106 | initResult = fmt.Sprintf(`{"protocolVersion":"1","name":"boot-fake","version":"1.0.0","stateSchemaVersion":0,"providers":[%s]}`, providerDescriptor()) |
| 107 | } |
| 108 | if initResult == "" { |
| 109 | initResult = `{"protocolVersion":"1","name":"boot-fake","version":"1.0.0","stateSchemaVersion":0}` |
| 110 | } |
| 111 | ignoreShutdown := os.Getenv(bootFakeEnvMode) == "ignore_shutdown" |
| 112 | |
| 113 | // streamFakeCompletion answers stream/open and then pushes the fixed |
| 114 | // completion — two text chunks and one usage chunk, sealed by stream/end — |
| 115 | // from its own goroutine so the read loop keeps answering other requests. |
| 116 | streamFakeCompletion := func(id json.RawMessage, rawParams json.RawMessage) { |
| 117 | var params struct { |
| 118 | StreamID string `json:"streamId"` |
| 119 | } |
| 120 | _ = json.Unmarshal(rawParams, ¶ms) |
| 121 | write(`{"jsonrpc":"2.0","id":%s,"result":{"accepted":true}}`, string(id)) |
| 122 | go func() { |
| 123 | chunk := func(seq int, body string) { |
| 124 | write(`{"jsonrpc":"2.0","method":"extension/provider/stream/chunk","params":{"streamId":%q,"seq":%d,"chunk":%s}}`, params.StreamID, seq, body) |
| 125 | } |
| 126 | chunk(1, `{"type":"text","text":"fake-hello "}`) |
| 127 | chunk(2, `{"type":"text","text":"fake-world"}`) |
| 128 | chunk(3, `{"type":"usage","usage":{"promptTokens":5,"completionTokens":7,"totalTokens":12,"cacheHitTokens":2,"cacheMissTokens":3,"reasoningTokens":4,"finishReason":"stop"}}`) |
| 129 | write(`{"jsonrpc":"2.0","method":"extension/provider/stream/end","params":{"streamId":%q,"lastSeq":3}}`, params.StreamID) |
| 130 | }() |
| 131 | } |
| 132 | |
| 133 | in := bufio.NewReader(stdin) |
| 134 | var sessionID string |
| 135 | var generation uint64 |
| 136 | uiPublish := os.Getenv(bootFakeEnvUIPublish) == "1" |
| 137 | for { |
| 138 | line, err := in.ReadBytes('\n') |
| 139 | if len(line) > 0 { |
| 140 | var frame struct { |
| 141 | ID json.RawMessage `json:"id"` |
| 142 | Method string `json:"method"` |
| 143 | Params json.RawMessage `json:"params"` |
| 144 | } |
| 145 | if json.Unmarshal(line, &frame) == nil && frame.Method != "" { |
| 146 | var result string |
| 147 | switch frame.Method { |
| 148 | case "extension/initialize": |
| 149 | var params struct { |
| 150 | Session struct { |
| 151 | SessionID string `json:"sessionId"` |
| 152 | Generation uint64 `json:"generation"` |
| 153 | } `json:"session"` |
| 154 | } |
| 155 | _ = json.Unmarshal(frame.Params, ¶ms) |
| 156 | sessionID = params.Session.SessionID |
| 157 | generation = params.Session.Generation |
| 158 | result = initResult |
| 159 | case "extension/initialized": |
| 160 | // notification; the stage-8a publish mode fires one |
| 161 | // credential-bearing status surface once the handshake |
| 162 | // completes (the host must redact before surfacing). |
| 163 | if uiPublish { |
| 164 | uiPublish = false |
| 165 | payload, _ := json.Marshal(map[string]any{ |
| 166 | "surfaceId": "boot-status", "sessionId": sessionID, "generation": generation, |
| 167 | "kind": "status", |
| 168 | "payload": map[string]any{ |
| 169 | "label": "boot fake ready api_key=sk-abcdef1234567890SECRETKEY", "severity": "info", |
| 170 | }, |
| 171 | }) |
| 172 | write(`{"jsonrpc":"2.0","id":66001,"method":"host/ui/publish","params":%s}`, string(payload)) |
| 173 | } |
| 174 | continue |
| 175 | case "extension/ui/action": |
| 176 | result = `{"accepted":true,"message":"boot fake action ran"}` |
| 177 | case "extension/ui/submit": |
| 178 | result = `{"accepted":true}` |
| 179 | case "extension/intercept": |
| 180 | result = bootFakeInterceptAnswer(frame.Params) |
| 181 | case "extension/event": |
| 182 | bootFakeLogEvent(frame.Params) |
| 183 | continue // notification: never answer |
| 184 | case "extension/provider/catalog": |
| 185 | result = fmt.Sprintf(`{"providers":[%s]}`, providerDescriptor()) |
| 186 | case "extension/provider/stream/open": |
| 187 | streamFakeCompletion(frame.ID, frame.Params) |
| 188 | continue |
| 189 | case "extension/provider/stream/cancel": |
| 190 | result = `{"cancelled":true}` |
| 191 | case "extension/shutdown": |
| 192 | if ignoreShutdown { |
| 193 | continue |
| 194 | } |
| 195 | write(`{"jsonrpc":"2.0","id":%s,"result":{"accepted":true}}`, string(frame.ID)) |
| 196 | return |
| 197 | default: |
| 198 | continue |
| 199 | } |
| 200 | write(`{"jsonrpc":"2.0","id":%s,"result":%s}`, string(frame.ID), result) |
| 201 | } |
| 202 | } |
| 203 | if err != nil { |
| 204 | return |
| 205 | } |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // bootFakeInterceptAnswer computes the steered ruling for one |
| 210 | // extension/intercept call from the env knobs. |
| 211 | func bootFakeInterceptAnswer(rawParams json.RawMessage) string { |
| 212 | var params struct { |
| 213 | Event string `json:"event"` |
| 214 | Payload json.RawMessage `json:"payload"` |
| 215 | } |
| 216 | _ = json.Unmarshal(rawParams, ¶ms) |
| 217 | switch { |
| 218 | case params.Event != "" && params.Event == os.Getenv(bootFakeEnvBlockEvent): |
| 219 | return `{"decision":"block","reason":"boot fake block"}` |
| 220 | case params.Event != "" && params.Event == os.Getenv(bootFakeEnvInvalidEvent): |
| 221 | // A replacement that fails the point's DTO: the host must treat it as |
| 222 | // a contract violation, not apply it. |
| 223 | return `{"decision":"replace","replacement":{"bogus":true}}` |
| 224 | case params.Event == "system_prompt.build" && os.Getenv(bootFakeEnvReplacePrompt) != "": |
| 225 | // Echo the incoming workspaceRoot back so the replacement passes the |
| 226 | // payload DTO validation. |
| 227 | var payload struct { |
| 228 | WorkspaceRoot string `json:"workspaceRoot"` |
| 229 | } |
| 230 | _ = json.Unmarshal(params.Payload, &payload) |
| 231 | replacement, _ := json.Marshal(map[string]string{ |
| 232 | "prompt": os.Getenv(bootFakeEnvReplacePrompt), |
| 233 | "workspaceRoot": payload.WorkspaceRoot, |
| 234 | }) |
| 235 | return fmt.Sprintf(`{"decision":"replace","replacement":%s}`, string(replacement)) |
| 236 | case params.Event == "input.receive" && os.Getenv(bootFakeEnvReplaceInput) != "": |
| 237 | replacement, _ := json.Marshal(map[string]string{"text": os.Getenv(bootFakeEnvReplaceInput)}) |
| 238 | return fmt.Sprintf(`{"decision":"replace","replacement":%s}`, string(replacement)) |
| 239 | default: |
| 240 | return `{"decision":"continue"}` |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | // bootFakeLogEvent appends one "event payload" line per extension/event |
| 245 | // notification to the env-named log file, so the parent test can assert what |
| 246 | // observers received. |
| 247 | func bootFakeLogEvent(rawParams json.RawMessage) { |
| 248 | logPath := os.Getenv(bootFakeEnvEventLog) |
| 249 | if logPath == "" { |
| 250 | return |
| 251 | } |
| 252 | var params struct { |
| 253 | Event string `json:"event"` |
| 254 | Payload json.RawMessage `json:"payload"` |
| 255 | } |
| 256 | if json.Unmarshal(rawParams, ¶ms) != nil { |
| 257 | return |
| 258 | } |
| 259 | f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) |
| 260 | if err != nil { |
| 261 | return |
| 262 | } |
| 263 | defer f.Close() |
| 264 | fmt.Fprintf(f, "%s %s\n", params.Event, string(params.Payload)) |
| 265 | } |
| 266 | |
| 267 | // installBootFakePlugin installs an enabled v1 runtime package (the |
| 268 | // re-executed test binary) into the pluginpkg state under home. |
| 269 | func installBootFakePlugin(t *testing.T, home, name string, runtime map[string]any) { |
| 270 | t.Helper() |
| 271 | exe, err := os.Executable() |
| 272 | if err != nil { |
| 273 | t.Fatalf("os.Executable: %v", err) |
| 274 | } |
| 275 | env := map[string]any{bootFakeEnvEnable: "1"} |
| 276 | for key, value := range runtime { |
| 277 | if key == "env" { |
| 278 | for k, v := range value.(map[string]string) { |
| 279 | env[k] = v |
| 280 | } |
| 281 | delete(runtime, "env") |
| 282 | } |
| 283 | } |
| 284 | runtime["command"] = exe |
| 285 | runtime["args"] = []string{"-test.run=^TestExtensionFakeSidecarHelperProcess$"} |
| 286 | runtime["env"] = env |
| 287 | |
| 288 | root := filepath.Join(home, "plugins", name) |
| 289 | if err := os.MkdirAll(root, 0o755); err != nil { |
| 290 | t.Fatalf("MkdirAll: %v", err) |
| 291 | } |
| 292 | manifest, err := json.Marshal(map[string]any{ |
| 293 | "apiVersion": pluginpkg.ManifestAPIVersionV1, |
| 294 | "name": name, |
| 295 | "version": "1.0.0", |
| 296 | "runtime": runtime, |
| 297 | }) |
| 298 | if err != nil { |
| 299 | t.Fatalf("marshal manifest: %v", err) |
| 300 | } |
| 301 | if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), manifest, 0o644); err != nil { |
| 302 | t.Fatalf("write manifest: %v", err) |
| 303 | } |
| 304 | if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{ |
| 305 | Name: name, Root: pluginpkg.RelativeRoot(home, root), Version: "1.0.0", Enabled: true, |
| 306 | }); err != nil { |
| 307 | t.Fatalf("Upsert: %v", err) |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | // bootWithFakePlugin builds the runtime fixture with one installed sidecar |
| 312 | // package and returns the build result. |
| 313 | func bootWithFakePlugin(t *testing.T, name string, runtime map[string]any) *BuildResult { |
| 314 | t.Helper() |
| 315 | isolateConfigHome(t) |
| 316 | dir := robustTempDir(t) |
| 317 | t.Chdir(dir) |
| 318 | writeRuntimeFixture(t, dir) |
| 319 | installBootFakePlugin(t, config.ReasonixHomeDir(), name, runtime) |
| 320 | res, err := BuildRuntime(context.Background(), Options{}) |
| 321 | if err != nil { |
| 322 | t.Fatalf("BuildRuntime: %v", err) |
| 323 | } |
| 324 | t.Cleanup(res.Controller.Close) |
| 325 | return res |
| 326 | } |
| 327 | |
| 328 | func TestBootStartsExtensionSidecar(t *testing.T) { |
| 329 | res := bootWithFakePlugin(t, "bootplugin", map[string]any{ |
| 330 | "intercepts": []string{"input.receive"}, |
| 331 | }) |
| 332 | if res.Extensions == nil { |
| 333 | t.Fatal("BuildRuntime returned no extension manager") |
| 334 | } |
| 335 | if res.Runtime == nil || res.Runtime.Len() != 1 { |
| 336 | t.Fatalf("runtime set holds %d closers, want 1 (the sidecar manager)", res.Runtime.Len()) |
| 337 | } |
| 338 | client := res.Extensions.Client("bootplugin") |
| 339 | if client == nil { |
| 340 | t.Fatal("manager has no client for bootplugin") |
| 341 | } |
| 342 | |
| 343 | // The sidecar speaks the real protocol: ping it with an intercept. |
| 344 | result, err := client.Intercept(context.Background(), protocol.EventInputReceive, json.RawMessage(`{"text":"ping"}`), 5*time.Second) |
| 345 | if err != nil { |
| 346 | t.Fatalf("Intercept: %v", err) |
| 347 | } |
| 348 | if result.Decision != protocol.DecisionContinue { |
| 349 | t.Fatalf("decision = %q", result.Decision) |
| 350 | } |
| 351 | |
| 352 | // The snapshot catalog carries the declaration-level contribution. |
| 353 | if res.Snapshot == nil { |
| 354 | t.Fatal("snapshot is nil") |
| 355 | } |
| 356 | stubs := res.Snapshot.Catalog().Get(extension.KindInterceptor, "input.receive") |
| 357 | if len(stubs) != 1 || stubs[0].Source.PluginID != "bootplugin" { |
| 358 | t.Fatalf("interceptor stubs = %+v", stubs) |
| 359 | } |
| 360 | |
| 361 | // Controller teardown retires the sidecar: process exits, runtime set |
| 362 | // closes with the controller generation. |
| 363 | res.Controller.Close() |
| 364 | waitForCond(t, "sidecar process exit", 10*time.Second, client.Exited) |
| 365 | if !res.Runtime.Closed() { |
| 366 | t.Fatal("runtime set was not closed by controller teardown") |
| 367 | } |
| 368 | } |
| 369 | |
| 370 | func TestBootExtensionStrategyClaimInSnapshot(t *testing.T) { |
| 371 | res := bootWithFakePlugin(t, "claimer", map[string]any{ |
| 372 | "replaces": []string{"compaction"}, |
| 373 | }) |
| 374 | if res.Snapshot == nil { |
| 375 | t.Fatal("snapshot is nil") |
| 376 | } |
| 377 | owner, ok := res.Snapshot.Replacements()[extension.SlotCompaction] |
| 378 | if !ok || owner.PluginID != "claimer" { |
| 379 | t.Fatalf("compaction slot owner = %+v (ok=%v)", owner, ok) |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | func TestBootFailsWhenTwoRuntimesClaimOneSlot(t *testing.T) { |
| 384 | isolateConfigHome(t) |
| 385 | dir := robustTempDir(t) |
| 386 | t.Chdir(dir) |
| 387 | writeRuntimeFixture(t, dir) |
| 388 | reasonixHome := config.ReasonixHomeDir() |
| 389 | installBootFakePlugin(t, reasonixHome, "claim-one", map[string]any{ |
| 390 | "replaces": []string{"system_prompt"}, |
| 391 | }) |
| 392 | installBootFakePlugin(t, reasonixHome, "claim-two", map[string]any{ |
| 393 | "replaces": []string{"system_prompt"}, |
| 394 | }) |
| 395 | _, err := BuildRuntime(context.Background(), Options{}) |
| 396 | if err == nil { |
| 397 | t.Fatal("BuildRuntime succeeded with two runtimes claiming system_prompt") |
| 398 | } |
| 399 | var slotErr *extension.SlotConflictError |
| 400 | if !errors.As(err, &slotErr) { |
| 401 | t.Fatalf("error %v is not a SlotConflictError", err) |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | func TestBootFailsWhenRequiredRuntimeFails(t *testing.T) { |
| 406 | isolateConfigHome(t) |
| 407 | dir := robustTempDir(t) |
| 408 | t.Chdir(dir) |
| 409 | writeRuntimeFixture(t, dir) |
| 410 | installBootFakePlugin(t, config.ReasonixHomeDir(), "required-broken", map[string]any{ |
| 411 | "required": true, |
| 412 | "env": map[string]string{bootFakeEnvInitResult: `{"protocolVersion":"2","name":"x","version":"1","stateSchemaVersion":0}`}, |
| 413 | }) |
| 414 | _, err := BuildRuntime(context.Background(), Options{}) |
| 415 | if err == nil { |
| 416 | t.Fatal("BuildRuntime succeeded with a broken required runtime") |
| 417 | } |
| 418 | var requiredErr *sidecar.RequiredStartError |
| 419 | if !errors.As(err, &requiredErr) { |
| 420 | t.Fatalf("error %v is not a RequiredStartError", err) |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | func TestBootOptionalRuntimeFailureDegradesToWarning(t *testing.T) { |
| 425 | res := bootWithFakePlugin(t, "optional-broken", map[string]any{ |
| 426 | "env": map[string]string{bootFakeEnvInitResult: `{"protocolVersion":"2","name":"x","version":"1","stateSchemaVersion":0}`}, |
| 427 | }) |
| 428 | // Optional failure: boot succeeds, no manager, empty runtime set. |
| 429 | if res.Extensions != nil { |
| 430 | t.Fatal("broken optional runtime produced a manager") |
| 431 | } |
| 432 | if res.Runtime == nil || res.Runtime.Len() != 0 { |
| 433 | t.Fatalf("runtime set holds %d closers, want 0", res.Runtime.Len()) |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | // TestRebuildRetiresOldSidecars pins the Rebuild contract: the old |
| 438 | // controller's Close retires its sidecars, while the replacement build's |
| 439 | // sidecars keep serving their own generation. |
| 440 | func TestRebuildRetiresOldSidecars(t *testing.T) { |
| 441 | isolateConfigHome(t) |
| 442 | dir := robustTempDir(t) |
| 443 | t.Chdir(dir) |
| 444 | writeRuntimeFixture(t, dir) |
| 445 | installBootFakePlugin(t, config.ReasonixHomeDir(), "rebuildplugin", map[string]any{}) |
| 446 | |
| 447 | oldRes, err := BuildRuntime(context.Background(), Options{}) |
| 448 | if err != nil { |
| 449 | t.Fatalf("BuildRuntime: %v", err) |
| 450 | } |
| 451 | newRes, err := Rebuild(context.Background(), oldRes.Controller, Options{}) |
| 452 | if err != nil { |
| 453 | oldRes.Controller.Close() |
| 454 | t.Fatalf("Rebuild: %v", err) |
| 455 | } |
| 456 | t.Cleanup(newRes.Controller.Close) |
| 457 | if oldRes.Extensions == nil || newRes.Extensions == nil { |
| 458 | t.Fatal("both builds must have extension managers") |
| 459 | } |
| 460 | oldClient := oldRes.Extensions.Client("rebuildplugin") |
| 461 | newClient := newRes.Extensions.Client("rebuildplugin") |
| 462 | if oldClient == nil || newClient == nil { |
| 463 | t.Fatal("both builds must have a sidecar client") |
| 464 | } |
| 465 | if oldRes.Snapshot.Generation() == newRes.Snapshot.Generation() { |
| 466 | t.Fatal("rebuild reused the old generation") |
| 467 | } |
| 468 | |
| 469 | // Closing the old controller retires the old sidecar only. |
| 470 | oldRes.Controller.Close() |
| 471 | waitForCond(t, "old sidecar exit", 10*time.Second, oldClient.Exited) |
| 472 | result, err := newClient.Intercept(context.Background(), protocol.EventSessionStart, json.RawMessage(`{}`), 5*time.Second) |
| 473 | if err != nil || result.Decision != protocol.DecisionContinue { |
| 474 | t.Fatalf("new sidecar Intercept after old close = %+v, %v", result, err) |
| 475 | } |
| 476 | |
| 477 | newRes.Controller.Close() |
| 478 | waitForCond(t, "new sidecar exit", 10*time.Second, newClient.Exited) |
| 479 | } |
| 480 | |
| 481 | func waitForCond(t *testing.T, what string, timeout time.Duration, cond func() bool) { |
| 482 | t.Helper() |
| 483 | deadline := time.Now().Add(timeout) |
| 484 | for time.Now().Before(deadline) { |
| 485 | if cond() { |
| 486 | return |
| 487 | } |
| 488 | time.Sleep(10 * time.Millisecond) |
| 489 | } |
| 490 | t.Fatalf("timed out waiting for %s", what) |
| 491 | } |
| 492 |