Skip to content

Stop a bad transcript or a stale search batch crashing the TUI - #50

Merged
jongracecox merged 1 commit into
mainfrom
worktree/20260921-parse-failure
Sep 21, 2026
Merged

jongracecox merged 1 commit into
mainfrom
worktree/20260921-parse-failure

Conversation

@jongracecox

Copy link
Copy Markdown
Owner

Summary

Searching transcripts could kill the TUI outright with DuplicateIds: Tried to insert a widget with ID 'sr-0'. This fixes the race behind it, and adds the fallbacks that stop one unreadable transcript taking the session with it.

The crash

_clear_search_results emptied _search_hits and awaited ListView.clear() without first bumping _search_token. That await is a yield point, so a batch the outgoing scan had already handed over with call_from_thread was applied during the clear — its token still looked current, so it mounted sr-0 into the list. The fresh scan then appended its own sr-0.

sequenceDiagram
    participant W as scan worker (thread)
    participant L as event loop
    participant V as #search-results
    W->>L: call_from_thread(_apply_search_batch, token=T)
    Note over L: query changes → _clear_search_results()
    L->>V: await ListView.clear()
    L-->>L: queued batch runs during the await
    Note over L: token still T — looks current
    L->>V: append sr-0  (stale)
    Note over L: _start_transcript_search() bumps to T+1
    L->>V: append sr-0  💥 DuplicateIds
Loading

The token is now bumped (and the scan cancelled) before the await, which is the invariant the other teardown paths — _start_transcript_search, _stop_transcript_search — already followed. This one just didn't.

Not crashing on a bad transcript

Three places, each degrading to something specific rather than dying:

  1. Search rows — _apply_search_batch builds and mounts each result inside a try. A row that won't mount costs that row (logged as tui.search_row_failed with the traceback). _search_hits is appended to only after the row mounts, so the sr-{index} ids stay in step with the hits a restore indexes into.
  2. Session discovery — get_sessions_for_path caught only ClaudeLogError, so an AttributeError from one unknown entry shape raised straight into the home screen's render path, which runs on every render. It now catches Exception per log, counts it into the existing failed= field and logs claude.session_unparsed with the traceback. Same contract search.iter_hits already kept.
  3. Log viewer rendering — the raw fallback only covered read_transcript. A transcript can parse cleanly and still carry text a widget refuses (a Collapsible title is parsed as markup — the documented MarkupError crash). _show_session_log now wraps the render too and re-renders raw, which builds nothing but Statics. A failure of the raw render is an error screen, since there is nothing simpler to draw.

Tests

994 passing. New coverage:

  • test_a_batch_in_flight_when_results_are_cleared_is_dropped — reproduces the crash; verified it fails without the token fix.
  • test_a_row_that_cannot_be_mounted_is_skipped
  • test_an_unexpected_parser_crash_is_skipped_too
  • test_a_render_crash_falls_back_to_the_raw_log
  • test_a_raw_render_crash_is_an_error_screen

Docs

CLAUDE.md updated in three places: the is_cancelled-isn't-a-synchronisation-primitive gotcha now names the await-before-bump trap, and the raw-fallback section covers the render half and the discovery path.

Searching transcripts could die with `DuplicateIds: sr-0`.
`_clear_search_results` emptied the hit list and awaited
`ListView.clear()` without first bumping `_search_token`, and that
await is a yield point: a batch the outgoing scan had already handed
over with `call_from_thread` was applied during the clear, its token
still looking current, mounting an `sr-0` that the fresh scan then
collided with. Bump the token and cancel the scan before the await,
the invariant the other teardown paths already follow.

Three fallbacks stop an unreadable transcript taking the session with
it. `_apply_search_batch` builds and mounts each row inside a `try`,
appending to `_search_hits` only once the row is up so the `sr-{index}`
ids stay in step. `get_sessions_for_path` caught only `ClaudeLogError`,
so an unknown entry shape raised straight into the home screen's render
path; it now drops the log and counts it, as `iter_hits` already did.
And `_show_session_log` wraps the render as well as the parse — a
transcript can parse cleanly and still carry text a widget refuses — so
a render failure falls back to the raw log rather than an empty screen.
@jongracecox
jongracecox merged commit 665b9f5 into main Sep 21, 2026
5 checks passed
@jongracecox
jongracecox deleted the worktree/20260921-parse-failure branch September 21, 2026 21:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant