Tool runner: the cap's reason fails the call; the test proves the ending; the refused-answer limit keys on the caller's prefix and forgets old entries (issue 1966, review round 1)

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
This commit is contained in:
2026-11-09 13:59:13 +01:00
parent f3a9cc276e
commit 312e29478e
4 changed files with 73 additions and 27 deletions
+42 -19
View File
@@ -80,7 +80,7 @@ const callingBundle = `#!/bin/sh
while IFS= read -r line; do
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\)[,}].*/\1/p')
case "$line" in
*'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
*'"method":"initialize"'*) echo started >> "$STARTS"; printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
*'"method":"tools/list"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"t","description":"d"}]}}\n' "$id" ;;
*'"method":"tools/call"'*) [ "$MODE" = long ] && printf '%4096s\n' x ;;
esac
@@ -91,21 +91,28 @@ done
// the over-long line ends the bundle and fails the call at once, never leaving the runner reading nothing while the
// bundle's next write blocks.
func TestAnOverlongLineAndAnUnansweredCallAreSaid(t *testing.T) {
oldMax, oldCall := maxLine, CallTimeout
maxLine, CallTimeout = 1024, 300*time.Millisecond
t.Cleanup(func() { maxLine, CallTimeout = oldMax, oldCall })
for _, row := range []struct{ mode, said string }{
{"long", "bundle wrote a line over 1024 bytes"},
{"silent", ""},
oldMax, oldCall, oldFirst := maxLine, CallTimeout, RestartFirst
maxLine, RestartFirst = 1024, 10*time.Millisecond
t.Cleanup(func() { maxLine, CallTimeout, RestartFirst = oldMax, oldCall, oldFirst })
for _, row := range []struct {
mode string
timeout time.Duration
}{
// The call's own bound is long: only the runner ending the bundle fails it within a second.
{"long", 10 * time.Second},
{"silent", 300 * time.Millisecond},
} {
t.Run(row.mode, func(t *testing.T) {
entry := filepath.Join(t.TempDir(), "bundle")
CallTimeout = row.timeout
dir := t.TempDir()
entry := filepath.Join(dir, "bundle")
starts := filepath.Join(dir, "starts")
if err := os.WriteFile(entry, []byte(callingBundle), 0o755); err != nil {
t.Fatal(err)
}
said := make(chan string, 32)
logf := func(f string, a ...any) { said <- fmt.Sprintf(f, a...) }
l, err := Start("caller", entry, append(os.Environ(), "MODE="+row.mode), &refusingState{}, logf)
l, err := Start("caller", entry, append(os.Environ(), "MODE="+row.mode, "STARTS="+starts), &refusingState{}, logf)
if err != nil {
t.Fatal(err)
}
@@ -115,25 +122,41 @@ func TestAnOverlongLineAndAnUnansweredCallAreSaid(t *testing.T) {
if err == nil {
t.Fatal("the call was answered")
}
if took := time.Since(begun); took > 5*time.Second {
t.Fatalf("the call hung for %s", took)
}
var heard []string
timeout := time.After(5 * time.Second)
for {
want := "caller.t called by \"caller-1\""
if row.mode == "long" {
want = "ending the bundle"
if took := time.Since(begun); took > time.Second {
t.Fatalf("the call hung for %s", took)
}
if !strings.Contains(err.Error(), "over 1024 bytes") || strings.Contains(err.Error(), "did not answer") {
t.Fatalf("the call failed with %q, not the cap's reason", err)
}
}
for found := false; !found; {
select {
case line := <-said:
heard = append(heard, line)
if row.mode == "long" && strings.Contains(line, row.said) && strings.Contains(line, "ending the bundle") {
return
}
if row.mode == "silent" && strings.Contains(line, "caller.t called by \"caller-1\"") && strings.Contains(line, "did not answer tools/call") {
return
}
found = strings.Contains(line, want)
case <-timeout:
t.Fatalf("not said; the log: %v", heard)
}
}
if row.mode != "long" {
return
}
// The bundle is started again: its start is written once more.
for {
if b, _ := os.ReadFile(starts); strings.Count(string(b), "started") >= 2 {
return
}
select {
case <-timeout:
t.Fatal("the bundle was not started again")
case <-time.After(10 * time.Millisecond):
}
}
})
}
}