diff --git a/install.ps1 b/install.ps1 index 3f26cd07..6bc44b5f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" - $defaultInstallSha256 = "2409cff28ad4eed1546161aa5abd6808fcd6a010bda7943eb1076f4ab0559c9f" + $defaultInstallSha256 = "caec72e5ebd146509e3b1fabc7d8d4c5c921567592d6d1581df6e5cc50f3e96f" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index e3dae02f..2c87e2f0 100644 --- a/install.py +++ b/install.py @@ -26,6 +26,7 @@ import platform import re import shlex import shutil +import socket import stat import subprocess import sys @@ -2711,6 +2712,8 @@ def _download_one( reason = f"HTTP {exc.code}" if exc.code in (403, 429): reason += " (GitHub rate limit or access refused)" + if exc.code >= 500 or exc.code == 429: + reason = NetworkReason(reason) if verbose: print(f" Attempt {attempt} failed: {exc}", file=sys.stderr) if tmp_path is not None and partial is None and tmp_path.exists(): @@ -2731,6 +2734,8 @@ def _download_one( # place is not a folder this user cannot write. reason = READ_ONLY if _read_only_file(dest) else NOT_WRITABLE refused = True + elif _transfer_failure(exc): + reason = NetworkReason(getattr(exc, "reason", "") or exc) else: reason = str(getattr(exc, "reason", "") or exc) if verbose: @@ -3071,6 +3076,34 @@ def _plural(count: int, word: str) -> str: return f"{count} {word}" if count == 1 else f"{count} {word}s" +class NetworkReason(str): + """A failure the connection caused: cut, timed out, refused by a server + or a proxy. The summary names the network instead of the folder.""" + + +def _transfer_failure(exc: BaseException) -> bool: + """Whether a download error came from the connection, not the disk.""" + # urlopen raises URLError for the name lookup, the connection and TLS. + # A cut read surfaces as a timeout (socket.timeout is no TimeoutError + # before 3.10), a reset, an SSL error or a size that does not add up. + ssl_error = getattr(sys.modules.get("ssl"), "SSLError", ()) + if isinstance(exc, (urllib.error.URLError, http.client.HTTPException, ValueError, + ConnectionError, TimeoutError, socket.timeout)): + return True + if ssl_error and isinstance(exc, ssl_error): + return True + return isinstance(exc, OSError) and exc.errno in ( + errno.ECONNRESET, errno.ECONNREFUSED, errno.ECONNABORTED, errno.ETIMEDOUT, + errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ENETDOWN, errno.EPIPE, + ) + + +NETWORK_FAILURE_ADVICE = ( + " The transfers were cut or refused on the way: check the connection, and " + "HTTPS_PROXY if this network needs a proxy.\n" + f" More help: {TROUBLESHOOTING_PAGE}") + + def _report_outcome( paths: list[Path], downloaded: int, @@ -3132,11 +3165,18 @@ def _report_outcome( if any(reason == READ_ONLY for _dest, reason in failures or []): advice.append(" Files marked read-only are in the way: clear that attribute " "(or delete them), then run again.") + if any(isinstance(reason, NetworkReason) for _dest, reason in failures or []): + advice.append(NETWORK_FAILURE_ADVICE) paths = paths + [folder for folder in elsewhere if folder not in paths] if errors and not downloaded: print(f"\nNothing was installed. {_plural(errors, 'file')} failed.") - print(f"Location that was being written to: {_locations(paths)}") + if failures and all(isinstance(r, NetworkReason) for _d, r in failures): + # The folder was never the problem: naming it as the place being + # written to sent people looking at their disk. + print(f"Destination: {_locations(paths)}") + else: + print(f"Location that was being written to: {_locations(paths)}") for line in advice: print(line) print("Run the same command again once that is resolved. " diff --git a/install.sh b/install.sh index d7a4c4d5..72d44f87 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="2409cff28ad4eed1546161aa5abd6808fcd6a010bda7943eb1076f4ab0559c9f" +DEFAULT_INSTALL_SHA256="caec72e5ebd146509e3b1fabc7d8d4c5c921567592d6d1581df6e5cc50f3e96f" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install.py b/tests/test_install.py index 06b714f3..b6282f3a 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -19,6 +19,7 @@ import threading import unittest from types import SimpleNamespace import unittest.mock +import urllib.error from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent @@ -3747,5 +3748,57 @@ class TheEmulatorNamesHaveTheirOwnListing(unittest.TestCase, _LoopbackRun): ["mednafen_psx"]) +class ANetworkFailureNamesTheNetwork(unittest.TestCase): + """Every transfer cut by the connection ended on "Location that was being + written to" and "once that is resolved", naming the folder and nothing + about the connection or the proxy.""" + + def _fail(self, error: BaseException) -> str: + def urlopen(*_args, **_kwargs): + raise error + + entry = {"dest": "a.bin", "repo_path": "bios/a.bin", "size": 10, + "sha256": "0" * 64} + with unittest.mock.patch.object(install.urllib.request, "urlopen", urlopen), \ + unittest.mock.patch.object(install, "_retry_delay", return_value=0): + _dest, ok, reason = install._download_one(entry, Path(_tempdir(self))) + self.assertFalse(ok) + return reason + + def test_cuts_and_refusals_are_network_failures(self): + import http.client + import socket + + for error in ( + urllib.error.URLError(socket.gaierror(-2, "Name or service not known")), + http.client.IncompleteRead(b"abc", 10), + ConnectionResetError(104, "Connection reset by peer"), + socket.timeout("timed out"), + urllib.error.HTTPError("u", 503, "Service Unavailable", None, None), + ): + with self.subTest(error=error): + self.assertIsInstance(self._fail(error), install.NetworkReason) + + def test_a_full_disk_and_a_missing_file_are_not(self): + import errno + + for error in (OSError(errno.ENOSPC, "No space left on device"), + urllib.error.HTTPError("u", 404, "Not Found", None, None)): + with self.subTest(error=error): + self.assertNotIsInstance(self._fail(error), install.NetworkReason) + + def test_the_summary_points_at_the_connection(self): + failures = [("a.bin", install.NetworkReason("timed out")), + ("b.bin", install.NetworkReason("Connection reset by peer"))] + out = io.StringIO() + with contextlib.redirect_stdout(out), self.assertRaises(SystemExit) as raised: + install._report_outcome([Path("/bios")], 0, 0, 2, 0, False, failures=failures) + self.assertEqual(raised.exception.code, 2) + text = out.getvalue() + self.assertIn("HTTPS_PROXY", text) + self.assertIn("Destination: /bios", text) + self.assertNotIn("being written to", text) + + if __name__ == "__main__": unittest.main()