From b069ded58275ccb19d01b7dfa9944f27f0b4bb4d Mon Sep 17 00:00:00 2001 From: Ruben Herold Date: Sun, 9 Aug 2026 23:06:47 +0200 Subject: utils: T9185: fix ask_yes_no() busy-looping forever on non-tty stdin commit-confirm (and ~40 other call sites) hang and burn a full CPU core indefinitely when invoked with stdin that isn't a TTY, e.g. a non-interactive vbash session (vbash -c "... commit-confirm 10; exit"). input() raises EOFError immediately and repeatedly in that case, and the except handler in ask_yes_no() looped straight back to input() with no backoff or exit condition. Check stdin.isatty() up front and return the default immediately when it's not a TTY, matching what a user pressing Enter (accepting the default) would already do interactively. --- python/vyos/utils/io.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/utils/io.py b/python/vyos/utils/io.py index 77e30cbb0..13ef8709a 100644 --- a/python/vyos/utils/io.py +++ b/python/vyos/utils/io.py @@ -56,7 +56,10 @@ def ask_input(question, default='', numeric_only=False, valid_responses=[], def ask_yes_no(question, default=False) -> bool: """Ask a yes/no question via input() and return their answer.""" - from sys import stdout + from sys import stdin, stdout + + if not stdin.isatty(): + return default default_msg = "[Y/n]" if default else "[y/N]" while True: try: -- cgit v1.2.3 From 82c8f97d7ea5d49694b6256a388ba17c8a0cd58e Mon Sep 17 00:00:00 2001 From: Ruben Herold Date: Tue, 11 Aug 2026 22:25:10 +0200 Subject: utils: T9185: document ask_yes_no() non-tty default behavior The docstring said the function always prompts via input(), but the non-TTY fast-path added in this branch returns default without prompting. Document that. Flagged by Copilot review on PR #5390. --- python/vyos/utils/io.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/utils/io.py b/python/vyos/utils/io.py index 13ef8709a..360fc1ffb 100644 --- a/python/vyos/utils/io.py +++ b/python/vyos/utils/io.py @@ -55,7 +55,11 @@ def ask_input(question, default='', numeric_only=False, valid_responses=[], return response def ask_yes_no(question, default=False) -> bool: - """Ask a yes/no question via input() and return their answer.""" + """Ask a yes/no question via input() and return their answer. + + If stdin is not a TTY (e.g. non-interactive scripts), returns + default without prompting. + """ from sys import stdin, stdout if not stdin.isatty(): -- cgit v1.2.3 From 6396d9aa43063569dfc9be2daac27f89a643252f Mon Sep 17 00:00:00 2001 From: Ruben Herold Date: Thu, 13 Aug 2026 09:04:28 +0200 Subject: utils: T9185: ask_yes_no() fails fast on non-tty instead of silent default jestabro (and dmbaturin previously, on #5362) pointed out that silently returning `default` on a non-TTY stdin still violates ask_yes_no()'s interactive-only contract, even though it stops the CPU-spin. Per T9185, sageframe's original incident confirms why: a caller that forgets an explicit non-interactive guard gets no error and no visible hang, just a process quietly pegging a core for days. Raise EOFError immediately instead, so a missing guard fails loudly and fast rather than either spinning or silently succeeding. Existing callers that are meant to run non-interactively already check no_prompt/-y before calling this (config_mgmt.py, backend.py) and are unaffected. --- python/vyos/utils/io.py | 16 +++++++++++++--- src/tests/test_utils_io.py | 15 ++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) (limited to 'python') diff --git a/python/vyos/utils/io.py b/python/vyos/utils/io.py index 360fc1ffb..58ceb3476 100644 --- a/python/vyos/utils/io.py +++ b/python/vyos/utils/io.py @@ -57,13 +57,23 @@ def ask_input(question, default='', numeric_only=False, valid_responses=[], def ask_yes_no(question, default=False) -> bool: """Ask a yes/no question via input() and return their answer. - If stdin is not a TTY (e.g. non-interactive scripts), returns - default without prompting. + Raises EOFError immediately if stdin is not a TTY (e.g. non-interactive + scripts), instead of ever calling input(). A silent default here has + caused real incidents (T9185): a caller that forgets to guard an + interactive-only prompt gets no error and no visible hang, just a + process quietly pegging a core. Callers that are meant to run + non-interactively must check for that explicitly (see the no_prompt + parameter used across config_mgmt.py/backend.py) rather than rely on + this function to fall back to a default. """ from sys import stdin, stdout if not stdin.isatty(): - return default + raise EOFError( + f'Cannot prompt "{question}" ([Y/n] default={default}): ' + 'stdin is not a TTY. Pass an explicit non-interactive option ' + '(e.g. no_prompt/-y) instead of relying on ask_yes_no().' + ) default_msg = "[Y/n]" if default else "[y/N]" while True: try: diff --git a/src/tests/test_utils_io.py b/src/tests/test_utils_io.py index 7adb0dc61..9d6471e16 100644 --- a/src/tests/test_utils_io.py +++ b/src/tests/test_utils_io.py @@ -19,15 +19,20 @@ from vyos.utils.io import ask_yes_no class TestVyOSUtilsIO(unittest.TestCase): - def test_ask_yes_no_non_interactive_returns_default_without_reading_stdin(self): + def test_ask_yes_no_non_interactive_raises_without_reading_stdin(self): # T9185: on a non-TTY stdin (e.g. a non-interactive vbash session), - # input() raises EOFError immediately and forever; ask_yes_no() must - # return the default straight away instead of ever looping on input(). + # input() raises EOFError immediately and forever; looping on it + # spins a core at 100% CPU. Returning a silent default instead is + # also wrong (jestabro/dmbaturin, PR #5390): a caller that forgot + # to guard this with an explicit non-interactive option would get + # no error and no visible hang, just a quietly-succeeding prompt. + # ask_yes_no() must raise immediately instead of ever calling + # input() or returning a default. with patch('sys.stdin') as mock_stdin: mock_stdin.isatty.return_value = False with patch('builtins.input') as mock_input: - self.assertTrue(ask_yes_no('Proceed?', default=True)) - self.assertFalse(ask_yes_no('Proceed?', default=False)) + self.assertRaises(EOFError, ask_yes_no, 'Proceed?', default=True) + self.assertRaises(EOFError, ask_yes_no, 'Proceed?', default=False) mock_input.assert_not_called() def test_ask_yes_no_interactive_reads_input(self): -- cgit v1.2.3