Skip to content

fix: Ctrl+C double-action and sticky _user_cancelled_handler flag#15

Merged
shoom1 merged 1 commit into
developfrom
fix/ctrl-c-double-action-and-sticky-cancel-flag
May 1, 2026
Merged

fix: Ctrl+C double-action and sticky _user_cancelled_handler flag#15
shoom1 merged 1 commit into
developfrom
fix/ctrl-c-double-action-and-sticky-cancel-flag

Conversation

@shoom1

@shoom1 shoom1 commented May 1, 2026

Copy link
Copy Markdown
Owner

Summary

Two follow-up bugs in the lifecycle refactor (#14), surfaced by review feedback:

1. Ctrl+C exits after clearing an orphan thinking box

The cancel binding finished active boxes with finish_all() and then re-checked self._manager.has_active_boxes for its "idle, exit" fallback. After finish_all() the manager is empty, so the same Ctrl+C that cleared the orphan box also called event.app.exit(). Users with a box left open outside a handler (or by an earlier handler) would lose their session.

Fix: snapshot handler_running / had_active_boxes / had_pending_input before mutating any of them, and fall through to exit() only when none of them were in flight at keypress time.

2. _user_cancelled_handler stays True if the cancel doesn't propagate

The flag was only reset inside except asyncio.CancelledError. Two paths leave it sticky:

  • user code catches CancelledError and returns normally → outer await task returns a value, no exception, flag stays True;
  • cancel races against natural completion and loses → same.

The next handler invocation that received an outer cancellation (e.g. shutdown) would then see the leftover True flag, get treated as Ctrl+C, and the input loop couldn't exit.

Fix: reset _user_cancelled_handler in finally so it's always cleared.

Note on test fix

The original test_flag_reset_when_handler_swallows_cancel (added in #14) wasn't actually exercising user-suppression — it cancelled the task before its body ran, so the task was cancelled at first tick and the outer await did raise CancelledError, hiding the bug. This PR uses an explicit started event so cancel only arrives once the handler is awaiting its gate, exposing the original sticky-flag bug.

Test plan

  • 4 new tests added (1 orphan-box, 3 flag-reset), all initially failing then green
  • Full lifecycle suite: 13 passed (was 9)
  • pytest: 368 passed (was 364)
  • ruff check thinking_prompt/: clean
  • mypy thinking_prompt/: clean
  • CI green

Two follow-up bugs in the session-lifecycle refactor:

1. Ctrl+C exited the app after clearing an orphan thinking box.

   The Ctrl+C binding finished active boxes via finish_all() and then
   re-checked self._manager.has_active_boxes for the "idle, exit"
   fallback. After finish_all the manager is empty, so the same Ctrl+C
   that cleared the boxes also called event.app.exit().

   Snapshot handler_running / had_active_boxes / had_pending_input
   before mutating any of them, and only fall through to exit when none
   of them were in flight at keypress time. has_active_boxes is also
   the right idle signal for boxes opened outside a handler (which the
   reviewer flagged), since previously such boxes would be cleared but
   the user would also lose their session.

2. _user_cancelled_handler stayed True if the inner cancel did not
   propagate as CancelledError out of `await task`.

   The flag was only reset inside `except asyncio.CancelledError`, so:
   - if user code caught CancelledError and returned normally, the
     outer await returned a value (no exception) and the flag stuck;
   - if cancel raced against natural completion and lost, same thing.

   In both cases the *next* handler invocation that received an outer
   cancellation (e.g. session shutdown) would have the leftover True
   flag, _run_handler would treat it as Ctrl+C, swallow CancelledError,
   and the input loop could not exit.

   Reset _user_cancelled_handler in `finally` so it's always cleared
   regardless of whether CancelledError propagated. The except branch
   no longer needs to reset it explicitly.

Tests: extends test_session_lifecycle.py with one Ctrl+C test
(orphan box must not also exit) and three flag-reset tests:
- handler swallows CancelledError (uses a `started` event so cancel
  arrives during await rather than before first tick — otherwise the
  task is cancelled before its body runs and the bug is masked);
- cancel races against natural completion;
- outer cancel after a swallowing handler must propagate.
@shoom1 shoom1 merged commit 1e2c18e into develop May 1, 2026
4 checks passed
@shoom1 shoom1 deleted the fix/ctrl-c-double-action-and-sticky-cancel-flag branch May 1, 2026 03:20
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