Skip to content

Commit 72dd2cc

Browse files
authored
fix: Ctrl+C double-exit in interactive shell (Tracer-Cloud#1105)
* fix: Ctrl+C double-exit in interactive shell (first press hints, second exits) Implements the two-press Ctrl+C exit pattern: the first Ctrl+C displays "(Press Ctrl+C again to exit)" and re-displays the prompt; the second Ctrl+C within 2 seconds prints "Goodbye!" and exits cleanly (code 0). - Add `_with_ctrl_c_double_exit()` wrapping `question.unsafe_ask()` in a retry loop — avoids key-binding conflicts that caused the sentinel approach to fail - Add `_HardQuitInterrupt(KeyboardInterrupt)` so Ctrl+Q hard-quit bypasses the retry guard - Restore explicit ControlC binding in wizard `_base_bindings()` because `InquirerControl` is not a `BufferControl`, making prompt_toolkit's default Ctrl+C binding inactive for custom wizard prompts - Install a SIGINT handler in `main()` for between-prompt Ctrl+C - Add/update unit tests in `tests/cli/test_prompt_support.py` - Fix smoke test to use Escape (PTY-safe) instead of Ctrl+C Fixes Tracer-Cloud#1091 * fix: add questionary.password to Ctrl+C double-exit and Escape-cancel patches * fix: use None sentinel for _last_ctrl_c to make never-pressed state explicit * fix: print newline on unhandled KeyboardInterrupt in main() for clean terminal output * fix: reset _last_ctrl_c after successful prompt return to prevent cross-prompt leakage
1 parent cfa7723 commit 72dd2cc

5 files changed

Lines changed: 229 additions & 26 deletions

File tree

app/cli/__main__.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from __future__ import annotations
1111

1212
import os
13+
import signal
1314
import sys
1415

1516
import click
@@ -19,7 +20,11 @@
1920
from app.analytics.provider import capture_first_run_if_needed, shutdown_analytics
2021
from app.cli.commands import register_commands
2122
from app.cli.layout import RichGroup, render_landing
22-
from app.cli.prompt_support import install_questionary_escape_cancel
23+
from app.cli.prompt_support import (
24+
handle_ctrl_c_press,
25+
install_questionary_ctrl_c_double_exit,
26+
install_questionary_escape_cancel,
27+
)
2328
from app.version import get_version
2429

2530

@@ -87,14 +92,37 @@ def cli(
8792
register_commands(cli)
8893

8994

95+
def _install_sigint_handler() -> None:
96+
"""Handle Ctrl+C between prompts (when prompt_toolkit is not active).
97+
98+
prompt_toolkit intercepts Ctrl+C internally while a prompt is running, so
99+
the key binding in prompt_support.py handles that case. This SIGINT handler
100+
covers everything else: long-running operations, streaming output, etc.
101+
"""
102+
103+
def _handler(signum: int, frame: object) -> None: # noqa: ARG001
104+
handle_ctrl_c_press()
105+
106+
signal.signal(signal.SIGINT, _handler)
107+
108+
90109
def main(argv: list[str] | None = None) -> int:
91110
"""Entry point for the ``opensre`` console script."""
92111
load_dotenv(override=False)
93112
install_questionary_escape_cancel()
113+
install_questionary_ctrl_c_double_exit()
114+
_install_sigint_handler()
94115
capture_first_run_if_needed()
95116

96117
try:
97118
cli(args=argv, standalone_mode=True)
119+
except KeyboardInterrupt:
120+
# A KeyboardInterrupt that escapes cli() was not handled by our
121+
# double-exit logic (e.g. click.prompt, an unpatched library prompt).
122+
# Print a newline so the terminal cursor lands on a clean line, then
123+
# exit quietly — Click's "Aborted!" message is intentionally suppressed.
124+
print(flush=True)
125+
return 0
98126
except SystemExit as exc:
99127
if isinstance(exc.code, int):
100128
return exc.code

app/cli/prompt_support.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import sys
6+
import time
57
from collections.abc import Callable
68
from typing import Any
79

@@ -10,6 +12,15 @@
1012
from prompt_toolkit.keys import Keys
1113

1214
_escape_patch_installed: list[bool] = [False]
15+
_ctrl_c_patch_installed: list[bool] = [False]
16+
17+
# Shared timestamp of the last Ctrl+C press (None = never pressed).
18+
_last_ctrl_c: list[float | None] = [None]
19+
_CTRL_C_EXIT_WINDOW: float = 2.0
20+
21+
22+
class _HardQuitInterrupt(KeyboardInterrupt):
23+
"""Raised by explicit quit keys (Ctrl+Q) to bypass the Ctrl+C double-exit guard."""
1324

1425

1526
def _with_escape_cancel(question: questionary.question.Question) -> questionary.question.Question:
@@ -46,6 +57,7 @@ def install_questionary_escape_cancel() -> None:
4657
import questionary
4758
import questionary.prompts.checkbox as checkbox_mod
4859
import questionary.prompts.confirm as confirm_mod
60+
import questionary.prompts.password as password_mod
4961
import questionary.prompts.path as path_mod
5062
import questionary.prompts.select as select_mod
5163
import questionary.prompts.text as text_mod
@@ -55,11 +67,108 @@ def install_questionary_escape_cancel() -> None:
5567
confirm_mod.confirm = _wrap_question_prompt(confirm_mod.confirm)
5668
text_mod.text = _wrap_question_prompt(text_mod.text)
5769
path_mod.path = _wrap_question_prompt(path_mod.path)
70+
password_mod.password = _wrap_question_prompt(password_mod.password)
5871

5972
questionary.select = select_mod.select
6073
questionary.checkbox = checkbox_mod.checkbox
6174
questionary.confirm = confirm_mod.confirm
6275
questionary.text = text_mod.text
6376
questionary.path = path_mod.path
77+
questionary.password = password_mod.password
6478

6579
_escape_patch_installed[0] = True
80+
81+
82+
def handle_ctrl_c_press() -> None:
83+
"""Handle Ctrl+C from the SIGINT signal handler (between prompts).
84+
85+
First call: prints hint.
86+
Second call within _CTRL_C_EXIT_WINDOW seconds: prints Goodbye and exits.
87+
"""
88+
now = time.monotonic()
89+
if _last_ctrl_c[0] is not None and now - _last_ctrl_c[0] <= _CTRL_C_EXIT_WINDOW:
90+
print("\nGoodbye!", flush=True)
91+
sys.exit(0)
92+
_last_ctrl_c[0] = now
93+
print("\n(Press Ctrl+C again to exit)", flush=True)
94+
95+
96+
def _with_ctrl_c_double_exit(
97+
question: questionary.question.Question,
98+
) -> questionary.question.Question:
99+
"""Add Ctrl+C double-exit handling to a questionary prompt.
100+
101+
Patches question.ask() to call unsafe_ask() (which does NOT swallow
102+
KeyboardInterrupt) in a retry loop. On the first Ctrl+C the hint is
103+
printed and the prompt is re-displayed; on the second Ctrl+C within
104+
_CTRL_C_EXIT_WINDOW seconds the process exits.
105+
106+
We do NOT add extra key bindings — the prompt_toolkit default Ctrl+C
107+
binding already raises KeyboardInterrupt from application.run(), and
108+
fighting it with another eager binding causes unpredictable ordering.
109+
"""
110+
111+
def _patched_ask(*args: Any, **kwargs: Any) -> Any:
112+
while True:
113+
try:
114+
# unsafe_ask() propagates KeyboardInterrupt instead of
115+
# swallowing it the way ask() does.
116+
result = question.unsafe_ask(*args, **kwargs)
117+
_last_ctrl_c[0] = None # reset on clean exit so next prompt starts fresh
118+
return result
119+
except KeyboardInterrupt as exc:
120+
if isinstance(exc, _HardQuitInterrupt):
121+
raise # Ctrl+Q hard-quit — bypass retry logic
122+
now = time.monotonic()
123+
if _last_ctrl_c[0] is not None and now - _last_ctrl_c[0] <= _CTRL_C_EXIT_WINDOW:
124+
print("\nGoodbye!", flush=True)
125+
sys.exit(0)
126+
_last_ctrl_c[0] = now
127+
print("\n(Press Ctrl+C again to exit)", flush=True)
128+
# Loop: re-run the same application (Application.run() is
129+
# safe to call again after a clean exit or KeyboardInterrupt).
130+
131+
question.ask = _patched_ask # type: ignore[method-assign]
132+
return question
133+
134+
135+
def _wrap_question_ctrl_c(
136+
orig: Callable[..., questionary.question.Question],
137+
) -> Callable[..., questionary.question.Question]:
138+
def wrapped(*args: Any, **kwargs: Any) -> questionary.question.Question:
139+
return _with_ctrl_c_double_exit(orig(*args, **kwargs))
140+
141+
wrapped.__name__ = orig.__name__
142+
wrapped.__doc__ = orig.__doc__
143+
wrapped.__qualname__ = getattr(orig, "__qualname__", orig.__name__)
144+
return wrapped
145+
146+
147+
def install_questionary_ctrl_c_double_exit() -> None:
148+
"""Make Ctrl+C show a hint on first press and exit on second press within 2 s."""
149+
if _ctrl_c_patch_installed[0]:
150+
return
151+
152+
import questionary
153+
import questionary.prompts.checkbox as checkbox_mod
154+
import questionary.prompts.confirm as confirm_mod
155+
import questionary.prompts.password as password_mod
156+
import questionary.prompts.path as path_mod
157+
import questionary.prompts.select as select_mod
158+
import questionary.prompts.text as text_mod
159+
160+
select_mod.select = _wrap_question_ctrl_c(select_mod.select)
161+
checkbox_mod.checkbox = _wrap_question_ctrl_c(checkbox_mod.checkbox)
162+
confirm_mod.confirm = _wrap_question_ctrl_c(confirm_mod.confirm)
163+
text_mod.text = _wrap_question_ctrl_c(text_mod.text)
164+
path_mod.path = _wrap_question_ctrl_c(path_mod.path)
165+
password_mod.password = _wrap_question_ctrl_c(password_mod.password)
166+
167+
questionary.select = select_mod.select
168+
questionary.checkbox = checkbox_mod.checkbox
169+
questionary.confirm = confirm_mod.confirm
170+
questionary.text = text_mod.text
171+
questionary.path = path_mod.path
172+
questionary.password = password_mod.password
173+
174+
_ctrl_c_patch_installed[0] = True

app/cli/wizard/prompts.py

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from questionary.question import Question
2121
from questionary.styles import merge_styles_default
2222

23+
from app.cli.prompt_support import _HardQuitInterrupt, _with_ctrl_c_double_exit
24+
2325

2426
class _CheckboxControl(InquirerControl):
2527
"""Render checked items neutrally unless they are the active row."""
@@ -104,8 +106,15 @@ def _base_bindings(
104106
bindings = KeyBindings()
105107

106108
@bindings.add(Keys.ControlQ, eager=True)
109+
def _quit(event: Any) -> None:
110+
# ControlQ is an intentional hard-quit; use _HardQuitInterrupt so the
111+
# Ctrl+C double-exit retry loop does not swallow this as a first press.
112+
event.app.exit(exception=_HardQuitInterrupt(), style="class:aborting")
113+
107114
@bindings.add(Keys.ControlC, eager=True)
108-
def _abort(event: Any) -> None:
115+
def _ctrl_c(event: Any) -> None:
116+
# Raise KeyboardInterrupt so the double-exit logic in _with_ctrl_c_double_exit
117+
# can implement hint-on-first / exit-on-second behavior via the retry loop.
109118
event.app.exit(exception=KeyboardInterrupt, style="class:aborting")
110119

111120
def _move_down(_event: Any) -> None:
@@ -207,17 +216,19 @@ def _submit(event: Any) -> None:
207216
ic.is_answered = True
208217
event.app.exit(result=ic.get_pointed_at().value)
209218

210-
return Question(
211-
Application(
212-
layout=common.create_inquirer_layout(
213-
ic,
214-
_tokens,
215-
**_layout_kwargs(input=input, output=output),
216-
),
217-
key_bindings=bindings,
218-
style=merge_styles_default([style]),
219-
input=input,
220-
output=output,
219+
return _with_ctrl_c_double_exit(
220+
Question(
221+
Application(
222+
layout=common.create_inquirer_layout(
223+
ic,
224+
_tokens,
225+
**_layout_kwargs(input=input, output=output),
226+
),
227+
key_bindings=bindings,
228+
style=merge_styles_default([style]),
229+
input=input,
230+
output=output,
231+
)
221232
)
222233
)
223234

@@ -272,16 +283,18 @@ def _submit(event: Any) -> None:
272283
ic.is_answered = True
273284
event.app.exit(result=[choice.value for choice in ic.get_selected_values()])
274285

275-
return Question(
276-
Application(
277-
layout=common.create_inquirer_layout(
278-
ic,
279-
_tokens,
280-
**_layout_kwargs(input=input, output=output),
281-
),
282-
key_bindings=bindings,
283-
style=merge_styles_default([style]),
284-
input=input,
285-
output=output,
286+
return _with_ctrl_c_double_exit(
287+
Question(
288+
Application(
289+
layout=common.create_inquirer_layout(
290+
ic,
291+
_tokens,
292+
**_layout_kwargs(input=input, output=output),
293+
),
294+
key_bindings=bindings,
295+
style=merge_styles_default([style]),
296+
input=input,
297+
output=output,
298+
)
286299
)
287300
)

tests/cli/test_prompt_support.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
from __future__ import annotations
22

3+
import time
4+
5+
import pytest
36
import questionary
47
from prompt_toolkit.input.defaults import create_pipe_input # type: ignore[import-not-found]
58
from prompt_toolkit.output import DummyOutput # type: ignore[import-not-found]
69

7-
from app.cli.prompt_support import install_questionary_escape_cancel
10+
from app.cli.prompt_support import (
11+
_last_ctrl_c,
12+
handle_ctrl_c_press,
13+
install_questionary_ctrl_c_double_exit,
14+
install_questionary_escape_cancel,
15+
)
816

917

1018
def test_install_questionary_escape_cancel_is_idempotent() -> None:
@@ -28,3 +36,46 @@ def test_stock_questionary_select_escape_cancels() -> None:
2836
app.input = pipe_input
2937
app.output = DummyOutput()
3038
assert app.run() is None
39+
40+
41+
def test_install_questionary_ctrl_c_double_exit_is_idempotent() -> None:
42+
install_questionary_ctrl_c_double_exit()
43+
first = questionary.select
44+
install_questionary_ctrl_c_double_exit()
45+
assert questionary.select is first
46+
47+
48+
def test_ctrl_c_first_press_shows_hint_and_reprompts(capsys) -> None:
49+
"""First Ctrl+C prints the hint and re-displays the prompt; Enter then submits."""
50+
_last_ctrl_c[0] = None
51+
install_questionary_ctrl_c_double_exit()
52+
with create_pipe_input() as pipe_input:
53+
q = questionary.select(
54+
"Pick",
55+
choices=["a", "b"],
56+
input=pipe_input,
57+
output=DummyOutput(),
58+
)
59+
# Ctrl+C cancels the first run; Enter submits the re-displayed prompt.
60+
pipe_input.send_bytes(b"\x03\r")
61+
result = q.ask()
62+
assert "(Press Ctrl+C again to exit)" in capsys.readouterr().out
63+
# After the hint the prompt was re-run and "a" was selected (first choice).
64+
assert result == "a"
65+
66+
67+
def test_ctrl_c_second_press_exits(capsys) -> None:
68+
# Simulate a previous Ctrl+C just now so the second press fires immediately.
69+
_last_ctrl_c[0] = time.monotonic()
70+
with pytest.raises(SystemExit) as exc_info:
71+
handle_ctrl_c_press()
72+
assert exc_info.value.code == 0
73+
assert "Goodbye" in capsys.readouterr().out
74+
75+
76+
def test_ctrl_c_hint_resets_after_window(capsys) -> None:
77+
# A press older than the exit window should show the hint again, not exit.
78+
_last_ctrl_c[0] = None # effectively "long ago"
79+
handle_ctrl_c_press()
80+
out = capsys.readouterr().out
81+
assert "(Press Ctrl+C again to exit)" in out

tests/cli_smoke_test.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,10 +612,12 @@ def test_integrations_remove_datadog_interactive_smoke(cli_sandbox: CliSandbox)
612612

613613
@pytest.mark.skipif(os.name == "nt", reason="interactive smoke uses POSIX PTYs")
614614
def test_tests_interactive_launcher_smoke(cli_sandbox: CliSandbox) -> None:
615+
# The prompt instruction reads "Esc exit"; Escape is the PTY-safe way to
616+
# dismiss the prompt in automation (no SIGINT/raw-mode race conditions).
615617
result = _run_cli_pty(
616618
cli_sandbox,
617619
"tests",
618-
actions=[PtyAction(expect="Choose a test category:", send=b"\x03")],
620+
actions=[PtyAction(expect="Choose a test category:", send=b"\x1b")],
619621
)
620622

621623
assert result.exit_code == 0

0 commit comments

Comments
 (0)