From 2ab01d3eec5162590d86ad37fc48236c2fffdd55 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:39:50 +0100 Subject: [PATCH] fix: ignore Ctrl-Z when no shell can resume Started with no job-control shell, a Ctrl-Z stopped the screen and its launcher for good. The key now stops it only when its process group is a shell's job, and the bar says why otherwise. --- install.ps1 | 2 +- install.py | 22 +++++++- install.sh | 2 +- install/launchers/index.json | 16 +++--- tests/test_install_tui.py | 100 +++++++++++++++++++++++++++-------- wiki/installer.md | 5 +- 6 files changed, 112 insertions(+), 35 deletions(-) diff --git a/install.ps1 b/install.ps1 index ac037e590..19b3da3a3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5" + $defaultInstallSha256 = "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 38b6d8dfc..e9568eea3 100644 --- a/install.py +++ b/install.py @@ -17138,6 +17138,24 @@ def _closing_lines(state: TuiState) -> list[str]: return ["RetroBIOS: stopped. Nothing was installed."] +CTRL_Z_NO_SHELL = ("Ctrl-Z is ignored: no shell started this installer, and none could " + "bring it back.") + + +def tui_ctrl_z(state: TuiState) -> bool: + """Whether a Ctrl-Z typed on the screen stops it: only where a shell + with job control can resume it, the installer's process group being one + of its jobs, not its session's own. Started from a terminal app's -e, + a desktop entry or a launcher's sh -c, nothing ever sent SIGCONT, and + the screen and its launcher stayed stopped for good (code_audit_18 + P3-1). Otherwise the key is ignored and the bar says why.""" + if (hasattr(signal, "SIGSTOP") and hasattr(os, "getsid") + and os.getpgrp() != os.getsid(0)): + return True + state.status, state.status_more = CTRL_Z_NO_SHELL, "" + return False + + def tui_suspend(runs: list[_EngineRun], whole: bool) -> None: """Stop the screen and the runs it started until fg continues them. @@ -17309,8 +17327,8 @@ def run_tui(args: argparse.Namespace) -> int: for key in terminal.keys(TUI_FRAME): if key.name == "ctrl-z": # ISIG is off, so Ctrl-Z comes as a key: it stops what - # the terminal's would, where the system has job control. - if hasattr(signal, "SIGSTOP"): + # the terminal's would, where a shell can resume it. + if tui_ctrl_z(state): flags["suspend"] = "key" break continue diff --git a/install.sh b/install.sh index 28022ef1d..e90e5d961 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="00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5" +DEFAULT_INSTALL_SHA256="3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 250dd18c8..34cf8b91e 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 794767, - "sha256": "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5", + "size": 795560, + "sha256": "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 847316, - "sha256": "56cac10af6b7afaac0d1b3c0964d54caeabda46e75d35580dcedda264e2daac8" + "size": 848109, + "sha256": "0a210d563bdb3a6f7ca87eaeb11d48a8ac07f1a1c580011f227fcccc5bba546e" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 794767, - "sha256": "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5", + "size": 795560, + "sha256": "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 794767, - "sha256": "00ad7b4c93b5d69e17943e904121485da924ee681c2796a6bbd45aae6052e2b5", + "size": 795560, + "sha256": "3e97159b3ab76eb1f3cf748b1b47de23b1acf091899eca1dff1be1d2d9406dd4", "executable": false }, { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 82632ca43..fe0da3021 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -5159,25 +5159,38 @@ class TheTerminalComesBack(unittest.TestCase): self.assertGivenBack(term) self.assertModesBack(term.modes) + def shell(self) -> Pty: + """An interactive bash on a pseudo-terminal, a shell with job control + as a person's terminal runs one.""" + self.repo() + env = {**self.env, "PS1": "PROMPT$ ", + "PATH": f"{Path(sys.executable).parent}{os.pathsep}{self.env.get('PATH', '')}"} + term = Pty(["--norc", "--noprofile", "-i"], env, program=["/bin/bash"]) + self.addCleanup(lambda: term.status is None and os.killpg(term.pid, 9)) + term.wait("PROMPT$") + return term + + def typed(self, term: Pty, line: str) -> None: + os.write(term.fd, line.encode() + b"\r") + + @unittest.skipUnless(Path("/bin/bash").exists(), "a shell with job control") def test_ctrl_z_then_fg(self): """ISIG is off on the screen, so Ctrl-Z reached it as a byte and did - nothing: the shell never got its terminal back.""" - import signal - - term = self.ended(lambda term: None) + nothing: the shell never got its terminal back. From a shell with + job control it stops, and fg brings the screen back.""" + term = self.shell() + self.typed(term, f"{shlex.quote(sys.executable)} {REPO_ROOT / 'install.py'} --tui") + term.wait("Where do you play?") mark = len(term.raw) term.send("\x1a") - end, stopped = time.monotonic() + 10, False - while time.monotonic() < end and not stopped: + end = time.monotonic() + 10 + while b"PROMPT$" not in term.raw[mark:] and time.monotonic() < end: term._pump(0.05) - pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED) - stopped = bool(pid) and os.WIFSTOPPED(status) - self.assertTrue(stopped, term.text()) + self.assertIn(b"Stopped", term.raw[mark:], term.raw[mark:][-300:]) self.assertGivenBack(term, mark) - self.assertModesBack(termios.tcgetattr(term.fd)) # fg: the shell's SIGCONT, and the screen is back, raw again. mark = len(term.raw) - os.kill(term.pid, signal.SIGCONT) + self.typed(term, "fg") end = time.monotonic() + 5 while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end: term._pump(0.05) @@ -5185,9 +5198,43 @@ class TheTerminalComesBack(unittest.TestCase): term.wait("Where do you play?") self.assertFalse(termios.tcgetattr(term.fd)[3] & termios.ECHO) term.send("q") + term.wait("PROMPT$") + self.typed(term, "exit") self.assertEqual(term.close(), 130) self.assertGivenBack(term) - self.assertModesBack(term.modes) + + def test_ctrl_z_with_no_shell_to_resume_it_is_ignored(self): + """code_audit_18 P3-1: started straight on a terminal (a terminal + app's -e, a desktop entry) or under a launcher's sh -c, nothing ever + resumed the screen a Ctrl-Z stopped. It now goes on, and says why.""" + self.repo() + for program in (None, ["/bin/sh", "-c", f"{shlex.quote(sys.executable)} " + f"{REPO_ROOT / 'install.py'} \"$@\"", "sh"]): + with self.subTest(program=program): + term = Pty(["--tui"], self.env, program=program) + self.addCleanup(lambda term=term: term.status is None + and os.killpg(term.pid, 9)) + term.wait("Where do you play?") + term.send("\x1a") + screen = term.wait("Ctrl-Z is ignored") + self.assertIn("no shell started this installer", " ".join(screen.split())) + pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED) + self.assertEqual(pid, 0, "stopped") + term.send("q") + self.assertEqual(term.close(), 130) + self.assertGivenBack(term) + + def test_ctrl_z_needs_a_shell_that_can_resume(self): + state = install.TuiState(os_type="linux", home=HOME) + with mock.patch.object(install.os, "getpgrp", return_value=10), \ + mock.patch.object(install.os, "getsid", return_value=10): + self.assertFalse(install.tui_ctrl_z(state)) + self.assertEqual(state.status, install.CTRL_Z_NO_SHELL) + state.status = "" + with mock.patch.object(install.os, "getpgrp", return_value=12), \ + mock.patch.object(install.os, "getsid", return_value=10): + self.assertTrue(install.tui_ctrl_z(state)) + self.assertEqual(state.status, "") @staticmethod def proc_state(pid: int) -> str: @@ -5217,27 +5264,34 @@ class TheTerminalComesBack(unittest.TestCase): import signal repo = self.repo(GameDataRepository, slow=True) - self.env["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{self.env['PATH']}" - term = Pty(["--jobs", "1"], self.env, program=["/bin/sh", str(REPO_ROOT / "install.sh")]) + env = {**self.env, "PS1": "PROMPT$ ", + "PATH": f"{Path(sys.executable).parent}{os.pathsep}{self.env['PATH']}"} + # Typed at an interactive bash, as a person runs it: install.sh is a + # job of a shell that can resume it (code_audit_18 P3-1). + term = Pty(["--norc", "--noprofile", "-i"], env, program=["/bin/bash"]) self.addCleanup(lambda: term.status is None and os.killpg(term.pid, 9)) + term.wait("PROMPT$") + self.typed(term, f"sh {REPO_ROOT / 'install.sh'} --jobs 1") term.wait("Where do you play?") term.send("\r") term.wait("to download") term.send("\r") term.wait("Installing") time.sleep(0.8) - installer = self.children(term.pid) + wrapper = self.children(term.pid) + self.assertEqual(len(wrapper), 1, wrapper) + installer = self.children(wrapper[0]) self.assertEqual(len(installer), 1, installer) runs = self.children(installer[0]) self.assertTrue(runs, "no install run") + mark = len(term.raw) term.send("\x1a") - end, stopped = time.monotonic() + 10, False - while time.monotonic() < end and not stopped: + end = time.monotonic() + 10 + while b"PROMPT$" not in term.raw[mark:] and time.monotonic() < end: term._pump(0.05) - pid, status = os.waitpid(term.pid, os.WNOHANG | os.WUNTRACED) - stopped = bool(pid) and os.WIFSTOPPED(status) - # The wrapper is stopped: a shell's job of it is reported stopped. - self.assertTrue(stopped, term.text()) + # The wrapper is stopped: the shell reports its job stopped. + self.assertIn(b"Stopped", term.raw[mark:], term.raw[mark:][-300:]) + self.assertEqual(self.proc_state(wrapper[0]), "T") self.assertEqual(self.proc_state(installer[0]), "T") self.assertEqual([self.proc_state(run) for run in runs], ["T"] * len(runs)) written = sorted(repo.dest.rglob("*")) @@ -5246,13 +5300,15 @@ class TheTerminalComesBack(unittest.TestCase): # fg continues the job's group: the screen comes back and the install # carries on to its end. mark = len(term.raw) - os.killpg(term.pid, signal.SIGCONT) + self.typed(term, "fg") end = time.monotonic() + 5 while b"\x1b[?1049h" not in term.raw[mark:] and time.monotonic() < end: term._pump(0.05) self.assertIn(b"\x1b[?1049h", term.raw[mark:]) term.wait("files installed", timeout=30) term.send("\r") + term.wait("PROMPT$") + self.typed(term, "exit") self.assertEqual(term.close(), 0) diff --git a/wiki/installer.md b/wiki/installer.md index 779cc430f..6ee5aac62 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -217,7 +217,10 @@ However the screen ends (Enter on the result, `q`, Ctrl+C, Ctrl+D, SIGTERM, a hangup), the terminal is given back as the screen found it: the cursor shown, the main screen back, echo and line editing on. Ctrl+Z, or SIGTSTP from outside, gives it back the same way and stops the run, and `fg` redraws the -screen where it was. +screen where it was. Ctrl+Z stops it only when a shell with job control +started it, the one thing that can resume it: started from a terminal app's +`-e`, a desktop entry or a launcher's `sh -c`, the key is ignored and the bar +says why. - `--tui` asks for the full screen, and names the reason when it cannot open (code 2).