Skip to content

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

Description

@hertznsk

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

Symptom

After a conductor run <workflow.yaml> completes (exit code 0), the invoking terminal is left with -echo -icanon: typed characters are not displayed (input still works). Recovery requires typing reset or stty sane blindly.

Observed on Linux (kernel pty, tmux 3.7b and bare terminal), conductor-cli v0.1.20 (also reproduced on main @ 7aaa589), Python 3.12. The failure is intermittent — most runs restore the terminal correctly; some runs leave it broken.

Root cause

KeyboardListener._original_settings (the termios attrs to restore on exit) is captured once in start():

# src/conductor/interrupt/listener.py:106-111
try:
    self._original_settings = termios.tcgetattr(sys.stdin.fileno())
except termios.error:
    ...

but the listener lifecycle allows cbreak mode to be re-entered without re-capturing or invalidating the saved attrs:

  • suspend() (called around gates/dialogs, e.g. engine/workflow.py _suspend_listener) restores the terminal via tcsetattr(TCSADRAIN, self._original_settings) but keeps _original_settings set (listener.py:157-185);
  • resume() re-enters cbreak via tty.setcbreak() without re-reading the current attrs (listener.py:187-221);
  • if start() or setcbreak is ever executed while the tty is already in cbreak (double listener instance, or a suspend/resume interleaving), tcgetattr captures the cbreak state itself as the "original".

stop() then "restores" by applying that captured cbreak state, and the atexit fallback is a no-op (_original_settings was already cleared), so the terminal stays at -echo -icanon.

Evidence (instrumented real run)

Monkey-patched termios.tcsetattr/KeyboardListener in a real conductor run of an 11-agent workflow. Broken run:

02:07:11  tcsetattr(0) TCSAFLUSH  lflag=0x8a31   ← start(): entering cbreak (echo/icanon OFF)
02:08:08  tcsetattr(0) TCSADRAIN  lflag=0x8a31   ← stop(): "restore" applies the SAME cbreak lflag
02:08:08  tcgetattr(0) → echo=False icanon=False ← terminal remains broken
02:08:11  ATEXIT → echo=False icanon=False       ← fallback handler is a no-op

Clean run (for comparison) — the restore applies the correct pre-cbreak attrs (lflag=0x8a3b) and the terminal survives:

02:17:27  tcsetattr(0) TCSADRAIN  lflag=0x8a3b   ← stop(): real restore
02:17:27  tcgetattr(0) → echo=True icanon=True

Reproduced 8 times across runs; the deciding factor is whether _original_settings still holds the true pre-cbreak attrs at stop() time.

Minimal unit demonstration of the capture bug:

# second start() while already in cbreak saves the cbreak state as "original"
l1 = KeyboardListener(interrupt_event=ev); await l1.start()   # saves sane attrs
l2 = KeyboardListener(interrupt_event=ev); await l2.start()   # saves cbreak attrs (lflag & ~ECHO & ~ICANON)
await l2.stop()   # "restores" cbreak — terminal stays broken
# → l2._original_settings shows echo=False icanon=False

Why current tests don't catch it

tests/test_interrupt/test_listener.py mocks termios (test_stop_restores_terminal asserts tcsetattr was called with the mock's settings) — it never exercises a real pty, so capturing a degraded state goes unnoticed.

Suggested fixes

  1. Re-capture on every cbreak entry. Before each tty.setcbreak() (both start() and resume()), read tcgetattr and store it as the restore target only if the tty is not already in cbreak — or simpler: capture the initial attrs once per process (module-level), never overwrite them.
  2. Invalidate on suspend. suspend() should clear _original_settings the same way stop() does, forcing resume() to re-capture.
  3. Use TCSANOW instead of TCSADRAIN for restore so it cannot be delayed by output drain.
  4. Add an integration test on a real pty (pty.fork()): start → suspend → resume → stop, then tcgetattr and assert ECHO|ICANON are set.

Workaround for users

reset or stty sane restores echo. Alternatively run conductor run --no-interactive when Esc-interrupt is not needed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions