Skip to content

fix(cli): restore terminal from cbreak mode after run exits - #294

Draft
hertznsk wants to merge 1 commit into
microsoft:mainfrom
hertznsk:issue-290-terminal-cbreak
Draft

fix(cli): restore terminal from cbreak mode after run exits#294
hertznsk wants to merge 1 commit into
microsoft:mainfrom
hertznsk:issue-290-terminal-cbreak

Conversation

@hertznsk

@hertznsk hertznsk commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #290 — Conductor could leave the invoking terminal in cbreak mode (no echo / no line editing) after a workflow exited, requiring a blind reset or stty sane to recover.

Root cause

KeyboardListener.start() captured the tty settings on every call. Under specific interleavings — a second listener instance in the same process, or a second start() on an already-active listener — the captured "original" settings were the already-broken cbreak state, which stop() then faithfully restored. Additionally, provider cleanup could alter terminal settings after listener.stop() had completed, and the SIGTERM handler swallowed the signal, leaving the process running.

Changes

  • Process-global baseline: Capture the tty baseline once per process into a module-level cache before the first tty.setcbreak(); every later listener instance reuses it instead of re-snapshotting the live tty.
  • Idempotent start: Make start() idempotent while the listener is truly active (baseline held AND reader thread running), so a duplicate call can't overwrite the baseline or spawn duplicate threads. Start-after-suspend() still restarts correctly.
  • Robust restore: Restore with TCSANOW instead of TCSADRAIN so a blocked output drain can't delay the restore.
  • Retry capability: Clear the saved baseline only after a successful tcsetattr, so a transient restore failure can be retried by atexit/SIGTERM/stop().
  • SIGTERM correctness: The SIGTERM cleanup handler now delegates to the previously-installed disposition after restoring: SIG_DFL resets and re-raises via os.kill, SIG_IGN stays ignored, a callable previous handler is invoked. The previous disposition is captured into the handler closure at registration time to prevent infinite self-recursion.
  • Outermost teardown protection: The run and resume commands now reapply and retire the process baseline at their outermost cleanup boundary (after provider shutdown and all other teardown steps). This ensures that even if a provider's cleanup puts the terminal back into cbreak, normal exit, Ctrl+C, and late cleanup failures all return a sane terminal to the shell.

Tests

  • New real-pty integration tests (tests/test_interrupt/test_listener_pty.py) reproduce both corruption scenarios on a genuine pseudo-terminal: two listener instances in one process, and double-start() on one instance.
  • CLI boundary real-pty tests (tests/test_interrupt/test_cli_terminal_pty.py) verify that provider teardown cannot bypass final TTY restoration, that a later non-interactive run doesn't reuse an old TTY baseline, and that the real CLI restores exact TTY attrs on exit and Ctrl+C.
  • New mocked unit tests for the baseline cache, idempotent start, TCSANOW restore paths, restore retry semantics, all SIGTERM delegation cases, and process baseline restoration after listener stop.
  • Full suite passes; make lint and make typecheck clean.

Scope

  • src/conductor/interrupt/listener.py
  • src/conductor/cli/run.py
  • Tests in tests/test_interrupt/
  • CHANGELOG.md

No behavior change for suspend()/resume() semantics. Windows is unaffected (the listener is already Unix-only).

@hertznsk
hertznsk force-pushed the issue-290-terminal-cbreak branch from 8ebd92f to 9983ccb Compare September 9, 2026 00:06
@hertznsk
hertznsk marked this pull request as draft September 10, 2026 12:52
Fixes microsoft#290 — Conductor could leave the invoking terminal in cbreak mode (no echo / no line editing) after a workflow exited, requiring a blind `reset` or `stty sane` to recover.

The tty baseline is now captured once per process, before the first `tty.setcbreak()`, and reused by every later listener; `start()` is idempotent while active so a duplicate call can't overwrite the baseline; restore uses `TCSANOW` so a blocked output drain can't delay it; and the saved baseline is cleared only after a successful `tcsetattr` so a transient failure can be retried.

The SIGTERM cleanup handler also no longer swallows the signal: after restoring the terminal it delegates to the previously-installed disposition (reset-and-re-raise for `SIG_DFL`, ignore for `SIG_IGN`, invoke a callable previous handler), and re-registration captures the previous disposition in the handler closure so the listener can no longer recurse into itself.

The run and resume commands now also reapply and retire the baseline at their outermost cleanup boundary, after provider shutdown and every other teardown step. This closes a later race where normal completion or Ctrl+C could restore the terminal correctly and then a provider's cleanup could put it back into cbreak; SIGTERM during that same late-cleanup window restores the process baseline before terminating.
@hertznsk
hertznsk force-pushed the issue-290-terminal-cbreak branch from 9983ccb to 0679668 Compare September 10, 2026 17:35
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.

Terminal left in cbreak mode (no echo) after conductor run exits

1 participant