| 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 v2 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, fmt.Appendf(nil, "%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":"2","name":"boot-fake","version":"1.0.0","stateSchemaVersion":0,"providers":[%s]}`, providerDescriptor()) |
| 107 | } |
| 108 | if initResult == "" { |
| 109 | initResult = `{"protocolVersion":"2","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 v2 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.ManifestAPIVersionV2, |
| 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 TestBootIsolatesIncompatibleExternalPlugin(t *testing.T) { |
| 329 | isolateConfigHome(t) |
| 330 | dir := robustTempDir(t) |
| 331 | t.Chdir(dir) |
| 332 | writeRuntimeFixture(t, dir) |
| 333 | |
| 334 | root := robustTempDir(t) |
| 335 | if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(`{"name":"irmia-devkit","version":"1.0.0"}`), 0o644); err != nil { |
| 336 | t.Fatal(err) |
| 337 | } |
| 338 | home := config.ReasonixHomeDir() |
| 339 | if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{Name: "irmia-devkit", Root: root, Enabled: true}); err != nil { |
| 340 | t.Fatal(err) |
| 341 | } |
| 342 | |
| 343 | res, err := BuildRuntime(context.Background(), Options{}) |
| 344 | if err != nil { |
| 345 | t.Fatalf("incompatible plugin blocked core controller: %v", err) |
| 346 | } |
| 347 | t.Cleanup(res.Controller.Close) |
| 348 | if res.Controller == nil || res.Extensions != nil { |
| 349 | t.Fatalf("build result = %#v, want core controller without extensions", res) |
| 350 | } |
| 351 | state, err := pluginpkg.LoadState(home) |
| 352 | if err != nil { |
| 353 | t.Fatal(err) |
| 354 | } |
| 355 | if len(state.Plugins) != 1 || state.Plugins[0].Status != pluginpkg.PluginStatusDisabledIncompatible { |
| 356 | t.Fatalf("plugin state = %#v", state.Plugins) |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | func TestBootStartsExtensionSidecar(t *testing.T) { |
| 361 | res := bootWithFakePlugin(t, "bootplugin", map[string]any{ |
| 362 | "intercepts": []string{"input.receive"}, |
| 363 | }) |
| 364 | if res.Extensions == nil { |
| 365 | t.Fatal("BuildRuntime returned no extension manager") |
| 366 | } |
| 367 | if res.Runtime == nil || res.Runtime.Len() < 1 { |
| 368 | t.Fatalf("runtime set holds %d effects, want at least the sidecar manager", res.Runtime.Len()) |
| 369 | } |
| 370 | client := res.Extensions.Client("bootplugin") |
| 371 | if client == nil { |
| 372 | t.Fatal("manager has no client for bootplugin") |
| 373 | } |
| 374 | |
| 375 | // The sidecar speaks the real protocol: ping it with an intercept. |
| 376 | result, err := client.Intercept(context.Background(), protocol.EventInputReceive, json.RawMessage(`{"text":"ping"}`), 5*time.Second) |
| 377 | if err != nil { |
| 378 | t.Fatalf("Intercept: %v", err) |
| 379 | } |
| 380 | if result.Decision != protocol.DecisionContinue { |
| 381 | t.Fatalf("decision = %q", result.Decision) |
| 382 | } |
| 383 | |
| 384 | // The snapshot catalog carries the declaration-level contribution. |
| 385 | if res.Snapshot == nil { |
| 386 | t.Fatal("snapshot is nil") |
| 387 | } |
| 388 | stubs := res.Snapshot.Catalog().Get(extension.KindInterceptor, "input.receive") |
| 389 | if len(stubs) != 1 || stubs[0].Source.PluginID != "bootplugin" { |
| 390 | t.Fatalf("interceptor stubs = %+v", stubs) |
| 391 | } |
| 392 | |
| 393 | // Controller teardown retires the sidecar: process exits, runtime set |
| 394 | // closes with the controller generation. |
| 395 | res.Controller.Close() |
| 396 | waitForCond(t, "sidecar process exit", 10*time.Second, client.Exited) |
| 397 | if !res.Runtime.Closed() { |
| 398 | t.Fatal("runtime set was not closed by controller teardown") |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | func TestBootExtensionStrategyClaimInSnapshot(t *testing.T) { |
| 403 | res := bootWithFakePlugin(t, "claimer", map[string]any{ |
| 404 | "replaces": []string{"compaction"}, |
| 405 | }) |
| 406 | if res.Snapshot == nil { |
| 407 | t.Fatal("snapshot is nil") |
| 408 | } |
| 409 | owner, ok := res.Snapshot.Replacements()[extension.SlotCompaction] |
| 410 | if !ok || owner.PluginID != "claimer" { |
| 411 | t.Fatalf("compaction slot owner = %+v (ok=%v)", owner, ok) |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | func TestBootFailsWhenTwoRuntimesClaimOneSlot(t *testing.T) { |
| 416 | isolateConfigHome(t) |
| 417 | dir := robustTempDir(t) |
| 418 | t.Chdir(dir) |
| 419 | writeRuntimeFixture(t, dir) |
| 420 | reasonixHome := config.ReasonixHomeDir() |
| 421 | installBootFakePlugin(t, reasonixHome, "claim-one", map[string]any{ |
| 422 | "replaces": []string{"system_prompt"}, |
| 423 | }) |
| 424 | installBootFakePlugin(t, reasonixHome, "claim-two", map[string]any{ |
| 425 | "replaces": []string{"system_prompt"}, |
| 426 | }) |
| 427 | _, err := BuildRuntime(context.Background(), Options{}) |
| 428 | if err == nil { |
| 429 | t.Fatal("BuildRuntime succeeded with two runtimes claiming system_prompt") |
| 430 | } |
| 431 | var slotErr *extension.SlotConflictError |
| 432 | if !errors.As(err, &slotErr) { |
| 433 | t.Fatalf("error %v is not a SlotConflictError", err) |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | func TestBootFailsWhenRequiredRuntimeFails(t *testing.T) { |
| 438 | isolateConfigHome(t) |
| 439 | dir := robustTempDir(t) |
| 440 | t.Chdir(dir) |
| 441 | writeRuntimeFixture(t, dir) |
| 442 | installBootFakePlugin(t, config.ReasonixHomeDir(), "required-broken", map[string]any{ |
| 443 | "required": true, |
| 444 | // Extension Protocol v1 is rejected by the v2 host. |
| 445 | "env": map[string]string{bootFakeEnvInitResult: `{"protocolVersion":"1","name":"x","version":"1","stateSchemaVersion":0}`}, |
| 446 | }) |
| 447 | _, err := BuildRuntime(context.Background(), Options{}) |
| 448 | if err == nil { |
| 449 | t.Fatal("BuildRuntime succeeded with a broken required runtime") |
| 450 | } |
| 451 | var requiredErr *sidecar.RequiredStartError |
| 452 | if !errors.As(err, &requiredErr) { |
| 453 | t.Fatalf("error %v is not a RequiredStartError", err) |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | func TestBootOptionalRuntimeFailureDegradesToWarning(t *testing.T) { |
| 458 | res := bootWithFakePlugin(t, "optional-broken", map[string]any{ |
| 459 | "env": map[string]string{bootFakeEnvInitResult: `{"protocolVersion":"1","name":"x","version":"1","stateSchemaVersion":0}`}, |
| 460 | }) |
| 461 | // Optional failure: boot succeeds, no manager, empty runtime set. |
| 462 | if res.Extensions != nil { |
| 463 | t.Fatal("broken optional runtime produced a manager") |
| 464 | } |
| 465 | if res.Runtime == nil || res.Runtime.Len() != 0 { |
| 466 | t.Fatalf("runtime set holds %d closers, want 0", res.Runtime.Len()) |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | // TestRebuildRetiresOldSidecars pins the Rebuild contract: the old |
| 471 | // controller's Close retires its sidecars, while the replacement build's |
| 472 | // sidecars keep serving their own generation. |
| 473 | func TestRebuildRetiresOldSidecars(t *testing.T) { |
| 474 | isolateConfigHome(t) |
| 475 | dir := robustTempDir(t) |
| 476 | t.Chdir(dir) |
| 477 | writeRuntimeFixture(t, dir) |
| 478 | installBootFakePlugin(t, config.ReasonixHomeDir(), "rebuildplugin", map[string]any{}) |
| 479 | |
| 480 | oldRes, err := BuildRuntime(context.Background(), Options{}) |
| 481 | if err != nil { |
| 482 | t.Fatalf("BuildRuntime: %v", err) |
| 483 | } |
| 484 | newRes, err := Rebuild(context.Background(), oldRes.Controller, Options{}) |
| 485 | if err != nil { |
| 486 | oldRes.Controller.Close() |
| 487 | t.Fatalf("Rebuild: %v", err) |
| 488 | } |
| 489 | t.Cleanup(newRes.Controller.Close) |
| 490 | if oldRes.Extensions == nil || newRes.Extensions == nil { |
| 491 | t.Fatal("both builds must have extension managers") |
| 492 | } |
| 493 | oldClient := oldRes.Extensions.Client("rebuildplugin") |
| 494 | newClient := newRes.Extensions.Client("rebuildplugin") |
| 495 | if oldClient == nil || newClient == nil { |
| 496 | t.Fatal("both builds must have a sidecar client") |
| 497 | } |
| 498 | if oldRes.Snapshot.Generation() == newRes.Snapshot.Generation() { |
| 499 | t.Fatal("rebuild reused the old generation") |
| 500 | } |
| 501 | |
| 502 | // Closing the old controller retires the old sidecar only. |
| 503 | oldRes.Controller.Close() |
| 504 | waitForCond(t, "old sidecar exit", 10*time.Second, oldClient.Exited) |
| 505 | result, err := newClient.Intercept(context.Background(), protocol.EventSessionStart, json.RawMessage(`{}`), 5*time.Second) |
| 506 | if err != nil || result.Decision != protocol.DecisionContinue { |
| 507 | t.Fatalf("new sidecar Intercept after old close = %+v, %v", result, err) |
| 508 | } |
| 509 | |
| 510 | newRes.Controller.Close() |
| 511 | waitForCond(t, "new sidecar exit", 10*time.Second, newClient.Exited) |
| 512 | } |
| 513 | |
| 514 | // TestExplicitReloadReplacesUnchangedSidecar pins the linked-development |
| 515 | // contract: a user-requested reload must start a fresh process even when the |
| 516 | // manifest graph is unchanged. The provider-visible prefix stays stable when |
| 517 | // the replacement contributes identical bytes. |
| 518 | func TestExplicitReloadReplacesUnchangedSidecar(t *testing.T) { |
| 519 | isolateConfigHome(t) |
| 520 | dir := robustTempDir(t) |
| 521 | t.Chdir(dir) |
| 522 | writeRuntimeFixture(t, dir) |
| 523 | pidFile := filepath.Join(dir, "linked-sidecar.pid") |
| 524 | installBootFakePlugin(t, config.ReasonixHomeDir(), "linkedplugin", map[string]any{ |
| 525 | "env": map[string]string{bootFakeEnvPIDFile: pidFile}, |
| 526 | }) |
| 527 | |
| 528 | oldRes, err := BuildRuntime(context.Background(), Options{}) |
| 529 | if err != nil { |
| 530 | t.Fatalf("BuildRuntime: %v", err) |
| 531 | } |
| 532 | oldPID := readFakePID(t, pidFile) |
| 533 | if err := os.Remove(pidFile); err != nil { |
| 534 | oldRes.Controller.Close() |
| 535 | t.Fatalf("remove first-generation PID file: %v", err) |
| 536 | } |
| 537 | newRes, err := RebuildFrom(context.Background(), oldRes, Options{ |
| 538 | RuntimeReload: RuntimeReload{ForceFullRebuild: true}, |
| 539 | }) |
| 540 | if err != nil { |
| 541 | oldRes.Controller.Close() |
| 542 | t.Fatalf("RebuildFrom: %v", err) |
| 543 | } |
| 544 | t.Cleanup(newRes.Controller.Close) |
| 545 | |
| 546 | oldClient := oldRes.Extensions.Client("linkedplugin") |
| 547 | newClient := newRes.Extensions.Client("linkedplugin") |
| 548 | if oldClient == nil || newClient == nil { |
| 549 | t.Fatal("both generations must have a sidecar client") |
| 550 | } |
| 551 | if oldClient == newClient { |
| 552 | t.Fatal("explicit reload adopted the outgoing sidecar instead of starting a replacement") |
| 553 | } |
| 554 | newPID := readFakePID(t, pidFile) |
| 555 | if newPID == oldPID { |
| 556 | t.Fatalf("explicit reload kept sidecar PID %d", oldPID) |
| 557 | } |
| 558 | if oldClient.Exited() { |
| 559 | t.Fatal("outgoing sidecar exited before the replacement controller published") |
| 560 | } |
| 561 | if oldRes.Snapshot.CacheHash() != newRes.Snapshot.CacheHash() { |
| 562 | t.Fatalf("unchanged extension bytes changed cache hash: old=%s new=%s", oldRes.Snapshot.CacheHash(), newRes.Snapshot.CacheHash()) |
| 563 | } |
| 564 | |
| 565 | oldRes.Controller.Close() |
| 566 | waitForCond(t, "outgoing sidecar exit", 10*time.Second, oldClient.Exited) |
| 567 | if newClient.Exited() { |
| 568 | t.Fatal("replacement sidecar exited with the outgoing controller") |
| 569 | } |
| 570 | } |
| 571 | |
| 572 | // TestExplicitReloadSidecarFailureKeepsOldProcess proves that forcing a fresh |
| 573 | // linked process does not weaken reload failure atomicity. The replacement can |
| 574 | // fail before publish while the previous process keeps answering requests. |
| 575 | func TestExplicitReloadSidecarFailureKeepsOldProcess(t *testing.T) { |
| 576 | isolateConfigHome(t) |
| 577 | dir := robustTempDir(t) |
| 578 | t.Chdir(dir) |
| 579 | writeRuntimeFixture(t, dir) |
| 580 | installBootFakePlugin(t, config.ReasonixHomeDir(), "stable-linked", map[string]any{ |
| 581 | "required": true, |
| 582 | }) |
| 583 | |
| 584 | oldRes, err := BuildRuntime(context.Background(), Options{}) |
| 585 | if err != nil { |
| 586 | t.Fatalf("BuildRuntime: %v", err) |
| 587 | } |
| 588 | t.Cleanup(oldRes.Controller.Close) |
| 589 | oldClient := oldRes.Extensions.Client("stable-linked") |
| 590 | if oldClient == nil { |
| 591 | t.Fatal("first build has no sidecar client") |
| 592 | } |
| 593 | |
| 594 | // Keep the declared graph identical while making the linked program fail on |
| 595 | // its next launch. Without the explicit-restart instruction, RebuildFrom |
| 596 | // would adopt oldClient and incorrectly report success. |
| 597 | installBootFakePlugin(t, config.ReasonixHomeDir(), "stable-linked", map[string]any{ |
| 598 | "required": true, |
| 599 | "env": map[string]string{bootFakeEnvExitImmediately: "1"}, |
| 600 | }) |
| 601 | _, err = RebuildFrom(context.Background(), oldRes, Options{ |
| 602 | RuntimeReload: RuntimeReload{ForceFullRebuild: true}, |
| 603 | }) |
| 604 | if err == nil { |
| 605 | t.Fatal("explicit reload succeeded after the replacement sidecar failed") |
| 606 | } |
| 607 | var requiredErr *sidecar.RequiredStartError |
| 608 | if !errors.As(err, &requiredErr) { |
| 609 | t.Fatalf("reload error %v is not a RequiredStartError", err) |
| 610 | } |
| 611 | if oldClient.Exited() || oldRes.Runtime.Closed() { |
| 612 | t.Fatal("failed explicit reload retired the outgoing runtime") |
| 613 | } |
| 614 | result, interceptErr := oldClient.Intercept(context.Background(), protocol.EventSessionStart, json.RawMessage(`{}`), 5*time.Second) |
| 615 | if interceptErr != nil || result.Decision != protocol.DecisionContinue { |
| 616 | t.Fatalf("outgoing sidecar after failed reload = %+v, %v", result, interceptErr) |
| 617 | } |
| 618 | } |
| 619 | |
| 620 | func waitForCond(t *testing.T, what string, timeout time.Duration, cond func() bool) { |
| 621 | t.Helper() |
| 622 | deadline := time.Now().Add(timeout) |
| 623 | for time.Now().Before(deadline) { |
| 624 | if cond() { |
| 625 | return |
| 626 | } |
| 627 | time.Sleep(10 * time.Millisecond) |
| 628 | } |
| 629 | t.Fatalf("timed out waiting for %s", what) |
| 630 | } |
| 631 |