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.
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 20:22:42 +02:00
1 parent 58c239deb4
commit cf3b61a17b
4 files changed
+66 -13

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 = "e2cc5f073a8226fe26b0513ebd25f00de6f3f613d8c9851473c2d30b53356f48"
$defaultInstallSha256 = "c2a66e6b26004e96ef5f59a7fb5a2ed65bcaa9afc7602bd3e0d71f50cc561056"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+34 -7
View File
@@ -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)
+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="e2cc5f073a8226fe26b0513ebd25f00de6f3f613d8c9851473c2d30b53356f48"
DEFAULT_INSTALL_SHA256="c2a66e6b26004e96ef5f59a7fb5a2ed65bcaa9afc7602bd3e0d71f50cc561056"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+30 -4
View File
@@ -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"),