返回 DeepSeek-Reasonix
client_test.go
根目录 / internal / extension / sidecar / client_test.go
1 package sidecar
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "reasonix/internal/extension"
13 "reasonix/internal/extension/protocol"
14 "reasonix/internal/extension/rpcwire"
15 "reasonix/internal/pluginpkg"
16 )
17
18 func TestHandshakeSuccess(t *testing.T) {
19 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
20 rt.Intercepts = []string{"input.receive", "tool.before"}
21 rt.Env[fakeEnvInitResult] = `{"protocolVersion":"2","name":"fake-sidecar","version":"1.2.3","subscriptions":["input.receive"],"stateSchemaVersion":0}`
22 }, nil)
23 result := client.Handshake()
24 if result.Name != "fake-sidecar" || result.Version != "1.2.3" {
25 t.Fatalf("handshake identity = %q %q", result.Name, result.Version)
26 }
27 if len(result.Subscriptions) != 1 || result.Subscriptions[0] != "input.receive" {
28 t.Fatalf("subscriptions = %v", result.Subscriptions)
29 }
30 if client.Crashed() {
31 t.Fatal("client crashed during handshake")
32 }
33 }
34
35 func TestInitializeParamsCarryManifestV2DependencyIdentity(t *testing.T) {
36 c := &Client{
37 rt: &pluginpkg.RuntimeSpec{
38 Intercepts: []string{"input.receive", "system_prompt.build"},
39 Replaces: []string{"system_prompt"},
40 Capabilities: []string{"interceptors", "strategies", "providers", "ui"},
41 },
42 requires: []pluginpkg.CapabilityRef{{
43 Namespace: "reasonix", Kind: "provider", ID: "base", VersionRange: ">=1.0.0", Optional: true,
44 }},
45 provides: []pluginpkg.CapabilityRef{
46 {Namespace: "plugin/example", Kind: "provider", ID: "fake/echo", Version: "1.0.0", SchemaHash: "sha256:provider"},
47 {Namespace: "plugin/example", Kind: "uiaction", ID: "demo", Version: "1.0.0", SchemaHash: "sha256:ui"},
48 },
49 session: protocol.SessionContext{SessionID: "sess", WorkspaceRoot: "/workspace", Generation: 7},
50 uiHost: protocol.UIHostDesktop,
51 }
52
53 params := c.initializeParams()
54 if params.DependencySchemaVersion != protocol.DependencySchemaVersion || params.Capabilities.DependencySchemaVersion != protocol.DependencySchemaVersion {
55 t.Fatalf("dependency schema versions = %d/%d, want %d", params.DependencySchemaVersion, params.Capabilities.DependencySchemaVersion, protocol.DependencySchemaVersion)
56 }
57 if len(params.Manifest.Requires) != 1 || params.Manifest.Requires[0].ID != "base" || params.Manifest.Requires[0].VersionRange != ">=1.0.0" || !params.Manifest.Requires[0].Optional {
58 t.Fatalf("manifest requires = %+v", params.Manifest.Requires)
59 }
60 if len(params.Manifest.Provides) != 2 || params.Manifest.Provides[0].SchemaHash != "sha256:provider" || params.Manifest.Provides[1].SchemaHash != "sha256:ui" {
61 t.Fatalf("manifest provides = %+v", params.Manifest.Provides)
62 }
63 if len(params.Manifest.Providers) != 1 || params.Manifest.Providers[0] != "plugin/example/fake/echo" {
64 t.Fatalf("manifest providers = %v", params.Manifest.Providers)
65 }
66 if len(params.Manifest.UIActions) != 1 || params.Manifest.UIActions[0] != "demo" {
67 t.Fatalf("manifest uiActions = %v", params.Manifest.UIActions)
68 }
69 }
70
71 func TestHandshakeProtocolVersionMismatch(t *testing.T) {
72 pkg, installed := fakeSidecarPackage(t, "fakeplugin", func(rt *pluginpkg.RuntimeSpec) {
73 // Peer still speaking Extension Protocol v1 major must be rejected.
74 rt.Env[fakeEnvInitResult] = `{"protocolVersion":"1","name":"fake-sidecar","version":"1.0.0","stateSchemaVersion":0}`
75 })
76 _, err := StartClient(context.Background(), ClientOptions{Package: pkg, Installed: installed, Session: testSessionContext()})
77 if err == nil {
78 t.Fatal("StartClient succeeded with protocol major 1")
79 }
80 if reason := protocolReason(t, err); reason != protocol.ErrUnsupportedVersion {
81 t.Fatalf("reason = %q, want %q", reason, protocol.ErrUnsupportedVersion)
82 }
83 }
84
85 func TestMapRequestErrorRedactsPeerMessages(t *testing.T) {
86 const secret = "sk-abcdef1234567890SECRETKEY"
87 structuredData, err := json.Marshal(protocol.ProtocolErrorData{
88 Reason: protocol.ErrProviderFailed,
89 Retryable: true,
90 })
91 if err != nil {
92 t.Fatalf("marshal protocol error data: %v", err)
93 }
94 tests := []struct {
95 name string
96 data json.RawMessage
97 }{
98 {name: "structured protocol error", data: structuredData},
99 {name: "unstructured transport error", data: json.RawMessage(`{"unexpected":true}`)},
100 }
101 for _, tt := range tests {
102 t.Run(tt.name, func(t *testing.T) {
103 mapped := mapRequestError(&rpcwire.ResponseError{
104 Code: protocol.DomainErrorCode,
105 Message: "provider rejected api_key=" + secret,
106 Data: tt.data,
107 })
108 if strings.Contains(mapped.Error(), secret) {
109 t.Fatalf("mapped error leaked peer credential: %q", mapped)
110 }
111 if !strings.Contains(mapped.Error(), "****") {
112 t.Fatalf("mapped error contains no redaction marker: %q", mapped)
113 }
114 })
115 }
116 }
117
118 // TestHandshakeCapabilityViolations pins the declaration contract: anything
119 // the sidecar activates beyond its manifest fails the handshake with
120 // capability_not_declared.
121 func TestHandshakeCapabilityViolations(t *testing.T) {
122 cases := []struct {
123 name string
124 configure func(rt *pluginpkg.RuntimeSpec)
125 initResult string
126 }{
127 {
128 name: "subscriptions superset",
129 configure: func(rt *pluginpkg.RuntimeSpec) { rt.Intercepts = []string{"input.receive"} },
130 initResult: `{"protocolVersion":"2","name":"fake","version":"1","subscriptions":["input.receive","tool.before"],"stateSchemaVersion":0}`,
131 },
132 {
133 name: "replaces superset",
134 configure: func(rt *pluginpkg.RuntimeSpec) { rt.Replaces = []string{"system_prompt"} },
135 initResult: `{"protocolVersion":"2","name":"fake","version":"1","replaces":["system_prompt","compaction"],"stateSchemaVersion":0}`,
136 },
137 {
138 name: "providers without capability",
139 configure: nil,
140 initResult: `{"protocolVersion":"2","name":"fake","version":"1","providers":[{"ref":"plugin/fakeplugin/openai/gpt-5"}],"stateSchemaVersion":0}`,
141 },
142 {
143 name: "provider ref outside plugin namespace",
144 configure: func(rt *pluginpkg.RuntimeSpec) { rt.Capabilities = []string{"providers"} },
145 initResult: `{"protocolVersion":"2","name":"fake","version":"1","providers":[{"ref":"plugin/other/openai/gpt-5"}],"stateSchemaVersion":0}`,
146 },
147 {
148 name: "ui actions without capability",
149 configure: nil,
150 initResult: `{"protocolVersion":"2","name":"fake","version":"1","uiActions":[{"actionId":"a1"}],"stateSchemaVersion":0}`,
151 },
152 }
153 for _, tc := range cases {
154 t.Run(tc.name, func(t *testing.T) {
155 pkg, installed := fakeSidecarPackage(t, "fakeplugin", func(rt *pluginpkg.RuntimeSpec) {
156 if tc.configure != nil {
157 tc.configure(rt)
158 }
159 rt.Env[fakeEnvInitResult] = tc.initResult
160 })
161 _, err := StartClient(context.Background(), ClientOptions{Package: pkg, Installed: installed, Session: testSessionContext()})
162 if err == nil {
163 t.Fatal("StartClient succeeded with an undeclared capability in use")
164 }
165 if reason := protocolReason(t, err); reason != protocol.ErrCapabilityNotDeclared {
166 t.Fatalf("reason = %q, want %q", reason, protocol.ErrCapabilityNotDeclared)
167 }
168 })
169 }
170 }
171
172 func TestHandshakeDeclaredProvidersAndUIAccepted(t *testing.T) {
173 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
174 rt.Capabilities = []string{"providers", "ui"}
175 rt.Env[fakeEnvInitResult] = `{"protocolVersion":"2","name":"fake","version":"1",` +
176 `"providers":[{"ref":"plugin/fakeplugin/openai/gpt-5"}],` +
177 `"uiActions":[{"actionId":"act1","label":"Act"}],"stateSchemaVersion":0}`
178 }, nil)
179 result := client.Handshake()
180 if len(result.Providers) != 1 || result.Providers[0].Ref != "plugin/fakeplugin/openai/gpt-5" {
181 t.Fatalf("providers = %+v", result.Providers)
182 }
183 if len(result.UIActions) != 1 || result.UIActions[0].ActionID != "act1" {
184 t.Fatalf("uiActions = %+v", result.UIActions)
185 }
186 }
187
188 func TestHandshakeProvidesOutsideManifestRejected(t *testing.T) {
189 pkg, installed := fakeSidecarPackage(t, "fakeplugin", func(rt *pluginpkg.RuntimeSpec) {
190 rt.Env[fakeEnvInitResult] = `{"protocolVersion":"2","name":"fake","version":"1",` +
191 `"provides":[{"namespace":"plugin/fakeplugin","kind":"provider","id":"rogue","version":"1.0.0","schemaHash":"sha256:rogue"}],` +
192 `"stateSchemaVersion":0}`
193 })
194 pkg.Manifest.Provides = []pluginpkg.CapabilityRef{{
195 Namespace: "plugin/fakeplugin", Kind: "provider", ID: "declared", Version: "1.0.0", SchemaHash: "sha256:declared",
196 }}
197 _, err := StartClient(context.Background(), ClientOptions{Package: pkg, Installed: installed, Session: testSessionContext()})
198 if err == nil {
199 t.Fatal("StartClient accepted a handshake provides entry outside the manifest ceiling")
200 }
201 if reason := protocolReason(t, err); reason != protocol.ErrCapabilityNotDeclared {
202 t.Fatalf("reason = %q, want %q", reason, protocol.ErrCapabilityNotDeclared)
203 }
204 }
205
206 // TestTrafficBeforeInitializedPoisons covers both E→H frame kinds arriving
207 // before extension/initialized: the connection is poisoned and the start
208 // fails with protocol_error.
209 func TestTrafficBeforeInitializedPoisons(t *testing.T) {
210 for _, mode := range []string{"early_request", "early_notify"} {
211 t.Run(mode, func(t *testing.T) {
212 pkg, installed := fakeSidecarPackage(t, "fakeplugin", func(rt *pluginpkg.RuntimeSpec) {
213 rt.Env[fakeEnvMode] = mode
214 })
215 _, err := StartClient(context.Background(), ClientOptions{Package: pkg, Installed: installed, Session: testSessionContext()})
216 if err == nil {
217 t.Fatal("StartClient succeeded despite pre-initialized traffic")
218 }
219 if reason := protocolReason(t, err); reason != protocol.ErrProtocolError {
220 t.Fatalf("reason = %q, want %q", reason, protocol.ErrProtocolError)
221 }
222 if !strings.Contains(err.Error(), "before extension/initialized") {
223 t.Fatalf("error %q does not name the gating rule", err)
224 }
225 })
226 }
227 }
228
229 func TestInterceptContinueAndNotifications(t *testing.T) {
230 client := startFakeClient(t, nil, nil)
231 result, err := client.Intercept(context.Background(), protocol.EventInputReceive, json.RawMessage(`{"text":"hi"}`), 5*time.Second)
232 if err != nil {
233 t.Fatalf("Intercept: %v", err)
234 }
235 if result.Decision != protocol.DecisionContinue {
236 t.Fatalf("decision = %q, want continue", result.Decision)
237 }
238 if err := client.NotifyEvent(protocol.EventSessionStart, json.RawMessage(`{"at":1}`)); err != nil {
239 t.Fatalf("NotifyEvent: %v", err)
240 }
241 if err := client.NotifyResourcesChanged([]string{"skills/x.md"}); err != nil {
242 t.Fatalf("NotifyResourcesChanged: %v", err)
243 }
244 }
245
246 func TestInterceptTimeout(t *testing.T) {
247 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
248 rt.Env[fakeEnvMode] = "stall_intercept"
249 }, nil)
250 start := time.Now()
251 _, err := client.Intercept(context.Background(), protocol.EventToolBefore, json.RawMessage(`{}`), 300*time.Millisecond)
252 if err == nil {
253 t.Fatal("Intercept succeeded against a stalled sidecar")
254 }
255 if reason := protocolReason(t, err); reason != protocol.ErrInterceptTimeout {
256 t.Fatalf("reason = %q, want %q", reason, protocol.ErrInterceptTimeout)
257 }
258 if elapsed := time.Since(start); elapsed > 5*time.Second {
259 t.Fatalf("intercept timeout took %s, want near 300ms", elapsed)
260 }
261 }
262
263 // TestShutdownBounded covers a sidecar that ignores extension/shutdown: the
264 // bounded close kills and reaps the tree within budget, and Shutdown is
265 // idempotent.
266 func TestShutdownBounded(t *testing.T) {
267 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
268 rt.Env[fakeEnvMode] = "ignore_shutdown"
269 }, nil)
270 start := time.Now()
271 if err := client.Shutdown(context.Background(), 300*time.Millisecond); err != nil {
272 t.Fatalf("Shutdown: %v", err)
273 }
274 elapsed := time.Since(start)
275 // 300ms request + 750ms EOF grace + kill + 5s reap must finish far below
276 // this ceiling.
277 if elapsed > 10*time.Second {
278 t.Fatalf("bounded shutdown took %s", elapsed)
279 }
280 if !client.Exited() {
281 t.Fatal("sidecar process still running after bounded shutdown")
282 }
283 // Idempotent: a second call returns immediately.
284 second := time.Now()
285 _ = client.Shutdown(context.Background(), 5*time.Second)
286 if time.Since(second) > time.Second {
287 t.Fatal("second Shutdown was not idempotent")
288 }
289 }
290
291 // TestCrashFailsPendingAndFastAfter kills the fake sidecar mid-intercept:
292 // the pending call errors, the crash callback fires exactly once, and later
293 // calls fail fast with provider_interrupted.
294 func TestCrashFailsPendingAndFastAfter(t *testing.T) {
295 var crashes atomic.Int32
296 releaseCrash := make(chan struct{})
297 defer func() {
298 select {
299 case <-releaseCrash:
300 default:
301 close(releaseCrash)
302 }
303 }()
304 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
305 rt.Env[fakeEnvMode] = "stall_intercept"
306 }, func(opts *ClientOptions) {
307 opts.OnCrash = func(error) {
308 <-releaseCrash
309 crashes.Add(1)
310 }
311 })
312
313 pending := make(chan error, 1)
314 go func() {
315 _, err := client.Intercept(context.Background(), protocol.EventToolBefore, json.RawMessage(`{}`), 30*time.Second)
316 pending <- err
317 }()
318 waitFor(t, "the intercept to reach the sidecar", 5*time.Second, func() bool {
319 return strings.Contains(client.proc.stderr.String(), "intercept-stalled")
320 })
321
322 if err := client.proc.cmd.Process.Kill(); err != nil {
323 t.Fatalf("kill fake sidecar: %v", err)
324 }
325 select {
326 case err := <-pending:
327 if err == nil {
328 t.Fatal("pending Intercept succeeded after the sidecar was killed")
329 }
330 case <-time.After(5 * time.Second):
331 t.Fatal("pending Intercept did not error after the sidecar was killed")
332 }
333 waitFor(t, "crash detection", 5*time.Second, client.Crashed)
334 waitFor(t, "process reaping", 5*time.Second, client.Exited)
335 // Detection and reaping precede callback completion. Force that ordering
336 // rather than relying on the supervisor winning a scheduler race.
337 if got := crashes.Load(); got != 0 {
338 t.Fatalf("OnCrash completed before release: %d", got)
339 }
340 close(releaseCrash)
341 select {
342 case <-client.serveExited:
343 case <-time.After(5 * time.Second):
344 t.Fatal("crash supervisor did not finish its notification")
345 }
346 if got := crashes.Load(); got != 1 {
347 t.Fatalf("OnCrash fired %d times, want exactly 1", got)
348 }
349
350 // Later calls fail fast with the provider_interrupted family.
351 start := time.Now()
352 _, err := client.Intercept(context.Background(), protocol.EventToolBefore, json.RawMessage(`{}`), 30*time.Second)
353 if err == nil {
354 t.Fatal("Intercept succeeded after crash")
355 }
356 if reason := protocolReason(t, err); reason != protocol.ErrProviderInterrupted {
357 t.Fatalf("reason = %q, want %q", reason, protocol.ErrProviderInterrupted)
358 }
359 if elapsed := time.Since(start); elapsed > time.Second {
360 t.Fatalf("post-crash Intercept was not fail-fast (%s)", elapsed)
361 }
362 if got := crashes.Load(); got != 1 {
363 t.Fatalf("OnCrash fired %d times after fail-fast, want exactly 1", got)
364 }
365 }
366
367 func TestTimeoutFor(t *testing.T) {
368 client := startFakeClient(t, nil, nil)
369 fast := []extension.InterceptorPoint{
370 extension.PointInputReceive, extension.PointToolBefore,
371 extension.PointToolAfter, extension.PointPermissionDecision,
372 }
373 for _, point := range fast {
374 if got := client.TimeoutFor(point); got != fastInterceptTimeout {
375 t.Fatalf("TimeoutFor(%s) = %s, want %s", point, got, fastInterceptTimeout)
376 }
377 }
378 slow := []extension.InterceptorPoint{
379 extension.PointSessionStart, extension.PointSystemPromptBuild,
380 extension.PointContextPrepare, extension.PointCompactionPrepare,
381 }
382 for _, point := range slow {
383 if got := client.TimeoutFor(point); got != slowInterceptTimeout {
384 t.Fatalf("TimeoutFor(%s) = %s, want %s", point, got, slowInterceptTimeout)
385 }
386 }
387 }
388
389 func TestTimeoutForManifestOverrideClamped(t *testing.T) {
390 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
391 rt.TimeoutMillis = 250
392 }, nil)
393 if got := client.TimeoutFor(extension.PointInputReceive); got != 250*time.Millisecond {
394 t.Fatalf("TimeoutFor with manifest override = %s, want 250ms", got)
395 }
396 clamped := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
397 rt.TimeoutMillis = 10 * 60 * 1000 // 10 minutes
398 }, nil)
399 if got := clamped.TimeoutFor(extension.PointSessionStart); got != maxInterceptTimeout {
400 t.Fatalf("TimeoutFor beyond the ceiling = %s, want the 60s clamp", got)
401 }
402 }
403
404 func TestUIHandlerDefaultsToUnavailable(t *testing.T) {
405 client := startFakeClient(t, nil, nil)
406 _, err := client.ui.Publish(context.Background(), protocol.UIPublishParams{})
407 if err == nil {
408 t.Fatal("default UI handler accepted a publish")
409 }
410 if reason := protocolReason(t, err); reason != protocol.ErrUnknownMethod {
411 t.Fatalf("reason = %q, want %q", reason, protocol.ErrUnknownMethod)
412 }
413 }
414
415 // TestUIActionAndSubmitRoundTrip drives the host-initiated UI calls (stage 8)
416 // over the real wire: the fake sidecar echoes the action id and accepts the
417 // form submission.
418 func TestUIActionAndSubmitRoundTrip(t *testing.T) {
419 client := startFakeClient(t, nil, nil)
420 action, err := client.UIAction(context.Background(), protocol.UIActionParams{
421 ActionID: "act1", SessionID: "sess-test", Generation: 1, Args: map[string]string{"k": "v"},
422 })
423 if err != nil {
424 t.Fatalf("UIAction: %v", err)
425 }
426 if !action.Accepted || action.Message == "" {
427 t.Fatalf("UIAction result = %+v", action)
428 }
429 submit, err := client.UISubmit(context.Background(), protocol.UISubmitParams{
430 SurfaceID: "f1", SessionID: "sess-test", Generation: 1, Values: map[string]any{"name": "x"},
431 })
432 if err != nil {
433 t.Fatalf("UISubmit: %v", err)
434 }
435 if !submit.Accepted {
436 t.Fatalf("UISubmit result = %+v", submit)
437 }
438 }
439
440 func TestStartRejectsInvalidOptions(t *testing.T) {
441 pkg, installed := fakeSidecarPackage(t, "fakeplugin", nil)
442 if _, err := StartClient(context.Background(), ClientOptions{
443 Package: pkg,
444 Installed: installed,
445 Session: protocol.SessionContext{},
446 }); err == nil {
447 t.Fatal("StartClient accepted an empty session context")
448 }
449 pkgNoRuntime := pluginpkg.Package{Root: t.TempDir(), Manifest: pluginpkg.Manifest{Name: "x"}}
450 if _, err := StartClient(context.Background(), ClientOptions{
451 Package: pkgNoRuntime,
452 Installed: installed,
453 Session: testSessionContext(),
454 }); err == nil {
455 t.Fatal("StartClient accepted a package without a runtime")
456 }
457 }
458
459 // TestWriteStallKillsWedgedSidecar is the deterministic regression for the
460 // host-availability review finding: a sidecar that stays alive but stops
461 // reading stdin fills the pipe, and an unbounded write would hang the host
462 // forever. With the write-stall bound the call fails fast, the connection
463 // dies, and the process tree is killed.
464 func TestWriteStallKillsWedgedSidecar(t *testing.T) {
465 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
466 rt.Env[fakeEnvMode] = "wedge_after_init"
467 }, func(opts *ClientOptions) {
468 opts.WriteStallBound = 100 * time.Millisecond
469 })
470
471 // Bypass Intercept's externalization (payloads over 64 KiB offload to
472 // content refs) so the frame itself exceeds the OS pipe buffer.
473 big := json.RawMessage(`{"pad":"` + strings.Repeat("x", 1<<20) + `"}`)
474 start := time.Now()
475 _, err := client.conn.Request(context.Background(), string(protocol.MethodExtensionIntercept), json.RawMessage(big))
476 elapsed := time.Since(start)
477 var stall *rpcwire.WriteStallError
478 if !errors.As(err, &stall) {
479 t.Fatalf("error = %v, want WriteStallError", err)
480 }
481 if elapsed > 5*time.Second {
482 t.Fatalf("stall took %s to abort, want near 100ms", elapsed)
483 }
484
485 waitFor(t, "client marked crashed", 5*time.Second, client.Crashed)
486 waitFor(t, "wedged sidecar killed", 5*time.Second, client.Exited)
487 }
488
489 // TestWriteStallWatchdogOutlivesCallerTimeout: a per-call timeout shorter
490 // than the stall bound aborts only that call — the stall watchdog is an
491 // independent absolute bound that still fails the connection and kills the
492 // wedged sidecar afterwards. (Review finding: a 5s intercept ctx must not
493 // preempt the 10s stall watchdog.)
494 func TestWriteStallWatchdogOutlivesCallerTimeout(t *testing.T) {
495 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
496 rt.Env[fakeEnvMode] = "wedge_after_init"
497 }, func(opts *ClientOptions) {
498 opts.WriteStallBound = 300 * time.Millisecond
499 })
500
501 big := json.RawMessage(`{"pad":"` + strings.Repeat("x", 1<<20) + `"}`)
502 // Primer: a background-context request whose frame wedges the single
503 // writer for good (the fake never reads). Its write cannot be cancelled,
504 // so the stall watchdog has an active write to trip on.
505 go func() {
506 _, _ = client.conn.Request(context.Background(), string(protocol.MethodExtensionIntercept), big)
507 }()
508 // The timed call cancels fast — but the primer's write outlives it, and
509 // the 300ms stall watchdog still fires: the connection dies and the
510 // wedged process is killed. The caller timeout did not preempt it.
511 ctx, cancel := context.WithCancel(context.Background())
512 go func() {
513 time.Sleep(200 * time.Millisecond)
514 cancel()
515 }()
516 start := time.Now()
517 _, err := client.conn.Request(ctx, string(protocol.MethodExtensionIntercept), big)
518 elapsed := time.Since(start)
519 if !errors.Is(err, context.Canceled) {
520 t.Fatalf("error = %v, want context.Canceled", err)
521 }
522 if elapsed > 3*time.Second {
523 t.Fatalf("caller cancel took %s, want near 200ms", elapsed)
524 }
525
526 waitFor(t, "client marked crashed after caller cancel", 10*time.Second, client.Crashed)
527 waitFor(t, "wedged sidecar killed after caller cancel", 10*time.Second, client.Exited)
528 }
529
529 lines GO