返回 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 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
297 rt.Env[fakeEnvMode] = "stall_intercept"
298 }, func(opts *ClientOptions) {
299 opts.OnCrash = func(error) { crashes.Add(1) }
300 })
301
302 pending := make(chan error, 1)
303 go func() {
304 _, err := client.Intercept(context.Background(), protocol.EventToolBefore, json.RawMessage(`{}`), 30*time.Second)
305 pending <- err
306 }()
307 waitFor(t, "the intercept to reach the sidecar", 5*time.Second, func() bool {
308 return strings.Contains(client.proc.stderr.String(), "intercept-stalled")
309 })
310
311 if err := client.proc.cmd.Process.Kill(); err != nil {
312 t.Fatalf("kill fake sidecar: %v", err)
313 }
314 select {
315 case err := <-pending:
316 if err == nil {
317 t.Fatal("pending Intercept succeeded after the sidecar was killed")
318 }
319 case <-time.After(5 * time.Second):
320 t.Fatal("pending Intercept did not error after the sidecar was killed")
321 }
322 waitFor(t, "crash detection", 5*time.Second, client.Crashed)
323 waitFor(t, "process reaping", 5*time.Second, client.Exited)
324 if got := crashes.Load(); got != 1 {
325 t.Fatalf("OnCrash fired %d times, want exactly 1", got)
326 }
327
328 // Later calls fail fast with the provider_interrupted family.
329 start := time.Now()
330 _, err := client.Intercept(context.Background(), protocol.EventToolBefore, json.RawMessage(`{}`), 30*time.Second)
331 if err == nil {
332 t.Fatal("Intercept succeeded after crash")
333 }
334 if reason := protocolReason(t, err); reason != protocol.ErrProviderInterrupted {
335 t.Fatalf("reason = %q, want %q", reason, protocol.ErrProviderInterrupted)
336 }
337 if elapsed := time.Since(start); elapsed > time.Second {
338 t.Fatalf("post-crash Intercept was not fail-fast (%s)", elapsed)
339 }
340 if got := crashes.Load(); got != 1 {
341 t.Fatalf("OnCrash fired %d times after fail-fast, want exactly 1", got)
342 }
343 }
344
345 func TestTimeoutFor(t *testing.T) {
346 client := startFakeClient(t, nil, nil)
347 fast := []extension.InterceptorPoint{
348 extension.PointInputReceive, extension.PointToolBefore,
349 extension.PointToolAfter, extension.PointPermissionDecision,
350 }
351 for _, point := range fast {
352 if got := client.TimeoutFor(point); got != fastInterceptTimeout {
353 t.Fatalf("TimeoutFor(%s) = %s, want %s", point, got, fastInterceptTimeout)
354 }
355 }
356 slow := []extension.InterceptorPoint{
357 extension.PointSessionStart, extension.PointSystemPromptBuild,
358 extension.PointContextPrepare, extension.PointCompactionPrepare,
359 }
360 for _, point := range slow {
361 if got := client.TimeoutFor(point); got != slowInterceptTimeout {
362 t.Fatalf("TimeoutFor(%s) = %s, want %s", point, got, slowInterceptTimeout)
363 }
364 }
365 }
366
367 func TestTimeoutForManifestOverrideClamped(t *testing.T) {
368 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
369 rt.TimeoutMillis = 250
370 }, nil)
371 if got := client.TimeoutFor(extension.PointInputReceive); got != 250*time.Millisecond {
372 t.Fatalf("TimeoutFor with manifest override = %s, want 250ms", got)
373 }
374 clamped := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
375 rt.TimeoutMillis = 10 * 60 * 1000 // 10 minutes
376 }, nil)
377 if got := clamped.TimeoutFor(extension.PointSessionStart); got != maxInterceptTimeout {
378 t.Fatalf("TimeoutFor beyond the ceiling = %s, want the 60s clamp", got)
379 }
380 }
381
382 func TestUIHandlerDefaultsToUnavailable(t *testing.T) {
383 client := startFakeClient(t, nil, nil)
384 _, err := client.ui.Publish(context.Background(), protocol.UIPublishParams{})
385 if err == nil {
386 t.Fatal("default UI handler accepted a publish")
387 }
388 if reason := protocolReason(t, err); reason != protocol.ErrUnknownMethod {
389 t.Fatalf("reason = %q, want %q", reason, protocol.ErrUnknownMethod)
390 }
391 }
392
393 // TestUIActionAndSubmitRoundTrip drives the host-initiated UI calls (stage 8)
394 // over the real wire: the fake sidecar echoes the action id and accepts the
395 // form submission.
396 func TestUIActionAndSubmitRoundTrip(t *testing.T) {
397 client := startFakeClient(t, nil, nil)
398 action, err := client.UIAction(context.Background(), protocol.UIActionParams{
399 ActionID: "act1", SessionID: "sess-test", Generation: 1, Args: map[string]string{"k": "v"},
400 })
401 if err != nil {
402 t.Fatalf("UIAction: %v", err)
403 }
404 if !action.Accepted || action.Message == "" {
405 t.Fatalf("UIAction result = %+v", action)
406 }
407 submit, err := client.UISubmit(context.Background(), protocol.UISubmitParams{
408 SurfaceID: "f1", SessionID: "sess-test", Generation: 1, Values: map[string]any{"name": "x"},
409 })
410 if err != nil {
411 t.Fatalf("UISubmit: %v", err)
412 }
413 if !submit.Accepted {
414 t.Fatalf("UISubmit result = %+v", submit)
415 }
416 }
417
418 func TestStartRejectsInvalidOptions(t *testing.T) {
419 pkg, installed := fakeSidecarPackage(t, "fakeplugin", nil)
420 if _, err := StartClient(context.Background(), ClientOptions{
421 Package: pkg,
422 Installed: installed,
423 Session: protocol.SessionContext{},
424 }); err == nil {
425 t.Fatal("StartClient accepted an empty session context")
426 }
427 pkgNoRuntime := pluginpkg.Package{Root: t.TempDir(), Manifest: pluginpkg.Manifest{Name: "x"}}
428 if _, err := StartClient(context.Background(), ClientOptions{
429 Package: pkgNoRuntime,
430 Installed: installed,
431 Session: testSessionContext(),
432 }); err == nil {
433 t.Fatal("StartClient accepted a package without a runtime")
434 }
435 }
436
437 // TestWriteStallKillsWedgedSidecar is the deterministic regression for the
438 // host-availability review finding: a sidecar that stays alive but stops
439 // reading stdin fills the pipe, and an unbounded write would hang the host
440 // forever. With the write-stall bound the call fails fast, the connection
441 // dies, and the process tree is killed.
442 func TestWriteStallKillsWedgedSidecar(t *testing.T) {
443 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
444 rt.Env[fakeEnvMode] = "wedge_after_init"
445 }, func(opts *ClientOptions) {
446 opts.WriteStallBound = 100 * time.Millisecond
447 })
448
449 // Bypass Intercept's externalization (payloads over 64 KiB offload to
450 // content refs) so the frame itself exceeds the OS pipe buffer.
451 big := json.RawMessage(`{"pad":"` + strings.Repeat("x", 1<<20) + `"}`)
452 start := time.Now()
453 _, err := client.conn.Request(context.Background(), string(protocol.MethodExtensionIntercept), json.RawMessage(big))
454 elapsed := time.Since(start)
455 var stall *rpcwire.WriteStallError
456 if !errors.As(err, &stall) {
457 t.Fatalf("error = %v, want WriteStallError", err)
458 }
459 if elapsed > 5*time.Second {
460 t.Fatalf("stall took %s to abort, want near 100ms", elapsed)
461 }
462
463 waitFor(t, "client marked crashed", 5*time.Second, client.Crashed)
464 waitFor(t, "wedged sidecar killed", 5*time.Second, client.Exited)
465 }
466
467 // TestWriteStallWatchdogOutlivesCallerTimeout: a per-call timeout shorter
468 // than the stall bound aborts only that call — the stall watchdog is an
469 // independent absolute bound that still fails the connection and kills the
470 // wedged sidecar afterwards. (Review finding: a 5s intercept ctx must not
471 // preempt the 10s stall watchdog.)
472 func TestWriteStallWatchdogOutlivesCallerTimeout(t *testing.T) {
473 client := startFakeClient(t, func(rt *pluginpkg.RuntimeSpec) {
474 rt.Env[fakeEnvMode] = "wedge_after_init"
475 }, func(opts *ClientOptions) {
476 opts.WriteStallBound = 300 * time.Millisecond
477 })
478
479 big := json.RawMessage(`{"pad":"` + strings.Repeat("x", 1<<20) + `"}`)
480 // Primer: a background-context request whose frame wedges the single
481 // writer for good (the fake never reads). Its write cannot be cancelled,
482 // so the stall watchdog has an active write to trip on.
483 go func() {
484 _, _ = client.conn.Request(context.Background(), string(protocol.MethodExtensionIntercept), big)
485 }()
486 // The timed call cancels fast — but the primer's write outlives it, and
487 // the 300ms stall watchdog still fires: the connection dies and the
488 // wedged process is killed. The caller timeout did not preempt it.
489 ctx, cancel := context.WithCancel(context.Background())
490 go func() {
491 time.Sleep(200 * time.Millisecond)
492 cancel()
493 }()
494 start := time.Now()
495 _, err := client.conn.Request(ctx, string(protocol.MethodExtensionIntercept), big)
496 elapsed := time.Since(start)
497 if !errors.Is(err, context.Canceled) {
498 t.Fatalf("error = %v, want context.Canceled", err)
499 }
500 if elapsed > 3*time.Second {
501 t.Fatalf("caller cancel took %s, want near 200ms", elapsed)
502 }
503
504 waitFor(t, "client marked crashed after caller cancel", 10*time.Second, client.Crashed)
505 waitFor(t, "wedged sidecar killed after caller cancel", 10*time.Second, client.Exited)
506 }
507
507 lines GO