fix(agent/claude): surface stderr tail on writeClaudeInput failure + lock with e2e test (#1698)

#1674 wired claude's post-handshake error path through withAgentStderr but
left the writeClaudeInput failure branch returning a bare "broken pipe"
error. That branch fires precisely when claude crashes during startup —
exactly when the stderr tail is most useful for root-causing V8 aborts,
Bun panics, or missing native modules. cmd.Wait() before sampling Tail()
flushes os/exec's internal stderr copy goroutine, matching the
Wait→Tail synchronization contract spelled out in stderr_tail.go.

Adds TestClaudeExecuteSurfacesStderrWhenChildExitsEarly mirroring the
codex test: a fake claude binary drains stdin, writes a V8-abort line to
stderr, and exits 3. Locks in the contract that Result.Error carries the
stderr tail in the post-handshake failure path on the claude backend too.
This commit is contained in:
Bohan Jiang
2026-04-26 11:09:38 +08:00
committed by GitHub
parent 12e6ca9906
commit aca74293dd
2 changed files with 69 additions and 1 deletions

View File

@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -97,10 +98,16 @@ func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
return nil, fmt.Errorf("start claude: %w", err)
}
if err := writeClaudeInput(stdin, prompt); err != nil {
// claude almost certainly died during startup (broken pipe). The
// real reason is sitting in stderrBuf — surface it the same way the
// post-handshake error path does, otherwise the daemon log is the
// only place that knows whether it was a V8 abort, a missing native
// module, or anything else. cmd.Wait() flushes os/exec's stderr
// copy goroutine, so stderrBuf.Tail() is safe to read.
closeStdin()
cancel()
_ = cmd.Wait()
return nil, fmt.Errorf("write claude input: %w", err)
return nil, errors.New(withAgentStderr(fmt.Sprintf("write claude input: %v", err), "claude", stderrBuf.Tail()))
}
closeStdin()

View File

@@ -2,11 +2,15 @@ package agent
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestClaudeHandleAssistantText(t *testing.T) {
@@ -533,6 +537,63 @@ func TestResolveSessionID(t *testing.T) {
}
}
func TestClaudeExecuteSurfacesStderrWhenChildExitsEarly(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
// Fake claude binary: drains stdin so writeClaudeInput succeeds, writes a
// canonical V8-abort line to stderr, then exits non-zero before emitting
// any stream-json to stdout. This is the exact failure mode that motivated
// PR #1674 — without sampling stderrBuf.Tail() after cmd.Wait() returns,
// Result.Error would be a useless "exit status 3".
fakePath := filepath.Join(t.TempDir(), "claude")
script := "#!/bin/sh\n" +
"cat >/dev/null\n" +
"echo \"FATAL ERROR: V8 abort: assertion failed\" >&2\n" +
"exit 3\n"
writeTestExecutable(t, fakePath, []byte(script))
backend, err := New("claude", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new claude backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("execute: %v", err)
}
// Drain message stream so the lifecycle goroutine can progress.
go func() {
for range session.Messages {
}
}()
select {
case result, ok := <-session.Result:
if !ok {
t.Fatal("result channel closed without a value")
}
if result.Status != "failed" {
t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error)
}
if !strings.Contains(result.Error, "claude exited with error") {
t.Fatalf("expected error to mention exit, got %q", result.Error)
}
if !strings.Contains(result.Error, "V8 abort: assertion failed") {
t.Fatalf("expected error to include stderr hint, got %q", result.Error)
}
if !strings.Contains(result.Error, "claude stderr:") {
t.Fatalf("expected stderr label in error, got %q", result.Error)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
func mustMarshal(t *testing.T, v any) json.RawMessage {
t.Helper()
data, err := json.Marshal(v)