Skip to content

Commit 94859fd

Browse files
takemi-ohamaclaude
andcommitted
fix(tui): Esc+Ctrl-C 同時押下での Application.exit() 二重呼び出しを防止
実 TTY で Esc と Ctrl-C をほぼ同時に押す (または Ctrl-C 連打) と 「Return value already set. Application.exit() failed.」のクラッシュ画面が 出る問題を修正する。 Esc バインドは矢印キーのエスケープシーケンスと区別するため eager=False (確定待ち) で登録しており、Esc + Ctrl-C が同一入力バッチで届くと prompt_toolkit は 1 回のキー処理内で「Esc ハンドラ (exit 確定) → 残り バッファ再処理で questionary 組み込み Ctrl-C ハンドラ」を連続実行し、 exit が二重に呼ばれる。process_keys の is_done ガードは入力キューにしか 効かず、この同一バッファ再処理の経路は防げない。 全プロンプト共通の通過点 _ask_erased に _guard_after_done を追加し、 アプリの key_bindings (questionary 組み込み + 後付けの Esc/←) を ConditionalKeyBindings(kb, ~is_done) でラップして回答確定後のキー処理を 無効化する。 pty 回帰テストは Esc+Ctrl-C を 1 回の write で送出して再現する (ガードを 外すと実際にクラッシュ出力を検出して失敗することを確認済み)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a6a1976 commit 94859fd

3 files changed

Lines changed: 85 additions & 1 deletion

File tree

lib/devbase/tui/menu.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,40 @@ def _cancel(event):
9696
return _add_escape_binding(question, _cancel)
9797

9898

99+
def _guard_after_done(question):
100+
"""回答確定後 (``Application.exit`` 済み) のキー処理を無効化する。
101+
102+
prompt_toolkit は 1 回の読み取りで複数キーを同一バッチとして処理するため、
103+
確定キーの直後に入力が溜まっていると (例: Ctrl-C 連打 / Enter 直後の Ctrl-C)、
104+
1 つ目のキーで exit して戻り値が確定した後も残りのキーが同じバッチ内で
105+
処理され、questionary 組み込みの Ctrl-C ハンドラ等が再度 exit を呼んで
106+
「Return value already set. Application.exit() failed.」のクラッシュになる
107+
(実 TTY でのみ再現)。アプリ単位の key_bindings (questionary 組み込み + 本
108+
モジュールが後付けする Esc/← を含む) を ``~is_done`` でガードし、確定後の
109+
キーは無視する。
110+
"""
111+
from prompt_toolkit.filters import is_done
112+
from prompt_toolkit.key_binding import ConditionalKeyBindings
113+
114+
kb = question.application.key_bindings
115+
if kb is not None:
116+
question.application.key_bindings = ConditionalKeyBindings(kb, ~is_done)
117+
return question
118+
119+
99120
def _ask_erased(question):
100121
"""``erase_when_done`` を立ててから ``ask()`` する共通ヘルパ (全プロンプト用)。
101122
102123
questionary は回答確定時に「質問 + 回答」の collapse 行を画面へ残す。TUI は
103124
ループでメニューを再描画するため、回答のたびにこの行が蓄積して画面全体が
104125
下へずれていく (実 TTY でのみ再現する残留・行ずれ不具合)。回答後に描画ごと
105126
消去することで、メニューを常に同じ位置へ再描画する。
127+
128+
併せて ``_guard_after_done`` で確定後のキー処理を無効化する (全プロンプトが
129+
本ヘルパを通るため、ここが単一の適用点)。
106130
"""
107131
question.application.erase_when_done = True
108-
return question.ask()
132+
return _guard_after_done(question).ask()
109133

110134

111135
def _ask_with_escape(question):

tests/cli/tui/test_menu.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,25 @@ def test_back_handler_sets_erase_when_done():
112112
assert captured == {"result": menu.MENU_BACK}
113113

114114

115+
def test_guard_after_done_wraps_app_key_bindings():
116+
"""_guard_after_done が app の key_bindings を ~is_done 条件でラップすること。
117+
118+
回答確定 (Application.exit) 後に同一バッチへ溜まったキー (Ctrl-C 連打 /
119+
Enter 直後の Ctrl-C 等) が questionary 組み込みハンドラへ届くと exit が
120+
二重に呼ばれ「Return value already set」でクラッシュする (実 TTY のみで
121+
再現)。ガード適用で確定後のキーが無視されることの構造検証。
122+
"""
123+
questionary = pytest.importorskip("questionary")
124+
from prompt_toolkit.key_binding import ConditionalKeyBindings
125+
126+
q = questionary.select("t", choices=[questionary.Choice(title="a", value="a")])
127+
inner = q.application.key_bindings
128+
assert menu._guard_after_done(q) is q
129+
wrapped = q.application.key_bindings
130+
assert isinstance(wrapped, ConditionalKeyBindings)
131+
assert wrapped.key_bindings is inner, "既存バインドを内包したままガードする"
132+
133+
115134
# ---------------------------------------------------------------------------
116135
# select: バインドの仕込みと戻り値
117136
# ---------------------------------------------------------------------------

tests/cli/tui/test_menu_pty.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,44 @@ def test_answered_prompts_are_erased(session):
175175
if not ln.startswith("@") and _CPR_WARNING not in ln
176176
]
177177
assert residue == [], f"プロンプト行が画面に残留: {residue}"
178+
179+
180+
_RAPID_KEYS_DRIVER = """
181+
from devbase.tui import menu
182+
183+
OPS = [("再起動 (up)", "up"), ("停止 (down)", "down")]
184+
185+
sel = menu.select("SELECT-RAPID を選択:", OPS, back=True)
186+
print("@SEL=" + ("BACK" if sel is menu.MENU_BACK else repr(sel)), flush=True)
187+
print("@END", flush=True)
188+
"""
189+
190+
191+
@pytest.fixture
192+
def rapid_session():
193+
s = _PtySession(_RAPID_KEYS_DRIVER)
194+
yield s
195+
if s.proc.poll() is None:
196+
s.proc.kill()
197+
198+
199+
def test_buffered_key_after_answer_does_not_crash(rapid_session):
200+
"""Esc 確定と同時に届いた Ctrl-C で exit が二重に呼ばれないこと。
201+
202+
Esc バインドは矢印キーのシーケンスと区別するため eager=False (確定待ち)
203+
なので、Esc + Ctrl-C を 1 回の write で送ると prompt_toolkit は 1 回の
204+
キー処理の中で「Esc ハンドラ (exit 確定) → 残りバッファ再処理で Ctrl-C
205+
ハンドラ」を連続実行する。``_guard_after_done`` が無いと questionary
206+
組み込みの Ctrl-C ハンドラが確定後に再度 ``Application.exit()`` を呼び、
207+
「Return value already set. Application.exit() failed.」のクラッシュ画面が
208+
出て入力待ちで固まる (実 TTY でのみ再現。Ctrl-C 連打でも同様)。
209+
"""
210+
rapid_session.wait_for("SELECT-RAPID")
211+
rapid_session.send("\x1b\x03") # Esc 確定 + 直後の Ctrl-C を同一 write で送出
212+
rapid_session.wait_for("@SEL=BACK")
213+
rapid_session.wait_for("@END")
214+
215+
rapid_session.finish()
216+
raw = bytes(rapid_session._buf).decode("utf-8", errors="replace")
217+
assert "Application.exit() failed" not in raw
218+
assert "Unhandled exception" not in raw

0 commit comments

Comments
 (0)