From cf3b61a17baf60ae90ccf8add5b88fdcc06ba755 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:22:42 +0200 Subject: [PATCH] fix: exit on the window's signal when it closes A closed window under a changing screen failed the draw (EIO, or a termios.error from the SIGCONT that comes with SIGHUP) before the signal was read, and the run ended on 1 with a crash log kept. --- install.ps1 | 2 +- install.py | 41 ++++++++++++++++++++++++++++++++------- install.sh | 2 +- tests/test_install_tui.py | 34 ++++++++++++++++++++++++++++---- 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/install.ps1 b/install.ps1 index 70f6b4e5..9a1ae914 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "e2cc5f073a8226fe26b0513ebd25f00de6f3f613d8c9851473c2d30b53356f48" + $defaultInstallSha256 = "c2a66e6b26004e96ef5f59a7fb5a2ed65bcaa9afc7602bd3e0d71f50cc561056" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index b0eca90e..ee80f792 100644 --- a/install.py +++ b/install.py @@ -12500,13 +12500,14 @@ def run_tui(args: argparse.Namespace) -> int: if terminating["signal"] and state.screen == "result": # The result is printed in the terminal on the way out. state.exit_code = state.code if state.code is not None else 1 - except Exception: # noqa: BLE001 - # A traceback is where every reporter of #71, #73 and #74 stopped: - # it goes to a file, and the terminal says one sentence. - crash = work / "error.log" - crash.write_text(traceback.format_exc(), encoding="utf-8") - state.log, state.code, state.exit_code = str(crash), 1, 1 - state.stack = ["crashed"] + except Exception as exc: # noqa: BLE001 + if flags["term"] or _closed_terminal(exc): + # The window closed under a screen that was changing, or the + # SIGCONT that comes with its SIGHUP put the screen back: the + # write failed before the signal was read. That is its exit. + state.exit_code = 128 + (flags["term"] or getattr(signal, "SIGHUP", 1)) + else: + _tui_crashed(state, work) finally: # Each step on its own: a write the closed window refuses, or a # second signal while the install writes its summary, must not @@ -12560,6 +12561,32 @@ def _tui_startup(args: argparse.Namespace) -> tuple[TuiState, list[tuple]] | Non return tui_start(args, os_type) +# What a write to a closed window or a closed pipe answers. +_CLOSED_TERMINAL = frozenset({errno.EIO, errno.EPIPE, errno.ENXIO}) + + +def _closed_terminal(exc: BaseException) -> bool: + """Whether exc is the terminal gone: an OSError, or a termios.error, + whose first argument is the errno, answering EIO, EPIPE or ENXIO.""" + if isinstance(exc, OSError): + return exc.errno in _CLOSED_TERMINAL + if type(exc).__name__ == "error" and type(exc).__module__ == "termios": + return bool(exc.args) and exc.args[0] in _CLOSED_TERMINAL + return False + + +def _tui_crashed(state: TuiState, work: Path) -> None: + """A traceback is where every reporter of #71, #73 and #74 stopped: it + goes to a file, and the terminal says one sentence.""" + crash = work / "error.log" + try: + crash.write_text(traceback.format_exc(), encoding="utf-8") + except OSError as exc: + print(f"The error could not be written to {crash}: {exc}", file=sys.stderr) + state.log, state.code, state.exit_code = str(crash), 1, 1 + state.stack = ["crashed"] + + def _restore_handlers(handlers: dict) -> None: for number, previous in handlers.items(): signal.signal(number, previous) diff --git a/install.sh b/install.sh index 539267b4..6c8d516c 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="e2cc5f073a8226fe26b0513ebd25f00de6f3f613d8c9851473c2d30b53356f48" +DEFAULT_INSTALL_SHA256="c2a66e6b26004e96ef5f59a7fb5a2ed65bcaa9afc7602bd3e0d71f50cc561056" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 392ff1b9..8fe485e1 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -1444,13 +1444,14 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase): mock.patch.object(sys, "stderr", out): code = install.run_tui(_args()) self.assertIs(sys.stdout, out) - self.assertEqual(code, 1) + # A window that answers EIO is a closed window: its signal's code, + # not a crash. + self.assertEqual(code, 129) # The farewell found no terminal: what still flushes at exit goes to # the null device, and the exit code stays the one returned. self.assertEqual(os.fstat(screen_fd).st_rdev, os.stat(os.devnull).st_rdev) - # The crash keeps its log, marked kept, and nothing else of the run. - self.assertEqual(sorted(path.name for path in work.iterdir()), - ["error.log", "kept", "owner"]) + # No crash log is kept, and nothing else of the run. + self.assertFalse(work.exists()) self.assertEqual(termios.tcgetattr(slave), before) for number, handler in handlers.items(): self.assertEqual(signal.getsignal(number), handler) @@ -2859,6 +2860,31 @@ class EndToEnd(unittest.TestCase): self.assertTrue(os.WIFEXITED(status)) self.assertEqual(term.status, 129) + def test_closing_the_window_while_it_installs(self): + """The changing screen fails to draw on the closed window: that is + the window's signal, not a crash with a log kept.""" + repo = self.repo(GameDataRepository, slow=True) + scratch = repo.root / "scratch" + scratch.mkdir() + self.env["TMPDIR"] = str(scratch) + term = self.start("--platform", "retroarch", "--dest", str(repo.dest), "--jobs", "1") + term.wait("> Install") + term.send("\r") + term.wait("1 of 6 files") + os.close(term.fd) + end = time.monotonic() + 20 + while time.monotonic() < end: + pid, status = os.waitpid(term.pid, os.WNOHANG) + if pid: + break + time.sleep(0.05) + else: + os.kill(term.pid, 9) + self.fail("still running once its window closed") + term.status = os.WEXITSTATUS(status) + self.assertEqual(term.status, 129) + self.assertEqual(list(scratch.glob("retrobios-tui-*/error.log")), []) + def test_a_desktop_names_its_keys_and_a_pad_its_buttons(self): self.repo() for env, bar in (({"DISPLAY": ":0"}, "Enter Continue Esc Quit ? Help"),