From af3c90e4483336d5b452205003e02229198ca7c6 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:31:28 +0100 Subject: [PATCH] fix: unwind a terminated run to drop its scratch --- scripts/common.py | 13 ++++++++++++ tests/test_atomic_artifacts.py | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/scripts/common.py b/scripts/common.py index a8833cecf..104c7f951 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -15,6 +15,7 @@ import functools import json import os import re +import signal import sys import zipfile from pathlib import Path @@ -3564,6 +3565,10 @@ from dumpcatalog import ( # noqa: E402,F401 ) +def _exit_on_signal(signum: int, _frame: object) -> None: + sys.exit(128 + signum) + + def run_cli(main: Callable[[], int | None]) -> None: """Run a command-line entry point to its exit status. @@ -3571,7 +3576,15 @@ def run_cli(main: Callable[[], int | None]) -> None: and the next write died on a BrokenPipeError traceback. Output nobody reads any more ends the run quietly, with the status a shell gives a writer whose reader left. + + SIGTERM (timeout, kill) and SIGHUP (a closed terminal) end the process + without unwinding by default: a pack build left its multi-GB hidden + .partial behind. Raised as SystemExit, they run the cleanup a failed + write runs, and the status stays the one a shell gives the signal. """ + for name in ("SIGTERM", "SIGHUP"): + if hasattr(signal, name): + signal.signal(getattr(signal, name), _exit_on_signal) try: code = main() sys.stdout.flush() diff --git a/tests/test_atomic_artifacts.py b/tests/test_atomic_artifacts.py index ad3868035..f506b7573 100644 --- a/tests/test_atomic_artifacts.py +++ b/tests/test_atomic_artifacts.py @@ -296,5 +296,43 @@ class RestoreSkipsDownloadsInProgress(unittest.TestCase): self.assertIn(str(cache / "done.bin"), index.values()) +class ATerminatedBuildLeavesNoScratch(unittest.TestCase): + """`timeout 120 generate_pack.py --platform RetroArch` left a 3.4 GB + hidden .partial in dist/: SIGTERM ended the process without running the + cleanup a failed write runs.""" + + CHILD = ( + "import sys, time\n" + "sys.path.insert(0, 'scripts')\n" + "from common import run_cli\n" + "from generate_pack import _writing_pack\n" + "def main():\n" + " with _writing_pack(sys.argv[1]) as zf:\n" + " zf.writestr('a.bin', b'x')\n" + " print('writing', flush=True)\n" + " time.sleep(60)\n" + "run_cli(main)\n" + ) + + def test_sigterm_removes_the_partial_pack(self): + import signal # noqa: PLC0415 + import subprocess # noqa: PLC0415 + + if not hasattr(signal, "SIGTERM") or os.name != "posix": + self.skipTest("POSIX signals") + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + pack = Path(tmp) / "Demo_BIOS_Pack.zip" + child = subprocess.Popen( + [sys.executable, "-c", self.CHILD, str(pack)], cwd=REPO_ROOT, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + self.assertEqual(child.stdout.readline().strip(), "writing") + self.assertTrue(list(Path(tmp).glob(".*.partial"))) + child.send_signal(signal.SIGTERM) + child.communicate(timeout=60) + self.assertEqual(child.returncode, 128 + signal.SIGTERM) + self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), []) + + if __name__ == "__main__": unittest.main()