返回 DeepSeek-Reasonix
extension_sidecar_test.go
根目录 / internal / boot / extension_sidecar_test.go
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, &params)
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, &params)
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, &params)
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, &params) != 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
631 lines GO