Skip to content

Commit afa23fd

Browse files
Fovtydaniel-lxs
authored andcommitted
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 <[email protected]>
1 parent fbe3fb1 commit afa23fd

File tree

2 files changed

+279
-3
lines changed

2 files changed

+279
-3
lines changed

src/integrations/claude-code/__tests__/run.spec.ts

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,92 @@ vi.mock("vscode", () => ({
1313
},
1414
}))
1515

16+
// Mock execa to test stdin behavior
17+
const mockExeca = vi.fn()
18+
const mockStdin = {
19+
write: vi.fn((data, encoding, callback) => {
20+
// Simulate successful write
21+
if (callback) callback(null)
22+
}),
23+
end: vi.fn(),
24+
}
25+
26+
// Mock process that simulates successful execution
27+
const createMockProcess = () => {
28+
let resolveProcess: (value: { exitCode: number }) => void
29+
const processPromise = new Promise<{ exitCode: number }>((resolve) => {
30+
resolveProcess = resolve
31+
})
32+
33+
const mockProcess = {
34+
stdin: mockStdin,
35+
stdout: {
36+
on: vi.fn(),
37+
},
38+
stderr: {
39+
on: vi.fn((event, callback) => {
40+
// Don't emit any stderr data in tests
41+
}),
42+
},
43+
on: vi.fn((event, callback) => {
44+
if (event === "close") {
45+
// Simulate successful process completion after a short delay
46+
setTimeout(() => {
47+
callback(0)
48+
resolveProcess({ exitCode: 0 })
49+
}, 10)
50+
}
51+
if (event === "error") {
52+
// Don't emit any errors in tests
53+
}
54+
}),
55+
killed: false,
56+
kill: vi.fn(),
57+
then: processPromise.then.bind(processPromise),
58+
catch: processPromise.catch.bind(processPromise),
59+
finally: processPromise.finally.bind(processPromise),
60+
}
61+
return mockProcess
62+
}
63+
64+
vi.mock("execa", () => ({
65+
execa: mockExeca,
66+
}))
67+
68+
// Mock readline with proper interface simulation
69+
let mockReadlineInterface: any = null
70+
71+
vi.mock("readline", () => ({
72+
default: {
73+
createInterface: vi.fn(() => {
74+
mockReadlineInterface = {
75+
async *[Symbol.asyncIterator]() {
76+
// Simulate Claude CLI JSON output
77+
yield '{"type":"text","text":"Hello"}'
78+
yield '{"type":"text","text":" world"}'
79+
// Simulate end of stream - must return to terminate the iterator
80+
return
81+
},
82+
close: vi.fn(),
83+
}
84+
return mockReadlineInterface
85+
}),
86+
},
87+
}))
88+
1689
describe("runClaudeCode", () => {
1790
beforeEach(() => {
1891
vi.clearAllMocks()
92+
mockExeca.mockReturnValue(createMockProcess())
93+
// Mock setImmediate to run synchronously in tests
94+
vi.spyOn(global, "setImmediate").mockImplementation((callback: any) => {
95+
callback()
96+
return {} as any
97+
})
98+
})
99+
100+
afterEach(() => {
101+
vi.restoreAllMocks()
19102
})
20103

21104
test("should export runClaudeCode function", async () => {
@@ -34,4 +117,174 @@ describe("runClaudeCode", () => {
34117
expect(Symbol.asyncIterator in result).toBe(true)
35118
expect(typeof result[Symbol.asyncIterator]).toBe("function")
36119
})
120+
121+
test("should use stdin instead of command line arguments for messages", async () => {
122+
const { runClaudeCode } = await import("../run")
123+
const messages = [{ role: "user" as const, content: "Hello world!" }]
124+
const options = {
125+
systemPrompt: "You are a helpful assistant",
126+
messages,
127+
}
128+
129+
const generator = runClaudeCode(options)
130+
131+
// Consume the generator to completion
132+
const results = []
133+
for await (const chunk of generator) {
134+
results.push(chunk)
135+
}
136+
137+
// Verify execa was called with correct arguments (no JSON.stringify(messages) in args)
138+
expect(mockExeca).toHaveBeenCalledWith(
139+
"claude",
140+
expect.arrayContaining([
141+
"-p",
142+
"--system-prompt",
143+
"You are a helpful assistant",
144+
"--verbose",
145+
"--output-format",
146+
"stream-json",
147+
"--disallowedTools",
148+
expect.any(String),
149+
"--max-turns",
150+
"1",
151+
]),
152+
expect.objectContaining({
153+
stdin: "pipe",
154+
stdout: "pipe",
155+
stderr: "pipe",
156+
}),
157+
)
158+
159+
// Verify the arguments do NOT contain the stringified messages
160+
const [, args] = mockExeca.mock.calls[0]
161+
expect(args).not.toContain(JSON.stringify(messages))
162+
163+
// Verify messages were written to stdin with callback
164+
expect(mockStdin.write).toHaveBeenCalledWith(JSON.stringify(messages), "utf8", expect.any(Function))
165+
expect(mockStdin.end).toHaveBeenCalled()
166+
167+
// Verify we got the expected mock output
168+
expect(results).toHaveLength(2)
169+
expect(results[0]).toEqual({ type: "text", text: "Hello" })
170+
expect(results[1]).toEqual({ type: "text", text: " world" })
171+
})
172+
173+
test("should include model parameter when provided", async () => {
174+
const { runClaudeCode } = await import("../run")
175+
const options = {
176+
systemPrompt: "You are a helpful assistant",
177+
messages: [{ role: "user" as const, content: "Hello" }],
178+
modelId: "claude-3-5-sonnet-20241022",
179+
}
180+
181+
const generator = runClaudeCode(options)
182+
183+
// Consume at least one item to trigger process spawn
184+
await generator.next()
185+
186+
// Clean up the generator
187+
await generator.return(undefined)
188+
189+
const [, args] = mockExeca.mock.calls[0]
190+
expect(args).toContain("--model")
191+
expect(args).toContain("claude-3-5-sonnet-20241022")
192+
})
193+
194+
test("should use custom claude path when provided", async () => {
195+
const { runClaudeCode } = await import("../run")
196+
const options = {
197+
systemPrompt: "You are a helpful assistant",
198+
messages: [{ role: "user" as const, content: "Hello" }],
199+
path: "/custom/path/to/claude",
200+
}
201+
202+
const generator = runClaudeCode(options)
203+
204+
// Consume at least one item to trigger process spawn
205+
await generator.next()
206+
207+
// Clean up the generator
208+
await generator.return(undefined)
209+
210+
const [claudePath] = mockExeca.mock.calls[0]
211+
expect(claudePath).toBe("/custom/path/to/claude")
212+
})
213+
214+
test("should handle stdin write errors gracefully", async () => {
215+
const { runClaudeCode } = await import("../run")
216+
217+
// Create a mock process with stdin that fails
218+
const mockProcessWithError = createMockProcess()
219+
mockProcessWithError.stdin.write = vi.fn((data, encoding, callback) => {
220+
// Simulate write error
221+
if (callback) callback(new Error("EPIPE: broken pipe"))
222+
})
223+
224+
// Mock console.error to verify error logging
225+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
226+
227+
mockExeca.mockReturnValueOnce(mockProcessWithError)
228+
229+
const options = {
230+
systemPrompt: "You are a helpful assistant",
231+
messages: [{ role: "user" as const, content: "Hello" }],
232+
}
233+
234+
const generator = runClaudeCode(options)
235+
236+
// Try to consume the generator
237+
try {
238+
await generator.next()
239+
} catch (error) {
240+
// Expected to fail
241+
}
242+
243+
// Verify error was logged
244+
expect(consoleErrorSpy).toHaveBeenCalledWith("Error writing to Claude Code stdin:", expect.any(Error))
245+
246+
// Verify process was killed
247+
expect(mockProcessWithError.kill).toHaveBeenCalled()
248+
249+
// Clean up
250+
consoleErrorSpy.mockRestore()
251+
await generator.return(undefined)
252+
})
253+
254+
test("should handle stdin access errors gracefully", async () => {
255+
const { runClaudeCode } = await import("../run")
256+
257+
// Create a mock process without stdin
258+
const mockProcessWithoutStdin = createMockProcess()
259+
mockProcessWithoutStdin.stdin = null as any
260+
261+
// Mock console.error to verify error logging
262+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
263+
264+
mockExeca.mockReturnValueOnce(mockProcessWithoutStdin)
265+
266+
const options = {
267+
systemPrompt: "You are a helpful assistant",
268+
messages: [{ role: "user" as const, content: "Hello" }],
269+
}
270+
271+
const generator = runClaudeCode(options)
272+
273+
// Try to consume the generator
274+
try {
275+
await generator.next()
276+
} catch (error) {
277+
// Expected to fail
278+
}
279+
280+
// Verify error was logged
281+
expect(consoleErrorSpy).toHaveBeenCalledWith("Error accessing Claude Code stdin:", expect.any(Error))
282+
283+
// Verify process was killed
284+
expect(mockProcessWithoutStdin.kill).toHaveBeenCalled()
285+
286+
// Clean up
287+
consoleErrorSpy.mockRestore()
288+
await generator.return(undefined)
289+
})
37290
})

src/integrations/claude-code/run.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,6 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
123123

124124
const args = [
125125
"-p",
126-
JSON.stringify(messages),
127126
"--system-prompt",
128127
systemPrompt,
129128
"--verbose",
@@ -140,8 +139,8 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
140139
args.push("--model", modelId)
141140
}
142141

143-
return execa(claudePath, args, {
144-
stdin: "ignore",
142+
const child = execa(claudePath, args, {
143+
stdin: "pipe",
145144
stdout: "pipe",
146145
stderr: "pipe",
147146
env: {
@@ -153,6 +152,30 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
153152
maxBuffer: 1024 * 1024 * 1000,
154153
timeout: CLAUDE_CODE_TIMEOUT,
155154
})
155+
156+
// Write messages to stdin after process is spawned
157+
// This avoids the E2BIG error on Linux when passing large messages as command line arguments
158+
// Linux has a per-argument limit of ~128KiB for execve() system calls
159+
const messagesJson = JSON.stringify(messages)
160+
161+
// Use setImmediate to ensure the process has been spawned before writing to stdin
162+
// This prevents potential race conditions where stdin might not be ready
163+
setImmediate(() => {
164+
try {
165+
child.stdin.write(messagesJson, "utf8", (error) => {
166+
if (error) {
167+
console.error("Error writing to Claude Code stdin:", error)
168+
child.kill()
169+
}
170+
})
171+
child.stdin.end()
172+
} catch (error) {
173+
console.error("Error accessing Claude Code stdin:", error)
174+
child.kill()
175+
}
176+
})
177+
178+
return child
156179
}
157180

158181
function parseChunk(data: string, processState: ProcessState) {

0 commit comments

Comments
 (0)