Skip to content

Commit 04976d1

Browse files
phiatclaude
andcommitted
Fix crash on sessions with image content (fixes #4)
- Increase scanner buffer from 1MB to 10MB to handle base64 images (buffer starts at 64KB and only grows on demand) - Parser now gracefully skips malformed/truncated JSON lines instead of returning a fatal error - Add tests for image content blocks and truncated JSON Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2d0a383 commit 04976d1

File tree

3 files changed

+55
-8
lines changed

3 files changed

+55
-8
lines changed

internal/parser/parser.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ func ParseLine(line string) ([]StreamItem, error) {
107107

108108
var raw RawMessage
109109
if err := json.Unmarshal([]byte(line), &raw); err != nil {
110-
return nil, fmt.Errorf("failed to parse JSON: %w", err)
110+
// Gracefully skip malformed/truncated lines (e.g. base64 images
111+
// that exceeded the scanner buffer). A single bad line shouldn't
112+
// crash the app.
113+
return nil, nil
111114
}
112115

113116
timestamp, err := time.Parse(time.RFC3339, raw.Timestamp)

internal/parser/parser_test.go

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ func TestParseLine_EmptyLine(t *testing.T) {
2121
}
2222

2323
func TestParseLine_InvalidJSON(t *testing.T) {
24-
_, err := ParseLine("not json at all")
25-
if err == nil {
26-
t.Error("ParseLine with invalid JSON should return error")
24+
// Invalid JSON should be silently skipped, not return an error
25+
items, err := ParseLine("not json at all")
26+
if err != nil {
27+
t.Errorf("ParseLine should skip invalid JSON, got error: %v", err)
2728
}
28-
if !strings.Contains(err.Error(), "failed to parse JSON") {
29-
t.Errorf("error should mention JSON parsing, got: %v", err)
29+
if len(items) != 0 {
30+
t.Errorf("expected 0 items for invalid JSON, got %d", len(items))
3031
}
3132
}
3233

@@ -304,6 +305,47 @@ func TestFormatToolInput(t *testing.T) {
304305
}
305306
}
306307

308+
func TestParseLine_UserMessageWithImage(t *testing.T) {
309+
// User messages can contain image blocks (screenshots pasted into Claude Code)
310+
// These should be silently skipped, not cause errors
311+
line := `{"type":"user","timestamp":"2025-01-01T12:00:00Z","message":{"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"}}]}}`
312+
items, err := ParseLine(line)
313+
if err != nil {
314+
t.Fatalf("ParseLine should not error on image content, got: %v", err)
315+
}
316+
if len(items) != 0 {
317+
t.Errorf("expected 0 items (images skipped), got %d", len(items))
318+
}
319+
}
320+
321+
func TestParseLine_UserMessageWithImageAndToolResult(t *testing.T) {
322+
// A user message can contain both image blocks and tool results
323+
line := `{"type":"user","timestamp":"2025-01-01T12:00:00Z","message":{"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBOR"}},{"type":"tool_result","tool_use_id":"toolu_img1","content":"tool output here"}]}}`
324+
items, err := ParseLine(line)
325+
if err != nil {
326+
t.Fatalf("unexpected error: %v", err)
327+
}
328+
if len(items) != 1 {
329+
t.Fatalf("expected 1 item (tool_result only), got %d", len(items))
330+
}
331+
if items[0].Content != "tool output here" {
332+
t.Errorf("content = %q, want %q", items[0].Content, "tool output here")
333+
}
334+
}
335+
336+
func TestParseLine_TruncatedJSON(t *testing.T) {
337+
// When a JSONL line exceeds the scanner buffer, it gets truncated
338+
// producing invalid JSON. This should be skipped gracefully, not crash.
339+
truncated := `{"type":"user","timestamp":"2025-01-01T12:00:00Z","message":{"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"JVBER`
340+
items, err := ParseLine(truncated)
341+
if err != nil {
342+
t.Fatalf("ParseLine should gracefully skip truncated JSON, got error: %v", err)
343+
}
344+
if len(items) != 0 {
345+
t.Errorf("expected 0 items for truncated JSON, got %d", len(items))
346+
}
347+
}
348+
307349
// buildAssistantLine builds a valid JSONL line for an assistant tool_use message
308350
func buildAssistantLine(t *testing.T, toolName, toolID string, inputJSON json.RawMessage) string {
309351
t.Helper()

internal/watcher/watcher.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ const (
3737
FileReadBufferSize = 32 * 1024
3838
// ScannerInitBufferSize is the initial buffer for JSON line scanner (64KB)
3939
ScannerInitBufferSize = 64 * 1024
40-
// ScannerMaxBufferSize is the max buffer for JSON line scanner (1MB)
41-
ScannerMaxBufferSize = 1024 * 1024
40+
// ScannerMaxBufferSize is the max buffer for JSON line scanner (10MB)
41+
// Large because JSONL lines can contain base64-encoded images (screenshots).
42+
// The scanner starts at ScannerInitBufferSize and only grows on demand.
43+
ScannerMaxBufferSize = 10 * 1024 * 1024
4244
// AgentIDDisplayLength is how many chars of agent ID to show in display name
4345
AgentIDDisplayLength = 7
4446
// RecentActivityThreshold is how recent a session must be to show as "active" in listings

0 commit comments

Comments
 (0)