فهرست منبع

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]>
Johannes 6 ماه پیش
والد
کامیت
de99e348df
2فایلهای تغییر یافته به همراه279 افزوده شده و 3 حذف شده
  1. 253 0
      src/integrations/claude-code/__tests__/run.spec.ts
  2. 26 3
      src/integrations/claude-code/run.ts

+ 253 - 0
src/integrations/claude-code/__tests__/run.spec.ts

@@ -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)
+	})
 })

+ 26 - 3
src/integrations/claude-code/run.ts

@@ -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) {