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.
This commit is contained in:
Abdessamad Derraz committed 2026-10-27 15:39:50 +01:00
1 parent 0fa4552dae
commit 2ab01d3eec
6 files changed
+112 -35

No files matched your search

+1 -1
View File
@@ -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 }
+20 -2
View File
@@ -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
+1 -1
View File
@@ -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() {
+8 -8
View File
@@ -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
},
{
+78 -22
View File
@@ -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)
+4 -1
View File
@@ -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).