Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions libs/platform/src/subprocess.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
/**
* Subprocess management utilities for CLI providers
*
* JSONL parsing is tolerant of non-JSON output (e.g., from bun/npm install).
* Lines that don't start with { or [ are logged but not treated as errors.
*/

import { spawn, type ChildProcess } from 'child_process';
Expand Down Expand Up @@ -191,6 +194,15 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener
const trimmed = line.trim();
if (!trimmed) continue;

// Only try to parse lines that look like JSON (start with { or [)
// This tolerates non-JSON output from tools like bun/npm install
const firstChar = trimmed[0];
if (firstChar !== '{' && firstChar !== '[') {
// Non-JSON output (e.g., "bun install v1.3.10") - log but don't error
console.log(`[SubprocessManager] Non-JSON output: ${trimmed}`);
continue;
}

try {
eventQueue.push(JSON.parse(trimmed));
} catch (parseError) {
Expand All @@ -215,17 +227,25 @@ export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGener

// Process any remaining partial line
if (lineBuffer.trim()) {
try {
eventQueue.push(JSON.parse(lineBuffer.trim()));
} catch (parseError) {
console.error(
`[SubprocessManager] Failed to parse final JSONL line: ${lineBuffer}`,
parseError
);
eventQueue.push({
type: 'error',
error: `Failed to parse output: ${lineBuffer}`,
});
const trimmed = lineBuffer.trim();
const firstChar = trimmed[0];

// Only try to parse lines that look like JSON
if (firstChar === '{' || firstChar === '[') {
try {
eventQueue.push(JSON.parse(trimmed));
} catch (parseError) {
console.error(
`[SubprocessManager] Failed to parse final JSONL line: ${trimmed}`,
parseError
);
eventQueue.push({
type: 'error',
error: `Failed to parse output: ${trimmed}`,
});
}
} else {
console.log(`[SubprocessManager] Non-JSON output (final): ${trimmed}`);
}
lineBuffer = '';
}
Expand Down
27 changes: 27 additions & 0 deletions libs/platform/tests/subprocess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,33 @@ describe('subprocess.ts', () => {
expect(results[1]).toEqual({ type: 'second' });
});

it('should skip non-JSON output lines (e.g., from bun/npm install)', async () => {
const mockProcess = createMockProcess({
stdoutLines: [
'bun install v1.3.10 (30e609e0)',
'{"type":"progress","value":50}',
'Checked 3 installs across 4 packages (no changes) [4.00ms]',
'{"type":"complete"}',
],
exitCode: 0,
});

vi.mocked(cp.spawn).mockReturnValue(mockProcess);

const generator = spawnJSONLProcess(baseOptions);
const results = await collectAsyncGenerator(generator);

// Non-JSON lines should be skipped (logged but not yielded as errors)
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ type: 'progress', value: 50 });
expect(results[1]).toEqual({ type: 'complete' });

// Non-JSON lines should be logged
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining('[SubprocessManager] Non-JSON output: bun install')
);
});

it('should yield error for malformed JSON and continue processing', async () => {
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"valid"}', '{invalid json}', '{"type":"also_valid"}'],
Expand Down