Repository navigation
Fix crash dismissing a covered modal, and stop catalog error floods - #1172
Conversation
A key queued for an error modal before a second one covered it dismissed the modal on top and resolved the covered one's result, so the next key to reach it raised InvalidStateError. A text modal now ignores keys and clicks unless it is the active screen. Identical error modals are no longer stacked, so a dropped connection that fails every catalog node it tries to load raises one modal, not one per node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WFBeRM1DiYfXDTAtBawAPZ
tconbeer
left a comment
There was a problem hiding this comment.
Scope
Lenses: correctness, and whether other scenarios can cause the same crash. There were three narrow passes: the TextModal guard and its tests, a sweep of every other screen that dismisses, and the error-modal dedup and the catalog path behind it. I also did a short comments/conventions read.
Verdict
The diagnosis is right, and so is the TextModal fix. Textual's Screen.dismiss() spends this screen's result callback and then pops whatever is on top. The key test fails on main and passes here, and both new tests passed 10 out of 10 runs.
The fix isn't complete for the crash class, though. ConfirmModal, HistoryScreen and ExportScreen crash the same way, with InvalidStateError at textual/screen.py:141 and the app exiting, when an error modal is pushed over them while a button press or selection is still in flight. I reproduced all three on this head. A dropped tunnel, the #1171 scenario, is exactly when error modals arrive at unpredictable moments. I'd move the guard into a shared modal base (details inline).
Findings, most severe first
- Should fix: the same crash in
confirm_modal.py:31,34,history_screen.py:295andexport_screen.py:269.- Four modal handlers (help, debug info, export and history screens) call
app.pop_screen()and can drop an error or confirm modal unseen. - The fix: a shared
dismiss()guard. Details are ontext_modal.py:78.
- Four modal handlers (help, debug info, export and history screens) call
- Should fix: the
on_clickguard has no test. - Follow-up: the dedup only merges errors while the first modal is on screen. The catalog loader keeps fetching nearby nodes on a dead connection, so slow failures (30 s timeouts) bring the identical modal back after every dismissal. The source-level fix belongs in
DatabaseTree._loader. The dedup key also silently depends on adapters keeping the node name out ofstr(error). - Nit: the key test fails with an assertion rather than
InvalidStateError; the PR body should say so. The dedup test bypasses the real loader. - Nit: the pre-existing
## TODO: PREVENT DUPLICATE SCREENS HERE.inHarlequin.push_screenis now half-addressed one layer down; either resolve it or reword it. The changelog entry slightly overclaims ("shows a repeated error once"; see 3).
Comments otherwise follow AGENTS.md: they explain what and why, with no history.
The root cause of #1171 is out of scope, as the PR says: a reused tunnel is never watched, and catalog fetches bypass _connection_for_worker. It's worth its own issue.
Not checked
- I didn't run anything against a real Redshift or SSH tunnel. The redshift_connector error text and whether a half-open socket hangs forever come from reading harlequin-redshift 0.1.0, not from running it.
- I didn't examine harlequin-mysql, or the S3 tree's
CatalogErrorpath. - I didn't repro the double-key case in
harlequin --keys(keys_app.py:252InputModal). By reading, it's low risk, since that app pushes no async modals. - I didn't examine Textual's command palette or other system screens stacked over a
TextModal. - I didn't run
make check. I ran the new tests (10 times each),test_app.py,test_results_viewer.py,test_data_catalog.pyandtest_ssh_recovery.pyunder xdist, on Python 3.10 on Linux only. - I didn't check Windows or macOS event timing.
- Whether Textual's closed issue #4884 ("InvalidStateError when dismissing Screen") settled this upstream is unverified.
The merge call is yours.
Generated by Claude Code
… fetch Textual's Screen.dismiss() spends this screen's result and pops whatever is on top, so the stale-event crash in #1171 was not unique to TextModal: ConfirmModal, HistoryScreen and ExportScreen hit it too when an error modal covered them mid-press, and the modals that called app.pop_screen() could drop an error modal unseen. HarlequinModal's dismiss() now does nothing unless the modal is on top, every modal derives from it, and the modal-local pop_screen() calls go through it. A failed fetch_children() now pauses the Data Catalog's speculative loads (viewport prefetch and buffer-symbol loads) until a fetch succeeds or the catalog is replaced, so a lost connection fails once rather than once per node near the viewport. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WFBeRM1DiYfXDTAtBawAPZ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WFBeRM1DiYfXDTAtBawAPZ
Closes #1171
What are the key elements of this solution?
HarlequinModal, a shared base whosedismiss()only acts when the modal is on top (components/modal.py).Screen.dismiss()spends this screen's result callback but pops whatever is on top. An event a modal received before another covered it therefore popped the wrong modal and left this one up with its result spent. The next dismiss raisedasyncio.exceptions.InvalidStateError, which is the traceback in the report.TextModal/ErrorModal,ConfirmModal,HistoryScreen,ExportScreen,HelpScreenandDebugInfoScreen.app.pop_screen()calls now go throughself.dismiss(). Before, they could drop an error or confirm modal unseen.export_callbackreturns onNone, becausedismiss()runs the callback wherepop_screen()didn't.database_tree.py).fetch_children()fails, the viewport prefetch and the buffer-symbol loads stop queuing, and already-queued speculative items drain without a fetch._push_error_modaldoesn't push a duplicate of anErrorModalstill on the stack (same title, header and message), and logs the one it skipped. This is a backstop for any other source of repeated errors.Why did you design your solution this way? Did you assess any alternatives? Are there tradeoffs?
Harlequin.pop_screencan't tell.--ssh-allow-reuseis never watched, and catalog fetches don't go through_connection_for_worker. So after a drop, expanding a node still fails; a query or a catalog refresh recovers the tunnel and rebuilds the tree.dismiss()popping a screen other thanselflooks worth an issue against textualize/textual (8.2.8 still does it).Does this PR require a change to Harlequin's docs?
Did you add or update tests for this change?
I checked that each new test fails with its fix reverted:
test_key_queued_for_a_covered_error_modal_does_not_crash: without the guard, it fails at its first assertion because the wrong modal was popped. Its later steps are what raiseInvalidStateError.test_click_queued_for_a_covered_error_modal_does_not_dismiss_eitherandtest_confirm_pressed_under_an_error_modal_waits_for_the_user: both fail without the guard.test_every_modal_dismisses_only_from_the_top: fails if anyModalScreeninharlequin.componentsdoesn't derive fromHarlequinModal.test_failed_fetch_pauses_prefetch_until_one_succeeds: goes through the real loader, and fails without the pause or without the resume scan.test_identical_errors_raise_one_modal: covers the modal dedup.make checkis green: 2131 passed, plus the py3.12 tests, mypy and import-linter.Please complete the following checklist:
CHANGELOG.md, under the[Unreleased]section heading. That entry references the issue closed by this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01WFBeRM1DiYfXDTAtBawAPZ