diff --git a/install.ps1 b/install.ps1 index 9a1ae914..f6295a5b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "c2a66e6b26004e96ef5f59a7fb5a2ed65bcaa9afc7602bd3e0d71f50cc561056" + $defaultInstallSha256 = "b23c6da4ee131f898c25540bd53677ea4112964fd00b209e4fe85dfdba8427d1" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index ee80f792..0bcd62db 100644 --- a/install.py +++ b/install.py @@ -12279,8 +12279,9 @@ def _owner_alive(folder: Path) -> bool | None: pid = int((folder / TUI_OWNER).read_text(encoding="utf-8").strip()) except (OSError, ValueError): return None - if os.name == "nt": - # os.kill(pid, 0) ends a process on Windows instead of asking. + if os.name == "nt" or pid <= 0: + # os.kill(pid, 0) ends a process on Windows instead of asking, and + # 0 or a negative number names a process group, not the run. return None try: os.kill(pid, 0) @@ -12288,7 +12289,8 @@ def _owner_alive(folder: Path) -> bool | None: return False except PermissionError: return True - except OSError: + except (OSError, OverflowError): + # A number past what a pid holds is no process this run can ask. return None return True @@ -12375,9 +12377,20 @@ def run_tui(args: argparse.Namespace) -> int: state, effects = started if flags["term"]: state.exit_code = 128 + flags["term"] - work = Path(tempfile.mkdtemp(prefix=TUI_WORK_PREFIX)) - (work / TUI_OWNER).write_text(str(os.getpid()), encoding="utf-8") - _prune_work_folders(work.parent, time.time()) + try: + work = Path(tempfile.mkdtemp(prefix=TUI_WORK_PREFIX)) + except OSError as exc: + _restore_handlers(handlers) + print(f"Error: no work folder could be made in {tempfile.gettempdir()}: {exc}. " + "Free some space there, or point TMPDIR (TEMP on Windows) at another folder.", file=sys.stderr) + return 1 + try: + (work / TUI_OWNER).write_text(str(os.getpid()), encoding="utf-8") + except OSError as exc: + # A full temp disk: the folder has no owner, and a later run leaves + # it a week like any unknown one. The logs fail as they come. + print(f"The work folder {work} could not be marked as this run's: {exc}", + file=sys.stderr) script = str(Path(__file__).resolve()) inbox: queue.Queue = queue.Queue() current: list[_EngineRun] = [] @@ -12445,6 +12458,11 @@ def run_tui(args: argparse.Namespace) -> int: if number is not None: handlers[number] = signal.signal(number, handler) sys.stdout = sys.stderr = buffer + try: + _prune_work_folders(work.parent, time.time()) + except OSError as exc: + # What earlier runs left stays until a later run: this one opens. + print(f"Earlier work folders could not be removed: {exc}", file=sys.stderr) if state.exit_code is None: # Stopped while it started: the screen never opens. terminal.enter() diff --git a/install.sh b/install.sh index 6c8d516c..e2217f75 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="c2a66e6b26004e96ef5f59a7fb5a2ed65bcaa9afc7602bd3e0d71f50cc561056" +DEFAULT_INSTALL_SHA256="b23c6da4ee131f898c25540bd53677ea4112964fd00b209e4fe85dfdba8427d1" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 8fe485e1..ef7c2f67 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -10,6 +10,7 @@ installs it). from __future__ import annotations import argparse +import errno import fcntl import functools import hashlib @@ -1801,6 +1802,31 @@ class WorkFolders(unittest.TestCase): work, out = self.run_to(130) self.assertFalse(work.exists()) + def test_a_full_temp_disk_or_a_failing_pruner_still_opens(self): + real_write = Path.write_text + + def full(path, *args, **kwargs): + if path.name == install.TUI_OWNER: + raise OSError(errno.ENOSPC, "No space left on device") + return real_write(path, *args, **kwargs) + + with mock.patch.object(Path, "write_text", full): + work, out = self.run_to(0) + self.assertFalse(work.exists()) + with mock.patch.object(install, "_prune_work_folders", + side_effect=OSError(errno.EACCES, "denied")): + work, out = self.run_to(0) + self.assertFalse(work.exists()) + err = io.StringIO() + with mock.patch.object(install.tempfile, "mkdtemp", + side_effect=OSError(errno.ENOSPC, "No space left")), \ + mock.patch.object(install, "tui_start", return_value=(fresh_state(), [])), \ + mock.patch.object(install, "_open_local_sources"), \ + mock.patch.object(install, "detect_os", return_value="linux"), \ + mock.patch.object(sys, "stderr", err): + self.assertEqual(install.run_tui(_args()), 1) + self.assertIn("no work folder could be made", err.getvalue()) + def test_a_stop_while_it_starts_ends_on_the_farewell(self): """Start+Select during the detection: the signal is the interface's, never the default action that ended Python without a word.""" @@ -2456,8 +2482,17 @@ class AuditRoundThree(unittest.TestCase): unknown_old = folder("unknown-old", None, False, 2 * week) other = root / "something-else" other.mkdir() + # A stray or hostile owner file is an unknown owner: a number past + # what a pid holds raised OverflowError, and 0 or -1 name a group. + hostile = [folder(f"hostile{index}", None, False, 60) + for index in range(3)] + for path, text in zip(hostile, ("9" * 40, "0", "-1")): + (path / install.TUI_OWNER).write_text(text) + self.assertIsNone(install._owner_alive(path)) removed = install._prune_work_folders(root, now) self.assertEqual(sorted(removed), sorted([killed, failed_old, unknown_old])) + for path in hostile: + shutil.rmtree(path) self.assertEqual(sorted(path.name for path in root.iterdir()), sorted(path.name for path in (running, failed, unknown, other)))