fix: resolve E2BIG error by passing large prompts via stdin to Claude CLI (#5186)

* fix: resolve E2BIG error by passing large prompts via stdin to Claude CLI

- Pass messages via stdin instead of command line arguments to avoid Linux argument length limits
- Add --input-format text flag to claude CLI command
- Update execa configuration to use stdin pipe
- Fix corresponding unit tests with proper async iterator mocking
- Resolves spawn E2BIG errors when using very large conversation histories

* fix: address race condition and improve error handling

- Use setImmediate to ensure process is spawned before writing to stdin
- Add proper error handling for stdin write operations
- Add tests for error scenarios
- Update existing tests to handle async behavior properly

* fix: remove --input-format text flag to prevent CLI parsing errors

The --input-format text flag was causing the Claude CLI to misinterpret
the JSON content passed via stdin, leading to errors like 'unknown option -------'
when the system prompt contained dashes. Removing this flag allows the CLI
to properly handle the JSON input via stdin.

---------

Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
Johannes 2025-06-28 02:07:04 +02:00 committed by GitHub
parent 719d661478
commit de99e348df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 279 additions and 3 deletions

View file

@ -13,9 +13,92 @@ vi.mock("vscode", () => ({
},
}))
// Mock execa to test stdin behavior
const mockExeca = vi.fn()
const mockStdin = {
write: vi.fn((data, encoding, callback) => {
// Simulate successful write
if (callback) callback(null)
}),
end: vi.fn(),
}
// Mock process that simulates successful execution
const createMockProcess = () => {
let resolveProcess: (value: { exitCode: number }) => void
const processPromise = new Promise<{ exitCode: number }>((resolve) => {
resolveProcess = resolve
})
const mockProcess = {
stdin: mockStdin,
stdout: {
on: vi.fn(),
},
stderr: {
on: vi.fn((event, callback) => {
// Don't emit any stderr data in tests
}),
},
on: vi.fn((event, callback) => {
if (event === "close") {
// Simulate successful process completion after a short delay
setTimeout(() => {
callback(0)
resolveProcess({ exitCode: 0 })
}, 10)
}
if (event === "error") {
// Don't emit any errors in tests
}
}),
killed: false,
kill: vi.fn(),
then: processPromise.then.bind(processPromise),
catch: processPromise.catch.bind(processPromise),
finally: processPromise.finally.bind(processPromise),
}
return mockProcess
}
vi.mock("execa", () => ({
execa: mockExeca,
}))
// Mock readline with proper interface simulation
let mockReadlineInterface: any = null
vi.mock("readline", () => ({
default: {
createInterface: vi.fn(() => {
mockReadlineInterface = {
async *[Symbol.asyncIterator]() {
// Simulate Claude CLI JSON output
yield '{"type":"text","text":"Hello"}'
yield '{"type":"text","text":" world"}'
// Simulate end of stream - must return to terminate the iterator
return
},
close: vi.fn(),
}
return mockReadlineInterface
}),
},
}))
describe("runClaudeCode", () => {
beforeEach(() => {
vi.clearAllMocks()
mockExeca.mockReturnValue(createMockProcess())
// Mock setImmediate to run synchronously in tests
vi.spyOn(global, "setImmediate").mockImplementation((callback: any) => {
callback()
return {} as any
})
})
afterEach(() => {
vi.restoreAllMocks()
})
test("should export runClaudeCode function", async () => {
@ -34,4 +117,174 @@ describe("runClaudeCode", () => {
expect(Symbol.asyncIterator in result).toBe(true)
expect(typeof result[Symbol.asyncIterator]).toBe("function")
})
test("should use stdin instead of command line arguments for messages", async () => {
const { runClaudeCode } = await import("../run")
const messages = [{ role: "user" as const, content: "Hello world!" }]
const options = {
systemPrompt: "You are a helpful assistant",
messages,
}
const generator = runClaudeCode(options)
// Consume the generator to completion
const results = []
for await (const chunk of generator) {
results.push(chunk)
}
// Verify execa was called with correct arguments (no JSON.stringify(messages) in args)
expect(mockExeca).toHaveBeenCalledWith(
"claude",
expect.arrayContaining([
"-p",
"--system-prompt",
"You are a helpful assistant",
"--verbose",
"--output-format",
"stream-json",
"--disallowedTools",
expect.any(String),
"--max-turns",
"1",
]),
expect.objectContaining({
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
}),
)
// Verify the arguments do NOT contain the stringified messages
const [, args] = mockExeca.mock.calls[0]
expect(args).not.toContain(JSON.stringify(messages))
// Verify messages were written to stdin with callback
expect(mockStdin.write).toHaveBeenCalledWith(JSON.stringify(messages), "utf8", expect.any(Function))
expect(mockStdin.end).toHaveBeenCalled()
// Verify we got the expected mock output
expect(results).toHaveLength(2)
expect(results[0]).toEqual({ type: "text", text: "Hello" })
expect(results[1]).toEqual({ type: "text", text: " world" })
})
test("should include model parameter when provided", async () => {
const { runClaudeCode } = await import("../run")
const options = {
systemPrompt: "You are a helpful assistant",
messages: [{ role: "user" as const, content: "Hello" }],
modelId: "claude-3-5-sonnet-20241022",
}
const generator = runClaudeCode(options)
// Consume at least one item to trigger process spawn
await generator.next()
// Clean up the generator
await generator.return(undefined)
const [, args] = mockExeca.mock.calls[0]
expect(args).toContain("--model")
expect(args).toContain("claude-3-5-sonnet-20241022")
})
test("should use custom claude path when provided", async () => {
const { runClaudeCode } = await import("../run")
const options = {
systemPrompt: "You are a helpful assistant",
messages: [{ role: "user" as const, content: "Hello" }],
path: "/custom/path/to/claude",
}
const generator = runClaudeCode(options)
// Consume at least one item to trigger process spawn
await generator.next()
// Clean up the generator
await generator.return(undefined)
const [claudePath] = mockExeca.mock.calls[0]
expect(claudePath).toBe("/custom/path/to/claude")
})
test("should handle stdin write errors gracefully", async () => {
const { runClaudeCode } = await import("../run")
// Create a mock process with stdin that fails
const mockProcessWithError = createMockProcess()
mockProcessWithError.stdin.write = vi.fn((data, encoding, callback) => {
// Simulate write error
if (callback) callback(new Error("EPIPE: broken pipe"))
})
// Mock console.error to verify error logging
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
mockExeca.mockReturnValueOnce(mockProcessWithError)
const options = {
systemPrompt: "You are a helpful assistant",
messages: [{ role: "user" as const, content: "Hello" }],
}
const generator = runClaudeCode(options)
// Try to consume the generator
try {
await generator.next()
} catch (error) {
// Expected to fail
}
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith("Error writing to Claude Code stdin:", expect.any(Error))
// Verify process was killed
expect(mockProcessWithError.kill).toHaveBeenCalled()
// Clean up
consoleErrorSpy.mockRestore()
await generator.return(undefined)
})
test("should handle stdin access errors gracefully", async () => {
const { runClaudeCode } = await import("../run")
// Create a mock process without stdin
const mockProcessWithoutStdin = createMockProcess()
mockProcessWithoutStdin.stdin = null as any
// Mock console.error to verify error logging
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
mockExeca.mockReturnValueOnce(mockProcessWithoutStdin)
const options = {
systemPrompt: "You are a helpful assistant",
messages: [{ role: "user" as const, content: "Hello" }],
}
const generator = runClaudeCode(options)
// Try to consume the generator
try {
await generator.next()
} catch (error) {
// Expected to fail
}
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith("Error accessing Claude Code stdin:", expect.any(Error))
// Verify process was killed
expect(mockProcessWithoutStdin.kill).toHaveBeenCalled()
// Clean up
consoleErrorSpy.mockRestore()
await generator.return(undefined)
})
})

View file

@ -112,7 +112,6 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
const args = [
"-p",
JSON.stringify(messages),
"--system-prompt",
systemPrompt,
"--verbose",
@ -129,8 +128,8 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
args.push("--model", modelId)
}
return execa(claudePath, args, {
stdin: "ignore",
const child = execa(claudePath, args, {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
@ -142,6 +141,30 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
maxBuffer: 1024 * 1024 * 1000,
timeout: CLAUDE_CODE_TIMEOUT,
})
// Write messages to stdin after process is spawned
// This avoids the E2BIG error on Linux when passing large messages as command line arguments
// Linux has a per-argument limit of ~128KiB for execve() system calls
const messagesJson = JSON.stringify(messages)
// Use setImmediate to ensure the process has been spawned before writing to stdin
// This prevents potential race conditions where stdin might not be ready
setImmediate(() => {
try {
child.stdin.write(messagesJson, "utf8", (error) => {
if (error) {
console.error("Error writing to Claude Code stdin:", error)
child.kill()
}
})
child.stdin.end()
} catch (error) {
console.error("Error accessing Claude Code stdin:", error)
child.kill()
}
})
return child
}
function parseChunk(data: string, processState: ProcessState) {